mirror of
https://github.com/langgenius/dify.git
synced 2026-03-15 12:17:02 +00:00
Compare commits
2 Commits
move-token
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c29b67e22 | ||
|
|
c080c48aba |
91
web/app/components/app/configuration/debug/debug-header.tsx
Normal file
91
web/app/components/app/configuration/debug/debug-header.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { ModelAndParameter } from './types'
|
||||||
|
import {
|
||||||
|
RiAddLine,
|
||||||
|
RiEqualizer2Line,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||||
|
import TooltipPlus from '@/app/components/base/tooltip'
|
||||||
|
import { AppModeEnum } from '@/types/app'
|
||||||
|
|
||||||
|
type DebugHeaderProps = {
|
||||||
|
readonly?: boolean
|
||||||
|
mode: AppModeEnum
|
||||||
|
debugWithMultipleModel: boolean
|
||||||
|
multipleModelConfigs: ModelAndParameter[]
|
||||||
|
varListLength: number
|
||||||
|
expanded: boolean
|
||||||
|
onExpandedChange: (expanded: boolean) => void
|
||||||
|
onClearConversation: () => void
|
||||||
|
onAddModel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DebugHeader: FC<DebugHeaderProps> = ({
|
||||||
|
readonly,
|
||||||
|
mode,
|
||||||
|
debugWithMultipleModel,
|
||||||
|
multipleModelConfigs,
|
||||||
|
varListLength,
|
||||||
|
expanded,
|
||||||
|
onExpandedChange,
|
||||||
|
onClearConversation,
|
||||||
|
onAddModel,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-4 pb-2 pt-3">
|
||||||
|
<div className="system-xl-semibold text-text-primary">{t('inputs.title', { ns: 'appDebug' })}</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{debugWithMultipleModel && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost-accent"
|
||||||
|
onClick={onAddModel}
|
||||||
|
disabled={multipleModelConfigs.length >= 4}
|
||||||
|
>
|
||||||
|
<RiAddLine className="mr-1 h-3.5 w-3.5" />
|
||||||
|
{t('modelProvider.addModel', { ns: 'common' })}
|
||||||
|
(
|
||||||
|
{multipleModelConfigs.length}
|
||||||
|
/4)
|
||||||
|
</Button>
|
||||||
|
<div className="mx-2 h-[14px] w-[1px] bg-divider-regular" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{mode !== AppModeEnum.COMPLETION && (
|
||||||
|
<>
|
||||||
|
{!readonly && (
|
||||||
|
<TooltipPlus popupContent={t('operation.refresh', { ns: 'common' })}>
|
||||||
|
<ActionButton onClick={onClearConversation}>
|
||||||
|
<RefreshCcw01 className="h-4 w-4" />
|
||||||
|
</ActionButton>
|
||||||
|
</TooltipPlus>
|
||||||
|
)}
|
||||||
|
{varListLength > 0 && (
|
||||||
|
<div className="relative ml-1 mr-2">
|
||||||
|
<TooltipPlus popupContent={t('panel.userInputField', { ns: 'workflow' })}>
|
||||||
|
<ActionButton
|
||||||
|
state={expanded ? ActionButtonState.Active : undefined}
|
||||||
|
onClick={() => !readonly && onExpandedChange(!expanded)}
|
||||||
|
>
|
||||||
|
<RiEqualizer2Line className="h-4 w-4" />
|
||||||
|
</ActionButton>
|
||||||
|
</TooltipPlus>
|
||||||
|
{expanded && (
|
||||||
|
<div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DebugHeader
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
import type { ModelAndParameter } from '../types'
|
||||||
|
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||||
|
import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||||
|
import { ModelModeType } from '@/types/app'
|
||||||
|
import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from '../types'
|
||||||
|
import ChatItem from './chat-item'
|
||||||
|
|
||||||
|
const mockUseAppContext = vi.fn()
|
||||||
|
const mockUseDebugConfigurationContext = vi.fn()
|
||||||
|
const mockUseProviderContext = vi.fn()
|
||||||
|
const mockUseFeatures = vi.fn()
|
||||||
|
const mockUseConfigFromDebugContext = vi.fn()
|
||||||
|
const mockUseFormattingChangedSubscription = vi.fn()
|
||||||
|
const mockUseChat = vi.fn()
|
||||||
|
const mockUseEventEmitterContextContext = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/context/app-context', () => ({
|
||||||
|
useAppContext: () => mockUseAppContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/debug-configuration', () => ({
|
||||||
|
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/provider-context', () => ({
|
||||||
|
useProviderContext: () => mockUseProviderContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||||
|
useFeatures: (selector: (state: unknown) => unknown) => mockUseFeatures(selector),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../hooks', () => ({
|
||||||
|
useConfigFromDebugContext: () => mockUseConfigFromDebugContext(),
|
||||||
|
useFormattingChangedSubscription: (chatList: unknown) => mockUseFormattingChangedSubscription(chatList),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/chat/chat/hooks', () => ({
|
||||||
|
useChat: (...args: unknown[]) => mockUseChat(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/event-emitter', () => ({
|
||||||
|
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockStopChatMessageResponding = vi.fn()
|
||||||
|
const mockFetchConversationMessages = vi.fn()
|
||||||
|
const mockFetchSuggestedQuestions = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/service/debug', () => ({
|
||||||
|
fetchConversationMessages: (...args: unknown[]) => mockFetchConversationMessages(...args),
|
||||||
|
fetchSuggestedQuestions: (...args: unknown[]) => mockFetchSuggestedQuestions(...args),
|
||||||
|
stopChatMessageResponding: (...args: unknown[]) => mockStopChatMessageResponding(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils', () => ({
|
||||||
|
canFindTool: (collectionId: string, providerId: string) => collectionId === providerId,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/chat/utils', () => ({
|
||||||
|
getLastAnswer: (chatList: { id: string }[]) => chatList.length > 0 ? chatList[chatList.length - 1] : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
let capturedChatProps: Record<string, unknown> | null = null
|
||||||
|
vi.mock('@/app/components/base/chat/chat', () => ({
|
||||||
|
default: (props: Record<string, unknown>) => {
|
||||||
|
capturedChatProps = props
|
||||||
|
return <div data-testid="chat-component">Chat</div>
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/avatar', () => ({
|
||||||
|
default: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
let modelIdCounter = 0
|
||||||
|
|
||||||
|
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
|
||||||
|
id: `model-${++modelIdCounter}`,
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
provider: 'openai',
|
||||||
|
parameters: { temperature: 0.7 },
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createDefaultModelConfig = () => ({
|
||||||
|
provider: 'openai',
|
||||||
|
model_id: 'gpt-4',
|
||||||
|
mode: ModelModeType.chat,
|
||||||
|
configs: {
|
||||||
|
prompt_template: 'Hello {{name}}',
|
||||||
|
prompt_variables: [
|
||||||
|
{ key: 'name', name: 'Name', type: 'string' as const },
|
||||||
|
{ key: 'api-var', name: 'API Var', type: 'api' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||||
|
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||||
|
opening_statement: '',
|
||||||
|
more_like_this: null,
|
||||||
|
suggested_questions: [],
|
||||||
|
suggested_questions_after_answer: null,
|
||||||
|
speech_to_text: null,
|
||||||
|
text_to_speech: null,
|
||||||
|
file_upload: null,
|
||||||
|
retriever_resource: null,
|
||||||
|
sensitive_word_avoidance: null,
|
||||||
|
annotation_reply: null,
|
||||||
|
external_data_tools: [],
|
||||||
|
dataSets: [],
|
||||||
|
agentConfig: DEFAULT_AGENT_SETTING,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createDefaultFeatures = () => ({
|
||||||
|
moreLikeThis: { enabled: false },
|
||||||
|
opening: { enabled: true, opening_statement: 'Hello', suggested_questions: ['Q1'] },
|
||||||
|
moderation: { enabled: false },
|
||||||
|
speech2text: { enabled: true },
|
||||||
|
text2speech: { enabled: false },
|
||||||
|
file: { enabled: true, image: { enabled: true } },
|
||||||
|
suggested: { enabled: true },
|
||||||
|
citation: { enabled: false },
|
||||||
|
annotationReply: { enabled: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const createTextGenerationModelList = (models: Array<{
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
features?: string[]
|
||||||
|
mode?: string
|
||||||
|
}> = []) => {
|
||||||
|
const providerMap = new Map<string, { model: string, features: string[], model_properties: { mode: string } }[]>()
|
||||||
|
|
||||||
|
for (const m of models) {
|
||||||
|
if (!providerMap.has(m.provider)) {
|
||||||
|
providerMap.set(m.provider, [])
|
||||||
|
}
|
||||||
|
providerMap.get(m.provider)!.push({
|
||||||
|
model: m.model,
|
||||||
|
features: m.features ?? [],
|
||||||
|
model_properties: { mode: m.mode ?? 'chat' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(providerMap.entries()).map(([provider, modelsList]) => ({
|
||||||
|
provider,
|
||||||
|
models: modelsList,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatItem', () => {
|
||||||
|
let subscriptionCallback: ((v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) | null = null
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
modelIdCounter = 0
|
||||||
|
capturedChatProps = null
|
||||||
|
subscriptionCallback = null
|
||||||
|
|
||||||
|
mockUseAppContext.mockReturnValue({
|
||||||
|
userProfile: { avatar_url: 'avatar.png', name: 'Test User' },
|
||||||
|
})
|
||||||
|
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
modelConfig: createDefaultModelConfig(),
|
||||||
|
appId: 'test-app-id',
|
||||||
|
inputs: { name: 'World' },
|
||||||
|
collectionList: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
textGenerationModelList: createTextGenerationModelList([
|
||||||
|
{ provider: 'openai', model: 'gpt-3.5-turbo', features: [ModelFeatureEnum.vision], mode: 'chat' },
|
||||||
|
{ provider: 'openai', model: 'gpt-4', features: [], mode: 'chat' },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const features = createDefaultFeatures()
|
||||||
|
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
|
||||||
|
|
||||||
|
mockUseConfigFromDebugContext.mockReturnValue({
|
||||||
|
baseConfig: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList: [{ id: 'msg-1', content: 'Hello' }],
|
||||||
|
isResponding: false,
|
||||||
|
handleSend: vi.fn(),
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
mockUseEventEmitterContextContext.mockReturnValue({
|
||||||
|
eventEmitter: {
|
||||||
|
// eslint-disable-next-line react/no-unnecessary-use-prefix -- mocking real API
|
||||||
|
useSubscription: (callback: (v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) => {
|
||||||
|
subscriptionCallback = callback
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render Chat component when chatList is not empty', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null when chatList is empty', () => {
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList: [],
|
||||||
|
isResponding: false,
|
||||||
|
handleSend: vi.fn(),
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
const { container } = render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass correct props to Chat component', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedChatProps!.noChatInput).toBe(true)
|
||||||
|
expect(capturedChatProps!.noStopResponding).toBe(true)
|
||||||
|
expect(capturedChatProps!.showPromptLog).toBe(true)
|
||||||
|
expect(capturedChatProps!.hideLogModal).toBe(true)
|
||||||
|
expect(capturedChatProps!.noSpacing).toBe(true)
|
||||||
|
expect(capturedChatProps!.chatContainerClassName).toBe('p-4')
|
||||||
|
expect(capturedChatProps!.chatFooterClassName).toBe('p-4 pb-0')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('config building', () => {
|
||||||
|
it('should merge configTemplate with features', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
const config = capturedChatProps!.config as ChatConfig & { baseConfig?: boolean }
|
||||||
|
expect(config.baseConfig).toBe(true)
|
||||||
|
expect(config.more_like_this).toEqual({ enabled: false })
|
||||||
|
expect(config.opening_statement).toBe('Hello')
|
||||||
|
expect(config.suggested_questions).toEqual(['Q1'])
|
||||||
|
expect(config.speech_to_text).toEqual({ enabled: true })
|
||||||
|
expect(config.file_upload).toEqual({ enabled: true, image: { enabled: true } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use empty opening_statement when opening is disabled', () => {
|
||||||
|
const features = createDefaultFeatures()
|
||||||
|
features.opening = { enabled: false, opening_statement: 'Hello', suggested_questions: ['Q1'] }
|
||||||
|
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
const config = capturedChatProps!.config as ChatConfig
|
||||||
|
expect(config.opening_statement).toBe('')
|
||||||
|
expect(config.suggested_questions).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use empty string fallback when opening_statement is undefined', () => {
|
||||||
|
const features = createDefaultFeatures()
|
||||||
|
// eslint-disable-next-line ts/no-explicit-any -- Testing edge case with undefined
|
||||||
|
features.opening = { enabled: true, opening_statement: undefined as any, suggested_questions: ['Q1'] }
|
||||||
|
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
const config = capturedChatProps!.config as ChatConfig
|
||||||
|
expect(config.opening_statement).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use empty array fallback when suggested_questions is undefined', () => {
|
||||||
|
const features = createDefaultFeatures()
|
||||||
|
// eslint-disable-next-line ts/no-explicit-any -- Testing edge case with undefined
|
||||||
|
features.opening = { enabled: true, opening_statement: 'Hello', suggested_questions: undefined as any }
|
||||||
|
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
const config = capturedChatProps!.config as ChatConfig
|
||||||
|
expect(config.suggested_questions).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle undefined opening feature', () => {
|
||||||
|
const features = createDefaultFeatures()
|
||||||
|
// eslint-disable-next-line ts/no-explicit-any -- Testing edge case with undefined
|
||||||
|
features.opening = undefined as any
|
||||||
|
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
const config = capturedChatProps!.config as ChatConfig
|
||||||
|
expect(config.opening_statement).toBe('')
|
||||||
|
expect(config.suggested_questions).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('inputsForm transformation', () => {
|
||||||
|
it('should filter out api type variables and map to InputForm', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
// The useChat is called with inputsForm
|
||||||
|
const useChatCall = mockUseChat.mock.calls[0]
|
||||||
|
const inputsForm = useChatCall[1].inputsForm
|
||||||
|
|
||||||
|
expect(inputsForm).toHaveLength(1)
|
||||||
|
expect(inputsForm[0]).toEqual(expect.objectContaining({
|
||||||
|
key: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
variable: 'name',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('event subscription', () => {
|
||||||
|
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL event', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList: [{ id: 'msg-1' }],
|
||||||
|
isResponding: false,
|
||||||
|
handleSend,
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
// Trigger the event
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test message', files: [{ id: 'file-1' }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleSend).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL_RESTART event', async () => {
|
||||||
|
const handleRestart = vi.fn()
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList: [{ id: 'msg-1' }],
|
||||||
|
isResponding: false,
|
||||||
|
handleSend: vi.fn(),
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart,
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
// Trigger the event
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleRestart).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('doSend', () => {
|
||||||
|
it('should find current provider and model from textGenerationModelList', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList: [{ id: 'msg-1' }],
|
||||||
|
isResponding: false,
|
||||||
|
handleSend,
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleSend).toHaveBeenCalledWith(
|
||||||
|
'apps/test-app-id/chat-messages',
|
||||||
|
expect.objectContaining({
|
||||||
|
query: 'test',
|
||||||
|
inputs: { name: 'World' },
|
||||||
|
model_config: expect.objectContaining({
|
||||||
|
model: expect.objectContaining({
|
||||||
|
provider: 'openai',
|
||||||
|
name: 'gpt-3.5-turbo',
|
||||||
|
mode: 'chat',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include files when file upload is enabled and vision is supported', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList: [{ id: 'msg-1' }],
|
||||||
|
isResponding: false,
|
||||||
|
handleSend,
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// gpt-3.5-turbo has vision feature
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
const files = [{ id: 'file-1', name: 'image.png' }]
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleSend).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
files,
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not include files when vision is not supported', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList: [{ id: 'msg-1' }],
|
||||||
|
isResponding: false,
|
||||||
|
handleSend,
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// gpt-4 does not have vision feature
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
const files = [{ id: 'file-1', name: 'image.png' }]
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const callArgs = handleSend.mock.calls[0][1]
|
||||||
|
expect(callArgs.files).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle provider not found in textGenerationModelList', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList: [{ id: 'msg-1' }],
|
||||||
|
isResponding: false,
|
||||||
|
handleSend,
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use a provider that doesn't exist in the list
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'unknown-provider', model: 'unknown-model' })
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [{ id: 'file-1' }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleSend).toHaveBeenCalled()
|
||||||
|
const callArgs = handleSend.mock.calls[0][1]
|
||||||
|
// Files should not be included when provider/model not found (no vision support)
|
||||||
|
expect(callArgs.files).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle model with no features array', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList: [{ id: 'msg-1' }],
|
||||||
|
isResponding: false,
|
||||||
|
handleSend,
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Model list where model has no features property
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
textGenerationModelList: [
|
||||||
|
{
|
||||||
|
provider: 'custom',
|
||||||
|
models: [{ model: 'custom-model', model_properties: { mode: 'chat' } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'custom', model: 'custom-model' })
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [{ id: 'file-1' }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleSend).toHaveBeenCalled()
|
||||||
|
const callArgs = handleSend.mock.calls[0][1]
|
||||||
|
// Files should not be included when features is undefined
|
||||||
|
expect(callArgs.files).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle undefined files parameter', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList: [{ id: 'msg-1' }],
|
||||||
|
isResponding: false,
|
||||||
|
handleSend,
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: undefined },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleSend).toHaveBeenCalled()
|
||||||
|
const callArgs = handleSend.mock.calls[0][1]
|
||||||
|
expect(callArgs.files).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tool icons building', () => {
|
||||||
|
it('should build tool icons from agent config', () => {
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
modelConfig: {
|
||||||
|
...createDefaultModelConfig(),
|
||||||
|
agentConfig: {
|
||||||
|
tools: [
|
||||||
|
{ tool_name: 'search', provider_id: 'provider-1' },
|
||||||
|
{ tool_name: 'calculator', provider_id: 'provider-2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appId: 'test-app-id',
|
||||||
|
inputs: {},
|
||||||
|
collectionList: [
|
||||||
|
{ id: 'provider-1', icon: 'search-icon' },
|
||||||
|
{ id: 'provider-2', icon: 'calc-icon' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedChatProps!.allToolIcons).toEqual({
|
||||||
|
search: 'search-icon',
|
||||||
|
calculator: 'calc-icon',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing tools gracefully', () => {
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
modelConfig: {
|
||||||
|
...createDefaultModelConfig(),
|
||||||
|
agentConfig: {
|
||||||
|
tools: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appId: 'test-app-id',
|
||||||
|
inputs: {},
|
||||||
|
collectionList: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedChatProps!.allToolIcons).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useFormattingChangedSubscription', () => {
|
||||||
|
it('should call useFormattingChangedSubscription with chatList', () => {
|
||||||
|
const chatList = [{ id: 'msg-1' }, { id: 'msg-2' }]
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList,
|
||||||
|
isResponding: false,
|
||||||
|
handleSend: vi.fn(),
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(mockUseFormattingChangedSubscription).toHaveBeenCalledWith(chatList)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useChat callbacks', () => {
|
||||||
|
it('should pass stopChatMessageResponding callback to useChat', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
// Get the stopResponding callback passed to useChat (4th argument)
|
||||||
|
const useChatCall = mockUseChat.mock.calls[0]
|
||||||
|
const stopRespondingCallback = useChatCall[3]
|
||||||
|
|
||||||
|
// Invoke it with a taskId
|
||||||
|
stopRespondingCallback('test-task-id')
|
||||||
|
|
||||||
|
expect(mockStopChatMessageResponding).toHaveBeenCalledWith('test-app-id', 'test-task-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass onGetConversationMessages callback to handleSend', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList: [{ id: 'msg-1' }],
|
||||||
|
isResponding: false,
|
||||||
|
handleSend,
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleSend).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get the callbacks object (3rd argument to handleSend)
|
||||||
|
const callbacks = handleSend.mock.calls[0][2]
|
||||||
|
|
||||||
|
// Invoke onGetConversationMessages
|
||||||
|
const mockGetAbortController = vi.fn()
|
||||||
|
callbacks.onGetConversationMessages('conv-123', mockGetAbortController)
|
||||||
|
|
||||||
|
expect(mockFetchConversationMessages).toHaveBeenCalledWith('test-app-id', 'conv-123', mockGetAbortController)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass onGetSuggestedQuestions callback to handleSend', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseChat.mockReturnValue({
|
||||||
|
chatList: [{ id: 'msg-1' }],
|
||||||
|
isResponding: false,
|
||||||
|
handleSend,
|
||||||
|
suggestedQuestions: [],
|
||||||
|
handleRestart: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
|
||||||
|
|
||||||
|
render(<ChatItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleSend).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get the callbacks object (3rd argument to handleSend)
|
||||||
|
const callbacks = handleSend.mock.calls[0][2]
|
||||||
|
|
||||||
|
// Invoke onGetSuggestedQuestions
|
||||||
|
const mockGetAbortController = vi.fn()
|
||||||
|
callbacks.onGetSuggestedQuestions('response-item-123', mockGetAbortController)
|
||||||
|
|
||||||
|
expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith('test-app-id', 'response-item-123', mockGetAbortController)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
import type { ModelAndParameter } from '../types'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||||
|
import { AppModeEnum } from '@/types/app'
|
||||||
|
import DebugItem from './debug-item'
|
||||||
|
|
||||||
|
const mockUseTranslation = vi.fn()
|
||||||
|
const mockUseDebugConfigurationContext = vi.fn()
|
||||||
|
const mockUseDebugWithMultipleModelContext = vi.fn()
|
||||||
|
const mockUseProviderContext = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => mockUseTranslation(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/debug-configuration', () => ({
|
||||||
|
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./context', () => ({
|
||||||
|
useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/provider-context', () => ({
|
||||||
|
useProviderContext: () => mockUseProviderContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./chat-item', () => ({
|
||||||
|
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
|
||||||
|
<div data-testid="chat-item" data-model-id={modelAndParameter.id}>ChatItem</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./text-generation-item', () => ({
|
||||||
|
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
|
||||||
|
<div data-testid="text-generation-item" data-model-id={modelAndParameter.id}>TextGenerationItem</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./model-parameter-trigger', () => ({
|
||||||
|
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
|
||||||
|
<div data-testid="model-parameter-trigger" data-model-id={modelAndParameter.id}>ModelParameterTrigger</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
type DropdownItem = { value: string, text: string }
|
||||||
|
type DropdownProps = {
|
||||||
|
items?: DropdownItem[]
|
||||||
|
secondItems?: DropdownItem[]
|
||||||
|
onSelect: (item: DropdownItem) => void
|
||||||
|
}
|
||||||
|
let capturedDropdownProps: DropdownProps | null = null
|
||||||
|
vi.mock('@/app/components/base/dropdown', () => ({
|
||||||
|
default: (props: DropdownProps) => {
|
||||||
|
capturedDropdownProps = props
|
||||||
|
return (
|
||||||
|
<div data-testid="dropdown">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="dropdown-trigger"
|
||||||
|
onClick={() => {
|
||||||
|
// Mock dropdown menu showing items
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dropdown
|
||||||
|
</button>
|
||||||
|
{props.items?.map((item: DropdownItem) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
data-testid={`dropdown-item-${item.value}`}
|
||||||
|
onClick={() => props.onSelect(item)}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{props.secondItems?.map((item: DropdownItem) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
data-testid={`dropdown-second-item-${item.value}`}
|
||||||
|
onClick={() => props.onSelect(item)}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
let modelIdCounter = 0
|
||||||
|
|
||||||
|
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
|
||||||
|
id: `model-${++modelIdCounter}`,
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
provider: 'openai',
|
||||||
|
parameters: {},
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createTextGenerationModelList = (models: Array<{ provider: string, model: string, status?: ModelStatusEnum }> = []) => {
|
||||||
|
const providerMap = new Map<string, { model: string, status: ModelStatusEnum, model_properties: { mode: string }, features: string[] }[]>()
|
||||||
|
|
||||||
|
for (const m of models) {
|
||||||
|
if (!providerMap.has(m.provider)) {
|
||||||
|
providerMap.set(m.provider, [])
|
||||||
|
}
|
||||||
|
providerMap.get(m.provider)!.push({
|
||||||
|
model: m.model,
|
||||||
|
status: m.status ?? ModelStatusEnum.active,
|
||||||
|
model_properties: { mode: 'chat' },
|
||||||
|
features: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(providerMap.entries()).map(([provider, modelsList]) => ({
|
||||||
|
provider,
|
||||||
|
models: modelsList,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DebugItem', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
modelIdCounter = 0
|
||||||
|
capturedDropdownProps = null
|
||||||
|
|
||||||
|
mockUseTranslation.mockReturnValue({
|
||||||
|
t: (key: string) => key,
|
||||||
|
})
|
||||||
|
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
mode: AppModeEnum.CHAT,
|
||||||
|
})
|
||||||
|
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
textGenerationModelList: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render with index number', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ id: 'model-a' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('#1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render correct index for second model', () => {
|
||||||
|
const model1 = createModelAndParameter({ id: 'model-a' })
|
||||||
|
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [model1, model2],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={model2} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('#2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render ModelParameterTrigger', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('model-parameter-trigger')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render Dropdown', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('dropdown')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply custom className', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container } = render(<DebugItem modelAndParameter={modelAndParameter} className="custom-class" />)
|
||||||
|
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply custom style', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container } = render(<DebugItem modelAndParameter={modelAndParameter} style={{ width: '300px' }} />)
|
||||||
|
|
||||||
|
expect(container.firstChild).toHaveStyle({ width: '300px' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ChatItem rendering', () => {
|
||||||
|
it('should render ChatItem in CHAT mode with active model', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
mode: AppModeEnum.CHAT,
|
||||||
|
})
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
textGenerationModelList: createTextGenerationModelList([
|
||||||
|
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('chat-item')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render ChatItem in AGENT_CHAT mode with active model', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
mode: AppModeEnum.AGENT_CHAT,
|
||||||
|
})
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
textGenerationModelList: createTextGenerationModelList([
|
||||||
|
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('chat-item')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render ChatItem when model is not active', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
mode: AppModeEnum.CHAT,
|
||||||
|
})
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
textGenerationModelList: createTextGenerationModelList([
|
||||||
|
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.disabled },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('chat-item')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render ChatItem when provider not found', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'unknown', model: 'model' })
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
mode: AppModeEnum.CHAT,
|
||||||
|
})
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
textGenerationModelList: createTextGenerationModelList([
|
||||||
|
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('chat-item')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TextGenerationItem rendering', () => {
|
||||||
|
it('should render TextGenerationItem in COMPLETION mode with active model', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
mode: AppModeEnum.COMPLETION,
|
||||||
|
})
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
textGenerationModelList: createTextGenerationModelList([
|
||||||
|
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('text-generation-item')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render TextGenerationItem when model is not active', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
mode: AppModeEnum.COMPLETION,
|
||||||
|
})
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
textGenerationModelList: createTextGenerationModelList([
|
||||||
|
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.disabled },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('text-generation-item')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render TextGenerationItem in CHAT mode', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
mode: AppModeEnum.CHAT,
|
||||||
|
})
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
textGenerationModelList: createTextGenerationModelList([
|
||||||
|
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('text-generation-item')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dropdown menu items', () => {
|
||||||
|
it('should show duplicate option when less than 4 models', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter, createModelAndParameter()],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedDropdownProps!.items).toContainEqual(
|
||||||
|
expect.objectContaining({ value: 'duplicate' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide duplicate option when 4 or more models', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [
|
||||||
|
modelAndParameter,
|
||||||
|
createModelAndParameter(),
|
||||||
|
createModelAndParameter(),
|
||||||
|
createModelAndParameter(),
|
||||||
|
],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedDropdownProps!.items).not.toContainEqual(
|
||||||
|
expect.objectContaining({ value: 'duplicate' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show debug-as-single-model when provider and model are set', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedDropdownProps!.items).toContainEqual(
|
||||||
|
expect.objectContaining({ value: 'debug-as-single-model' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide debug-as-single-model when provider is missing', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: '', model: 'gpt-4' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedDropdownProps!.items).not.toContainEqual(
|
||||||
|
expect.objectContaining({ value: 'debug-as-single-model' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide debug-as-single-model when model is missing', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: '' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedDropdownProps!.items).not.toContainEqual(
|
||||||
|
expect.objectContaining({ value: 'debug-as-single-model' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show remove option when more than 2 models', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter, createModelAndParameter(), createModelAndParameter()],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedDropdownProps!.secondItems).toContainEqual(
|
||||||
|
expect.objectContaining({ value: 'remove' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide remove option when 2 or fewer models', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter, createModelAndParameter()],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedDropdownProps!.secondItems).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dropdown actions', () => {
|
||||||
|
it('should duplicate model when clicking duplicate', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
|
||||||
|
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||||
|
const onMultipleModelConfigsChange = vi.fn()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter, model2],
|
||||||
|
onMultipleModelConfigsChange,
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('dropdown-item-duplicate'))
|
||||||
|
|
||||||
|
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||||
|
true,
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ id: 'model-a' }),
|
||||||
|
expect.objectContaining({ provider: 'openai', model: 'gpt-4' }),
|
||||||
|
expect.objectContaining({ id: 'model-b' }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(onMultipleModelConfigsChange.mock.calls[0][1]).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not duplicate when already at 4 models', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
|
||||||
|
const onMultipleModelConfigsChange = vi.fn()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [
|
||||||
|
modelAndParameter,
|
||||||
|
createModelAndParameter(),
|
||||||
|
createModelAndParameter(),
|
||||||
|
createModelAndParameter(),
|
||||||
|
],
|
||||||
|
onMultipleModelConfigsChange,
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
// Duplicate option should not be rendered when at 4 models
|
||||||
|
expect(screen.queryByTestId('dropdown-item-duplicate')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should early return when trying to duplicate with 4 models via handleSelect', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
|
||||||
|
const onMultipleModelConfigsChange = vi.fn()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [
|
||||||
|
modelAndParameter,
|
||||||
|
createModelAndParameter(),
|
||||||
|
createModelAndParameter(),
|
||||||
|
createModelAndParameter(),
|
||||||
|
],
|
||||||
|
onMultipleModelConfigsChange,
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
// Directly call handleSelect with duplicate action to cover line 42
|
||||||
|
capturedDropdownProps!.onSelect({ value: 'duplicate', text: 'Duplicate' })
|
||||||
|
|
||||||
|
// Should not call onMultipleModelConfigsChange due to early return
|
||||||
|
expect(onMultipleModelConfigsChange).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onDebugWithMultipleModelChange when clicking debug-as-single-model', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
const onDebugWithMultipleModelChange = vi.fn()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('dropdown-item-debug-as-single-model'))
|
||||||
|
|
||||||
|
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove model when clicking remove', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
|
||||||
|
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||||
|
const model3 = createModelAndParameter({ id: 'model-c' })
|
||||||
|
const onMultipleModelConfigsChange = vi.fn()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter, model2, model3],
|
||||||
|
onMultipleModelConfigsChange,
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<DebugItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('dropdown-second-item-remove'))
|
||||||
|
|
||||||
|
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
expect.objectContaining({ id: 'model-b' }),
|
||||||
|
expect.objectContaining({ id: 'model-c' }),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { DebugWithMultipleModelContextType } from './context'
|
import type { DebugWithMultipleModelContextType } from './context'
|
||||||
import type { InputForm } from '@/app/components/base/chat/chat/type'
|
import type { InputForm } from '@/app/components/base/chat/chat/type'
|
||||||
|
import type { EnableType } from '@/app/components/base/chat/types'
|
||||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
@@ -40,13 +41,7 @@ const DebugWithMultipleModel = () => {
|
|||||||
if (checkCanSend && !checkCanSend())
|
if (checkCanSend && !checkCanSend())
|
||||||
return
|
return
|
||||||
|
|
||||||
eventEmitter?.emit({
|
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL, payload: { message, files } } as any) // eslint-disable-line ts/no-explicit-any
|
||||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
|
||||||
payload: {
|
|
||||||
message,
|
|
||||||
files,
|
|
||||||
},
|
|
||||||
} as any)
|
|
||||||
}, [eventEmitter, checkCanSend])
|
}, [eventEmitter, checkCanSend])
|
||||||
|
|
||||||
const twoLine = multipleModelConfigs.length === 2
|
const twoLine = multipleModelConfigs.length === 2
|
||||||
@@ -147,7 +142,7 @@ const DebugWithMultipleModel = () => {
|
|||||||
showFileUpload={false}
|
showFileUpload={false}
|
||||||
onFeatureBarClick={setShowAppConfigureFeaturesModal}
|
onFeatureBarClick={setShowAppConfigureFeaturesModal}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
speechToTextConfig={speech2text as any}
|
speechToTextConfig={speech2text as EnableType}
|
||||||
visionConfig={file}
|
visionConfig={file}
|
||||||
inputs={inputs}
|
inputs={inputs}
|
||||||
inputsForm={inputsForm}
|
inputsForm={inputsForm}
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import type * as React from 'react'
|
||||||
|
import type { ModelAndParameter } from '../types'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||||
|
import ModelParameterTrigger from './model-parameter-trigger'
|
||||||
|
|
||||||
|
// Mock MODEL_STATUS_TEXT that is imported in the component
|
||||||
|
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', async (importOriginal) => {
|
||||||
|
const original = await importOriginal() as object
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
MODEL_STATUS_TEXT: {
|
||||||
|
'disabled': { en_US: 'Disabled', zh_Hans: '已禁用' },
|
||||||
|
'quota-exceeded': { en_US: 'Quota Exceeded', zh_Hans: '配额已用完' },
|
||||||
|
'no-configure': { en_US: 'No Configure', zh_Hans: '未配置凭据' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockUseTranslation = vi.fn()
|
||||||
|
const mockUseDebugConfigurationContext = vi.fn()
|
||||||
|
const mockUseDebugWithMultipleModelContext = vi.fn()
|
||||||
|
const mockUseLanguage = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => mockUseTranslation(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/debug-configuration', () => ({
|
||||||
|
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./context', () => ({
|
||||||
|
useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||||
|
useLanguage: () => mockUseLanguage(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
type RenderTriggerParams = {
|
||||||
|
open: boolean
|
||||||
|
currentProvider: { provider: string, icon: string } | null
|
||||||
|
currentModel: { model: string, status: ModelStatusEnum } | null
|
||||||
|
}
|
||||||
|
type ModalProps = {
|
||||||
|
provider: string
|
||||||
|
modelId: string
|
||||||
|
isAdvancedMode: boolean
|
||||||
|
completionParams: Record<string, unknown>
|
||||||
|
debugWithMultipleModel: boolean
|
||||||
|
setModel: (model: { modelId: string, provider: string }) => void
|
||||||
|
onCompletionParamsChange: (params: Record<string, unknown>) => void
|
||||||
|
onDebugWithMultipleModelChange: () => void
|
||||||
|
renderTrigger: (params: RenderTriggerParams) => React.ReactElement
|
||||||
|
}
|
||||||
|
let capturedModalProps: ModalProps | null = null
|
||||||
|
let mockRenderTriggerFn: ((params: RenderTriggerParams) => React.ReactElement) | null = null
|
||||||
|
|
||||||
|
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
|
||||||
|
default: (props: ModalProps) => {
|
||||||
|
capturedModalProps = props
|
||||||
|
mockRenderTriggerFn = props.renderTrigger
|
||||||
|
|
||||||
|
// Render the trigger with some mock data
|
||||||
|
const triggerElement = props.renderTrigger({
|
||||||
|
open: false,
|
||||||
|
currentProvider: props.provider
|
||||||
|
? { provider: props.provider, icon: 'provider-icon' }
|
||||||
|
: null,
|
||||||
|
currentModel: props.modelId
|
||||||
|
? { model: props.modelId, status: ModelStatusEnum.active }
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="model-parameter-modal">
|
||||||
|
{triggerElement}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="select-model-btn"
|
||||||
|
onClick={() => props.setModel({ modelId: 'new-model', provider: 'new-provider' })}
|
||||||
|
>
|
||||||
|
Select Model
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="change-params-btn"
|
||||||
|
onClick={() => props.onCompletionParamsChange({ temperature: 0.9 })}
|
||||||
|
>
|
||||||
|
Change Params
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="debug-single-btn"
|
||||||
|
onClick={() => props.onDebugWithMultipleModelChange()}
|
||||||
|
>
|
||||||
|
Debug Single
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
|
||||||
|
default: ({ provider, modelName }: { provider: { provider: string } | null, modelName?: string }) => (
|
||||||
|
<div data-testid="model-icon" data-provider={provider?.provider} data-model={modelName}>
|
||||||
|
ModelIcon
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/header/account-setting/model-provider-page/model-name', () => ({
|
||||||
|
default: ({ modelItem }: { modelItem: { model: string } | null }) => (
|
||||||
|
<div data-testid="model-name" data-model={modelItem?.model}>
|
||||||
|
{modelItem?.model}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/icons/src/vender/line/shapes', () => ({
|
||||||
|
CubeOutline: () => <div data-testid="cube-icon">CubeOutline</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/icons/src/vender/line/alertsAndFeedback', () => ({
|
||||||
|
AlertTriangle: () => <div data-testid="alert-icon">AlertTriangle</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/tooltip', () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
let modelIdCounter = 0
|
||||||
|
|
||||||
|
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
|
||||||
|
id: `model-${++modelIdCounter}`,
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
provider: 'openai',
|
||||||
|
parameters: { temperature: 0.7 },
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ModelParameterTrigger', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
modelIdCounter = 0
|
||||||
|
capturedModalProps = null
|
||||||
|
mockRenderTriggerFn = null
|
||||||
|
|
||||||
|
mockUseTranslation.mockReturnValue({
|
||||||
|
t: (key: string) => key,
|
||||||
|
})
|
||||||
|
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
isAdvancedMode: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
mockUseLanguage.mockReturnValue('en_US')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render ModelParameterModal with correct props', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('model-parameter-modal')).toBeInTheDocument()
|
||||||
|
expect(capturedModalProps!.isAdvancedMode).toBe(false)
|
||||||
|
expect(capturedModalProps!.provider).toBe('openai')
|
||||||
|
expect(capturedModalProps!.modelId).toBe('gpt-4')
|
||||||
|
expect(capturedModalProps!.completionParams).toEqual({ temperature: 0.7 })
|
||||||
|
expect(capturedModalProps!.debugWithMultipleModel).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass isAdvancedMode from context', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
isAdvancedMode: true,
|
||||||
|
})
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedModalProps!.isAdvancedMode).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('trigger rendering', () => {
|
||||||
|
it('should render model icon when provider exists', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render cube icon when no provider', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: '', model: '' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('cube-icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render model name when model exists', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('model-name')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render select model text when no model', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: '', model: '' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('modelProvider.selectModel')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleSelectModel', () => {
|
||||||
|
it('should update model and provider in configs', () => {
|
||||||
|
const model1 = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-3.5' })
|
||||||
|
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||||
|
const onMultipleModelConfigsChange = vi.fn()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [model1, model2],
|
||||||
|
onMultipleModelConfigsChange,
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={model1} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('select-model-btn'))
|
||||||
|
|
||||||
|
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
expect.objectContaining({ id: 'model-a', model: 'new-model', provider: 'new-provider' }),
|
||||||
|
expect.objectContaining({ id: 'model-b' }),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update correct model when multiple configs exist', () => {
|
||||||
|
const model1 = createModelAndParameter({ id: 'model-a' })
|
||||||
|
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||||
|
const model3 = createModelAndParameter({ id: 'model-c' })
|
||||||
|
const onMultipleModelConfigsChange = vi.fn()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [model1, model2, model3],
|
||||||
|
onMultipleModelConfigsChange,
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={model2} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('select-model-btn'))
|
||||||
|
|
||||||
|
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
expect.objectContaining({ id: 'model-a' }),
|
||||||
|
expect.objectContaining({ id: 'model-b', model: 'new-model', provider: 'new-provider' }),
|
||||||
|
expect.objectContaining({ id: 'model-c' }),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleParamsChange', () => {
|
||||||
|
it('should update parameters in configs', () => {
|
||||||
|
const model1 = createModelAndParameter({ id: 'model-a', parameters: { temperature: 0.5 } })
|
||||||
|
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||||
|
const onMultipleModelConfigsChange = vi.fn()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [model1, model2],
|
||||||
|
onMultipleModelConfigsChange,
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={model1} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('change-params-btn'))
|
||||||
|
|
||||||
|
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
expect.objectContaining({ id: 'model-a', parameters: { temperature: 0.9 } }),
|
||||||
|
expect.objectContaining({ id: 'model-b' }),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('onDebugWithMultipleModelChange', () => {
|
||||||
|
it('should call onDebugWithMultipleModelChange with current modelAndParameter', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
|
||||||
|
const onDebugWithMultipleModelChange = vi.fn()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('debug-single-btn'))
|
||||||
|
|
||||||
|
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('index finding', () => {
|
||||||
|
it('should find correct index for model in middle of array', () => {
|
||||||
|
const model1 = createModelAndParameter({ id: 'model-a' })
|
||||||
|
const model2 = createModelAndParameter({ id: 'model-b' })
|
||||||
|
const model3 = createModelAndParameter({ id: 'model-c' })
|
||||||
|
const onMultipleModelConfigsChange = vi.fn()
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [model1, model2, model3],
|
||||||
|
onMultipleModelConfigsChange,
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={model2} />)
|
||||||
|
|
||||||
|
// Verify that the correct index is used by checking the result of handleSelectModel
|
||||||
|
fireEvent.click(screen.getByTestId('select-model-btn'))
|
||||||
|
|
||||||
|
// The second model (index 1) should be updated
|
||||||
|
const updatedConfigs = onMultipleModelConfigsChange.mock.calls[0][1]
|
||||||
|
expect(updatedConfigs[0].id).toBe('model-a')
|
||||||
|
expect(updatedConfigs[1].model).toBe('new-model') // This one should be updated
|
||||||
|
expect(updatedConfigs[2].id).toBe('model-c')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('renderTrigger styling and states', () => {
|
||||||
|
it('should render trigger with open state styling', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
// Call renderTrigger with open=true to test the open styling branch
|
||||||
|
const triggerWithOpen = mockRenderTriggerFn!({
|
||||||
|
open: true,
|
||||||
|
currentProvider: { provider: 'openai', icon: 'provider-icon' },
|
||||||
|
currentModel: { model: 'gpt-4', status: ModelStatusEnum.active },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(triggerWithOpen).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render warning tooltip when model status is not active', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
// Call renderTrigger with inactive model status to test the warning branch
|
||||||
|
const triggerWithInactiveModel = mockRenderTriggerFn!({
|
||||||
|
open: false,
|
||||||
|
currentProvider: { provider: 'openai', icon: 'provider-icon' },
|
||||||
|
currentModel: { model: 'gpt-4', status: ModelStatusEnum.disabled },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(triggerWithInactiveModel).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render warning background and tooltip for inactive model', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
|
||||||
|
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||||
|
multipleModelConfigs: [modelAndParameter],
|
||||||
|
onMultipleModelConfigsChange: vi.fn(),
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
// Test with quota_exceeded status (another inactive status)
|
||||||
|
const triggerWithQuotaExceeded = mockRenderTriggerFn!({
|
||||||
|
open: false,
|
||||||
|
currentProvider: { provider: 'openai', icon: 'provider-icon' },
|
||||||
|
currentModel: { model: 'gpt-4', status: ModelStatusEnum.quotaExceeded },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(triggerWithQuotaExceeded).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
import type { ModelAndParameter } from '../types'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { TransferMethod } from '@/app/components/base/chat/types'
|
||||||
|
import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||||
|
import { ModelModeType } from '@/types/app'
|
||||||
|
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
|
||||||
|
import TextGenerationItem from './text-generation-item'
|
||||||
|
|
||||||
|
const mockUseDebugConfigurationContext = vi.fn()
|
||||||
|
const mockUseProviderContext = vi.fn()
|
||||||
|
const mockUseFeatures = vi.fn()
|
||||||
|
const mockUseTextGeneration = vi.fn()
|
||||||
|
const mockUseEventEmitterContextContext = vi.fn()
|
||||||
|
const mockPromptVariablesToUserInputsForm = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/context/debug-configuration', () => ({
|
||||||
|
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/provider-context', () => ({
|
||||||
|
useProviderContext: () => mockUseProviderContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||||
|
useFeatures: (selector: (state: unknown) => unknown) => mockUseFeatures(selector),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/text-generation/hooks', () => ({
|
||||||
|
useTextGeneration: () => mockUseTextGeneration(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/event-emitter', () => ({
|
||||||
|
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/model-config', () => ({
|
||||||
|
promptVariablesToUserInputsForm: (vars: unknown) => mockPromptVariablesToUserInputsForm(vars),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let capturedTextGenerationProps: Record<string, unknown> | null = null
|
||||||
|
vi.mock('@/app/components/app/text-generate/item', () => ({
|
||||||
|
default: (props: Record<string, unknown>) => {
|
||||||
|
capturedTextGenerationProps = props
|
||||||
|
return <div data-testid="text-generation-component">TextGeneration</div>
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
let modelIdCounter = 0
|
||||||
|
|
||||||
|
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
|
||||||
|
id: `model-${++modelIdCounter}`,
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
provider: 'openai',
|
||||||
|
parameters: { temperature: 0.7 },
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createDefaultModelConfig = () => ({
|
||||||
|
provider: 'openai',
|
||||||
|
model_id: 'gpt-4',
|
||||||
|
mode: ModelModeType.completion,
|
||||||
|
configs: {
|
||||||
|
prompt_template: 'Hello {{name}}',
|
||||||
|
prompt_variables: [
|
||||||
|
{ key: 'name', name: 'Name', type: 'string' as const, is_context_var: false },
|
||||||
|
{ key: 'context', name: 'Context', type: 'string' as const, is_context_var: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||||
|
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||||
|
opening_statement: '',
|
||||||
|
more_like_this: null,
|
||||||
|
suggested_questions: [],
|
||||||
|
suggested_questions_after_answer: null,
|
||||||
|
speech_to_text: null,
|
||||||
|
text_to_speech: null,
|
||||||
|
file_upload: null,
|
||||||
|
retriever_resource: null,
|
||||||
|
sensitive_word_avoidance: null,
|
||||||
|
annotation_reply: null,
|
||||||
|
external_data_tools: [],
|
||||||
|
dataSets: [],
|
||||||
|
agentConfig: DEFAULT_AGENT_SETTING,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createDefaultFeatures = () => ({
|
||||||
|
moreLikeThis: { enabled: true },
|
||||||
|
moderation: { enabled: false },
|
||||||
|
text2speech: { enabled: true },
|
||||||
|
file: { enabled: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const createTextGenerationModelList = (models: Array<{
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
mode?: string
|
||||||
|
}> = []) => {
|
||||||
|
const providerMap = new Map<string, { model: string, model_properties: { mode: string } }[]>()
|
||||||
|
|
||||||
|
for (const m of models) {
|
||||||
|
if (!providerMap.has(m.provider)) {
|
||||||
|
providerMap.set(m.provider, [])
|
||||||
|
}
|
||||||
|
providerMap.get(m.provider)!.push({
|
||||||
|
model: m.model,
|
||||||
|
model_properties: { mode: m.mode ?? 'completion' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(providerMap.entries()).map(([provider, modelsList]) => ({
|
||||||
|
provider,
|
||||||
|
models: modelsList,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TextGenerationItem', () => {
|
||||||
|
let subscriptionCallback: ((v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) | null = null
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
modelIdCounter = 0
|
||||||
|
capturedTextGenerationProps = null
|
||||||
|
subscriptionCallback = null
|
||||||
|
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
isAdvancedMode: false,
|
||||||
|
modelConfig: createDefaultModelConfig(),
|
||||||
|
appId: 'test-app-id',
|
||||||
|
inputs: { name: 'World' },
|
||||||
|
promptMode: 'simple',
|
||||||
|
speechToTextConfig: { enabled: true },
|
||||||
|
introduction: 'Welcome',
|
||||||
|
suggestedQuestionsAfterAnswerConfig: { enabled: false },
|
||||||
|
citationConfig: { enabled: false },
|
||||||
|
externalDataToolsConfig: [],
|
||||||
|
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||||
|
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||||
|
dataSets: [{ id: 'ds-1', name: 'Dataset 1' }],
|
||||||
|
datasetConfigs: { retrieval_model: 'single' },
|
||||||
|
})
|
||||||
|
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
textGenerationModelList: createTextGenerationModelList([
|
||||||
|
{ provider: 'openai', model: 'gpt-3.5-turbo', mode: 'completion' },
|
||||||
|
{ provider: 'openai', model: 'gpt-4', mode: 'completion' },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const features = createDefaultFeatures()
|
||||||
|
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
|
||||||
|
|
||||||
|
mockUseTextGeneration.mockReturnValue({
|
||||||
|
completion: 'Generated text',
|
||||||
|
handleSend: vi.fn(),
|
||||||
|
isResponding: false,
|
||||||
|
messageId: 'msg-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
mockUseEventEmitterContextContext.mockReturnValue({
|
||||||
|
eventEmitter: {
|
||||||
|
// eslint-disable-next-line react/no-unnecessary-use-prefix -- mocking real API
|
||||||
|
useSubscription: (callback: (v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) => {
|
||||||
|
subscriptionCallback = callback
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
mockPromptVariablesToUserInputsForm.mockReturnValue([
|
||||||
|
{ key: 'name', label: 'Name', variable: 'name' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render TextGeneration component', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('text-generation-component')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass correct props to TextGeneration component', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedTextGenerationProps!.content).toBe('Generated text')
|
||||||
|
expect(capturedTextGenerationProps!.isLoading).toBe(false)
|
||||||
|
expect(capturedTextGenerationProps!.isResponding).toBe(false)
|
||||||
|
expect(capturedTextGenerationProps!.messageId).toBe('msg-1')
|
||||||
|
expect(capturedTextGenerationProps!.isError).toBe(false)
|
||||||
|
expect(capturedTextGenerationProps!.inSidePanel).toBe(true)
|
||||||
|
expect(capturedTextGenerationProps!.siteInfo).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show loading state when no completion and is responding', () => {
|
||||||
|
mockUseTextGeneration.mockReturnValue({
|
||||||
|
completion: '',
|
||||||
|
handleSend: vi.fn(),
|
||||||
|
isResponding: true,
|
||||||
|
messageId: 'msg-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedTextGenerationProps!.isLoading).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show loading state when has completion', () => {
|
||||||
|
mockUseTextGeneration.mockReturnValue({
|
||||||
|
completion: 'Some text',
|
||||||
|
handleSend: vi.fn(),
|
||||||
|
isResponding: true,
|
||||||
|
messageId: 'msg-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
expect(capturedTextGenerationProps!.isLoading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('config building', () => {
|
||||||
|
it('should build config with correct pre_prompt in simple mode', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
// The config is built internally, we verify via the handleSend call
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSend = mockUseTextGeneration().handleSend
|
||||||
|
expect(handleSend).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
model_config: expect.objectContaining({
|
||||||
|
pre_prompt: 'Hello {{name}}',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use empty pre_prompt in advanced mode', () => {
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
...mockUseDebugConfigurationContext(),
|
||||||
|
isAdvancedMode: true,
|
||||||
|
modelConfig: createDefaultModelConfig(),
|
||||||
|
appId: 'test-app-id',
|
||||||
|
inputs: {},
|
||||||
|
promptMode: 'advanced',
|
||||||
|
speechToTextConfig: { enabled: true },
|
||||||
|
introduction: '',
|
||||||
|
suggestedQuestionsAfterAnswerConfig: { enabled: false },
|
||||||
|
citationConfig: { enabled: false },
|
||||||
|
externalDataToolsConfig: [],
|
||||||
|
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||||
|
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||||
|
dataSets: [],
|
||||||
|
datasetConfigs: { retrieval_model: 'single' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSend = mockUseTextGeneration().handleSend
|
||||||
|
expect(handleSend).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
model_config: expect.objectContaining({
|
||||||
|
pre_prompt: '',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should find context variable from prompt_variables', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSend = mockUseTextGeneration().handleSend
|
||||||
|
expect(handleSend).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
model_config: expect.objectContaining({
|
||||||
|
dataset_query_variable: 'context',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use empty string for dataset_query_variable when no context var exists', () => {
|
||||||
|
const modelConfigWithoutContextVar = {
|
||||||
|
...createDefaultModelConfig(),
|
||||||
|
configs: {
|
||||||
|
prompt_template: 'Hello {{name}}',
|
||||||
|
prompt_variables: [
|
||||||
|
{ key: 'name', name: 'Name', type: 'string' as const, is_context_var: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
isAdvancedMode: false,
|
||||||
|
modelConfig: modelConfigWithoutContextVar,
|
||||||
|
appId: 'test-app-id',
|
||||||
|
inputs: { name: 'World' },
|
||||||
|
promptMode: 'simple',
|
||||||
|
speechToTextConfig: { enabled: true },
|
||||||
|
introduction: 'Welcome',
|
||||||
|
suggestedQuestionsAfterAnswerConfig: { enabled: false },
|
||||||
|
citationConfig: { enabled: false },
|
||||||
|
externalDataToolsConfig: [],
|
||||||
|
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||||
|
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||||
|
dataSets: [],
|
||||||
|
datasetConfigs: { retrieval_model: 'single' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseTextGeneration.mockReturnValue({
|
||||||
|
completion: 'text',
|
||||||
|
handleSend,
|
||||||
|
isResponding: false,
|
||||||
|
messageId: 'msg-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(handleSend).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
model_config: expect.objectContaining({
|
||||||
|
dataset_query_variable: '',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('datasets transformation', () => {
|
||||||
|
it('should transform dataSets to postDatasets format', () => {
|
||||||
|
mockUseDebugConfigurationContext.mockReturnValue({
|
||||||
|
...mockUseDebugConfigurationContext(),
|
||||||
|
isAdvancedMode: false,
|
||||||
|
modelConfig: createDefaultModelConfig(),
|
||||||
|
appId: 'test-app-id',
|
||||||
|
inputs: {},
|
||||||
|
promptMode: 'simple',
|
||||||
|
speechToTextConfig: { enabled: true },
|
||||||
|
introduction: '',
|
||||||
|
suggestedQuestionsAfterAnswerConfig: { enabled: false },
|
||||||
|
citationConfig: { enabled: false },
|
||||||
|
externalDataToolsConfig: [],
|
||||||
|
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||||
|
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||||
|
dataSets: [
|
||||||
|
{ id: 'ds-1', name: 'Dataset 1' },
|
||||||
|
{ id: 'ds-2', name: 'Dataset 2' },
|
||||||
|
],
|
||||||
|
datasetConfigs: { retrieval_model: 'single' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSend = mockUseTextGeneration().handleSend
|
||||||
|
expect(handleSend).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
model_config: expect.objectContaining({
|
||||||
|
dataset_configs: expect.objectContaining({
|
||||||
|
datasets: {
|
||||||
|
datasets: [
|
||||||
|
{ dataset: { enabled: true, id: 'ds-1' } },
|
||||||
|
{ dataset: { enabled: true, id: 'ds-2' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('event subscription', () => {
|
||||||
|
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL event', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseTextGeneration.mockReturnValue({
|
||||||
|
completion: 'text',
|
||||||
|
handleSend,
|
||||||
|
isResponding: false,
|
||||||
|
messageId: 'msg-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test message', files: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleSend).toHaveBeenCalledWith(
|
||||||
|
'apps/test-app-id/completion-messages',
|
||||||
|
expect.any(Object),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore non-matching events', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseTextGeneration.mockReturnValue({
|
||||||
|
completion: 'text',
|
||||||
|
handleSend,
|
||||||
|
isResponding: false,
|
||||||
|
messageId: 'msg-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: 'SOME_OTHER_EVENT',
|
||||||
|
payload: { message: 'test' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(handleSend).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('doSend', () => {
|
||||||
|
it('should build config data with model info', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseTextGeneration.mockReturnValue({
|
||||||
|
completion: 'text',
|
||||||
|
handleSend,
|
||||||
|
isResponding: false,
|
||||||
|
messageId: 'msg-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter({
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
parameters: { temperature: 0.8 },
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleSend).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
model_config: expect.objectContaining({
|
||||||
|
model: {
|
||||||
|
provider: 'openai',
|
||||||
|
name: 'gpt-3.5-turbo',
|
||||||
|
mode: 'completion',
|
||||||
|
completion_params: { temperature: 0.8 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should process local files by clearing url', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseTextGeneration.mockReturnValue({
|
||||||
|
completion: 'text',
|
||||||
|
handleSend,
|
||||||
|
isResponding: false,
|
||||||
|
messageId: 'msg-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
{ id: 'file-1', transfer_method: TransferMethod.local_file, url: 'http://example.com/file1' },
|
||||||
|
{ id: 'file-2', transfer_method: TransferMethod.remote_url, url: 'http://example.com/file2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const callArgs = handleSend.mock.calls[0][1]
|
||||||
|
expect(callArgs.files[0].url).toBe('')
|
||||||
|
expect(callArgs.files[1].url).toBe('http://example.com/file2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not include files when file upload is disabled', async () => {
|
||||||
|
const features = { ...createDefaultFeatures(), file: { enabled: false } }
|
||||||
|
mockUseFeatures.mockImplementation((selector: (state: { features: typeof features }) => unknown) => selector({ features }))
|
||||||
|
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseTextGeneration.mockReturnValue({
|
||||||
|
completion: 'text',
|
||||||
|
handleSend,
|
||||||
|
isResponding: false,
|
||||||
|
messageId: 'msg-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
const files = [{ id: 'file-1', transfer_method: TransferMethod.remote_url }]
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const callArgs = handleSend.mock.calls[0][1]
|
||||||
|
expect(callArgs.files).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not include files when no files provided', async () => {
|
||||||
|
const handleSend = vi.fn()
|
||||||
|
mockUseTextGeneration.mockReturnValue({
|
||||||
|
completion: 'text',
|
||||||
|
handleSend,
|
||||||
|
isResponding: false,
|
||||||
|
messageId: 'msg-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const callArgs = handleSend.mock.calls[0][1]
|
||||||
|
expect(callArgs.files).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('features integration', () => {
|
||||||
|
it('should include features in config', () => {
|
||||||
|
const modelAndParameter = createModelAndParameter()
|
||||||
|
|
||||||
|
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
|
||||||
|
|
||||||
|
subscriptionCallback?.({
|
||||||
|
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
|
payload: { message: 'test', files: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSend = mockUseTextGeneration().handleSend
|
||||||
|
expect(handleSend).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
model_config: expect.objectContaining({
|
||||||
|
more_like_this: { enabled: true },
|
||||||
|
sensitive_word_avoidance: { enabled: false },
|
||||||
|
text_to_speech: { enabled: true },
|
||||||
|
file_upload: { enabled: true },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,18 +6,26 @@ import type {
|
|||||||
ChatConfig,
|
ChatConfig,
|
||||||
ChatItem,
|
ChatItem,
|
||||||
} from '@/app/components/base/chat/types'
|
} from '@/app/components/base/chat/types'
|
||||||
|
import type { VisionFile } from '@/types/app'
|
||||||
import { cloneDeep } from 'es-toolkit/object'
|
import { cloneDeep } from 'es-toolkit/object'
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useContext } from 'use-context-selector'
|
||||||
|
import { ToastContext } from '@/app/components/base/toast'
|
||||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||||
import { useDebugConfigurationContext } from '@/context/debug-configuration'
|
import { useDebugConfigurationContext } from '@/context/debug-configuration'
|
||||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||||
import {
|
import {
|
||||||
AgentStrategy,
|
AgentStrategy,
|
||||||
|
AppModeEnum,
|
||||||
|
ModelModeType,
|
||||||
|
TransferMethod,
|
||||||
} from '@/types/app'
|
} from '@/types/app'
|
||||||
import { promptVariablesToUserInputsForm } from '@/utils/model-config'
|
import { promptVariablesToUserInputsForm } from '@/utils/model-config'
|
||||||
import { ORCHESTRATE_CHANGED } from './types'
|
import { ORCHESTRATE_CHANGED } from './types'
|
||||||
@@ -162,3 +170,111 @@ export const useFormattingChangedSubscription = (chatList: ChatItem[]) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useInputValidation = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { notify } = useContext(ToastContext)
|
||||||
|
const {
|
||||||
|
isAdvancedMode,
|
||||||
|
mode,
|
||||||
|
modelModeType,
|
||||||
|
hasSetBlockStatus,
|
||||||
|
modelConfig,
|
||||||
|
} = useDebugConfigurationContext()
|
||||||
|
|
||||||
|
const logError = useCallback((message: string) => {
|
||||||
|
notify({ type: 'error', message })
|
||||||
|
}, [notify])
|
||||||
|
|
||||||
|
const checkCanSend = useCallback((inputs: Record<string, unknown>, completionFiles: VisionFile[]) => {
|
||||||
|
if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) {
|
||||||
|
if (modelModeType === ModelModeType.completion) {
|
||||||
|
if (!hasSetBlockStatus.history) {
|
||||||
|
notify({ type: 'error', message: t('otherError.historyNoBeEmpty', { ns: 'appDebug' }) })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!hasSetBlockStatus.query) {
|
||||||
|
notify({ type: 'error', message: t('otherError.queryNoBeEmpty', { ns: 'appDebug' }) })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let hasEmptyInput = ''
|
||||||
|
const requiredVars = modelConfig.configs.prompt_variables.filter(({ key, name, required, type }) => {
|
||||||
|
if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number')
|
||||||
|
return false
|
||||||
|
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
requiredVars.forEach(({ key, name }) => {
|
||||||
|
if (hasEmptyInput)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!inputs[key])
|
||||||
|
hasEmptyInput = name
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasEmptyInput) {
|
||||||
|
logError(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
|
||||||
|
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !hasEmptyInput
|
||||||
|
}, [
|
||||||
|
hasSetBlockStatus.history,
|
||||||
|
hasSetBlockStatus.query,
|
||||||
|
isAdvancedMode,
|
||||||
|
mode,
|
||||||
|
modelConfig.configs.prompt_variables,
|
||||||
|
t,
|
||||||
|
logError,
|
||||||
|
notify,
|
||||||
|
modelModeType,
|
||||||
|
])
|
||||||
|
|
||||||
|
return { checkCanSend, logError }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFormattingChangeConfirm = () => {
|
||||||
|
const [isShowFormattingChangeConfirm, setIsShowFormattingChangeConfirm] = useState(false)
|
||||||
|
const { formattingChanged, setFormattingChanged } = useDebugConfigurationContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formattingChanged)
|
||||||
|
setIsShowFormattingChangeConfirm(true) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||||
|
}, [formattingChanged])
|
||||||
|
|
||||||
|
const handleConfirm = useCallback((onClear: () => void) => {
|
||||||
|
onClear()
|
||||||
|
setIsShowFormattingChangeConfirm(false)
|
||||||
|
setFormattingChanged(false)
|
||||||
|
}, [setFormattingChanged])
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
setIsShowFormattingChangeConfirm(false)
|
||||||
|
setFormattingChanged(false)
|
||||||
|
}, [setFormattingChanged])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isShowFormattingChangeConfirm,
|
||||||
|
handleConfirm,
|
||||||
|
handleCancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useModalWidth = (containerRef: React.RefObject<HTMLDivElement | null>) => {
|
||||||
|
const [width, setWidth] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const calculatedWidth = document.body.clientWidth - (containerRef.current.clientWidth + 16) - 8
|
||||||
|
setWidth(calculatedWidth) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||||
|
}
|
||||||
|
}, [containerRef])
|
||||||
|
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,54 +3,39 @@ import type { FC } from 'react'
|
|||||||
import type { DebugWithSingleModelRefType } from './debug-with-single-model'
|
import type { DebugWithSingleModelRefType } from './debug-with-single-model'
|
||||||
import type { ModelAndParameter } from './types'
|
import type { ModelAndParameter } from './types'
|
||||||
import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||||
import type { Inputs } from '@/models/debug'
|
import type { Inputs, PromptVariable } from '@/models/debug'
|
||||||
import type { ModelConfig as BackendModelConfig, VisionFile, VisionSettings } from '@/types/app'
|
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||||
import {
|
|
||||||
RiAddLine,
|
|
||||||
RiEqualizer2Line,
|
|
||||||
RiSparklingFill,
|
|
||||||
} from '@remixicon/react'
|
|
||||||
import { useBoolean } from 'ahooks'
|
|
||||||
import { noop } from 'es-toolkit/function'
|
|
||||||
import { cloneDeep } from 'es-toolkit/object'
|
|
||||||
import { produce, setAutoFreeze } from 'immer'
|
import { produce, setAutoFreeze } from 'immer'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useContext } from 'use-context-selector'
|
import { useContext } from 'use-context-selector'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import ChatUserInput from '@/app/components/app/configuration/debug/chat-user-input'
|
import ChatUserInput from '@/app/components/app/configuration/debug/chat-user-input'
|
||||||
import PromptValuePanel from '@/app/components/app/configuration/prompt-value-panel'
|
import PromptValuePanel from '@/app/components/app/configuration/prompt-value-panel'
|
||||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||||
import TextGeneration from '@/app/components/app/text-generate/item'
|
|
||||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
|
||||||
import AgentLogModal from '@/app/components/base/agent-log-modal'
|
import AgentLogModal from '@/app/components/base/agent-log-modal'
|
||||||
import Button from '@/app/components/base/button'
|
|
||||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||||
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
|
|
||||||
import PromptLogModal from '@/app/components/base/prompt-log-modal'
|
import PromptLogModal from '@/app/components/base/prompt-log-modal'
|
||||||
import { ToastContext } from '@/app/components/base/toast'
|
|
||||||
import TooltipPlus from '@/app/components/base/tooltip'
|
|
||||||
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||||
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG, IS_CE_EDITION } from '@/config'
|
import { IS_CE_EDITION } from '@/config'
|
||||||
import ConfigContext from '@/context/debug-configuration'
|
import ConfigContext from '@/context/debug-configuration'
|
||||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||||
import { useProviderContext } from '@/context/provider-context'
|
import { useProviderContext } from '@/context/provider-context'
|
||||||
import { sendCompletionMessage } from '@/service/debug'
|
import { AppModeEnum } from '@/types/app'
|
||||||
import { AppSourceType } from '@/service/share'
|
|
||||||
import { AppModeEnum, ModelModeType, TransferMethod } from '@/types/app'
|
|
||||||
import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config'
|
|
||||||
import GroupName from '../base/group-name'
|
|
||||||
import CannotQueryDataset from '../base/warning-mask/cannot-query-dataset'
|
import CannotQueryDataset from '../base/warning-mask/cannot-query-dataset'
|
||||||
import FormattingChanged from '../base/warning-mask/formatting-changed'
|
import FormattingChanged from '../base/warning-mask/formatting-changed'
|
||||||
import HasNotSetAPIKEY from '../base/warning-mask/has-not-set-api'
|
import HasNotSetAPIKEY from '../base/warning-mask/has-not-set-api'
|
||||||
|
import DebugHeader from './debug-header'
|
||||||
import DebugWithMultipleModel from './debug-with-multiple-model'
|
import DebugWithMultipleModel from './debug-with-multiple-model'
|
||||||
import DebugWithSingleModel from './debug-with-single-model'
|
import DebugWithSingleModel from './debug-with-single-model'
|
||||||
|
import { useFormattingChangeConfirm, useInputValidation, useModalWidth } from './hooks'
|
||||||
|
import TextCompletionResult from './text-completion-result'
|
||||||
import {
|
import {
|
||||||
APP_CHAT_WITH_MULTIPLE_MODEL,
|
APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||||
APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
|
APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
import { useTextCompletion } from './use-text-completion'
|
||||||
|
|
||||||
type IDebug = {
|
type IDebug = {
|
||||||
isAPIKeySet: boolean
|
isAPIKeySet: boolean
|
||||||
@@ -71,33 +56,17 @@ const Debug: FC<IDebug> = ({
|
|||||||
multipleModelConfigs,
|
multipleModelConfigs,
|
||||||
onMultipleModelConfigsChange,
|
onMultipleModelConfigsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
|
||||||
const {
|
const {
|
||||||
readonly,
|
readonly,
|
||||||
appId,
|
|
||||||
mode,
|
mode,
|
||||||
modelModeType,
|
|
||||||
hasSetBlockStatus,
|
|
||||||
isAdvancedMode,
|
|
||||||
promptMode,
|
|
||||||
chatPromptConfig,
|
|
||||||
completionPromptConfig,
|
|
||||||
introduction,
|
|
||||||
suggestedQuestionsAfterAnswerConfig,
|
|
||||||
speechToTextConfig,
|
|
||||||
textToSpeechConfig,
|
|
||||||
citationConfig,
|
|
||||||
formattingChanged,
|
|
||||||
setFormattingChanged,
|
|
||||||
dataSets,
|
|
||||||
modelConfig,
|
modelConfig,
|
||||||
completionParams,
|
|
||||||
hasSetContextVar,
|
|
||||||
datasetConfigs,
|
|
||||||
externalDataToolsConfig,
|
|
||||||
} = useContext(ConfigContext)
|
} = useContext(ConfigContext)
|
||||||
const { eventEmitter } = useEventEmitterContextContext()
|
const { eventEmitter } = useEventEmitterContextContext()
|
||||||
const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
|
const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
|
||||||
|
const features = useFeatures(s => s.features)
|
||||||
|
const featuresStore = useFeaturesStore()
|
||||||
|
|
||||||
|
// Disable immer auto-freeze for this component
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAutoFreeze(false)
|
setAutoFreeze(false)
|
||||||
return () => {
|
return () => {
|
||||||
@@ -105,226 +74,77 @@ const Debug: FC<IDebug> = ({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
|
// UI state
|
||||||
const [isShowFormattingChangeConfirm, setIsShowFormattingChangeConfirm] = useState(false)
|
const [expanded, setExpanded] = useState(true)
|
||||||
const [isShowCannotQueryDataset, setShowCannotQueryDataset] = useState(false)
|
const [isShowCannotQueryDataset, setShowCannotQueryDataset] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
useEffect(() => {
|
|
||||||
if (formattingChanged)
|
|
||||||
setIsShowFormattingChangeConfirm(true)
|
|
||||||
}, [formattingChanged])
|
|
||||||
|
|
||||||
const debugWithSingleModelRef = React.useRef<DebugWithSingleModelRefType>(null!)
|
const debugWithSingleModelRef = React.useRef<DebugWithSingleModelRefType>(null!)
|
||||||
const handleClearConversation = () => {
|
|
||||||
debugWithSingleModelRef.current?.handleRestart()
|
|
||||||
}
|
|
||||||
const clearConversation = async () => {
|
|
||||||
if (debugWithMultipleModel) {
|
|
||||||
eventEmitter?.emit({
|
|
||||||
type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
|
|
||||||
} as any)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClearConversation()
|
// Hooks
|
||||||
}
|
const { checkCanSend } = useInputValidation()
|
||||||
|
const { isShowFormattingChangeConfirm, handleConfirm, handleCancel } = useFormattingChangeConfirm()
|
||||||
|
const modalWidth = useModalWidth(containerRef)
|
||||||
|
|
||||||
const handleConfirm = () => {
|
// Wrapper for checkCanSend that uses current completionFiles
|
||||||
clearConversation()
|
const [completionFilesForValidation, setCompletionFilesForValidation] = useState<VisionFile[]>([])
|
||||||
setIsShowFormattingChangeConfirm(false)
|
const checkCanSendWithFiles = useCallback(() => {
|
||||||
setFormattingChanged(false)
|
return checkCanSend(inputs, completionFilesForValidation)
|
||||||
}
|
}, [checkCanSend, inputs, completionFilesForValidation])
|
||||||
|
|
||||||
const handleCancel = () => {
|
const {
|
||||||
setIsShowFormattingChangeConfirm(false)
|
isResponding,
|
||||||
setFormattingChanged(false)
|
completionRes,
|
||||||
}
|
messageId,
|
||||||
|
|
||||||
const { notify } = useContext(ToastContext)
|
|
||||||
const logError = useCallback((message: string) => {
|
|
||||||
notify({ type: 'error', message })
|
|
||||||
}, [notify])
|
|
||||||
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
|
|
||||||
|
|
||||||
const checkCanSend = useCallback(() => {
|
|
||||||
if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) {
|
|
||||||
if (modelModeType === ModelModeType.completion) {
|
|
||||||
if (!hasSetBlockStatus.history) {
|
|
||||||
notify({ type: 'error', message: t('otherError.historyNoBeEmpty', { ns: 'appDebug' }) })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!hasSetBlockStatus.query) {
|
|
||||||
notify({ type: 'error', message: t('otherError.queryNoBeEmpty', { ns: 'appDebug' }) })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let hasEmptyInput = ''
|
|
||||||
const requiredVars = modelConfig.configs.prompt_variables.filter(({ key, name, required, type }) => {
|
|
||||||
if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number')
|
|
||||||
return false
|
|
||||||
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
|
|
||||||
return res
|
|
||||||
}) // compatible with old version
|
|
||||||
requiredVars.forEach(({ key, name }) => {
|
|
||||||
if (hasEmptyInput)
|
|
||||||
return
|
|
||||||
|
|
||||||
if (!inputs[key])
|
|
||||||
hasEmptyInput = name
|
|
||||||
})
|
|
||||||
|
|
||||||
if (hasEmptyInput) {
|
|
||||||
logError(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
|
|
||||||
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return !hasEmptyInput
|
|
||||||
}, [
|
|
||||||
completionFiles,
|
completionFiles,
|
||||||
hasSetBlockStatus.history,
|
setCompletionFiles,
|
||||||
hasSetBlockStatus.query,
|
sendTextCompletion,
|
||||||
inputs,
|
} = useTextCompletion({
|
||||||
isAdvancedMode,
|
checkCanSend: checkCanSendWithFiles,
|
||||||
mode,
|
onShowCannotQueryDataset: () => setShowCannotQueryDataset(true),
|
||||||
modelConfig.configs.prompt_variables,
|
|
||||||
t,
|
|
||||||
logError,
|
|
||||||
notify,
|
|
||||||
modelModeType,
|
|
||||||
])
|
|
||||||
|
|
||||||
const [completionRes, setCompletionRes] = useState('')
|
|
||||||
const [messageId, setMessageId] = useState<string | null>(null)
|
|
||||||
const features = useFeatures(s => s.features)
|
|
||||||
const featuresStore = useFeaturesStore()
|
|
||||||
|
|
||||||
const sendTextCompletion = async () => {
|
|
||||||
if (isResponding) {
|
|
||||||
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dataSets.length > 0 && !hasSetContextVar) {
|
|
||||||
setShowCannotQueryDataset(true)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!checkCanSend())
|
|
||||||
return
|
|
||||||
|
|
||||||
const postDatasets = dataSets.map(({ id }) => ({
|
|
||||||
dataset: {
|
|
||||||
enabled: true,
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key
|
|
||||||
|
|
||||||
const postModelConfig: BackendModelConfig = {
|
|
||||||
pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
|
|
||||||
prompt_type: promptMode,
|
|
||||||
chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG),
|
|
||||||
completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG),
|
|
||||||
user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
|
|
||||||
dataset_query_variable: contextVar || '',
|
|
||||||
dataset_configs: {
|
|
||||||
...datasetConfigs,
|
|
||||||
datasets: {
|
|
||||||
datasets: [...postDatasets],
|
|
||||||
} as any,
|
|
||||||
},
|
|
||||||
agent_mode: {
|
|
||||||
enabled: false,
|
|
||||||
tools: [],
|
|
||||||
},
|
|
||||||
model: {
|
|
||||||
provider: modelConfig.provider,
|
|
||||||
name: modelConfig.model_id,
|
|
||||||
mode: modelConfig.mode,
|
|
||||||
completion_params: completionParams as any,
|
|
||||||
},
|
|
||||||
more_like_this: features.moreLikeThis as any,
|
|
||||||
sensitive_word_avoidance: features.moderation as any,
|
|
||||||
text_to_speech: features.text2speech as any,
|
|
||||||
file_upload: features.file as any,
|
|
||||||
opening_statement: introduction,
|
|
||||||
suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
|
|
||||||
speech_to_text: speechToTextConfig,
|
|
||||||
retriever_resource: citationConfig,
|
|
||||||
system_parameters: modelConfig.system_parameters,
|
|
||||||
external_data_tools: externalDataToolsConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: Record<string, any> = {
|
|
||||||
inputs: formatBooleanInputs(modelConfig.configs.prompt_variables, inputs),
|
|
||||||
model_config: postModelConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((features.file as any).enabled && completionFiles && completionFiles?.length > 0) {
|
|
||||||
data.files = completionFiles.map((item) => {
|
|
||||||
if (item.transfer_method === TransferMethod.local_file) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
url: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setCompletionRes('')
|
|
||||||
setMessageId('')
|
|
||||||
let res: string[] = []
|
|
||||||
|
|
||||||
setRespondingTrue()
|
|
||||||
sendCompletionMessage(appId, data, {
|
|
||||||
onData: (data: string, _isFirstMessage: boolean, { messageId }) => {
|
|
||||||
res.push(data)
|
|
||||||
setCompletionRes(res.join(''))
|
|
||||||
setMessageId(messageId)
|
|
||||||
},
|
|
||||||
onMessageReplace: (messageReplace) => {
|
|
||||||
res = [messageReplace.answer]
|
|
||||||
setCompletionRes(res.join(''))
|
|
||||||
},
|
|
||||||
onCompleted() {
|
|
||||||
setRespondingFalse()
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
setRespondingFalse()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSendTextCompletion = () => {
|
|
||||||
if (debugWithMultipleModel) {
|
|
||||||
eventEmitter?.emit({
|
|
||||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
|
||||||
payload: {
|
|
||||||
message: '',
|
|
||||||
files: completionFiles,
|
|
||||||
},
|
|
||||||
} as any)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sendTextCompletion()
|
|
||||||
}
|
|
||||||
|
|
||||||
const varList = modelConfig.configs.prompt_variables.map((item: any) => {
|
|
||||||
return {
|
|
||||||
label: item.key,
|
|
||||||
value: inputs[item.key],
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sync completionFiles for validation
|
||||||
|
useEffect(() => {
|
||||||
|
setCompletionFilesForValidation(completionFiles as VisionFile[]) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||||
|
}, [completionFiles])
|
||||||
|
|
||||||
|
// App store for modals
|
||||||
|
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
|
||||||
|
currentLogItem: state.currentLogItem,
|
||||||
|
setCurrentLogItem: state.setCurrentLogItem,
|
||||||
|
showPromptLogModal: state.showPromptLogModal,
|
||||||
|
setShowPromptLogModal: state.setShowPromptLogModal,
|
||||||
|
showAgentLogModal: state.showAgentLogModal,
|
||||||
|
setShowAgentLogModal: state.setShowAgentLogModal,
|
||||||
|
})))
|
||||||
|
|
||||||
|
// Provider context for model list
|
||||||
const { textGenerationModelList } = useProviderContext()
|
const { textGenerationModelList } = useProviderContext()
|
||||||
const handleChangeToSingleModel = (item: ModelAndParameter) => {
|
|
||||||
|
// Computed values
|
||||||
|
const varList = modelConfig.configs.prompt_variables.map((item: PromptVariable) => ({
|
||||||
|
label: item.key,
|
||||||
|
value: inputs[item.key],
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleClearConversation = useCallback(() => {
|
||||||
|
debugWithSingleModelRef.current?.handleRestart()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearConversation = useCallback(async () => {
|
||||||
|
if (debugWithMultipleModel) {
|
||||||
|
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } as any) // eslint-disable-line ts/no-explicit-any
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleClearConversation()
|
||||||
|
}, [debugWithMultipleModel, eventEmitter, handleClearConversation])
|
||||||
|
|
||||||
|
const handleFormattingConfirm = useCallback(() => {
|
||||||
|
handleConfirm(clearConversation)
|
||||||
|
}, [handleConfirm, clearConversation])
|
||||||
|
|
||||||
|
const handleChangeToSingleModel = useCallback((item: ModelAndParameter) => {
|
||||||
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === item.provider)
|
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === item.provider)
|
||||||
const currentModel = currentProvider?.models.find(model => model.model === item.model)
|
const currentModel = currentProvider?.models.find(model => model.model === item.model)
|
||||||
|
|
||||||
@@ -335,26 +155,18 @@ const Debug: FC<IDebug> = ({
|
|||||||
features: currentModel?.features,
|
features: currentModel?.features,
|
||||||
})
|
})
|
||||||
modelParameterParams.onCompletionParamsChange(item.parameters)
|
modelParameterParams.onCompletionParamsChange(item.parameters)
|
||||||
onMultipleModelConfigsChange(
|
onMultipleModelConfigsChange(false, [])
|
||||||
false,
|
}, [modelParameterParams, onMultipleModelConfigsChange, textGenerationModelList])
|
||||||
[],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleVisionConfigInMultipleModel = useCallback(() => {
|
const handleVisionConfigInMultipleModel = useCallback(() => {
|
||||||
if (debugWithMultipleModel && mode) {
|
if (debugWithMultipleModel && mode) {
|
||||||
const supportedVision = multipleModelConfigs.some((modelConfig) => {
|
const supportedVision = multipleModelConfigs.some((config) => {
|
||||||
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === modelConfig.provider)
|
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === config.provider)
|
||||||
const currentModel = currentProvider?.models.find(model => model.model === modelConfig.model)
|
const currentModel = currentProvider?.models.find(model => model.model === config.model)
|
||||||
|
|
||||||
return currentModel?.features?.includes(ModelFeatureEnum.vision)
|
return currentModel?.features?.includes(ModelFeatureEnum.vision)
|
||||||
})
|
})
|
||||||
const {
|
const { features: storeFeatures, setFeatures } = featuresStore!.getState()
|
||||||
features,
|
const newFeatures = produce(storeFeatures, (draft) => {
|
||||||
setFeatures,
|
|
||||||
} = featuresStore!.getState()
|
|
||||||
|
|
||||||
const newFeatures = produce(features, (draft) => {
|
|
||||||
draft.file = {
|
draft.file = {
|
||||||
...draft.file,
|
...draft.file,
|
||||||
enabled: supportedVision,
|
enabled: supportedVision,
|
||||||
@@ -368,210 +180,131 @@ const Debug: FC<IDebug> = ({
|
|||||||
handleVisionConfigInMultipleModel()
|
handleVisionConfigInMultipleModel()
|
||||||
}, [multipleModelConfigs, mode, handleVisionConfigInMultipleModel])
|
}, [multipleModelConfigs, mode, handleVisionConfigInMultipleModel])
|
||||||
|
|
||||||
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
|
const handleSendTextCompletion = useCallback(() => {
|
||||||
currentLogItem: state.currentLogItem,
|
if (debugWithMultipleModel) {
|
||||||
setCurrentLogItem: state.setCurrentLogItem,
|
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL, payload: { message: '', files: completionFiles } } as any) // eslint-disable-line ts/no-explicit-any
|
||||||
showPromptLogModal: state.showPromptLogModal,
|
return
|
||||||
setShowPromptLogModal: state.setShowPromptLogModal,
|
}
|
||||||
showAgentLogModal: state.showAgentLogModal,
|
sendTextCompletion()
|
||||||
setShowAgentLogModal: state.setShowAgentLogModal,
|
}, [completionFiles, debugWithMultipleModel, eventEmitter, sendTextCompletion])
|
||||||
})))
|
|
||||||
const [width, setWidth] = useState(0)
|
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const adjustModalWidth = () => {
|
const handleAddModel = useCallback(() => {
|
||||||
if (ref.current)
|
onMultipleModelConfigsChange(true, [...multipleModelConfigs, { id: `${Date.now()}`, model: '', provider: '', parameters: {} }])
|
||||||
setWidth(document.body.clientWidth - (ref.current?.clientWidth + 16) - 8)
|
}, [multipleModelConfigs, onMultipleModelConfigsChange])
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleClosePromptLogModal = useCallback(() => {
|
||||||
adjustModalWidth()
|
setCurrentLogItem()
|
||||||
}, [])
|
setShowPromptLogModal(false)
|
||||||
|
}, [setCurrentLogItem, setShowPromptLogModal])
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState(true)
|
const handleCloseAgentLogModal = useCallback(() => {
|
||||||
|
setCurrentLogItem()
|
||||||
|
setShowAgentLogModal(false)
|
||||||
|
}, [setCurrentLogItem, setShowAgentLogModal])
|
||||||
|
|
||||||
|
const isShowTextToSpeech = features.text2speech?.enabled && !!text2speechDefaultModel
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<div className="flex items-center justify-between px-4 pb-2 pt-3">
|
<DebugHeader
|
||||||
<div className="system-xl-semibold text-text-primary">{t('inputs.title', { ns: 'appDebug' })}</div>
|
readonly={readonly}
|
||||||
<div className="flex items-center">
|
mode={mode}
|
||||||
{
|
debugWithMultipleModel={debugWithMultipleModel}
|
||||||
debugWithMultipleModel
|
multipleModelConfigs={multipleModelConfigs}
|
||||||
? (
|
varListLength={varList.length}
|
||||||
<>
|
expanded={expanded}
|
||||||
<Button
|
onExpandedChange={setExpanded}
|
||||||
variant="ghost-accent"
|
onClearConversation={clearConversation}
|
||||||
onClick={() => onMultipleModelConfigsChange(true, [...multipleModelConfigs, { id: `${Date.now()}`, model: '', provider: '', parameters: {} }])}
|
onAddModel={handleAddModel}
|
||||||
disabled={multipleModelConfigs.length >= 4}
|
/>
|
||||||
>
|
|
||||||
<RiAddLine className="mr-1 h-3.5 w-3.5" />
|
|
||||||
{t('modelProvider.addModel', { ns: 'common' })}
|
|
||||||
(
|
|
||||||
{multipleModelConfigs.length}
|
|
||||||
/4)
|
|
||||||
</Button>
|
|
||||||
<div className="mx-2 h-[14px] w-[1px] bg-divider-regular" />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
{mode !== AppModeEnum.COMPLETION && (
|
|
||||||
<>
|
|
||||||
{
|
|
||||||
!readonly && (
|
|
||||||
<TooltipPlus
|
|
||||||
popupContent={t('operation.refresh', { ns: 'common' })}
|
|
||||||
>
|
|
||||||
<ActionButton onClick={clearConversation}>
|
|
||||||
<RefreshCcw01 className="h-4 w-4" />
|
|
||||||
</ActionButton>
|
|
||||||
|
|
||||||
</TooltipPlus>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
varList.length > 0 && (
|
|
||||||
<div className="relative ml-1 mr-2">
|
|
||||||
<TooltipPlus
|
|
||||||
popupContent={t('panel.userInputField', { ns: 'workflow' })}
|
|
||||||
>
|
|
||||||
<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => !readonly && setExpanded(!expanded)}>
|
|
||||||
<RiEqualizer2Line className="h-4 w-4" />
|
|
||||||
</ActionButton>
|
|
||||||
</TooltipPlus>
|
|
||||||
{expanded && <div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{mode !== AppModeEnum.COMPLETION && expanded && (
|
{mode !== AppModeEnum.COMPLETION && expanded && (
|
||||||
<div className="mx-3">
|
<div className="mx-3">
|
||||||
<ChatUserInput inputs={inputs} />
|
<ChatUserInput inputs={inputs} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{
|
{mode === AppModeEnum.COMPLETION && (
|
||||||
mode === AppModeEnum.COMPLETION && (
|
<PromptValuePanel
|
||||||
<PromptValuePanel
|
appType={mode as AppModeEnum}
|
||||||
appType={mode as AppModeEnum}
|
onSend={handleSendTextCompletion}
|
||||||
onSend={handleSendTextCompletion}
|
inputs={inputs}
|
||||||
inputs={inputs}
|
visionConfig={{
|
||||||
visionConfig={{
|
...features.file! as VisionSettings,
|
||||||
...features.file! as VisionSettings,
|
transfer_methods: features.file!.allowed_file_upload_methods || [],
|
||||||
transfer_methods: features.file!.allowed_file_upload_methods || [],
|
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
|
||||||
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
|
}}
|
||||||
}}
|
onVisionFilesChange={setCompletionFiles}
|
||||||
onVisionFilesChange={setCompletionFiles}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{
|
|
||||||
debugWithMultipleModel && (
|
|
||||||
<div className="mt-3 grow overflow-hidden" ref={ref}>
|
|
||||||
<DebugWithMultipleModel
|
|
||||||
multipleModelConfigs={multipleModelConfigs}
|
|
||||||
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
|
|
||||||
onDebugWithMultipleModelChange={handleChangeToSingleModel}
|
|
||||||
checkCanSend={checkCanSend}
|
|
||||||
/>
|
|
||||||
{showPromptLogModal && (
|
|
||||||
<PromptLogModal
|
|
||||||
width={width}
|
|
||||||
currentLogItem={currentLogItem}
|
|
||||||
onCancel={() => {
|
|
||||||
setCurrentLogItem()
|
|
||||||
setShowPromptLogModal(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showAgentLogModal && (
|
|
||||||
<AgentLogModal
|
|
||||||
width={width}
|
|
||||||
currentLogItem={currentLogItem}
|
|
||||||
onCancel={() => {
|
|
||||||
setCurrentLogItem()
|
|
||||||
setShowAgentLogModal(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{
|
|
||||||
!debugWithMultipleModel && (
|
|
||||||
<div className="flex grow flex-col" ref={ref}>
|
|
||||||
{/* Chat */}
|
|
||||||
{mode !== AppModeEnum.COMPLETION && (
|
|
||||||
<div className="h-0 grow overflow-hidden">
|
|
||||||
<DebugWithSingleModel
|
|
||||||
ref={debugWithSingleModelRef}
|
|
||||||
checkCanSend={checkCanSend}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Text Generation */}
|
|
||||||
{mode === AppModeEnum.COMPLETION && (
|
|
||||||
<>
|
|
||||||
{(completionRes || isResponding) && (
|
|
||||||
<>
|
|
||||||
<div className="mx-4 mt-3"><GroupName name={t('result', { ns: 'appDebug' })} /></div>
|
|
||||||
<div className="mx-3 mb-8">
|
|
||||||
<TextGeneration
|
|
||||||
appSourceType={AppSourceType.webApp}
|
|
||||||
className="mt-2"
|
|
||||||
content={completionRes}
|
|
||||||
isLoading={!completionRes && isResponding}
|
|
||||||
isShowTextToSpeech={textToSpeechConfig.enabled && !!text2speechDefaultModel}
|
|
||||||
isResponding={isResponding}
|
|
||||||
messageId={messageId}
|
|
||||||
isError={false}
|
|
||||||
onRetry={noop}
|
|
||||||
siteInfo={null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!completionRes && !isResponding && (
|
|
||||||
<div className="flex grow flex-col items-center justify-center gap-2">
|
|
||||||
<RiSparklingFill className="h-12 w-12 text-text-empty-state-icon" />
|
|
||||||
<div className="system-sm-regular text-text-quaternary">{t('noResult', { ns: 'appDebug' })}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{mode === AppModeEnum.COMPLETION && showPromptLogModal && (
|
|
||||||
<PromptLogModal
|
|
||||||
width={width}
|
|
||||||
currentLogItem={currentLogItem}
|
|
||||||
onCancel={() => {
|
|
||||||
setCurrentLogItem()
|
|
||||||
setShowPromptLogModal(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isShowCannotQueryDataset && (
|
|
||||||
<CannotQueryDataset
|
|
||||||
onConfirm={() => setShowCannotQueryDataset(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{
|
|
||||||
isShowFormattingChangeConfirm && (
|
|
||||||
<FormattingChanged
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
/>
|
/>
|
||||||
)
|
)}
|
||||||
}
|
</div>
|
||||||
{!isAPIKeySet && !readonly && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
|
|
||||||
|
{debugWithMultipleModel && (
|
||||||
|
<div className="mt-3 grow overflow-hidden" ref={containerRef}>
|
||||||
|
<DebugWithMultipleModel
|
||||||
|
multipleModelConfigs={multipleModelConfigs}
|
||||||
|
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
|
||||||
|
onDebugWithMultipleModelChange={handleChangeToSingleModel}
|
||||||
|
checkCanSend={checkCanSendWithFiles}
|
||||||
|
/>
|
||||||
|
{showPromptLogModal && (
|
||||||
|
<PromptLogModal
|
||||||
|
width={modalWidth}
|
||||||
|
currentLogItem={currentLogItem}
|
||||||
|
onCancel={handleClosePromptLogModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showAgentLogModal && (
|
||||||
|
<AgentLogModal
|
||||||
|
width={modalWidth}
|
||||||
|
currentLogItem={currentLogItem}
|
||||||
|
onCancel={handleCloseAgentLogModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!debugWithMultipleModel && (
|
||||||
|
<div className="flex grow flex-col" ref={containerRef}>
|
||||||
|
{mode !== AppModeEnum.COMPLETION && (
|
||||||
|
<div className="h-0 grow overflow-hidden">
|
||||||
|
<DebugWithSingleModel
|
||||||
|
ref={debugWithSingleModelRef}
|
||||||
|
checkCanSend={checkCanSendWithFiles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === AppModeEnum.COMPLETION && (
|
||||||
|
<TextCompletionResult
|
||||||
|
completionRes={completionRes}
|
||||||
|
isResponding={isResponding}
|
||||||
|
messageId={messageId}
|
||||||
|
isShowTextToSpeech={isShowTextToSpeech}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{mode === AppModeEnum.COMPLETION && showPromptLogModal && (
|
||||||
|
<PromptLogModal
|
||||||
|
width={modalWidth}
|
||||||
|
currentLogItem={currentLogItem}
|
||||||
|
onCancel={handleClosePromptLogModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isShowCannotQueryDataset && (
|
||||||
|
<CannotQueryDataset onConfirm={() => setShowCannotQueryDataset(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isShowFormattingChangeConfirm && (
|
||||||
|
<FormattingChanged
|
||||||
|
onConfirm={handleFormattingConfirm}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isAPIKeySet && !readonly && (
|
||||||
|
<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(Debug)
|
export default React.memo(Debug)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import { RiSparklingFill } from '@remixicon/react'
|
||||||
|
import { noop } from 'es-toolkit/function'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import TextGeneration from '@/app/components/app/text-generate/item'
|
||||||
|
import { AppSourceType } from '@/service/share'
|
||||||
|
import GroupName from '../base/group-name'
|
||||||
|
|
||||||
|
type TextCompletionResultProps = {
|
||||||
|
completionRes: string
|
||||||
|
isResponding: boolean
|
||||||
|
messageId: string | null
|
||||||
|
isShowTextToSpeech?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextCompletionResult: FC<TextCompletionResultProps> = ({
|
||||||
|
completionRes,
|
||||||
|
isResponding,
|
||||||
|
messageId,
|
||||||
|
isShowTextToSpeech,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (!completionRes && !isResponding) {
|
||||||
|
return (
|
||||||
|
<div className="flex grow flex-col items-center justify-center gap-2">
|
||||||
|
<RiSparklingFill className="h-12 w-12 text-text-empty-state-icon" />
|
||||||
|
<div className="system-sm-regular text-text-quaternary">{t('noResult', { ns: 'appDebug' })}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-4 mt-3">
|
||||||
|
<GroupName name={t('result', { ns: 'appDebug' })} />
|
||||||
|
</div>
|
||||||
|
<div className="mx-3 mb-8">
|
||||||
|
<TextGeneration
|
||||||
|
appSourceType={AppSourceType.webApp}
|
||||||
|
className="mt-2"
|
||||||
|
content={completionRes}
|
||||||
|
isLoading={!completionRes && isResponding}
|
||||||
|
isShowTextToSpeech={isShowTextToSpeech}
|
||||||
|
isResponding={isResponding}
|
||||||
|
messageId={messageId}
|
||||||
|
isError={false}
|
||||||
|
onRetry={noop}
|
||||||
|
siteInfo={null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextCompletionResult
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import type { ModelConfig as BackendModelConfig, VisionFile } from '@/types/app'
|
||||||
|
import { useBoolean } from 'ahooks'
|
||||||
|
import { cloneDeep } from 'es-toolkit/object'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useContext } from 'use-context-selector'
|
||||||
|
import { useFeatures } from '@/app/components/base/features/hooks'
|
||||||
|
import { ToastContext } from '@/app/components/base/toast'
|
||||||
|
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||||
|
import { useDebugConfigurationContext } from '@/context/debug-configuration'
|
||||||
|
import { sendCompletionMessage } from '@/service/debug'
|
||||||
|
import { TransferMethod } from '@/types/app'
|
||||||
|
import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config'
|
||||||
|
|
||||||
|
type UseTextCompletionOptions = {
|
||||||
|
checkCanSend: () => boolean
|
||||||
|
onShowCannotQueryDataset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTextCompletion = ({
|
||||||
|
checkCanSend,
|
||||||
|
onShowCannotQueryDataset,
|
||||||
|
}: UseTextCompletionOptions) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { notify } = useContext(ToastContext)
|
||||||
|
const {
|
||||||
|
appId,
|
||||||
|
isAdvancedMode,
|
||||||
|
promptMode,
|
||||||
|
chatPromptConfig,
|
||||||
|
completionPromptConfig,
|
||||||
|
introduction,
|
||||||
|
suggestedQuestionsAfterAnswerConfig,
|
||||||
|
speechToTextConfig,
|
||||||
|
citationConfig,
|
||||||
|
dataSets,
|
||||||
|
modelConfig,
|
||||||
|
completionParams,
|
||||||
|
hasSetContextVar,
|
||||||
|
datasetConfigs,
|
||||||
|
externalDataToolsConfig,
|
||||||
|
inputs,
|
||||||
|
} = useDebugConfigurationContext()
|
||||||
|
const features = useFeatures(s => s.features)
|
||||||
|
|
||||||
|
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
|
||||||
|
const [completionRes, setCompletionRes] = useState('')
|
||||||
|
const [messageId, setMessageId] = useState<string | null>(null)
|
||||||
|
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
|
||||||
|
|
||||||
|
const sendTextCompletion = useCallback(async () => {
|
||||||
|
if (isResponding) {
|
||||||
|
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataSets.length > 0 && !hasSetContextVar) {
|
||||||
|
onShowCannotQueryDataset()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkCanSend())
|
||||||
|
return
|
||||||
|
|
||||||
|
const postDatasets = dataSets.map(({ id }) => ({
|
||||||
|
dataset: {
|
||||||
|
enabled: true,
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key
|
||||||
|
|
||||||
|
const postModelConfig: BackendModelConfig = {
|
||||||
|
pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
|
||||||
|
prompt_type: promptMode,
|
||||||
|
chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG),
|
||||||
|
completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG),
|
||||||
|
user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
|
||||||
|
dataset_query_variable: contextVar || '',
|
||||||
|
/* eslint-disable ts/no-explicit-any */
|
||||||
|
dataset_configs: {
|
||||||
|
...datasetConfigs,
|
||||||
|
datasets: {
|
||||||
|
datasets: [...postDatasets],
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
agent_mode: {
|
||||||
|
enabled: false,
|
||||||
|
tools: [],
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
provider: modelConfig.provider,
|
||||||
|
name: modelConfig.model_id,
|
||||||
|
mode: modelConfig.mode,
|
||||||
|
completion_params: completionParams as any,
|
||||||
|
},
|
||||||
|
more_like_this: features.moreLikeThis as any,
|
||||||
|
sensitive_word_avoidance: features.moderation as any,
|
||||||
|
text_to_speech: features.text2speech as any,
|
||||||
|
file_upload: features.file as any,
|
||||||
|
/* eslint-enable ts/no-explicit-any */
|
||||||
|
opening_statement: introduction,
|
||||||
|
suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
|
||||||
|
speech_to_text: speechToTextConfig,
|
||||||
|
retriever_resource: citationConfig,
|
||||||
|
system_parameters: modelConfig.system_parameters,
|
||||||
|
external_data_tools: externalDataToolsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line ts/no-explicit-any
|
||||||
|
const data: Record<string, any> = {
|
||||||
|
inputs: formatBooleanInputs(modelConfig.configs.prompt_variables, inputs),
|
||||||
|
model_config: postModelConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line ts/no-explicit-any
|
||||||
|
if ((features.file as any).enabled && completionFiles && completionFiles?.length > 0) {
|
||||||
|
data.files = completionFiles.map((item) => {
|
||||||
|
if (item.transfer_method === TransferMethod.local_file) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
url: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setCompletionRes('')
|
||||||
|
setMessageId('')
|
||||||
|
let res: string[] = []
|
||||||
|
|
||||||
|
setRespondingTrue()
|
||||||
|
sendCompletionMessage(appId, data, {
|
||||||
|
onData: (data: string, _isFirstMessage: boolean, { messageId }) => {
|
||||||
|
res.push(data)
|
||||||
|
setCompletionRes(res.join(''))
|
||||||
|
setMessageId(messageId)
|
||||||
|
},
|
||||||
|
onMessageReplace: (messageReplace) => {
|
||||||
|
res = [messageReplace.answer]
|
||||||
|
setCompletionRes(res.join(''))
|
||||||
|
},
|
||||||
|
onCompleted() {
|
||||||
|
setRespondingFalse()
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
setRespondingFalse()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
appId,
|
||||||
|
checkCanSend,
|
||||||
|
chatPromptConfig,
|
||||||
|
citationConfig,
|
||||||
|
completionFiles,
|
||||||
|
completionParams,
|
||||||
|
completionPromptConfig,
|
||||||
|
datasetConfigs,
|
||||||
|
dataSets,
|
||||||
|
externalDataToolsConfig,
|
||||||
|
features,
|
||||||
|
hasSetContextVar,
|
||||||
|
inputs,
|
||||||
|
introduction,
|
||||||
|
isAdvancedMode,
|
||||||
|
isResponding,
|
||||||
|
modelConfig,
|
||||||
|
notify,
|
||||||
|
onShowCannotQueryDataset,
|
||||||
|
promptMode,
|
||||||
|
setRespondingFalse,
|
||||||
|
setRespondingTrue,
|
||||||
|
speechToTextConfig,
|
||||||
|
suggestedQuestionsAfterAnswerConfig,
|
||||||
|
t,
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isResponding,
|
||||||
|
completionRes,
|
||||||
|
messageId,
|
||||||
|
completionFiles,
|
||||||
|
setCompletionFiles,
|
||||||
|
sendTextCompletion,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -422,16 +422,6 @@
|
|||||||
"count": 6
|
"count": 6
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app/components/app/configuration/debug/debug-with-multiple-model/index.spec.tsx": {
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app/components/app/configuration/debug/debug-with-multiple-model/index.tsx": {
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx": {
|
"app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx": {
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 8
|
"count": 8
|
||||||
@@ -455,14 +445,6 @@
|
|||||||
"count": 3
|
"count": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app/components/app/configuration/debug/index.tsx": {
|
|
||||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 11
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app/components/app/configuration/debug/types.ts": {
|
"app/components/app/configuration/debug/types.ts": {
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
Reference in New Issue
Block a user