mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
fix(web): initialize empty agent models from workspace defaults (#42736)
This commit is contained in:
9 files changed
+515
-14
No files matched your search
@@ -9,9 +9,11 @@ import { agentComposerDraftAtom, agentComposerSavedDraftAtom } from './store'
|
||||
export function AgentComposerProvider({
|
||||
children,
|
||||
initialDraft,
|
||||
initialSavedDraft = initialDraft,
|
||||
}: {
|
||||
children: ReactNode
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
initialSavedDraft?: AgentSoulConfigFormState
|
||||
}) {
|
||||
const draft = initialDraft ?? defaultAgentSoulConfigFormState
|
||||
|
||||
@@ -19,7 +21,7 @@ export function AgentComposerProvider({
|
||||
<ScopeProvider
|
||||
atoms={[
|
||||
[agentComposerDraftAtom, draft],
|
||||
[agentComposerSavedDraftAtom, draft],
|
||||
[agentComposerSavedDraftAtom, initialSavedDraft ?? draft],
|
||||
]}
|
||||
name="AgentComposer"
|
||||
>
|
||||
|
||||
@@ -150,6 +150,9 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
useQuery: vi.fn((options: { queryKey?: readonly string[] }) => {
|
||||
const queryKey = options.queryKey?.[0]
|
||||
|
||||
if (queryKey === 'default-model') {
|
||||
return { data: { data: modelHooksState.defaultTextGenerationModel }, isPending: false }
|
||||
}
|
||||
if (queryKey === 'agent') return mocks.queryState.agent
|
||||
if (queryKey === 'composer') return mocks.queryState.composer
|
||||
if (queryKey === 'version') return mocks.queryState.version
|
||||
@@ -212,6 +215,13 @@ vi.mock('@/service/console', () => ({
|
||||
queryOptions: () => ({ queryKey: ['system-features'] }),
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
current: {
|
||||
defaultModel: {
|
||||
get: { queryOptions: () => ({ queryKey: ['default-model'] }) },
|
||||
},
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
get: {
|
||||
key: () => ['agents'],
|
||||
@@ -317,6 +327,7 @@ vi.mock('../components/orchestrate', async () => {
|
||||
return {
|
||||
AgentOrchestratePanel: (props: {
|
||||
bottomAction?: ReactNode
|
||||
currentModel?: { model: string }
|
||||
isBuildDraftActive?: boolean
|
||||
onExitVersions?: () => void
|
||||
onOpenVersions?: () => void
|
||||
@@ -333,6 +344,7 @@ vi.mock('../components/orchestrate', async () => {
|
||||
<span>{`readonly:${props.readOnly ? 'yes' : 'no'}`}</span>
|
||||
<span>{`publish:${props.showPublishBar ? 'yes' : 'no'}`}</span>
|
||||
<span>{`prompt:${prompt}`}</span>
|
||||
<span>{`model:${props.currentModel?.model ?? 'none'}`}</span>
|
||||
<button type="button" onClick={() => setPrompt('edited draft prompt')}>
|
||||
edit prompt
|
||||
</button>
|
||||
@@ -2340,7 +2352,62 @@ describe('AgentConfigurePage', () => {
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should require an explicit model even when the workspace has a default', async () => {
|
||||
it('should save the workspace default model before starting a build', async () => {
|
||||
const queryClient = createQueryClient()
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
mocks.queryState.buildDraft = {
|
||||
data: undefined as unknown,
|
||||
dataUpdatedAt: 0,
|
||||
error: new Response(null, { status: 404 }),
|
||||
isFetching: false,
|
||||
isError: true,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.checkoutBuildDraft).toHaveBeenCalled()
|
||||
})
|
||||
expect(mocks.saveComposerDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
model_provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
expectFirstMockCallBefore(mocks.saveComposerDraft, mocks.checkoutBuildDraft)
|
||||
})
|
||||
|
||||
it('should require a model when neither the draft nor the workspace has one', async () => {
|
||||
modelHooksState.defaultTextGenerationModel = undefined
|
||||
const queryClient = createQueryClient()
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
@@ -3380,6 +3447,9 @@ describe('AgentConfigurePage', () => {
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent(
|
||||
'model:none',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'discard build draft' }))
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -3401,6 +3471,11 @@ describe('AgentConfigurePage', () => {
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent(
|
||||
'model:gpt-4o-mini',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep exiting build draft when debug conversation refresh fails after discarding build draft', async () => {
|
||||
|
||||
+103
-1
@@ -13,6 +13,7 @@ import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-compo
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerSavedDraftAtom,
|
||||
isAgentComposerDirtyAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files'
|
||||
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
|
||||
@@ -240,6 +241,7 @@ function renderUseAgentConfigureSync({
|
||||
agentName = 'Agent',
|
||||
baseConfig,
|
||||
currentModel,
|
||||
savedDraft,
|
||||
enabled = true,
|
||||
publishEnabled,
|
||||
suspend = false,
|
||||
@@ -247,6 +249,7 @@ function renderUseAgentConfigureSync({
|
||||
agentName?: Parameters<typeof useAgentConfigureSync>[0]['agentName']
|
||||
baseConfig?: Parameters<typeof useAgentConfigureSync>[0]['baseConfig']
|
||||
currentModel?: AgentSoulConfigFormState['model']
|
||||
savedDraft?: AgentSoulConfigFormState
|
||||
enabled?: boolean
|
||||
publishEnabled?: boolean
|
||||
suspend?: boolean
|
||||
@@ -266,7 +269,7 @@ function renderUseAgentConfigureSync({
|
||||
? { ...defaultAgentSoulConfigFormState, model: currentModel }
|
||||
: defaultAgentSoulConfigFormState
|
||||
store.set(agentComposerDraftAtom, initialDraft)
|
||||
store.set(agentComposerSavedDraftAtom, initialDraft)
|
||||
store.set(agentComposerSavedDraftAtom, savedDraft ?? initialDraft)
|
||||
const pendingRender = new Promise<void>(() => {})
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -499,6 +502,105 @@ describe('useAgentConfigureSync', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('autosaves an initialized default model without another edit', async () => {
|
||||
const { store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
savedDraft: defaultAgentSoulConfigFormState,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
model_provider: configuredModel.provider,
|
||||
model: configuredModel.model,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(store.get(isAgentComposerDirtyAtom)).toBe(false)
|
||||
})
|
||||
|
||||
it('serializes a newly selected model after an in-flight default model save', async () => {
|
||||
const savingDefault = createDeferredPromise<{ agent_soul: Record<string, unknown> }>()
|
||||
composerPutMutationFn.mockReturnValueOnce(savingDefault.promise)
|
||||
const { store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
savedDraft: defaultAgentSoulConfigFormState,
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
const selectedModel = { ...configuredModel, model: 'gpt-4o' }
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...store.get(agentComposerDraftAtom),
|
||||
model: selectedModel,
|
||||
})
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
const savingSelection = createDeferredPromise<{ agent_soul: Record<string, unknown> }>()
|
||||
composerPutMutationFn.mockReturnValueOnce(savingSelection.promise)
|
||||
await act(async () => {
|
||||
savingDefault.resolve({ agent_soul: {} })
|
||||
})
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).model).toEqual(selectedModel)
|
||||
expect(store.get(isAgentComposerDirtyAtom)).toBe(true)
|
||||
expect(composerPutMutationFn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
model: expect.objectContaining({ model: selectedModel.model }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
savingSelection.resolve({ agent_soul: {} })
|
||||
})
|
||||
expect(store.get(agentComposerDraftAtom).model).toEqual(selectedModel)
|
||||
expect(store.get(isAgentComposerDirtyAtom)).toBe(false)
|
||||
})
|
||||
|
||||
it('autosaves an existing dirty model when saving becomes enabled', async () => {
|
||||
const { rerender } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
savedDraft: defaultAgentSoulConfigFormState,
|
||||
enabled: false,
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
|
||||
rerender({
|
||||
agentName: 'Agent',
|
||||
baseConfig: undefined,
|
||||
currentModel: configuredModel,
|
||||
enabled: true,
|
||||
suspend: false,
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should cancel pending autosave when the draft returns to the saved baseline', async () => {
|
||||
const { queryClient, store } = renderUseAgentConfigureSync()
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type {
|
||||
DefaultModelDataResponse,
|
||||
SimpleProviderEntityResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import {
|
||||
agentComposerSavedDraftAtom,
|
||||
isAgentComposerDirtyAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
|
||||
import { AgentConfigureComposerProvider } from '../composer-provider'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getDefaultModel: vi.fn<() => Promise<DefaultModelDataResponse>>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', () => ({
|
||||
consoleQuery: {
|
||||
workspaces: {
|
||||
current: {
|
||||
defaultModel: {
|
||||
get: {
|
||||
queryOptions: ({ enabled }: { enabled: boolean }) => ({
|
||||
queryKey: ['default-model'],
|
||||
queryFn: mocks.getDefaultModel,
|
||||
enabled,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
function ModelEditor() {
|
||||
const [model, setModel] = useAtom(agentComposerModelAtom)
|
||||
const savedDraft = useAtomValue(agentComposerSavedDraftAtom)
|
||||
const isDirty = useAtomValue(isAgentComposerDirtyAtom)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label>
|
||||
Model
|
||||
<input
|
||||
value={model?.model ?? ''}
|
||||
onChange={(event) =>
|
||||
setModel({ provider: 'langgenius/openai/openai', model: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<output aria-label="Saved model">{savedDraft?.model?.model ?? 'None'}</output>
|
||||
<output aria-label="Unsaved changes">{String(isDirty)}</output>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderComposer(initialConfig: AgentSoulConfig = {}, initializeDefaultModel = true) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigureComposerProvider
|
||||
initialConfig={initialConfig}
|
||||
initializeDefaultModel={initializeDefaultModel}
|
||||
>
|
||||
<ModelEditor />
|
||||
</AgentConfigureComposerProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
return queryClient
|
||||
}
|
||||
|
||||
const provider: SimpleProviderEntityResponse = {
|
||||
provider: 'langgenius/openai/openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
supported_model_types: ['llm'],
|
||||
tenant_id: 'workspace-1',
|
||||
}
|
||||
|
||||
const defaultModel: DefaultModelDataResponse = {
|
||||
data: { model: 'gpt-4o-mini', model_type: 'llm', provider },
|
||||
}
|
||||
|
||||
describe('Agent configure default model initialization', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getDefaultModel.mockResolvedValue(defaultModel)
|
||||
})
|
||||
|
||||
it('waits for the default before exposing an editable draft and preserves the unsaved baseline', async () => {
|
||||
let resolve!: (value: DefaultModelDataResponse) => void
|
||||
mocks.getDefaultModel.mockReturnValue(
|
||||
new Promise((promiseResolve) => {
|
||||
resolve = promiseResolve
|
||||
}),
|
||||
)
|
||||
renderComposer()
|
||||
|
||||
expect(screen.queryByRole('textbox', { name: 'Model' })).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => resolve(defaultModel))
|
||||
|
||||
expect(await screen.findByRole('textbox', { name: 'Model' })).toHaveValue('gpt-4o-mini')
|
||||
expect(screen.getByLabelText('Saved model')).toHaveTextContent('None')
|
||||
expect(screen.getByLabelText('Unsaved changes')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('preserves an explicitly configured model without fetching the default', () => {
|
||||
renderComposer({
|
||||
model: {
|
||||
model: 'configured-model',
|
||||
model_provider: 'openai',
|
||||
plugin_id: 'langgenius/openai',
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'Model' })).toHaveValue('configured-model')
|
||||
expect(screen.getByLabelText('Saved model')).toHaveTextContent('configured-model')
|
||||
expect(screen.getByLabelText('Unsaved changes')).toHaveTextContent('false')
|
||||
expect(mocks.getDefaultModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves model-less configurations unchanged when initialization is disabled', () => {
|
||||
renderComposer({}, false)
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'Model' })).toHaveValue('')
|
||||
expect(screen.getByLabelText('Unsaved changes')).toHaveTextContent('false')
|
||||
expect(mocks.getDefaultModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['missing', 'error'] as const)(
|
||||
'allows manual selection when the default is %s',
|
||||
async (result) => {
|
||||
const user = userEvent.setup()
|
||||
if (result === 'missing') mocks.getDefaultModel.mockResolvedValue({ data: null })
|
||||
else mocks.getDefaultModel.mockRejectedValue(new Error('Default model unavailable'))
|
||||
renderComposer()
|
||||
|
||||
const input = await screen.findByRole('textbox', { name: 'Model' })
|
||||
expect(input).toHaveValue('')
|
||||
await user.type(input, 'manual-model')
|
||||
|
||||
expect(input).toHaveValue('manual-model')
|
||||
expect(screen.getByLabelText('Unsaved changes')).toHaveTextContent('true')
|
||||
},
|
||||
)
|
||||
|
||||
it('preserves the user selection when another consumer refreshes the default model', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = renderComposer()
|
||||
const input = await screen.findByRole('textbox', { name: 'Model' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'manual-model')
|
||||
|
||||
mocks.getDefaultModel.mockResolvedValue({
|
||||
data: { model: 'new-default', model_type: 'llm', provider },
|
||||
})
|
||||
await act(async () => {
|
||||
await queryClient.query({ queryKey: ['default-model'], queryFn: mocks.getDefaultModel })
|
||||
})
|
||||
|
||||
expect(input).toHaveValue('manual-model')
|
||||
expect(screen.getByLabelText('Saved model')).toHaveTextContent('None')
|
||||
expect(screen.getByLabelText('Unsaved changes')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { AgentConfigurePageLoading } from './page-loading'
|
||||
|
||||
export function AgentConfigureComposerProvider({
|
||||
children,
|
||||
initialConfig,
|
||||
initializeDefaultModel,
|
||||
}: {
|
||||
children: ReactNode
|
||||
initialConfig?: AgentSoulConfig
|
||||
initializeDefaultModel: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const [session, setSession] = useState<{
|
||||
draft: AgentSoulConfigFormState
|
||||
savedDraft: AgentSoulConfigFormState
|
||||
}>()
|
||||
const savedDraft = agentSoulConfigToFormState(initialConfig)
|
||||
const needsDefaultModel = initializeDefaultModel && !savedDraft.model
|
||||
const defaultModelQuery = useQuery(
|
||||
consoleQuery.workspaces.current.defaultModel.get.queryOptions({
|
||||
input: { query: { model_type: 'llm' } },
|
||||
enabled: !session && needsDefaultModel,
|
||||
}),
|
||||
)
|
||||
|
||||
if (!session) {
|
||||
if (needsDefaultModel && defaultModelQuery.isPending) {
|
||||
return <AgentConfigurePageLoading label={t(($) => $['agentDetail.sections.configure'])} />
|
||||
}
|
||||
|
||||
const defaultModel = defaultModelQuery.data?.data
|
||||
// Capture defaults once for this keyed form session. Later query responses must not reset edits.
|
||||
setSession({
|
||||
savedDraft,
|
||||
draft:
|
||||
needsDefaultModel && defaultModel
|
||||
? {
|
||||
...savedDraft,
|
||||
model: {
|
||||
provider: defaultModel.provider.provider,
|
||||
model: defaultModel.model,
|
||||
},
|
||||
}
|
||||
: savedDraft,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentComposerProvider initialDraft={session.draft} initialSavedDraft={session.savedDraft}>
|
||||
{children}
|
||||
</AgentComposerProvider>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
import { rebaseAgentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
|
||||
import {
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
} from '../use-agent-configure-build-draft'
|
||||
import { useAgentConfigureSessionController } from '../use-agent-configure-session-controller'
|
||||
import { useAgentConfigureSync } from '../use-agent-configure-sync'
|
||||
import { AgentConfigureComposerProvider } from './composer-provider'
|
||||
import { AgentConfigureClearSessionConfirmDialog } from './confirm-clear-session-dialog'
|
||||
import { AgentOrchestratePanel } from './orchestrate'
|
||||
import { AgentBuildDraftBar } from './orchestrate/build-draft-bar'
|
||||
@@ -225,9 +225,15 @@ function AgentConfigurePageComposerSession({
|
||||
]}
|
||||
name="AgentConfigureConversation"
|
||||
>
|
||||
<AgentComposerProvider
|
||||
<AgentConfigureComposerProvider
|
||||
key={composerSessionKey}
|
||||
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
|
||||
initialConfig={buildDraft.agentSoulConfig}
|
||||
initializeDefaultModel={
|
||||
configureData.capabilities.canEdit &&
|
||||
configureData.composerQuery.isSuccess &&
|
||||
!isViewingVersion &&
|
||||
!buildDraft.isActive
|
||||
}
|
||||
>
|
||||
<AgentConfigurePageComposerContent
|
||||
agentId={agentId}
|
||||
@@ -247,7 +253,7 @@ function AgentConfigurePageComposerSession({
|
||||
onRefreshPreviewConversation={refreshPreviewConversation}
|
||||
onSelectVersion={onSelectVersion}
|
||||
/>
|
||||
</AgentComposerProvider>
|
||||
</AgentConfigureComposerProvider>
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
@@ -349,7 +355,7 @@ function AgentConfigurePageComposerContent({
|
||||
},
|
||||
[rebaseComposerDraft],
|
||||
)
|
||||
const [currentModel, setConfigureModel] = useAtom(agentComposerModelAtom)
|
||||
const [currentModel, setCurrentModel] = useAtom(agentComposerModelAtom)
|
||||
const { isPublishing, publishDraft, saveDraft } = useAgentConfigureSync({
|
||||
agentId,
|
||||
agentName: agentQuery.data?.name,
|
||||
@@ -511,7 +517,7 @@ function AgentConfigurePageComposerContent({
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onSelectModel={setConfigureModel}
|
||||
onSelectModel={setCurrentModel}
|
||||
onPublish={publishDraft}
|
||||
onOpenVersions={() => {
|
||||
workingDirectoryPanel.closeWorkingDirectory()
|
||||
|
||||
+73
-1
@@ -3,9 +3,15 @@ import type { AgentComposerModel } from '@/features/agent-v2/agent-composer/form
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { AgentModelField } from '../field'
|
||||
|
||||
const providerSummary = vi.hoisted(() => vi.fn())
|
||||
const credentialPanelState = vi.hoisted(() => vi.fn())
|
||||
|
||||
const modelList = vi.hoisted(() => vi.fn<() => Promise<{ data: ProviderWithModelsResponse[] }>>())
|
||||
|
||||
@@ -21,7 +27,7 @@ vi.mock('@/service/use-common', () => ({
|
||||
vi.mock(
|
||||
'@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state',
|
||||
() => ({
|
||||
useCredentialPanelState: () => ({ variant: 'api-unavailable', hasCredentials: false }),
|
||||
useCredentialPanelState: credentialPanelState,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -75,6 +81,7 @@ describe('AgentModelField', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
providerSummary.mockResolvedValue({ data: [] })
|
||||
credentialPanelState.mockReturnValue({ variant: 'api-unavailable', hasCredentials: false })
|
||||
})
|
||||
|
||||
it('disables model controls until the catalog loads without selecting a model', async () => {
|
||||
@@ -147,6 +154,71 @@ describe('AgentModelField', () => {
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['compatible', 'incompatible'] as const)(
|
||||
'waits for the catalog after provider metadata before showing a %s model status',
|
||||
async (compatibility) => {
|
||||
const modelId = compatibility === 'compatible' ? 'gemini-3.8-flash' : 'gemini-2.0-flash'
|
||||
const modelLabel = compatibility === 'compatible' ? 'Gemini 3.8 Flash' : 'Gemini 2.0 Flash'
|
||||
const provider: ProviderWithModelsResponse = {
|
||||
tenant_id: 'test-workspace',
|
||||
provider: 'google',
|
||||
label: { en_US: 'Google', zh_Hans: 'Google' },
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
icon_small_dark: { en_US: '', zh_Hans: '' },
|
||||
status: ModelStatusEnum.active,
|
||||
models: [
|
||||
{
|
||||
model: modelId,
|
||||
label: { en_US: modelLabel, zh_Hans: modelLabel },
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
features: [],
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: { mode: 'chat' },
|
||||
load_balancing_enabled: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
let resolveCatalog!: (value: { data: ProviderWithModelsResponse[] }) => void
|
||||
modelList.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveCatalog = resolve
|
||||
}),
|
||||
)
|
||||
providerSummary.mockResolvedValue({ data: [provider] })
|
||||
credentialPanelState.mockReturnValue({ variant: 'api-active', hasCredentials: true })
|
||||
const { queryClient, onSelect } = renderField({ provider: 'google', model: modelId })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryClient.getQueryState(['providers'])?.status).toBe('success')
|
||||
})
|
||||
expect(screen.getByRole('button', { name: modelId })).toBeDisabled()
|
||||
expect(
|
||||
screen.queryByText('common.modelProvider.selector.incompatible'),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
resolveCatalog({ data: [provider] })
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: new RegExp(modelLabel) })).toBeEnabled()
|
||||
})
|
||||
if (compatibility === 'compatible') {
|
||||
expect(screen.getByRole('button', { name: modelLabel })).toHaveAttribute(
|
||||
'data-model-status',
|
||||
'active',
|
||||
)
|
||||
expect(
|
||||
screen.queryByText('common.modelProvider.selector.incompatible'),
|
||||
).not.toBeInTheDocument()
|
||||
} else {
|
||||
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
|
||||
}
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps a failed initial load disabled and lets the user retry', async () => {
|
||||
const user = userEvent.setup()
|
||||
modelList.mockRejectedValueOnce(new Error('Unavailable')).mockResolvedValue({ data: [] })
|
||||
|
||||
@@ -405,7 +405,13 @@ export function useAgentConfigureBuildDraftActions({
|
||||
queryKey: buildDraftQueryOptions.queryKey,
|
||||
})
|
||||
rebaseComposerDraft(nextAgentSoulConfig)
|
||||
if (shouldRefetchComposer) onComposerRebased?.()
|
||||
if (
|
||||
shouldRefetchComposer ||
|
||||
!nextAgentSoulConfig?.model?.model ||
|
||||
!nextAgentSoulConfig.model.model_provider
|
||||
) {
|
||||
onComposerRebased?.()
|
||||
}
|
||||
},
|
||||
[
|
||||
buildDraftQueryOptions.queryKey,
|
||||
|
||||
@@ -374,7 +374,7 @@ export function useAgentConfigureSync({
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return store.sub(agentComposerDraftAtom, () => {
|
||||
const scheduleDirtyDraftSave = () => {
|
||||
const agentSoulDraft = getAgentSoulDraft()
|
||||
const agentSoulDraftKey = JSON.stringify(agentSoulDraft)
|
||||
const isDirty = store.get(isAgentComposerDirtyAtom)
|
||||
@@ -389,8 +389,11 @@ export function useAgentConfigureSync({
|
||||
}
|
||||
|
||||
debouncedSaveDraft()
|
||||
})
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, store])
|
||||
}
|
||||
|
||||
scheduleDirtyDraftSave()
|
||||
return store.sub(agentComposerDraftAtom, scheduleDirtyDraftSave)
|
||||
}, [debouncedSaveDraft, enabled, getAgentSoulDraft, store])
|
||||
|
||||
useEffect(() => {
|
||||
const saveDraftWhenPageHidden = () => {
|
||||
|
||||
Reference in new issue
Block a user