From 5e80a3f5deda9889b0a4e65b2f9a5ddd8c7c5e2e Mon Sep 17 00:00:00 2001 From: yyh Date: Wed, 11 Mar 2026 00:03:58 +0800 Subject: [PATCH 1/5] fix: use css icons --- .../plugins/plugin-detail-panel/detail-header/index.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx index feed7c3576..111cb94a82 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx @@ -1,10 +1,6 @@ 'use client' import type { PluginDetail } from '../../types' -import { - RiArrowLeftRightLine, - RiCloseLine, -} from '@remixicon/react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' @@ -180,7 +176,7 @@ const DetailHeader = ({ text={( <>
{isFromGitHub ? (meta?.version ?? version ?? '') : version}
- {isFromMarketplace && !isReadmeView && } + {isFromMarketplace && !isReadmeView && } )} hasRedCornerMark={hasNewVersion} @@ -255,7 +251,7 @@ const DetailHeader = ({ detailUrl={detailUrl} /> - + )} From e8ade9ad64f4382e22fefa5b03e68cf2b39beb0d Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 11 Mar 2026 09:49:09 +0800 Subject: [PATCH 2/5] test(debug): add unit tests for Debug component and enhance Trigger component tests - Introduced comprehensive unit tests for the Debug component, covering various states and interactions. - Enhanced Trigger component tests to include new status badges, empty states, and improved rendering logic. - Updated mock implementations to reflect changes in provider context and credential panel state. - Ensured tests validate the correct rendering of UI elements based on different props and states. --- .../app/configuration/debug/index.spec.tsx | 1021 +++++++++++++++++ .../model-parameter-modal/trigger.spec.tsx | 296 +++-- 2 files changed, 1211 insertions(+), 106 deletions(-) create mode 100644 web/app/components/app/configuration/debug/index.spec.tsx diff --git a/web/app/components/app/configuration/debug/index.spec.tsx b/web/app/components/app/configuration/debug/index.spec.tsx new file mode 100644 index 0000000000..e94695f1ef --- /dev/null +++ b/web/app/components/app/configuration/debug/index.spec.tsx @@ -0,0 +1,1021 @@ +import type { ComponentProps } from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { ToastContext } from '@/app/components/base/toast/context' +import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import ConfigContext from '@/context/debug-configuration' +import { AppModeEnum, ModelModeType, TransferMethod } from '@/types/app' +import Debug from './index' +import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from './types' + +type DebugContextValue = ComponentProps['value'] +type DebugProps = ComponentProps + +const mockState = vi.hoisted(() => ({ + mockSendCompletionMessage: vi.fn(), + mockHandleRestart: vi.fn(), + mockSetFeatures: vi.fn(), + mockEventEmitterEmit: vi.fn(), + mockText2speechDefaultModel: null as unknown, + mockStoreState: { + currentLogItem: null as unknown, + setCurrentLogItem: vi.fn(), + showPromptLogModal: false, + setShowPromptLogModal: vi.fn(), + showAgentLogModal: false, + setShowAgentLogModal: vi.fn(), + }, + mockFeaturesState: { + moreLikeThis: { enabled: false }, + moderation: { enabled: false }, + text2speech: { enabled: false }, + file: { enabled: false, allowed_file_upload_methods: [] as string[], fileUploadConfig: undefined as { image_file_size_limit?: number } | undefined }, + }, + mockProviderContext: { + textGenerationModelList: [] as Array<{ + provider: string + models: Array<{ + model: string + features?: string[] + model_properties: { mode?: string } + }> + }>, + }, +})) + +vi.mock('@/app/components/app/configuration/debug/chat-user-input', () => ({ + default: () =>
ChatUserInput
, +})) + +vi.mock('@/app/components/app/configuration/prompt-value-panel', () => ({ + default: ({ onSend, onVisionFilesChange }: { + onSend: () => void + onVisionFilesChange: (files: Array>) => void + }) => ( +
+ + + + +
+ ), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { + currentLogItem: unknown + setCurrentLogItem: () => void + showPromptLogModal: boolean + setShowPromptLogModal: () => void + showAgentLogModal: boolean + setShowAgentLogModal: () => void + }) => unknown) => selector(mockState.mockStoreState), +})) + +vi.mock('@/app/components/app/text-generate/item', () => ({ + default: ({ content, isLoading, isShowTextToSpeech, messageId }: { + content: string + isLoading: boolean + isShowTextToSpeech: boolean + messageId: string | null + }) => ( +
+ {content} +
+ ), +})) + +vi.mock('@/app/components/base/action-button', () => ({ + default: ({ children, onClick, state }: { children: React.ReactNode, onClick?: () => void, state?: string }) => ( + + ), + ActionButtonState: { + Active: 'active', + }, +})) + +vi.mock('@/app/components/base/agent-log-modal', () => ({ + default: ({ onCancel }: { onCancel: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/base/features/hooks', () => ({ + useFeatures: (selector: (state: { features: { + moreLikeThis: { enabled: boolean } + moderation: { enabled: boolean } + text2speech: { enabled: boolean } + file: { enabled: boolean, allowed_file_upload_methods: string[], fileUploadConfig?: { image_file_size_limit?: number } } + } }) => unknown) => selector({ features: mockState.mockFeaturesState }), + useFeaturesStore: () => ({ + getState: () => ({ + features: mockState.mockFeaturesState, + setFeatures: mockState.mockSetFeatures, + }), + }), +})) + +vi.mock('@/app/components/base/prompt-log-modal', () => ({ + default: ({ onCancel }: { onCancel: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useDefaultModel: () => ({ data: mockState.mockText2speechDefaultModel }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { emit: mockState.mockEventEmitterEmit }, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockState.mockProviderContext, +})) + +vi.mock('@/service/debug', () => ({ + sendCompletionMessage: mockState.mockSendCompletionMessage, +})) + +vi.mock('../base/group-name', () => ({ + default: ({ name }: { name: string }) =>
{name}
, +})) + +vi.mock('../base/warning-mask/cannot-query-dataset', () => ({ + default: ({ onConfirm }: { onConfirm: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('../base/warning-mask/formatting-changed', () => ({ + default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => ( +
+ + +
+ ), +})) + +vi.mock('./debug-with-multiple-model', () => ({ + default: ({ + checkCanSend, + onDebugWithMultipleModelChange, + }: { + checkCanSend: () => boolean + onDebugWithMultipleModelChange: (item: { id: string, model: string, provider: string, parameters: Record }) => void + }) => ( +
+ + +
+ ), +})) + +vi.mock('./debug-with-single-model', () => ({ + default: React.forwardRef((props: { checkCanSend: () => boolean }, ref) => { + React.useImperativeHandle(ref, () => ({ + handleRestart: mockState.mockHandleRestart, + })) + + return ( +
+ +
+ ) + }), +})) + +const createContextValue = (overrides: Partial = {}): DebugContextValue => ({ + readonly: false, + appId: 'app-id', + isAPIKeySet: true, + isTrailFinished: false, + mode: AppModeEnum.CHAT, + modelModeType: ModelModeType.chat, + promptMode: 'simple' as DebugContextValue['promptMode'], + setPromptMode: vi.fn(), + isAdvancedMode: false, + isAgent: false, + isFunctionCall: false, + isOpenAI: true, + collectionList: [], + canReturnToSimpleMode: false, + setCanReturnToSimpleMode: vi.fn(), + chatPromptConfig: { prompt: [] } as DebugContextValue['chatPromptConfig'], + completionPromptConfig: { + prompt: { text: '' }, + conversation_histories_role: { user_prefix: 'user', assistant_prefix: 'assistant' }, + } as DebugContextValue['completionPromptConfig'], + currentAdvancedPrompt: [], + setCurrentAdvancedPrompt: vi.fn(), + showHistoryModal: vi.fn(), + conversationHistoriesRole: { user_prefix: 'user', assistant_prefix: 'assistant' }, + setConversationHistoriesRole: vi.fn(), + hasSetBlockStatus: { context: false, history: true, query: true }, + conversationId: null, + setConversationId: vi.fn(), + introduction: '', + setIntroduction: vi.fn(), + suggestedQuestions: [], + setSuggestedQuestions: vi.fn(), + controlClearChatMessage: 0, + setControlClearChatMessage: vi.fn(), + prevPromptConfig: { prompt_template: '', prompt_variables: [] }, + setPrevPromptConfig: vi.fn(), + moreLikeThisConfig: { enabled: false }, + setMoreLikeThisConfig: vi.fn(), + suggestedQuestionsAfterAnswerConfig: { enabled: false }, + setSuggestedQuestionsAfterAnswerConfig: vi.fn(), + speechToTextConfig: { enabled: false }, + setSpeechToTextConfig: vi.fn(), + textToSpeechConfig: { enabled: false, voice: '', language: '' }, + setTextToSpeechConfig: vi.fn(), + citationConfig: { enabled: false }, + setCitationConfig: vi.fn(), + annotationConfig: { + id: '', + enabled: false, + score_threshold: 0.7, + embedding_model: { + embedding_model_name: '', + embedding_provider_name: '', + }, + }, + setAnnotationConfig: vi.fn(), + moderationConfig: { enabled: false }, + setModerationConfig: vi.fn(), + externalDataToolsConfig: [], + setExternalDataToolsConfig: vi.fn(), + formattingChanged: false, + setFormattingChanged: vi.fn(), + inputs: {}, + setInputs: vi.fn(), + query: '', + setQuery: vi.fn(), + completionParams: {}, + setCompletionParams: vi.fn(), + modelConfig: { + provider: 'openai', + model_id: 'gpt-4', + mode: ModelModeType.chat, + configs: { + prompt_template: '', + prompt_variables: [], + }, + chat_prompt_config: { prompt: [] }, + completion_prompt_config: { + prompt: { text: '' }, + conversation_histories_role: { user_prefix: 'user', assistant_prefix: 'assistant' }, + }, + more_like_this: null, + opening_statement: '', + suggested_questions: [], + sensitive_word_avoidance: null, + speech_to_text: null, + text_to_speech: null, + file_upload: null, + suggested_questions_after_answer: null, + retriever_resource: null, + annotation_reply: null, + external_data_tools: [], + system_parameters: { + audio_file_size_limit: 0, + file_size_limit: 0, + image_file_size_limit: 0, + video_file_size_limit: 0, + workflow_file_upload_limit: 0, + }, + dataSets: [], + agentConfig: { + enabled: false, + max_iteration: 5, + tools: [], + strategy: 'react', + }, + } as DebugContextValue['modelConfig'], + setModelConfig: vi.fn(), + dataSets: [], + setDataSets: vi.fn(), + showSelectDataSet: vi.fn(), + datasetConfigs: { + retrieval_model: 'single', + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 4, + score_threshold_enabled: false, + score_threshold: 0.7, + datasets: { datasets: [] }, + } as DebugContextValue['datasetConfigs'], + datasetConfigsRef: { current: null } as unknown as DebugContextValue['datasetConfigsRef'], + setDatasetConfigs: vi.fn(), + hasSetContextVar: false, + isShowVisionConfig: false, + visionConfig: { + enabled: false, + number_limits: 2, + detail: 'low', + transfer_methods: [], + } as DebugContextValue['visionConfig'], + setVisionConfig: vi.fn(), + isAllowVideoUpload: false, + isShowDocumentConfig: false, + isShowAudioConfig: false, + rerankSettingModalOpen: false, + setRerankSettingModalOpen: vi.fn(), + ...overrides, +}) + +const renderDebug = (options: { + contextValue?: Partial + props?: Partial +} = {}) => { + const onSetting = vi.fn() + const notify = vi.fn() + const props: ComponentProps = { + isAPIKeySet: true, + onSetting, + inputs: {}, + modelParameterParams: { + setModel: vi.fn(), + onCompletionParamsChange: vi.fn(), + }, + debugWithMultipleModel: false, + multipleModelConfigs: [], + onMultipleModelConfigsChange: vi.fn(), + ...options.props, + } + + render( + + + + + , + ) + + return { onSetting, notify, props } +} + +describe('Debug', () => { + beforeEach(() => { + vi.clearAllMocks() + mockState.mockSendCompletionMessage.mockReset() + mockState.mockHandleRestart.mockReset() + mockState.mockSetFeatures.mockReset() + mockState.mockEventEmitterEmit.mockReset() + mockState.mockText2speechDefaultModel = null + mockState.mockStoreState = { + currentLogItem: null, + setCurrentLogItem: vi.fn(), + showPromptLogModal: false, + setShowPromptLogModal: vi.fn(), + showAgentLogModal: false, + setShowAgentLogModal: vi.fn(), + } + mockState.mockFeaturesState = { + moreLikeThis: { enabled: false }, + moderation: { enabled: false }, + text2speech: { enabled: false }, + file: { enabled: false, allowed_file_upload_methods: [], fileUploadConfig: undefined }, + } + mockState.mockProviderContext = { + textGenerationModelList: [{ + provider: 'openai', + models: [{ + model: 'vision-model', + features: [ModelFeatureEnum.vision], + model_properties: { mode: 'chat' }, + }], + }], + } + }) + + describe('Empty states', () => { + it('should render no-provider empty state and forward manage action', () => { + const { onSetting } = renderDebug({ + contextValue: { + modelConfig: { + ...createContextValue().modelConfig, + provider: '', + model_id: '', + }, + }, + props: { + isAPIKeySet: false, + }, + }) + + expect(screen.getByText('appDebug.noModelProviderConfigured')).toBeInTheDocument() + expect(screen.getByText('appDebug.noModelProviderConfiguredTip')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'appDebug.manageModels' })) + expect(onSetting).toHaveBeenCalledTimes(1) + }) + + it('should render no-model-selected empty state when provider exists but model is missing', () => { + renderDebug({ + contextValue: { + modelConfig: { + ...createContextValue().modelConfig, + provider: 'openai', + model_id: '', + }, + }, + props: { + isAPIKeySet: true, + }, + }) + + expect(screen.getByText('appDebug.noModelSelected')).toBeInTheDocument() + expect(screen.getByText('appDebug.noModelSelectedTip')).toBeInTheDocument() + expect(screen.queryByText('appDebug.noModelProviderConfigured')).not.toBeInTheDocument() + }) + }) + + describe('Single model mode', () => { + it('should render single-model panel and refresh conversation', () => { + renderDebug() + + expect(screen.getByTestId('debug-with-single-model')).toBeInTheDocument() + + fireEvent.click(screen.getAllByTestId('action-button')[0]) + expect(mockState.mockHandleRestart).toHaveBeenCalledTimes(1) + }) + + it('should toggle chat input visibility when variable panel button is clicked', () => { + renderDebug({ + contextValue: { + inputs: { question: 'hello' }, + modelConfig: { + ...createContextValue().modelConfig, + configs: { + prompt_template: '', + prompt_variables: [{ + key: 'question', + name: 'Question', + type: 'string', + required: true, + }] as DebugContextValue['modelConfig']['configs']['prompt_variables'], + }, + }, + }, + }) + + expect(screen.getByTestId('chat-user-input')).toBeInTheDocument() + fireEvent.click(screen.getAllByTestId('action-button')[1]) + expect(screen.queryByTestId('chat-user-input')).not.toBeInTheDocument() + }) + + it('should not render refresh action when readonly is true', () => { + renderDebug({ + contextValue: { + readonly: true, + }, + }) + + expect(screen.queryByTestId('action-button')).not.toBeInTheDocument() + }) + + it('should show formatting confirmation and handle cancel', () => { + const setFormattingChanged = vi.fn() + + renderDebug({ + contextValue: { + formattingChanged: true, + setFormattingChanged, + }, + }) + + expect(screen.getByTestId('formatting-changed')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('formatting-cancel')) + expect(setFormattingChanged).toHaveBeenCalledWith(false) + }) + + it('should handle formatting confirmation with restart', () => { + const setFormattingChanged = vi.fn() + + renderDebug({ + contextValue: { + formattingChanged: true, + setFormattingChanged, + }, + }) + + fireEvent.click(screen.getByTestId('formatting-confirm')) + expect(setFormattingChanged).toHaveBeenCalledWith(false) + expect(mockState.mockHandleRestart).toHaveBeenCalledTimes(1) + }) + + it('should notify when history block is missing in advanced completion mode', () => { + const { notify } = renderDebug({ + contextValue: { + isAdvancedMode: true, + mode: AppModeEnum.CHAT, + modelModeType: ModelModeType.completion, + hasSetBlockStatus: { context: false, history: false, query: true }, + }, + }) + + fireEvent.click(screen.getByTestId('single-check-can-send')) + expect(notify).toHaveBeenCalledWith({ + type: 'error', + message: 'appDebug.otherError.historyNoBeEmpty', + }) + }) + + it('should notify when query block is missing in advanced completion mode', () => { + const { notify } = renderDebug({ + contextValue: { + isAdvancedMode: true, + mode: AppModeEnum.CHAT, + modelModeType: ModelModeType.completion, + hasSetBlockStatus: { context: false, history: true, query: false }, + }, + }) + + fireEvent.click(screen.getByTestId('single-check-can-send')) + expect(notify).toHaveBeenCalledWith({ + type: 'error', + message: 'appDebug.otherError.queryNoBeEmpty', + }) + }) + }) + + describe('Completion mode', () => { + it('should render prompt value panel and no-result placeholder', () => { + renderDebug({ + contextValue: { + mode: AppModeEnum.COMPLETION, + }, + }) + + expect(screen.getByTestId('prompt-value-panel')).toBeInTheDocument() + expect(screen.getByText('appDebug.noResult')).toBeInTheDocument() + }) + + it('should notify when required input is missing', () => { + const { notify } = renderDebug({ + contextValue: { + mode: AppModeEnum.COMPLETION, + inputs: {}, + modelConfig: { + ...createContextValue().modelConfig, + configs: { + prompt_template: '', + prompt_variables: [{ + key: 'question', + name: 'Question', + type: 'string', + required: true, + }] as DebugContextValue['modelConfig']['configs']['prompt_variables'], + }, + }, + }, + }) + + fireEvent.click(screen.getByTestId('panel-send')) + expect(notify).toHaveBeenCalledWith({ + type: 'error', + message: 'appDebug.errorMessage.valueOfVarRequired:{"key":"Question"}', + }) + expect(mockState.mockSendCompletionMessage).not.toHaveBeenCalled() + }) + + it('should notify when local file upload is still pending', () => { + const { notify } = renderDebug({ + contextValue: { + mode: AppModeEnum.COMPLETION, + modelConfig: { + ...createContextValue().modelConfig, + configs: { + prompt_template: '', + prompt_variables: [], + }, + }, + }, + }) + + fireEvent.click(screen.getByTestId('panel-set-pending-file')) + fireEvent.click(screen.getByTestId('panel-send')) + + expect(notify).toHaveBeenCalledWith({ + type: 'info', + message: 'appDebug.errorMessage.waitForFileUpload', + }) + expect(mockState.mockSendCompletionMessage).not.toHaveBeenCalled() + }) + + it('should show cannot-query-dataset warning when dataset context variable is missing', () => { + renderDebug({ + contextValue: { + mode: AppModeEnum.COMPLETION, + dataSets: [{ id: 'dataset-1' }] as DebugContextValue['dataSets'], + hasSetContextVar: false, + modelConfig: { + ...createContextValue().modelConfig, + configs: { + prompt_template: '', + prompt_variables: [], + }, + }, + }, + }) + + fireEvent.click(screen.getByTestId('panel-send')) + expect(screen.getByTestId('cannot-query-dataset')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('cannot-query-confirm')) + expect(screen.queryByTestId('cannot-query-dataset')).not.toBeInTheDocument() + }) + + it('should send completion request and render completion result', async () => { + mockState.mockText2speechDefaultModel = { provider: 'openai' } + mockState.mockFeaturesState = { + ...mockState.mockFeaturesState, + text2speech: { enabled: true }, + file: { + enabled: true, + allowed_file_upload_methods: [], + fileUploadConfig: { image_file_size_limit: 2 }, + }, + } + + mockState.mockSendCompletionMessage.mockImplementation((_appId, _data, handlers: { + onData: (chunk: string, isFirst: boolean, payload: { messageId: string }) => void + onMessageReplace: (payload: { answer: string }) => void + onCompleted: () => void + onError: () => void + }) => { + handlers.onData('hello', true, { messageId: 'msg-1' }) + handlers.onMessageReplace({ answer: 'final answer' }) + handlers.onCompleted() + }) + + renderDebug({ + contextValue: { + mode: AppModeEnum.COMPLETION, + promptMode: 'simple' as DebugContextValue['promptMode'], + textToSpeechConfig: { enabled: true, voice: 'alloy', language: 'en' }, + modelConfig: { + ...createContextValue().modelConfig, + configs: { + prompt_template: 'Prompt', + prompt_variables: [{ + key: 'question', + name: 'Question', + type: 'string', + required: true, + is_context_var: true, + }] as DebugContextValue['modelConfig']['configs']['prompt_variables'], + }, + }, + }, + props: { + inputs: { question: 'hello' }, + }, + }) + + fireEvent.click(screen.getByTestId('panel-send')) + + await waitFor(() => expect(mockState.mockSendCompletionMessage).toHaveBeenCalledTimes(1)) + const [, requestData] = mockState.mockSendCompletionMessage.mock.calls[0] + expect(requestData).toMatchObject({ + inputs: { question: 'hello' }, + model_config: { + model: { + provider: 'openai', + name: 'gpt-4', + }, + dataset_query_variable: 'question', + }, + }) + expect(screen.getByTestId('text-generation')).toHaveTextContent('final answer') + expect(screen.getByTestId('text-generation')).toHaveAttribute('data-message-id', 'msg-1') + expect(screen.getByTestId('text-generation')).toHaveAttribute('data-tts', 'true') + }) + + it('should notify when sending again while a response is in progress', async () => { + mockState.mockSendCompletionMessage.mockImplementation(() => undefined) + const { notify } = renderDebug({ + contextValue: { + mode: AppModeEnum.COMPLETION, + modelConfig: { + ...createContextValue().modelConfig, + configs: { + prompt_template: '', + prompt_variables: [], + }, + }, + }, + }) + + fireEvent.click(screen.getByTestId('panel-send')) + fireEvent.click(screen.getByTestId('panel-send')) + + await waitFor(() => expect(mockState.mockSendCompletionMessage).toHaveBeenCalledTimes(1)) + expect(notify).toHaveBeenCalledWith({ + type: 'info', + message: 'appDebug.errorMessage.waitForResponse', + }) + }) + + it('should keep remote files and reset responding state on send error', async () => { + mockState.mockFeaturesState = { + ...mockState.mockFeaturesState, + file: { + enabled: true, + allowed_file_upload_methods: [], + fileUploadConfig: undefined, + }, + } + + mockState.mockSendCompletionMessage.mockImplementation((_appId, data, handlers: { + onError: () => void + }) => { + expect(data.files).toEqual([{ + transfer_method: TransferMethod.remote_url, + url: 'https://example.com/file.png', + }]) + handlers.onError() + }) + + renderDebug({ + contextValue: { + mode: AppModeEnum.COMPLETION, + modelConfig: { + ...createContextValue().modelConfig, + configs: { + prompt_template: '', + prompt_variables: [], + }, + }, + }, + }) + + fireEvent.click(screen.getByTestId('panel-set-remote-file')) + fireEvent.click(screen.getByTestId('panel-send')) + + await waitFor(() => expect(mockState.mockSendCompletionMessage).toHaveBeenCalledTimes(1)) + expect(screen.getByText('appDebug.noResult')).toBeInTheDocument() + }) + + it('should render prompt log modal in completion mode when store flag is enabled', () => { + mockState.mockStoreState = { + ...mockState.mockStoreState, + showPromptLogModal: true, + } + + renderDebug({ + contextValue: { + mode: AppModeEnum.COMPLETION, + }, + }) + + expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument() + }) + + it('should close prompt log modal in completion mode', () => { + const setCurrentLogItem = vi.fn() + const setShowPromptLogModal = vi.fn() + + mockState.mockStoreState = { + ...mockState.mockStoreState, + currentLogItem: { id: 'log-1' }, + setCurrentLogItem, + showPromptLogModal: true, + setShowPromptLogModal, + } + + renderDebug({ + contextValue: { + mode: AppModeEnum.COMPLETION, + }, + }) + + fireEvent.click(screen.getByTestId('prompt-log-cancel')) + expect(setCurrentLogItem).toHaveBeenCalledTimes(1) + expect(setShowPromptLogModal).toHaveBeenCalledWith(false) + }) + }) + + describe('Multiple model mode', () => { + it('should append a blank model when add-model button is clicked', () => { + const onMultipleModelConfigsChange = vi.fn() + + renderDebug({ + props: { + debugWithMultipleModel: true, + multipleModelConfigs: [{ id: 'model-1', model: 'vision-model', provider: 'openai', parameters: {} }], + onMultipleModelConfigsChange, + }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'common.modelProvider.addModel(1/4)' })) + expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(true, [ + { id: 'model-1', model: 'vision-model', provider: 'openai', parameters: {} }, + expect.objectContaining({ model: '', provider: '', parameters: {} }), + ]) + }) + + it('should disable add-model button when there are already four models', () => { + renderDebug({ + props: { + debugWithMultipleModel: true, + multipleModelConfigs: [ + { id: '1', model: 'a', provider: 'p', parameters: {} }, + { id: '2', model: 'b', provider: 'p', parameters: {} }, + { id: '3', model: 'c', provider: 'p', parameters: {} }, + { id: '4', model: 'd', provider: 'p', parameters: {} }, + ], + }, + }) + + expect(screen.getByRole('button', { name: 'common.modelProvider.addModel(4/4)' })).toBeDisabled() + }) + + it('should emit completion event in multiple-model completion mode', () => { + renderDebug({ + contextValue: { + mode: AppModeEnum.COMPLETION, + modelConfig: { + ...createContextValue().modelConfig, + configs: { + prompt_template: '', + prompt_variables: [], + }, + }, + }, + props: { + debugWithMultipleModel: true, + multipleModelConfigs: [{ id: '1', model: 'vision-model', provider: 'openai', parameters: {} }], + }, + }) + + fireEvent.click(screen.getByTestId('panel-set-uploaded-file')) + fireEvent.click(screen.getByTestId('panel-send')) + + expect(mockState.mockEventEmitterEmit).toHaveBeenCalledWith({ + type: APP_CHAT_WITH_MULTIPLE_MODEL, + payload: { + message: '', + files: [{ transfer_method: TransferMethod.local_file, upload_file_id: 'file-id' }], + }, + }) + }) + + it('should emit restart event when refresh is clicked in multiple-model mode', () => { + renderDebug({ + props: { + debugWithMultipleModel: true, + multipleModelConfigs: [{ id: '1', model: 'vision-model', provider: 'openai', parameters: {} }], + }, + }) + + fireEvent.click(screen.getAllByTestId('action-button')[0]) + expect(mockState.mockEventEmitterEmit).toHaveBeenCalledWith({ + type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART, + }) + }) + + it('should switch from multiple model to single model with selected parameters', () => { + const setModel = vi.fn() + const onCompletionParamsChange = vi.fn() + const onMultipleModelConfigsChange = vi.fn() + + renderDebug({ + props: { + debugWithMultipleModel: true, + multipleModelConfigs: [{ id: 'model-1', model: 'vision-model', provider: 'openai', parameters: { temperature: 0.2 } }], + onMultipleModelConfigsChange, + modelParameterParams: { + setModel, + onCompletionParamsChange, + }, + }, + }) + + fireEvent.click(screen.getByTestId('multiple-switch-to-single')) + + expect(setModel).toHaveBeenCalledWith({ + modelId: 'vision-model', + provider: 'openai', + mode: 'chat', + features: [ModelFeatureEnum.vision], + }) + expect(onCompletionParamsChange).toHaveBeenCalledWith({ temperature: 0.2 }) + expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(false, []) + }) + + it('should update feature store according to multiple-model vision support', () => { + renderDebug({ + contextValue: { + mode: AppModeEnum.CHAT, + }, + props: { + debugWithMultipleModel: true, + multipleModelConfigs: [{ id: '1', model: 'vision-model', provider: 'openai', parameters: {} }], + }, + }) + + expect(mockState.mockSetFeatures).toHaveBeenCalledWith(expect.objectContaining({ + file: expect.objectContaining({ + enabled: true, + }), + })) + }) + + it('should render prompt and agent log modals in multiple-model mode', () => { + mockState.mockStoreState = { + ...mockState.mockStoreState, + showPromptLogModal: true, + showAgentLogModal: true, + } + + renderDebug({ + props: { + debugWithMultipleModel: true, + multipleModelConfigs: [{ id: '1', model: 'vision-model', provider: 'openai', parameters: {} }], + }, + }) + + expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument() + expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument() + }) + + it('should close prompt and agent log modals in multiple-model mode', () => { + const setCurrentLogItem = vi.fn() + const setShowPromptLogModal = vi.fn() + const setShowAgentLogModal = vi.fn() + + mockState.mockStoreState = { + ...mockState.mockStoreState, + currentLogItem: { id: 'log-1' }, + setCurrentLogItem, + showPromptLogModal: true, + setShowPromptLogModal, + showAgentLogModal: true, + setShowAgentLogModal, + } + + renderDebug({ + props: { + debugWithMultipleModel: true, + multipleModelConfigs: [{ id: '1', model: 'vision-model', provider: 'openai', parameters: {} }], + }, + }) + + fireEvent.click(screen.getByTestId('prompt-log-cancel')) + fireEvent.click(screen.getByTestId('agent-log-cancel')) + + expect(setCurrentLogItem).toHaveBeenCalledTimes(2) + expect(setShowPromptLogModal).toHaveBeenCalledWith(false) + expect(setShowAgentLogModal).toHaveBeenCalledWith(false) + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.spec.tsx index 8a3484cc1f..46c153c6d6 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.spec.tsx @@ -1,18 +1,26 @@ import type { ComponentProps } from 'react' import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' import Trigger from './trigger' +const mockUseCredentialPanelState = vi.fn() + vi.mock('../hooks', () => ({ useLanguage: () => 'en_US', })) vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ - modelProviders: [{ provider: 'openai', label: { en_US: 'OpenAI' } }], + modelProviders: [{ + provider: 'openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + }], }), })) +vi.mock('../provider-added-card/use-credential-panel-state', () => ({ + useCredentialPanelState: () => mockUseCredentialPanelState(), +})) + vi.mock('../model-icon', () => ({ default: () =>
Icon
, })) @@ -22,119 +30,195 @@ vi.mock('../model-name', () => ({ })) describe('Trigger', () => { - const currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } as unknown as ComponentProps['currentProvider'] - const currentModel = { model: 'gpt-4' } as unknown as ComponentProps['currentModel'] + const currentProvider = { + provider: 'openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + } as unknown as ComponentProps['currentProvider'] + + const currentModel = { + model: 'gpt-4', + status: 'active', + } as unknown as ComponentProps['currentModel'] beforeEach(() => { vi.clearAllMocks() + mockUseCredentialPanelState.mockReturnValue({ + variant: 'api-active', + supportsCredits: true, + isCreditsExhausted: false, + priority: 'apiKey', + showPrioritySwitcher: true, + hasCredentials: true, + credentialName: 'Primary Key', + credits: 10, + }) }) - it('should render initialized state', () => { - render( - , - ) - expect(screen.getByText('gpt-4')).toBeInTheDocument() - expect(screen.getByTestId('model-icon')).toBeInTheDocument() + describe('Rendering', () => { + it('should render initialized state when provider and model are available', () => { + render( + , + ) + + expect(screen.getByText('gpt-4')).toBeInTheDocument() + expect(screen.getByTestId('model-icon')).toBeInTheDocument() + }) + + it('should render fallback model id when current model is missing', () => { + render( + , + ) + + expect(screen.getByText('gpt-4')).toBeInTheDocument() + }) + + it('should render workflow styles when workflow mode is enabled', () => { + const { container } = render( + , + ) + + expect(container.firstChild).toHaveClass('border-workflow-block-parma-bg') + expect(container.firstChild).toHaveClass('bg-workflow-block-parma-bg') + }) + + it('should render workflow empty state when no provider or model is selected', () => { + const { container } = render() + + expect(screen.getByText('workflow:errorMsg.configureModel')).toBeInTheDocument() + expect(container.firstChild).toHaveClass('border-text-warning') + expect(container.firstChild).toHaveClass('bg-state-warning-hover') + }) }) - it('should render fallback model id when current model is missing', () => { - render( - , - ) - expect(screen.getByText('gpt-4')).toBeInTheDocument() + describe('Status badges', () => { + it('should render credits exhausted split layout in non-workflow mode', () => { + mockUseCredentialPanelState.mockReturnValue({ + variant: 'credits-exhausted', + supportsCredits: true, + isCreditsExhausted: true, + priority: 'credits', + showPrioritySwitcher: true, + hasCredentials: false, + credentialName: undefined, + credits: 0, + }) + + render( + , + ) + + expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument() + expect(screen.getByTestId('model-icon')).toBeInTheDocument() + }) + + it('should resolve provider from context when currentProvider is missing in split layout', () => { + mockUseCredentialPanelState.mockReturnValue({ + variant: 'credits-exhausted', + supportsCredits: true, + isCreditsExhausted: true, + priority: 'credits', + showPrioritySwitcher: true, + hasCredentials: false, + credentialName: undefined, + credits: 0, + }) + + render( + , + ) + + expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument() + expect(screen.getByTestId('model-icon')).toBeInTheDocument() + }) + + it('should render api unavailable split layout in non-workflow mode', () => { + mockUseCredentialPanelState.mockReturnValue({ + variant: 'api-unavailable', + supportsCredits: true, + isCreditsExhausted: false, + priority: 'apiKey', + showPrioritySwitcher: true, + hasCredentials: true, + credentialName: 'Primary Key', + credits: 0, + }) + + render( + , + ) + + expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument() + }) + + it('should render incompatible badge when deprecated model is disabled', () => { + render( + , + ) + + expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument() + }) + + it('should render warning icon when model status is disabled but not deprecated', () => { + render( + , + ) + + expect(document.querySelector('.text-\\[\\#F79009\\]')).toBeInTheDocument() + }) }) - // isInWorkflow=true: workflow border class + RiArrowDownSLine arrow - it('should render workflow styles when isInWorkflow is true', () => { - // Act - const { container } = render( - , - ) + describe('Edge cases', () => { + it('should render without crashing when providerName does not match any provider', () => { + render( + , + ) - // Assert - expect(container.firstChild).toHaveClass('border-workflow-block-parma-bg') - expect(container.firstChild).toHaveClass('bg-workflow-block-parma-bg') - expect(container.querySelectorAll('svg').length).toBe(2) - }) - - // disabled=true + hasDeprecated=true: AlertTriangle + deprecated tooltip - it('should show deprecated warning when disabled with hasDeprecated', () => { - // Act - render( - , - ) - - // Assert - AlertTriangle renders with warning color - const warningIcon = document.querySelector('.text-\\[\\#F79009\\]') - expect(warningIcon).toBeInTheDocument() - }) - - // disabled=true + modelDisabled=true: status text tooltip - it('should show model status tooltip when disabled with modelDisabled', () => { - // Act - render( - , - ) - - // Assert - AlertTriangle warning icon should be present - const warningIcon = document.querySelector('.text-\\[\\#F79009\\]') - expect(warningIcon).toBeInTheDocument() - }) - - it('should render empty tooltip content when disabled without deprecated or modelDisabled', async () => { - const user = userEvent.setup() - const { container } = render( - , - ) - const warningIcon = document.querySelector('.text-\\[\\#F79009\\]') - expect(warningIcon).toBeInTheDocument() - const trigger = container.querySelector('[data-state]') - expect(trigger).toBeInTheDocument() - await user.hover(trigger as HTMLElement) - const tooltip = screen.queryByRole('tooltip') - if (tooltip) - expect(tooltip).toBeEmptyDOMElement() - expect(screen.queryByText('modelProvider.deprecated')).not.toBeInTheDocument() - expect(screen.queryByText('No Configure')).not.toBeInTheDocument() - }) - - // providerName not matching any provider: find() returns undefined - it('should render without crashing when providerName does not match any provider', () => { - // Act - render( - , - ) - - // Assert - expect(screen.getByText('gpt-4')).toBeInTheDocument() + expect(screen.getByText('gpt-4')).toBeInTheDocument() + }) }) }) From 5709a34a7fef8978e38c75277db4a5f29f617d9c Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 11 Mar 2026 11:09:03 +0800 Subject: [PATCH 3/5] test: enhance ModelSelectorTrigger tests and integrate credential panel state - Added tests for ModelSelectorTrigger to validate rendering based on credential panel state, including handling of credits exhausted scenarios. - Updated ModelSelectorTrigger component to utilize useCredentialPanelState for determining status and rendering appropriate UI elements. - Adjusted related tests to ensure correct behavior when model quota is exceeded and when the selected model is readonly. - Improved styling for credits exhausted badge in the component. --- .../model-selector-trigger.spec.tsx | 36 +++++++++++++ .../model-selector/model-selector-trigger.tsx | 26 ++++++++-- .../provider-added-card/credential-panel.tsx | 2 +- .../update-plugin/__tests__/index.spec.tsx | 51 ++++++++++++++++++- .../update-plugin/from-market-place.tsx | 26 ++++++++-- 5 files changed, 130 insertions(+), 11 deletions(-) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.spec.tsx index 50c06b80a0..f22060c202 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.spec.tsx @@ -10,9 +10,13 @@ import { import ModelSelectorTrigger from './model-selector-trigger' const mockUseProviderContext = vi.hoisted(() => vi.fn()) +const mockUseCredentialPanelState = vi.hoisted(() => vi.fn()) vi.mock('@/context/provider-context', () => ({ useProviderContext: mockUseProviderContext, })) +vi.mock('../provider-added-card/use-credential-panel-state', () => ({ + useCredentialPanelState: mockUseCredentialPanelState, +})) const createModelItem = (overrides: Partial = {}): ModelItem => ({ model: 'gpt-4', @@ -48,6 +52,16 @@ describe('ModelSelectorTrigger', () => { mockUseProviderContext.mockReturnValue({ modelProviders: [createModel()], }) + mockUseCredentialPanelState.mockReturnValue({ + variant: 'credits-active', + priority: 'credits', + supportsCredits: true, + showPrioritySwitcher: true, + hasCredentials: false, + isCreditsExhausted: false, + credentialName: undefined, + credits: 100, + }) }) describe('Rendering', () => { @@ -132,6 +146,28 @@ describe('ModelSelectorTrigger', () => { expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument() }) + it('should apply credits exhausted badge style when model quota is exceeded', () => { + mockUseCredentialPanelState.mockReturnValue({ + variant: 'credits-exhausted', + priority: 'credits', + supportsCredits: true, + showPrioritySwitcher: true, + hasCredentials: false, + isCreditsExhausted: true, + credentialName: undefined, + credits: 0, + }) + + render( + , + ) + + expect(screen.getByText('common.modelProvider.selector.creditsExhausted').parentElement).toHaveClass('bg-components-badge-bg-dimm') + }) + it('should not show status badge when selected model is readonly', () => { render( > = { [ModelStatusEnum.quotaExceeded]: 'modelProvider.selector.creditsExhausted', @@ -47,10 +48,22 @@ const ModelSelectorTrigger: FC = ({ const isSelected = !!currentProvider && !!currentModel const isDeprecated = !isSelected && !!defaultModel const isEmpty = !isSelected && !defaultModel + const selectedProvider = isSelected + ? modelProviders.find(provider => provider.provider === currentProvider.provider) + : undefined + const selectedProviderState = useCredentialPanelState(selectedProvider) + const shouldShowCreditsExhausted = isSelected + && selectedProviderState.priority === 'credits' + && selectedProviderState.supportsCredits + && selectedProviderState.isCreditsExhausted + const effectiveStatus = shouldShowCreditsExhausted + ? ModelStatusEnum.quotaExceeded + : currentModel?.status - const isActive = isSelected && currentModel.status === ModelStatusEnum.active + const isActive = isSelected && effectiveStatus === ModelStatusEnum.active const isDisabled = isDeprecated || (isSelected && !isActive) - const statusI18nKey = isSelected ? STATUS_I18N_KEY[currentModel.status] : undefined + const statusI18nKey = isSelected && effectiveStatus ? STATUS_I18N_KEY[effectiveStatus] : undefined + const isCreditsExhausted = isSelected && effectiveStatus === ModelStatusEnum.quotaExceeded const deprecatedProvider = isDeprecated ? modelProviders.find(p => p.provider === defaultModel.provider) @@ -107,9 +120,14 @@ const ModelSelectorTrigger: FC = ({ {isSelected && !readonly && !isActive && statusI18nKey && ( +
{t(statusI18nKey as 'modelProvider.selector.creditsExhausted', { ns: 'common' })} diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx index 6db3f4d7fb..0d8b12c05b 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx @@ -96,7 +96,7 @@ function StatusLabel({ variant, credentialName }: { {credentialName} {showWarning && ( - + )} {variant === 'api-unavailable' && ( diff --git a/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx index 5404bfc2d6..73fb132850 100644 --- a/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx @@ -701,8 +701,8 @@ describe('update-plugin', () => { }) }) - it('should show error toast when task status is failed', async () => { - // Arrange - covers lines 99-100 + it('should reset loading state when task status check fails', async () => { + // Arrange const mockToastNotify = vi.fn() vi.mocked(await import('../../../base/toast')).default.notify = mockToastNotify @@ -739,6 +739,53 @@ describe('update-plugin', () => { }) // onSave should NOT be called when task fails expect(onSave).not.toHaveBeenCalled() + await waitFor(() => { + expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() + }) + + it('should stop loading when upgrade API returns failed task directly', async () => { + // Arrange + const mockToastNotify = vi.fn() + vi.mocked(await import('../../../base/toast')).default.notify = mockToastNotify + + mockUpdateFromMarketPlace.mockResolvedValue({ + task: { + status: TaskStatus.failed, + plugins: [{ + plugin_unique_identifier: 'test-target-id', + status: TaskStatus.failed, + message: 'failed to init environment', + }], + }, + }) + const onSave = vi.fn() + const payload = createMockMarketPlacePayload() + + // Act + renderWithQueryClient( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })) + + // Assert + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'failed to init environment', + }) + }) + expect(mockCheck).not.toHaveBeenCalled() + expect(onSave).not.toHaveBeenCalled() + await waitFor(() => { + expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/update-plugin/from-market-place.tsx b/web/app/components/plugins/update-plugin/from-market-place.tsx index 8040103a7c..8bfe3a0a59 100644 --- a/web/app/components/plugins/update-plugin/from-market-place.tsx +++ b/web/app/components/plugins/update-plugin/from-market-place.tsx @@ -33,6 +33,16 @@ type Props = { isShowDowngradeWarningModal?: boolean } +type FailedUpgradeResponse = { + task?: { + status?: TaskStatus + plugins?: Array<{ + plugin_unique_identifier: string + message: string + }> + } +} + enum UploadStep { notStarted = 'notStarted', upgrading = 'upgrading', @@ -83,13 +93,20 @@ const UpdatePluginModal: FC = ({ if (uploadStep === UploadStep.notStarted) { setUploadStep(UploadStep.upgrading) try { + const response = await updateFromMarketPlace({ + original_plugin_unique_identifier: originalPackageInfo.id, + new_plugin_unique_identifier: targetPackageInfo.id, + }) as Awaited> & FailedUpgradeResponse + + if (response.task?.status === TaskStatus.failed) { + setUploadStep(UploadStep.notStarted) + return + } + const { all_installed: isInstalled, task_id: taskId, - } = await updateFromMarketPlace({ - original_plugin_unique_identifier: originalPackageInfo.id, - new_plugin_unique_identifier: targetPackageInfo.id, - }) + } = response if (isInstalled) { onSave() @@ -102,6 +119,7 @@ const UpdatePluginModal: FC = ({ }) if (status === TaskStatus.failed) { Toast.notify({ type: 'error', message: error! }) + setUploadStep(UploadStep.notStarted) return } onSave() From 250450a54e1f9fb45974ebb6121415342c4144e0 Mon Sep 17 00:00:00 2001 From: yyh Date: Wed, 11 Mar 2026 11:40:40 +0800 Subject: [PATCH 4/5] fix: use primary button variant for api-required-add credential state Align the "Add API Key" button to Figma design by switching from secondary-accent to primary variant (blue bg + white text) for providers with no AI credits and no API key configured. --- .../model-auth-dropdown/index.spec.tsx | 4 ++-- .../model-auth-dropdown/index.tsx | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.spec.tsx index af0137417a..e1bec972f0 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.spec.tsx @@ -156,7 +156,7 @@ describe('ModelAuthDropdown', () => { }) describe('Button variant styling', () => { - it('should use secondary-accent for api-required-add', () => { + it('should use primary for api-required-add', () => { const { container } = render( { />, ) const button = container.querySelector('button') - expect(button?.getAttribute('data-variant') ?? button?.className).toMatch(/accent/) + expect(button?.getAttribute('data-variant') ?? button?.className).toMatch(/primary/) }) it('should use secondary-accent for api-required-configure', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx index 780de9fe6e..da7394a407 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.tsx @@ -17,17 +17,17 @@ type ModelAuthDropdownProps = { onChangePriority: (key: PreferredProviderTypeEnum) => void } -const ACCENT_VARIANTS = new Set([ - 'api-required-add', - 'api-required-configure', -]) - function getButtonConfig(variant: CardVariant, hasCredentials: boolean, t: (key: string, opts?: Record) => string) { - if (ACCENT_VARIANTS.has(variant)) { + if (variant === 'api-required-add') { return { - text: variant === 'api-required-add' - ? t('modelProvider.auth.addApiKey', { ns: 'common' }) - : t('operation.config', { ns: 'common' }), + text: t('modelProvider.auth.addApiKey', { ns: 'common' }), + variant: 'primary' as const, + } + } + + if (variant === 'api-required-configure') { + return { + text: t('operation.config', { ns: 'common' }), variant: 'secondary-accent' as const, } } From 08da3906783466ec13f0c5ccc097335934a3e4b3 Mon Sep 17 00:00:00 2001 From: yyh Date: Wed, 11 Mar 2026 11:56:50 +0800 Subject: [PATCH 5/5] fix: use destructive text color for api-unavailable credential name and remove redundant Unavailable label The card-level StatusLabel now shows a red credential name for the api-unavailable variant to match the Figma design. The "Unavailable" text was removed since it only belongs inside the dropdown key list. --- .../credential-panel.spec.tsx | 24 +++++++++++++++++-- .../provider-added-card/credential-panel.tsx | 11 +++------ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx index e4a0e409f7..387f2d9f17 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx @@ -212,7 +212,7 @@ describe('CredentialPanel', () => { expect(screen.queryByTestId('warning-icon')).not.toBeInTheDocument() }) - it('should show red indicator and "Unavailable" for api-unavailable (exhausted + named unauthorized key)', () => { + it('should show red indicator and credential name for api-unavailable (exhausted + named unauthorized key)', () => { mockTrialCredits.isExhausted = true renderWithQueryClient(createProvider({ preferred_provider_type: PreferredProviderTypeEnum.custom, @@ -224,7 +224,6 @@ describe('CredentialPanel', () => { }, })) expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'red') - expect(screen.getByText(/unavailable/i)).toBeInTheDocument() expect(screen.getByText('Bad Key')).toBeInTheDocument() }) }) @@ -303,6 +302,27 @@ describe('CredentialPanel', () => { const { container } = renderWithQueryClient(createProvider()) expect(container.querySelector('.text-text-secondary')).toBeTruthy() }) + + it('should use destructive text color for api-unavailable credential name', () => { + mockTrialCredits.isExhausted = true + renderWithQueryClient(createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: 'Bad Key', + available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], + }, + })) + expect(screen.getByText('Bad Key')).toHaveClass('text-text-destructive') + }) + + it('should use secondary text color for api-active credential name', () => { + renderWithQueryClient(createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + })) + expect(screen.getByText('test-credential')).toHaveClass('text-text-secondary') + }) }) describe('Priority change', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx index 0d8b12c05b..67a30740e6 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx @@ -82,15 +82,15 @@ function StatusLabel({ variant, credentialName }: { variant: CardVariant credentialName: string | undefined }) { - const { t } = useTranslation() - const dotColor = variant === 'api-unavailable' ? 'red' : 'green' + const isDestructive = isDestructiveVariant(variant) + const dotColor = isDestructive ? 'red' : 'green' const showWarning = variant === 'api-fallback' return ( <> {credentialName} @@ -98,11 +98,6 @@ function StatusLabel({ variant, credentialName }: { {showWarning && ( )} - {variant === 'api-unavailable' && ( - - {t('modelProvider.card.unavailable', { ns: 'common' })} - - )} ) }