mirror of
https://github.com/langgenius/dify.git
synced 2026-07-28 23:59:34 +08:00
refactor: make agent composer save and publish state consistent (#39637)
This commit is contained in:
parent
3c80857ea3
commit
481354ca70
@ -4,8 +4,7 @@ import { getDefaultStore } from 'jotai'
|
||||
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
agentComposerSavedDraftAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { renderWorkflowHook } from '../../../__tests__/workflow-test-env'
|
||||
@ -591,13 +590,11 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const store = getDefaultStore()
|
||||
store.set(agentComposerOriginalConfigAtom, undefined)
|
||||
store.set(agentComposerOriginalDraftAtom, defaultAgentSoulConfigFormState)
|
||||
store.set(agentComposerSavedDraftAtom, defaultAgentSoulConfigFormState)
|
||||
store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState)
|
||||
})
|
||||
|
||||
it('saves inline agent composer changes through the workflow node composer API', async () => {
|
||||
vi.setSystemTime(1710000300000)
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@ -667,7 +664,6 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
},
|
||||
expect.any(Object),
|
||||
)
|
||||
await waitFor(() => expect(result.current.draftSavedAt).toBe(1710000300000))
|
||||
expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual(
|
||||
expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
@ -851,7 +847,6 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
|
||||
expect(mockComposerMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toBeUndefined()
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('saves the effective inline model when the form draft is unchanged', async () => {
|
||||
|
||||
@ -7,7 +7,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import isEqual from 'fast-deep-equal'
|
||||
import { useStore as useJotaiStore, useSetAtom } from 'jotai'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import {
|
||||
agentSoulConfigToFormState,
|
||||
@ -15,8 +15,7 @@ import {
|
||||
} from '@/features/agent-v2/agent-composer/conversions'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
agentComposerSavedDraftAtom,
|
||||
isAgentComposerDirtyAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -77,9 +76,7 @@ export function useWorkflowInlineAgentConfigureSync({
|
||||
const queryClient = useQueryClient()
|
||||
const configsMap = useHooksStore((state) => state.configsMap)
|
||||
const store = useJotaiStore()
|
||||
const setOriginalConfig = useSetAtom(agentComposerOriginalConfigAtom)
|
||||
const setOriginalDraft = useSetAtom(agentComposerOriginalDraftAtom)
|
||||
const [draftSavedAt, setDraftSavedAt] = useState<number | undefined>(undefined)
|
||||
const setSavedDraft = useSetAtom(agentComposerSavedDraftAtom)
|
||||
const baseConfigRef = useRef(baseConfig)
|
||||
const currentModelRef = useRef(currentModel)
|
||||
const enabledRef = useRef(enabled)
|
||||
@ -165,9 +162,7 @@ export function useWorkflowInlineAgentConfigureSync({
|
||||
composerState,
|
||||
)
|
||||
}
|
||||
setOriginalConfig(composerState.agent_soul)
|
||||
setOriginalDraft(agentSoulConfigToFormState(composerState.agent_soul))
|
||||
setDraftSavedAt(Date.now())
|
||||
setSavedDraft(agentSoulConfigToFormState(composerState.agent_soul))
|
||||
lastAutosavedDraftKeyRef.current = savedDraftKey
|
||||
onDraftSavedRef.current?.(composerState)
|
||||
return composerState
|
||||
@ -230,7 +225,6 @@ export function useWorkflowInlineAgentConfigureSync({
|
||||
}, [autoSaveEnabled, debouncedSaveDraft])
|
||||
|
||||
return {
|
||||
draftSavedAt,
|
||||
saveAgentSoulConfig,
|
||||
saveDraft,
|
||||
}
|
||||
|
||||
@ -183,7 +183,6 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/preview-c
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/agent-v2/agent-soul-config', () => ({
|
||||
useWorkflowInlineAgentConfigureSync: () => ({
|
||||
draftSavedAt: undefined,
|
||||
saveAgentSoulConfig: mocks.saveAgentSoulConfig,
|
||||
saveDraft: mocks.saveDraft,
|
||||
}),
|
||||
|
||||
@ -130,10 +130,8 @@ export function WorkflowRosterAgentOrchestratePanelContent(
|
||||
<AgentComposerProvider
|
||||
key={composerSessionKey}
|
||||
initialDraft={agentSoulConfigToFormState(initialAgentSoulConfig)}
|
||||
initialOriginalConfig={initialAgentSoulConfig}
|
||||
>
|
||||
<WorkflowRosterAgentOrchestratePanelContentInner
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
agentId={agentId}
|
||||
agentSoulConfig={initialAgentSoulConfig}
|
||||
composerState={composerState}
|
||||
@ -143,12 +141,10 @@ export function WorkflowRosterAgentOrchestratePanelContent(
|
||||
}
|
||||
|
||||
function WorkflowRosterAgentOrchestratePanelContentInner({
|
||||
activeConfigSnapshot,
|
||||
agentId,
|
||||
agentSoulConfig,
|
||||
composerState,
|
||||
}: {
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
agentId: string
|
||||
agentSoulConfig: AgentSoulConfig
|
||||
composerState?: {
|
||||
@ -163,7 +159,6 @@ function WorkflowRosterAgentOrchestratePanelContentInner({
|
||||
return (
|
||||
<AgentOrchestratePanel
|
||||
agentId={agentId}
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
agentSoulConfig={agentSoulConfig}
|
||||
agentName={composerState?.agent?.name}
|
||||
currentModel={currentModel}
|
||||
@ -268,11 +263,9 @@ function WorkflowInlineAgentConfigureWorkspaceComposerScope({
|
||||
<AgentComposerProvider
|
||||
key={composerSessionKey}
|
||||
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
|
||||
initialOriginalConfig={buildDraft.agentSoulConfig}
|
||||
>
|
||||
<WorkflowInlineAgentConfigureWorkspaceContent
|
||||
{...props}
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
agentId={agentId}
|
||||
agentSoulConfig={agentSoulConfig}
|
||||
buildDraft={buildDraft}
|
||||
@ -283,7 +276,6 @@ function WorkflowInlineAgentConfigureWorkspaceComposerScope({
|
||||
}
|
||||
|
||||
function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
activeConfigSnapshot,
|
||||
agentId,
|
||||
agentSoulConfig,
|
||||
buildDraft,
|
||||
@ -296,7 +288,6 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
onSaveInlineToRoster,
|
||||
open,
|
||||
}: Omit<WorkflowInlineAgentConfigureWorkspaceProps, 'agentId'> & {
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
agentId: string
|
||||
agentSoulConfig: AgentSoulConfig
|
||||
buildDraft: ReturnType<typeof useAgentConfigureBuildDraftData>
|
||||
@ -331,7 +322,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
const { currentModel, setConfigureModel, textGenerationModelList } =
|
||||
useAgentOrchestrateModelOptions()
|
||||
const [isApplyingInlineBuildDraft, setIsApplyingInlineBuildDraft] = useState(false)
|
||||
const { draftSavedAt, saveAgentSoulConfig, saveDraft } = useWorkflowInlineAgentConfigureSync({
|
||||
const { saveAgentSoulConfig, saveDraft } = useWorkflowInlineAgentConfigureSync({
|
||||
nodeId,
|
||||
baseConfig: agentSoulConfig,
|
||||
currentModel,
|
||||
@ -462,7 +453,6 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
(agentSoulConfig?: AgentSoulConfig) => {
|
||||
rebaseComposerDraft({
|
||||
draft: agentSoulConfigToFormState(agentSoulConfig),
|
||||
originalConfig: agentSoulConfig,
|
||||
})
|
||||
},
|
||||
[rebaseComposerDraft],
|
||||
@ -688,12 +678,10 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
agentId={agentId}
|
||||
appId={appId}
|
||||
nodeId={nodeId}
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
agentSoulConfig={buildDraft.agentSoulConfig}
|
||||
agentName={composerState?.agent?.name}
|
||||
currentModel={currentModel}
|
||||
textGenerationModelList={textGenerationModelList}
|
||||
draftSavedAt={draftSavedAt}
|
||||
readOnly={buildDraft.isActive}
|
||||
isBuildDraftActive={buildDraft.isActive}
|
||||
buildDraftChangedKeys={buildDraft.changedKeys}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@ -6,35 +5,23 @@ import { defaultAgentSoulConfigFormState } from '../form-state'
|
||||
import { AgentComposerProvider } from '../provider'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
agentComposerPublishedDraftAtom,
|
||||
hasAgentComposerUnpublishedChangesAtom,
|
||||
agentComposerSavedDraftAtom,
|
||||
isAgentComposerDirtyAtom,
|
||||
} from '../store'
|
||||
|
||||
function StoreSnapshot() {
|
||||
const draft = useAtomValue(agentComposerDraftAtom)
|
||||
const originalDraft = useAtomValue(agentComposerOriginalDraftAtom)
|
||||
const publishedDraft = useAtomValue(agentComposerPublishedDraftAtom)
|
||||
const originalConfig = useAtomValue(agentComposerOriginalConfigAtom)
|
||||
const savedDraft = useAtomValue(agentComposerSavedDraftAtom)
|
||||
const isDirty = useAtomValue(isAgentComposerDirtyAtom)
|
||||
const hasUnpublishedChanges = useAtomValue(hasAgentComposerUnpublishedChangesAtom)
|
||||
|
||||
return (
|
||||
<dl>
|
||||
<dt>draft</dt>
|
||||
<dd>{draft.prompt}</dd>
|
||||
<dt>original draft</dt>
|
||||
<dd>{originalDraft?.prompt}</dd>
|
||||
<dt>published draft</dt>
|
||||
<dd>{publishedDraft?.prompt}</dd>
|
||||
<dt>original config</dt>
|
||||
<dd>{originalConfig?.prompt?.system_prompt}</dd>
|
||||
<dt>saved draft</dt>
|
||||
<dd>{savedDraft?.prompt}</dd>
|
||||
<dt>dirty</dt>
|
||||
<dd>{String(isDirty)}</dd>
|
||||
<dt>unpublished</dt>
|
||||
<dd>{String(hasUnpublishedChanges)}</dd>
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@ -49,27 +36,15 @@ describe('AgentComposerProvider', () => {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Be precise.',
|
||||
}
|
||||
const initialOriginalConfig = {
|
||||
prompt: {
|
||||
system_prompt: 'Be precise.',
|
||||
},
|
||||
} satisfies AgentSoulConfig
|
||||
|
||||
render(
|
||||
<AgentComposerProvider
|
||||
initialDraft={initialDraft}
|
||||
initialOriginalConfig={initialOriginalConfig}
|
||||
>
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<StoreSnapshot />
|
||||
</AgentComposerProvider>,
|
||||
)
|
||||
|
||||
expect(getDefinition('draft')).toHaveTextContent('Be precise.')
|
||||
expect(getDefinition('original draft')).toHaveTextContent('Be precise.')
|
||||
expect(getDefinition('published draft')).toHaveTextContent('Be precise.')
|
||||
expect(getDefinition('original config')).toHaveTextContent('Be precise.')
|
||||
expect(getDefinition('saved draft')).toHaveTextContent('Be precise.')
|
||||
expect(getDefinition('dirty')).toHaveTextContent('false')
|
||||
expect(getDefinition('unpublished')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('creates a new scoped store when the composer session key changes', () => {
|
||||
@ -96,7 +71,6 @@ describe('AgentComposerProvider', () => {
|
||||
)
|
||||
|
||||
expect(getDefinition('draft')).toHaveTextContent('Agent two draft')
|
||||
expect(getDefinition('original draft')).toHaveTextContent('Agent two draft')
|
||||
expect(getDefinition('published draft')).toHaveTextContent('Agent two draft')
|
||||
expect(getDefinition('saved draft')).toHaveTextContent('Agent two draft')
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,9 +5,7 @@ import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '../conve
|
||||
import { defaultAgentSoulConfigFormState } from '../form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
agentComposerPublishedDraftAtom,
|
||||
agentComposerSavedDraftAtom,
|
||||
rebaseAgentComposerDraftAtom,
|
||||
} from '../store'
|
||||
|
||||
@ -91,23 +89,12 @@ describe('agent composer store conversions', () => {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Build draft prompt',
|
||||
}
|
||||
const originalConfig = {
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
} satisfies AgentSoulConfig
|
||||
|
||||
store.set(rebaseAgentComposerDraftAtom, {
|
||||
draft: nextDraft,
|
||||
originalConfig,
|
||||
})
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).prompt).toBe('Build draft prompt')
|
||||
expect(store.get(agentComposerOriginalDraftAtom)?.prompt).toBe('Build draft prompt')
|
||||
expect(store.get(agentComposerPublishedDraftAtom)?.prompt).toBe('Build draft prompt')
|
||||
expect(store.get(agentComposerOriginalConfigAtom)?.prompt?.system_prompt).toBe(
|
||||
'Build draft prompt',
|
||||
)
|
||||
expect(store.get(agentComposerSavedDraftAtom)?.prompt).toBe('Build draft prompt')
|
||||
})
|
||||
|
||||
it('should hydrate editable form state from an AgentSoulConfig and preserve it in the config snapshot', () => {
|
||||
|
||||
@ -1,35 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentSoulConfigFormState } from './form-state'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { defaultAgentSoulConfigFormState } from './form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
agentComposerPublishedDraftAtom,
|
||||
} from './store'
|
||||
import { agentComposerDraftAtom, agentComposerSavedDraftAtom } from './store'
|
||||
|
||||
export function AgentComposerProvider({
|
||||
children,
|
||||
initialDraft,
|
||||
initialOriginalConfig,
|
||||
}: {
|
||||
children: ReactNode
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
initialOriginalConfig?: AgentSoulConfig
|
||||
}) {
|
||||
const draft = initialDraft ?? defaultAgentSoulConfigFormState
|
||||
|
||||
return (
|
||||
<ScopeProvider
|
||||
atoms={[
|
||||
[agentComposerOriginalConfigAtom, initialOriginalConfig],
|
||||
[agentComposerDraftAtom, draft],
|
||||
[agentComposerOriginalDraftAtom, draft],
|
||||
[agentComposerPublishedDraftAtom, draft],
|
||||
[agentComposerSavedDraftAtom, draft],
|
||||
]}
|
||||
name="AgentComposer"
|
||||
>
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentSoulConfigFormState } from './form-state'
|
||||
import isEqual from 'fast-deep-equal'
|
||||
import { atom } from 'jotai'
|
||||
import { defaultAgentSoulConfigFormState } from './form-state'
|
||||
|
||||
export const agentComposerOriginalConfigAtom = atom<AgentSoulConfig | undefined>(undefined)
|
||||
export const agentComposerOriginalDraftAtom = atom<AgentSoulConfigFormState | undefined>(
|
||||
defaultAgentSoulConfigFormState,
|
||||
)
|
||||
export const agentComposerPublishedDraftAtom = atom<AgentSoulConfigFormState | undefined>(
|
||||
export const agentComposerSavedDraftAtom = atom<AgentSoulConfigFormState | undefined>(
|
||||
defaultAgentSoulConfigFormState,
|
||||
)
|
||||
export const agentComposerDraftAtom = atom<AgentSoulConfigFormState>(
|
||||
@ -22,29 +17,18 @@ export const rebaseAgentComposerDraftAtom = atom(
|
||||
set,
|
||||
{
|
||||
draft,
|
||||
originalConfig,
|
||||
}: {
|
||||
draft: AgentSoulConfigFormState
|
||||
originalConfig?: AgentSoulConfig
|
||||
},
|
||||
) => {
|
||||
set(agentComposerOriginalConfigAtom, originalConfig)
|
||||
set(agentComposerDraftAtom, draft)
|
||||
set(agentComposerOriginalDraftAtom, draft)
|
||||
set(agentComposerPublishedDraftAtom, draft)
|
||||
set(agentComposerSavedDraftAtom, draft)
|
||||
},
|
||||
)
|
||||
|
||||
export const isAgentComposerDirtyAtom = atom((get) => {
|
||||
const originalDraft = get(agentComposerOriginalDraftAtom)
|
||||
const savedDraft = get(agentComposerSavedDraftAtom)
|
||||
const draft = get(agentComposerDraftAtom)
|
||||
|
||||
return !isEqual(draft, originalDraft ?? defaultAgentSoulConfigFormState)
|
||||
})
|
||||
|
||||
export const hasAgentComposerUnpublishedChangesAtom = atom((get) => {
|
||||
const publishedDraft = get(agentComposerPublishedDraftAtom)
|
||||
const draft = get(agentComposerDraftAtom)
|
||||
|
||||
return !isEqual(draft, publishedDraft ?? defaultAgentSoulConfigFormState)
|
||||
return !isEqual(draft, savedDraft ?? defaultAgentSoulConfigFormState)
|
||||
})
|
||||
|
||||
@ -710,6 +710,55 @@ describe('AgentConfigurePage', () => {
|
||||
).toBeVisible()
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should initialize the composer from recovered query data after the initial request fails', () => {
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.composer = {
|
||||
data: undefined as unknown,
|
||||
isFetching: false,
|
||||
isError: true,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
const view = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigureComposerScopeHarness />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent(
|
||||
'readonly:yes',
|
||||
)
|
||||
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'recovered draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
view.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigureComposerScopeHarness />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent(
|
||||
'prompt:recovered draft prompt',
|
||||
)
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent(
|
||||
'readonly:no',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Right panel mode', () => {
|
||||
|
||||
@ -6,7 +6,7 @@ import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowl
|
||||
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerPublishedDraftAtom,
|
||||
agentComposerSavedDraftAtom,
|
||||
} 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'
|
||||
@ -32,9 +32,17 @@ const composerPutMutationFn = vi.hoisted(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const composerPutRequestContexts = vi.hoisted(
|
||||
() => [] as Array<{ keepalive?: boolean; silent?: boolean } | undefined>,
|
||||
)
|
||||
|
||||
const composerPutMutationOptions = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
(options?: {
|
||||
context?: {
|
||||
keepalive?: boolean
|
||||
silent?: boolean
|
||||
}
|
||||
onSuccess?: (
|
||||
data: { agent_soul: Record<string, unknown> },
|
||||
variables: {
|
||||
@ -51,6 +59,7 @@ const composerPutMutationOptions = vi.hoisted(() =>
|
||||
agent_soul: Record<string, unknown>
|
||||
}
|
||||
}) => {
|
||||
composerPutRequestContexts.push(options?.context)
|
||||
const data = await composerPutMutationFn(variables)
|
||||
options?.onSuccess?.(data, variables)
|
||||
return data
|
||||
@ -85,6 +94,9 @@ const publishAgentMutationFn = vi.hoisted(() =>
|
||||
const publishAgentMutationOptions = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
(options?: {
|
||||
context?: {
|
||||
silent?: boolean
|
||||
}
|
||||
onSuccess?: (data: PublishAgentResponse, variables: PublishAgentVariables) => void
|
||||
}) => ({
|
||||
mutationFn: async (variables: PublishAgentVariables) => {
|
||||
@ -98,11 +110,13 @@ const publishAgentMutationOptions = vi.hoisted(() =>
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
function setDocumentVisibilityState(visibilityState: DocumentVisibilityState) {
|
||||
@ -168,10 +182,12 @@ function renderUseAgentConfigureSync({
|
||||
agentName = 'Agent',
|
||||
baseConfig,
|
||||
currentModel,
|
||||
enabled = true,
|
||||
}: {
|
||||
agentName?: Parameters<typeof useAgentConfigureSync>[0]['agentName']
|
||||
baseConfig?: Parameters<typeof useAgentConfigureSync>[0]['baseConfig']
|
||||
currentModel?: Parameters<typeof useAgentConfigureSync>[0]['currentModel']
|
||||
enabled?: boolean
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@ -194,7 +210,7 @@ function renderUseAgentConfigureSync({
|
||||
agentName,
|
||||
baseConfig,
|
||||
currentModel,
|
||||
enabled: true,
|
||||
enabled,
|
||||
}),
|
||||
{ wrapper },
|
||||
),
|
||||
@ -207,6 +223,7 @@ describe('useAgentConfigureSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
composerPutRequestContexts.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -215,16 +232,12 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
it('should automatically save configure page changes to draft', async () => {
|
||||
vi.setSystemTime(1710000100000)
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
const { queryClient, store } = renderUseAgentConfigureSync()
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
@ -258,25 +271,14 @@ describe('useAgentConfigureSync', () => {
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toEqual({
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Draft only prompt',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['agent-detail', 'agent-1'],
|
||||
})
|
||||
expect(result.current.draftSavedAt).toBe(1710000105000)
|
||||
})
|
||||
|
||||
it('should cancel pending autosave when the draft returns to the saved baseline', async () => {
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
const { queryClient, store } = renderUseAgentConfigureSync()
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
@ -301,7 +303,6 @@ describe('useAgentConfigureSync', () => {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should save dirty draft once when the page is closing', async () => {
|
||||
@ -326,6 +327,7 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(composerPutRequestContexts).toEqual([{ keepalive: true, silent: true }])
|
||||
expect(composerPutMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: {
|
||||
@ -349,6 +351,101 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should dispatch the latest keepalive save while an earlier save is pending', async () => {
|
||||
const saveDeferred = createDeferredPromise<{ agent_soul: Record<string, unknown> }>()
|
||||
composerPutMutationFn.mockReturnValueOnce(saveDeferred.promise)
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Explicit save prompt',
|
||||
})
|
||||
})
|
||||
|
||||
let saveDraftPromise!: Promise<void>
|
||||
act(() => {
|
||||
saveDraftPromise = result.current.saveDraft()
|
||||
})
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Latest closing prompt',
|
||||
})
|
||||
})
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('beforeunload'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(2)
|
||||
expect(composerPutRequestContexts).toEqual([
|
||||
{ silent: true },
|
||||
{ keepalive: true, silent: true },
|
||||
])
|
||||
expect(composerPutMutationFn).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Latest closing prompt',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
saveDeferred.resolve({ agent_soul: {} })
|
||||
await saveDraftPromise
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(store.get(agentComposerSavedDraftAtom)?.prompt).toBe('Latest closing prompt')
|
||||
})
|
||||
|
||||
it('should repeat an in-flight explicit save with keepalive before unload', async () => {
|
||||
const saveDeferred = createDeferredPromise<{ agent_soul: Record<string, unknown> }>()
|
||||
composerPutMutationFn.mockReturnValueOnce(saveDeferred.promise)
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Pending explicit save',
|
||||
})
|
||||
})
|
||||
|
||||
let saveDraftPromise!: Promise<void>
|
||||
act(() => {
|
||||
saveDraftPromise = result.current.saveDraft()
|
||||
})
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('beforeunload'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(2)
|
||||
expect(composerPutRequestContexts).toEqual([
|
||||
{ silent: true },
|
||||
{ keepalive: true, silent: true },
|
||||
])
|
||||
|
||||
await act(async () => {
|
||||
saveDeferred.resolve({ agent_soul: {} })
|
||||
await saveDraftPromise
|
||||
await Promise.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
it('should save the latest dirty draft when Configure unmounts before autosave runs', async () => {
|
||||
const { store, unmount } = renderUseAgentConfigureSync()
|
||||
|
||||
@ -551,7 +648,7 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
it('should autosave when knowledge retrieval validation fails', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
const { store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
@ -571,12 +668,11 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.draftSavedAt).toBeDefined()
|
||||
})
|
||||
|
||||
it('should keep autosave failures silent and leave the local draft dirty', async () => {
|
||||
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
const { store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
@ -590,13 +686,12 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
expect(store.get(agentComposerDraftAtom).prompt).toBe('Unsaved autosave prompt')
|
||||
expect(toastMock.error).not.toHaveBeenCalled()
|
||||
expect(composerPutRequestContexts).toEqual([{ silent: true }])
|
||||
})
|
||||
|
||||
it('should save the latest draft immediately when requested', async () => {
|
||||
vi.setSystemTime(1710000200000)
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
@ -627,7 +722,6 @@ describe('useAgentConfigureSync', () => {
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(result.current.draftSavedAt).toBe(1710000200000)
|
||||
})
|
||||
|
||||
it('should reject explicit save requests when the draft cannot be saved', async () => {
|
||||
@ -642,7 +736,6 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
await expect(result.current.saveDraft()).rejects.toThrow('Failed to save agent composer draft.')
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
expect(store.get(agentComposerDraftAtom).prompt).toBe('Run prompt')
|
||||
expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed')
|
||||
})
|
||||
@ -663,7 +756,6 @@ describe('useAgentConfigureSync', () => {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should save the effective model before run when the form draft is unchanged', async () => {
|
||||
@ -723,14 +815,9 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
it('should publish only when publishDraft is called explicitly', async () => {
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync({
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: false,
|
||||
name: 'Agent',
|
||||
})
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
@ -764,13 +851,6 @@ describe('useAgentConfigureSync', () => {
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['agent-composer', 'agent-1'],
|
||||
})
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(trackEventMock).toHaveBeenCalledWith('app_published_time', {
|
||||
action_mode: 'app',
|
||||
app_id: 'agent-1',
|
||||
@ -800,7 +880,7 @@ describe('useAgentConfigureSync', () => {
|
||||
expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel')
|
||||
})
|
||||
|
||||
it('should keep default model fallback from creating unpublished changes after publish', async () => {
|
||||
it('should keep default model fallback from leaving the local draft dirty after publish', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
@ -816,13 +896,13 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
expect(publishAgentMutationFn).toHaveBeenCalledTimes(1)
|
||||
const publishedDraft = store.get(agentComposerPublishedDraftAtom)
|
||||
const savedDraft = store.get(agentComposerSavedDraftAtom)
|
||||
expect(store.get(agentComposerDraftAtom).model).toBeUndefined()
|
||||
expect(publishedDraft?.model).toBeUndefined()
|
||||
expect(publishedDraft).toEqual(store.get(agentComposerDraftAtom))
|
||||
expect(savedDraft?.model).toBeUndefined()
|
||||
expect(savedDraft).toEqual(store.get(agentComposerDraftAtom))
|
||||
})
|
||||
|
||||
it('should keep base config fallback fields from creating unpublished changes after publish', async () => {
|
||||
it('should keep base config fallback fields from leaving the local draft dirty after publish', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
baseConfig: {
|
||||
@ -845,10 +925,10 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
expect(publishAgentMutationFn).toHaveBeenCalledTimes(1)
|
||||
const publishedDraft = store.get(agentComposerPublishedDraftAtom)
|
||||
const savedDraft = store.get(agentComposerSavedDraftAtom)
|
||||
expect(store.get(agentComposerDraftAtom).appFeatures).toBeUndefined()
|
||||
expect(publishedDraft?.appFeatures).toBeUndefined()
|
||||
expect(publishedDraft).toEqual(store.get(agentComposerDraftAtom))
|
||||
expect(savedDraft?.appFeatures).toBeUndefined()
|
||||
expect(savedDraft).toEqual(store.get(agentComposerDraftAtom))
|
||||
})
|
||||
|
||||
it('should publish the current draft snapshot instead of a stale caller payload', async () => {
|
||||
@ -884,13 +964,9 @@ describe('useAgentConfigureSync', () => {
|
||||
|
||||
it('should reject publish and keep the publish mutation untouched when saving the draft fails', async () => {
|
||||
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync({
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: false,
|
||||
name: 'Agent',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
@ -904,13 +980,23 @@ describe('useAgentConfigureSync', () => {
|
||||
)
|
||||
|
||||
expect(publishAgentMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: false,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed')
|
||||
})
|
||||
|
||||
it('should skip publish while the Composer Query is unavailable', async () => {
|
||||
const { result } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.publishDraft()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
expect(publishAgentMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toast and skip publish when knowledge retrieval validation fails', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
@ -1000,4 +1086,180 @@ describe('useAgentConfigureSync', () => {
|
||||
|
||||
expect(result.current.isPublishing).toBe(false)
|
||||
})
|
||||
|
||||
it('should pause autosave during publish and resume it for edits made in flight', async () => {
|
||||
const publishDeferred = createDeferredPromise<PublishAgentResponse>()
|
||||
publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise)
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Draft captured for publish',
|
||||
})
|
||||
})
|
||||
|
||||
let publishPromise!: Promise<void>
|
||||
act(() => {
|
||||
publishPromise = result.current.publishDraft()
|
||||
})
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(publishAgentMutationFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Edited while publish is pending',
|
||||
})
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
publishDeferred.resolve({
|
||||
active_config_snapshot: {},
|
||||
active_config_snapshot_id: 'snapshot-1',
|
||||
result: 'success',
|
||||
})
|
||||
await publishPromise
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(2)
|
||||
expect(composerPutMutationFn).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Edited while publish is pending',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should dispatch a keepalive save for edits made while publish is pending', async () => {
|
||||
const publishDeferred = createDeferredPromise<PublishAgentResponse>()
|
||||
publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise)
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Draft captured for publish',
|
||||
})
|
||||
})
|
||||
|
||||
let publishPromise!: Promise<void>
|
||||
act(() => {
|
||||
publishPromise = result.current.publishDraft()
|
||||
})
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
expect(publishAgentMutationFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Latest edit before closing',
|
||||
})
|
||||
})
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('beforeunload'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(2)
|
||||
expect(composerPutRequestContexts).toEqual([
|
||||
{ silent: true },
|
||||
{ keepalive: true, silent: true },
|
||||
])
|
||||
expect(composerPutMutationFn).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Latest edit before closing',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
publishDeferred.resolve({
|
||||
active_config_snapshot: {},
|
||||
active_config_snapshot_id: 'snapshot-1',
|
||||
result: 'success',
|
||||
})
|
||||
await publishPromise
|
||||
await Promise.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
it('should resume autosave for edits made while publish fails', async () => {
|
||||
const publishDeferred = createDeferredPromise<PublishAgentResponse>()
|
||||
publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise)
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Draft captured for failed publish',
|
||||
})
|
||||
})
|
||||
|
||||
let publishPromise!: Promise<void>
|
||||
act(() => {
|
||||
publishPromise = result.current.publishDraft()
|
||||
})
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Edited while failed publish is pending',
|
||||
})
|
||||
})
|
||||
await act(async () => {
|
||||
publishDeferred.reject(new Error('publish failed'))
|
||||
await expect(publishPromise).rejects.toThrow('publish failed')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
|
||||
expect(result.current.isPublishing).toBe(false)
|
||||
expect(toastMock.error).toHaveBeenCalledTimes(1)
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(2)
|
||||
expect(composerPutMutationFn).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Edited while failed publish is pending',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -99,7 +99,8 @@ export function AgentConfigureComposerScope({
|
||||
}
|
||||
|
||||
initializedComposerAgentIdRef.current = agentId
|
||||
const composerSessionKey = `${agentId}:${activeVersionId ?? selectedVersionId ?? 'draft'}:${composerRebaseRevision}`
|
||||
const composerHydrationState = composerQuery.data === undefined ? 'unavailable' : 'loaded'
|
||||
const composerSessionKey = `${agentId}:${activeVersionId ?? selectedVersionId ?? 'draft'}:${composerHydrationState}:${composerRebaseRevision}`
|
||||
|
||||
return (
|
||||
<AgentConfigurePageComposerSession
|
||||
@ -218,7 +219,6 @@ function AgentConfigurePageComposerSession({
|
||||
<AgentComposerProvider
|
||||
key={composerSessionKey}
|
||||
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
|
||||
initialOriginalConfig={buildDraft.agentSoulConfig}
|
||||
>
|
||||
<AgentConfigurePageComposerContent
|
||||
agentId={agentId}
|
||||
@ -320,14 +320,13 @@ function AgentConfigurePageComposerContent({
|
||||
(agentSoulConfig?: AgentSoulConfig) => {
|
||||
rebaseComposerDraft({
|
||||
draft: agentSoulConfigToFormState(agentSoulConfig),
|
||||
originalConfig: agentSoulConfig,
|
||||
})
|
||||
},
|
||||
[rebaseComposerDraft],
|
||||
)
|
||||
const { currentModel, setConfigureModel, textGenerationModelList } =
|
||||
useAgentConfigureModelOptions()
|
||||
const { draftSavedAt, isPublishing, publishDraft, saveDraft } = useAgentConfigureSync({
|
||||
const { isPublishing, publishDraft, saveDraft } = useAgentConfigureSync({
|
||||
agentId,
|
||||
agentName: agentQuery.data?.name,
|
||||
baseConfig: agentSoulConfig,
|
||||
@ -449,20 +448,21 @@ function AgentConfigurePageComposerContent({
|
||||
leftPanel={
|
||||
<AgentOrchestratePanel
|
||||
agentId={agentId}
|
||||
activeConfigIsPublished={composerQuery.data?.active_config_is_published}
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
agentSoulConfig={buildDraft.agentSoulConfig}
|
||||
agentName={agentQuery.data?.name}
|
||||
currentModel={currentModel}
|
||||
textGenerationModelList={textGenerationModelList}
|
||||
draftSavedAt={draftSavedAt}
|
||||
isPublishing={isPublishing}
|
||||
readOnly={isViewingVersion || buildDraft.isActive || buildDraftActionsDisabled}
|
||||
readOnly={
|
||||
!composerQuery.isSuccess ||
|
||||
isViewingVersion ||
|
||||
buildDraft.isActive ||
|
||||
buildDraftActionsDisabled
|
||||
}
|
||||
selectedVersionSnapshot={isViewingVersion ? activeConfigSnapshot : undefined}
|
||||
isBuildDraftActive={buildDraft.isActive}
|
||||
buildDraftChangedKeys={buildDraft.changedKeys}
|
||||
showPublishBar={!buildDraft.isActive}
|
||||
workflowReferencesEnabled={agentQuery.isSuccess}
|
||||
bottomAction={
|
||||
showBuildDraftBar ? (
|
||||
<AgentBuildDraftBar
|
||||
|
||||
@ -10,8 +10,7 @@ import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
agentComposerPublishedDraftAtom,
|
||||
agentComposerSavedDraftAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
|
||||
import { AgentConfigurePublishBar } from '../publish-bar'
|
||||
@ -46,6 +45,11 @@ const toastMock = vi.hoisted(() => ({
|
||||
const workflowReferences = vi.hoisted(() => ({
|
||||
fetchCount: 0,
|
||||
data: [] as AgentReferencingWorkflowResponse[],
|
||||
shouldFail: false,
|
||||
}))
|
||||
const composerQuery = vi.hoisted(() => ({
|
||||
data: undefined as unknown,
|
||||
shouldFail: false,
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
@ -92,22 +96,39 @@ vi.mock('@/service/client', () => ({
|
||||
'agent-composer',
|
||||
input,
|
||||
],
|
||||
queryOptions: ({ input }: { input: { params: { agent_id: string } } }) => ({
|
||||
queryKey: ['agent-composer', input],
|
||||
queryFn: async () => {
|
||||
if (composerQuery.shouldFail) throw new Error('Composer query failed')
|
||||
|
||||
return composerQuery.data
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
referencingWorkflows: {
|
||||
get: {
|
||||
queryOptions: ({
|
||||
context,
|
||||
enabled = true,
|
||||
input,
|
||||
}: {
|
||||
context?: { silent?: boolean }
|
||||
enabled?: boolean
|
||||
input: { params: { agent_id: string } }
|
||||
}) => ({
|
||||
queryKey: ['agent-referencing-workflows', input],
|
||||
enabled,
|
||||
queryFn: async () => ({
|
||||
data: (workflowReferences.fetchCount++, workflowReferences.data),
|
||||
}),
|
||||
context,
|
||||
queryFn: async () => {
|
||||
workflowReferences.fetchCount++
|
||||
if (workflowReferences.shouldFail)
|
||||
throw new Error('Workflow references query failed')
|
||||
|
||||
return {
|
||||
data: workflowReferences.data,
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
@ -135,7 +156,7 @@ const activeConfigSnapshot: AgentConfigSnapshotSummaryResponse = {
|
||||
created_at: 1710000000,
|
||||
}
|
||||
|
||||
const originalDraftWithFile = {
|
||||
const savedDraftWithFile = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
tools: [
|
||||
{
|
||||
@ -184,6 +205,8 @@ function renderPublishBar({
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
draftSavedAt,
|
||||
composerQueryAvailable = true,
|
||||
composerQueryFails = false,
|
||||
isPublishing,
|
||||
onPublish = vi.fn<PublishHandler>(),
|
||||
onExitVersions = vi.fn(),
|
||||
@ -192,11 +215,12 @@ function renderPublishBar({
|
||||
selectedVersionSnapshot,
|
||||
setupStore,
|
||||
usedByAppReferences = [],
|
||||
workflowReferencesEnabled,
|
||||
}: {
|
||||
activeConfigIsPublished?: boolean
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
draftSavedAt?: number
|
||||
composerQueryAvailable?: boolean
|
||||
composerQueryFails?: boolean
|
||||
isPublishing?: boolean
|
||||
onPublish?: PublishMock
|
||||
onExitVersions?: Mock<() => void>
|
||||
@ -205,9 +229,9 @@ function renderPublishBar({
|
||||
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
setupStore?: (store: ReturnType<typeof createStore>) => void
|
||||
usedByAppReferences?: AgentReferencingWorkflowResponse[]
|
||||
workflowReferencesEnabled?: boolean
|
||||
} = {}) {
|
||||
workflowReferences.data = usedByAppReferences
|
||||
composerQuery.shouldFail = composerQueryFails
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
@ -217,31 +241,41 @@ function renderPublishBar({
|
||||
const store = createStore()
|
||||
store.set(agentComposerPromptAtom, prompt)
|
||||
setupStore?.(store)
|
||||
const composerQueryKey = ['agent-composer', { params: { agent_id: 'agent-1' } }]
|
||||
const composerState = {
|
||||
active_config_is_published: activeConfigIsPublished ?? false,
|
||||
active_config_snapshot: activeConfigSnapshot,
|
||||
agent: {
|
||||
id: 'agent-1',
|
||||
name: 'Iris',
|
||||
},
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
},
|
||||
draft: draftSavedAt
|
||||
? {
|
||||
agent_id: 'agent-1',
|
||||
draft_type: 'draft',
|
||||
id: 'draft-1',
|
||||
updated_at: draftSavedAt / 1000,
|
||||
}
|
||||
: null,
|
||||
save_options: ['save_to_current_version'],
|
||||
variant: 'agent_app',
|
||||
}
|
||||
composerQuery.data = composerState
|
||||
if (composerQueryAvailable) {
|
||||
queryClient.setQueryData(composerQueryKey, composerState)
|
||||
}
|
||||
|
||||
const renderPublishBarTree = (nextProps?: {
|
||||
activeConfigIsPublished?: boolean
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
isPublishing?: boolean
|
||||
}) => (
|
||||
const renderPublishBarTree = (nextProps?: { isPublishing?: boolean }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider store={store}>
|
||||
<AgentConfigurePublishBar
|
||||
agentId="agent-1"
|
||||
activeConfigIsPublished={
|
||||
nextProps && 'activeConfigIsPublished' in nextProps
|
||||
? nextProps.activeConfigIsPublished
|
||||
: activeConfigIsPublished
|
||||
}
|
||||
activeConfigSnapshot={
|
||||
nextProps && 'activeConfigSnapshot' in nextProps
|
||||
? nextProps.activeConfigSnapshot
|
||||
: activeConfigSnapshot
|
||||
}
|
||||
draftSavedAt={draftSavedAt}
|
||||
agentName="Iris"
|
||||
isPublishing={nextProps?.isPublishing ?? isPublishing}
|
||||
selectedVersionSnapshot={selectedVersionSnapshot}
|
||||
workflowReferencesEnabled={workflowReferencesEnabled}
|
||||
onPublish={onPublish}
|
||||
onExitVersions={onExitVersions}
|
||||
onOpenVersions={vi.fn()}
|
||||
@ -272,6 +306,9 @@ describe('AgentConfigurePublishBar', () => {
|
||||
})
|
||||
workflowReferences.data = []
|
||||
workflowReferences.fetchCount = 0
|
||||
workflowReferences.shouldFail = false
|
||||
composerQuery.data = undefined
|
||||
composerQuery.shouldFail = false
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
@ -412,25 +449,35 @@ describe('AgentConfigurePublishBar', () => {
|
||||
expect(onPublish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep published state when the published detail updates before the active snapshot is refreshed', () => {
|
||||
const { rerender, rerenderPublishBar } = renderPublishBar({
|
||||
activeConfigIsPublished: true,
|
||||
activeConfigSnapshot: null,
|
||||
it('should fail closed while the Composer Query is unavailable', async () => {
|
||||
renderPublishBar({
|
||||
composerQueryAvailable: false,
|
||||
composerQueryFails: true,
|
||||
})
|
||||
|
||||
rerender(
|
||||
rerenderPublishBar({
|
||||
activeConfigIsPublished: undefined,
|
||||
activeConfigSnapshot: undefined,
|
||||
}),
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.publish/ })).toBeDisabled()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.publish/ })).toBeDisabled()
|
||||
expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual(
|
||||
expect.objectContaining({ enabled: false, ignoreInputs: false }),
|
||||
)
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' }),
|
||||
).toBeDisabled()
|
||||
it('should fail closed when refreshing cached Composer state fails', async () => {
|
||||
renderPublishBar({
|
||||
activeConfigIsPublished: false,
|
||||
activeConfigSnapshot,
|
||||
composerQueryFails: true,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/,
|
||||
}),
|
||||
).toBeDisabled()
|
||||
})
|
||||
expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual(
|
||||
expect.objectContaining({ enabled: false, ignoreInputs: false }),
|
||||
)
|
||||
@ -512,16 +559,15 @@ describe('AgentConfigurePublishBar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should publish without loading workflow references when references are disabled', async () => {
|
||||
it('should fail closed and show feedback when workflow references cannot be loaded', async () => {
|
||||
workflowReferences.shouldFail = true
|
||||
const { onPublish } = renderPublishBar({
|
||||
activeConfigSnapshot,
|
||||
prompt: 'Updated system prompt',
|
||||
usedByAppReferences: publishedReferences,
|
||||
workflowReferencesEnabled: false,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(workflowReferences.fetchCount).toBe(0)
|
||||
expect(workflowReferences.fetchCount).toBe(1)
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
@ -530,24 +576,18 @@ describe('AgentConfigurePublishBar', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPublish).toHaveBeenCalledTimes(1)
|
||||
expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed')
|
||||
})
|
||||
expect(workflowReferences.fetchCount).toBe(0)
|
||||
expect(
|
||||
screen.queryByRole('region', {
|
||||
name: /agentV2\.agentDetail\.configure\.publishImpact\.title/,
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
expect(onPublish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should mark non-prompt draft changes as unpublished', () => {
|
||||
renderPublishBar({
|
||||
activeConfigSnapshot,
|
||||
setupStore: (store) => {
|
||||
store.set(agentComposerPublishedDraftAtom, originalDraftWithFile)
|
||||
store.set(agentComposerOriginalDraftAtom, originalDraftWithFile)
|
||||
store.set(agentComposerSavedDraftAtom, savedDraftWithFile)
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...originalDraftWithFile,
|
||||
...savedDraftWithFile,
|
||||
tools: [],
|
||||
})
|
||||
},
|
||||
@ -559,20 +599,16 @@ describe('AgentConfigurePublishBar', () => {
|
||||
})
|
||||
|
||||
it('should keep unpublished state after draft autosave updates the saved draft baseline', () => {
|
||||
const publishedDraft = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Published prompt',
|
||||
}
|
||||
const savedDraft = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Autosaved draft prompt',
|
||||
}
|
||||
|
||||
renderPublishBar({
|
||||
activeConfigIsPublished: false,
|
||||
activeConfigSnapshot,
|
||||
setupStore: (store) => {
|
||||
store.set(agentComposerPublishedDraftAtom, publishedDraft)
|
||||
store.set(agentComposerOriginalDraftAtom, savedDraft)
|
||||
store.set(agentComposerSavedDraftAtom, savedDraft)
|
||||
store.set(agentComposerDraftAtom, savedDraft)
|
||||
},
|
||||
})
|
||||
@ -588,10 +624,6 @@ describe('AgentConfigurePublishBar', () => {
|
||||
})
|
||||
|
||||
it('should trust backend published state after autosave confirms the draft matches the active snapshot', () => {
|
||||
const stalePublishedDraftBaseline = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Old unpublished normal draft',
|
||||
}
|
||||
const savedDraftMatchingActiveSnapshot = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Published prompt',
|
||||
@ -601,8 +633,7 @@ describe('AgentConfigurePublishBar', () => {
|
||||
activeConfigIsPublished: true,
|
||||
activeConfigSnapshot,
|
||||
setupStore: (store) => {
|
||||
store.set(agentComposerPublishedDraftAtom, stalePublishedDraftBaseline)
|
||||
store.set(agentComposerOriginalDraftAtom, savedDraftMatchingActiveSnapshot)
|
||||
store.set(agentComposerSavedDraftAtom, savedDraftMatchingActiveSnapshot)
|
||||
store.set(agentComposerDraftAtom, savedDraftMatchingActiveSnapshot)
|
||||
},
|
||||
})
|
||||
@ -757,6 +788,42 @@ describe('AgentConfigurePublishBar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep impact confirmation open without leaking a rejected publish command', async () => {
|
||||
const onPublish = vi.fn<PublishHandler>(() => Promise.reject(new Error('publish failed')))
|
||||
renderPublishBar({
|
||||
activeConfigSnapshot,
|
||||
onPublish,
|
||||
prompt: 'Updated system prompt',
|
||||
usedByAppReferences: publishedReferences,
|
||||
})
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/,
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await screen.findByRole('region', {
|
||||
name: /agentV2\.agentDetail\.configure\.publishImpact\.title/,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/,
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPublish).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(
|
||||
screen.getByRole('region', {
|
||||
name: /agentV2\.agentDetail\.configure\.publishImpact\.title/,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collapse affected workflow details from the expanded footer cancel action', async () => {
|
||||
const { onPublish } = renderPublishBar({
|
||||
activeConfigSnapshot,
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentConfigApiContext } from '../../config-context'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
@ -170,12 +169,10 @@ function createInitialDraft(
|
||||
|
||||
function renderAgentFiles({
|
||||
initialDraft = createInitialDraft(),
|
||||
initialOriginalConfig,
|
||||
apiContext = { agentId: 'agent-1', draftType: 'draft' } satisfies AgentConfigApiContext,
|
||||
readOnly = false,
|
||||
}: {
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
initialOriginalConfig?: AgentSoulConfig
|
||||
apiContext?: AgentConfigApiContext
|
||||
readOnly?: boolean
|
||||
} = {}) {
|
||||
@ -193,10 +190,7 @@ function renderAgentFiles({
|
||||
return render(
|
||||
<QueryClientTestProvider queryClient={queryClient}>
|
||||
<AgentConfigApiContextProvider value={apiContext}>
|
||||
<AgentComposerProvider
|
||||
initialDraft={initialDraft}
|
||||
initialOriginalConfig={initialOriginalConfig}
|
||||
>
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentFiles />
|
||||
<ConfigSnapshotProbe />
|
||||
|
||||
@ -33,18 +33,14 @@ type AgentOrchestratePanelProps = {
|
||||
agentId: string
|
||||
appId?: string
|
||||
nodeId?: string
|
||||
activeConfigIsPublished?: boolean
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
agentSoulConfig?: AgentConfigSnapshotDetailResponse['config_snapshot']
|
||||
agentName?: string | null
|
||||
currentModel?: AgentComposerModel
|
||||
textGenerationModelList: Model[]
|
||||
draftSavedAt?: number
|
||||
isPublishing?: boolean
|
||||
className?: string
|
||||
readOnly?: boolean
|
||||
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
workflowReferencesEnabled?: boolean
|
||||
isBuildDraftActive?: boolean
|
||||
buildDraftChangedKeys?: readonly AgentBuildDraftChangedKey[]
|
||||
showHeader?: boolean
|
||||
@ -62,18 +58,14 @@ export function AgentOrchestratePanel({
|
||||
agentId,
|
||||
appId,
|
||||
nodeId,
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
agentSoulConfig: _agentSoulConfig,
|
||||
agentName,
|
||||
currentModel,
|
||||
textGenerationModelList,
|
||||
draftSavedAt,
|
||||
isPublishing,
|
||||
className,
|
||||
readOnly = false,
|
||||
selectedVersionSnapshot,
|
||||
workflowReferencesEnabled,
|
||||
isBuildDraftActive = false,
|
||||
buildDraftChangedKeys = [],
|
||||
showHeader = true,
|
||||
@ -94,13 +86,9 @@ export function AgentOrchestratePanel({
|
||||
(showPublishBar ? (
|
||||
<AgentConfigurePublishBar
|
||||
agentId={agentId}
|
||||
activeConfigIsPublished={activeConfigIsPublished}
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
agentName={agentName}
|
||||
draftSavedAt={draftSavedAt}
|
||||
isPublishing={isPublishing}
|
||||
selectedVersionSnapshot={selectedVersionSnapshot}
|
||||
workflowReferencesEnabled={workflowReferencesEnabled}
|
||||
onPublish={onPublish}
|
||||
onExitVersions={onExitVersions}
|
||||
onOpenVersions={onOpenVersions}
|
||||
|
||||
@ -14,12 +14,9 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
hasAgentComposerUnpublishedChangesAtom,
|
||||
isAgentComposerDirtyAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { isAgentComposerDirtyAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -35,13 +32,9 @@ type PublishBarMode =
|
||||
|
||||
type AgentConfigurePublishBarProps = {
|
||||
agentId: string
|
||||
activeConfigIsPublished?: boolean
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
agentName?: string | null
|
||||
draftSavedAt?: number
|
||||
isPublishing?: boolean
|
||||
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
workflowReferencesEnabled?: boolean
|
||||
onPublish?: () => void | Promise<void>
|
||||
onExitVersions?: () => void
|
||||
onOpenVersions?: () => void
|
||||
@ -52,13 +45,11 @@ function getPublishState({
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
hasLocalChanges,
|
||||
hasUnpublishedChanges,
|
||||
isPublishing,
|
||||
}: {
|
||||
activeConfigIsPublished?: boolean
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
hasLocalChanges: boolean
|
||||
hasUnpublishedChanges: boolean
|
||||
isPublishing: boolean
|
||||
}): AgentConfigurePublishState {
|
||||
if (isPublishing) return 'publishing'
|
||||
@ -67,13 +58,9 @@ function getPublishState({
|
||||
|
||||
if (activeConfigIsPublished) return 'published'
|
||||
|
||||
if (hasUnpublishedChanges) return 'unpublished'
|
||||
|
||||
if (!activeConfigSnapshot) return 'draft'
|
||||
|
||||
if (!activeConfigIsPublished) return 'unpublished'
|
||||
|
||||
return 'published'
|
||||
return 'unpublished'
|
||||
}
|
||||
|
||||
function PublishShortcut() {
|
||||
@ -90,13 +77,9 @@ function PublishShortcut() {
|
||||
|
||||
export function AgentConfigurePublishBar({
|
||||
agentId,
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
agentName,
|
||||
draftSavedAt,
|
||||
isPublishing = false,
|
||||
selectedVersionSnapshot,
|
||||
workflowReferencesEnabled = true,
|
||||
onPublish,
|
||||
onExitVersions,
|
||||
onOpenVersions,
|
||||
@ -107,29 +90,37 @@ export function AgentConfigurePublishBar({
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const queryClient = useQueryClient()
|
||||
const [publishBarMode, setPublishBarMode] = useState<PublishBarMode>({ status: 'compact' })
|
||||
const lastKnownPublishedRef = useRef(false)
|
||||
if (activeConfigIsPublished === true) lastKnownPublishedRef.current = true
|
||||
if (activeConfigIsPublished === false) lastKnownPublishedRef.current = false
|
||||
const stableActiveConfigIsPublished =
|
||||
activeConfigIsPublished ?? (lastKnownPublishedRef.current ? true : undefined)
|
||||
const hasUnpublishedChanges = useAtomValue(hasAgentComposerUnpublishedChangesAtom)
|
||||
const composerQuery = useQuery(
|
||||
consoleQuery.agent.byAgentId.composer.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const activeConfigIsPublished = composerQuery.data?.active_config_is_published
|
||||
const activeConfigSnapshot = composerQuery.data?.active_config_snapshot
|
||||
const draftSavedAt = composerQuery.data?.draft?.updated_at
|
||||
? composerQuery.data.draft.updated_at * 1000
|
||||
: undefined
|
||||
const hasLocalChanges = useAtomValue(isAgentComposerDirtyAtom)
|
||||
const publishableState = getPublishState({
|
||||
activeConfigIsPublished: stableActiveConfigIsPublished,
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
hasLocalChanges,
|
||||
hasUnpublishedChanges,
|
||||
isPublishing: false,
|
||||
})
|
||||
const publishState = getPublishState({
|
||||
activeConfigIsPublished: stableActiveConfigIsPublished,
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
hasLocalChanges,
|
||||
hasUnpublishedChanges,
|
||||
isPublishing,
|
||||
})
|
||||
const publishIsAvailable =
|
||||
!isPublishing && (publishableState === 'draft' || publishableState === 'unpublished')
|
||||
composerQuery.isSuccess &&
|
||||
!isPublishing &&
|
||||
(publishableState === 'draft' || publishableState === 'unpublished')
|
||||
const workflowReferencesQueryOptions =
|
||||
consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({
|
||||
input: {
|
||||
@ -137,7 +128,10 @@ export function AgentConfigurePublishBar({
|
||||
agent_id: agentId,
|
||||
},
|
||||
},
|
||||
enabled: workflowReferencesEnabled && publishIsAvailable && !selectedVersionSnapshot,
|
||||
context: {
|
||||
silent: true,
|
||||
},
|
||||
enabled: publishIsAvailable && !selectedVersionSnapshot,
|
||||
})
|
||||
const workflowReferencesQuery = useQuery(workflowReferencesQueryOptions)
|
||||
const restoreVersionMutation = useMutation(
|
||||
@ -206,16 +200,19 @@ export function AgentConfigurePublishBar({
|
||||
return
|
||||
}
|
||||
|
||||
const cachedReferences = queryClient.getQueryData<AgentReferencingWorkflowsResponse>(
|
||||
workflowReferencesQueryOptions.queryKey,
|
||||
)
|
||||
const references = workflowReferencesEnabled
|
||||
? ((
|
||||
cachedReferences ??
|
||||
workflowReferencesQuery.data ??
|
||||
(await queryClient.ensureQueryData(workflowReferencesQueryOptions))
|
||||
)?.data ?? [])
|
||||
: []
|
||||
let referencesResponse: AgentReferencingWorkflowsResponse | undefined
|
||||
try {
|
||||
referencesResponse =
|
||||
queryClient.getQueryData<AgentReferencingWorkflowsResponse>(
|
||||
workflowReferencesQueryOptions.queryKey,
|
||||
) ??
|
||||
workflowReferencesQuery.data ??
|
||||
(await queryClient.ensureQueryData(workflowReferencesQueryOptions))
|
||||
} catch {
|
||||
toast.error(tCommon(($) => $['api.actionFailed']))
|
||||
return
|
||||
}
|
||||
const references = referencesResponse?.data ?? []
|
||||
|
||||
if (references.length > 0) {
|
||||
setPublishBarMode({ status: 'confirmingImpact', references })
|
||||
@ -225,11 +222,15 @@ export function AgentConfigurePublishBar({
|
||||
await handlePublish()
|
||||
}
|
||||
|
||||
const requestPublish = () => {
|
||||
void handlePublishRequest().catch(() => undefined)
|
||||
}
|
||||
|
||||
useHotkey(
|
||||
PUBLISH_AGENT_HOTKEY,
|
||||
(event) => {
|
||||
event.preventDefault()
|
||||
void handlePublishRequest()
|
||||
requestPublish()
|
||||
},
|
||||
{
|
||||
enabled: canPublish && !selectedVersionSnapshot,
|
||||
@ -331,7 +332,7 @@ export function AgentConfigurePublishBar({
|
||||
canPublish={canPublish}
|
||||
onCancelImpact={() => setPublishBarMode({ status: 'compact' })}
|
||||
onOpenVersions={() => onOpenVersions?.()}
|
||||
onPublishRequest={handlePublishRequest}
|
||||
onPublishRequest={requestPublish}
|
||||
/>
|
||||
</Collapsible>
|
||||
)
|
||||
@ -360,7 +361,7 @@ function PublishBarActions({
|
||||
canPublish: boolean
|
||||
onCancelImpact: () => void
|
||||
onOpenVersions: () => void
|
||||
onPublishRequest: () => void | Promise<void>
|
||||
onPublishRequest: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
|
||||
@ -402,9 +403,7 @@ function PublishBarActions({
|
||||
disabled={!canPublish}
|
||||
loading={isPublishing}
|
||||
className="h-8 gap-1 rounded-lg px-3"
|
||||
onClick={() => {
|
||||
void onPublishRequest()
|
||||
}}
|
||||
onClick={onPublishRequest}
|
||||
>
|
||||
{actionIcon && <span aria-hidden className={`${actionIcon} size-4 shrink-0`} />}
|
||||
<span className="shrink-0">{actionLabel}</span>
|
||||
|
||||
@ -12,8 +12,7 @@ import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-compo
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
agentComposerPublishedDraftAtom,
|
||||
agentComposerSavedDraftAtom,
|
||||
isAgentComposerDirtyAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
|
||||
@ -325,8 +324,7 @@ function renderAgentToolsWithStore(initialDraft: AgentSoulConfigFormState = agen
|
||||
})
|
||||
const store = createStore()
|
||||
store.set(agentComposerDraftAtom, initialDraft)
|
||||
store.set(agentComposerOriginalDraftAtom, initialDraft)
|
||||
store.set(agentComposerPublishedDraftAtom, initialDraft)
|
||||
store.set(agentComposerSavedDraftAtom, initialDraft)
|
||||
|
||||
const view = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@ -4,11 +4,11 @@ import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.ge
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { mutationOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import isEqual from 'fast-deep-equal'
|
||||
import { useSetAtom, useStore } from 'jotai'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback'
|
||||
@ -19,9 +19,7 @@ import {
|
||||
} from '@/features/agent-v2/agent-composer/knowledge-validation'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
agentComposerPublishedDraftAtom,
|
||||
agentComposerSavedDraftAtom,
|
||||
isAgentComposerDirtyAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -45,15 +43,13 @@ export function useAgentConfigureSync({
|
||||
const getKnowledgeValidationMessage = useKnowledgeValidationMessage()
|
||||
const queryClient = useQueryClient()
|
||||
const store = useStore()
|
||||
const setOriginalConfig = useSetAtom(agentComposerOriginalConfigAtom)
|
||||
const setOriginalDraft = useSetAtom(agentComposerOriginalDraftAtom)
|
||||
const setPublishedDraft = useSetAtom(agentComposerPublishedDraftAtom)
|
||||
const [draftSavedAt, setDraftSavedAt] = useState<number | undefined>(undefined)
|
||||
const [isPublishInFlight, setIsPublishInFlight] = useState(false)
|
||||
const setSavedDraft = useSetAtom(agentComposerSavedDraftAtom)
|
||||
const baseConfigRef = useRef(baseConfig)
|
||||
const currentModelRef = useRef(currentModel)
|
||||
const enabledRef = useRef(enabled)
|
||||
const lastAutosavedDraftKeyRef = useRef<string | undefined>(undefined)
|
||||
const latestAppliedSaveSequenceRef = useRef(0)
|
||||
const nextSaveSequenceRef = useRef(0)
|
||||
const pageCloseSavingDraftKeyRef = useRef<string | undefined>(undefined)
|
||||
const explicitlySavingDraftKeysRef = useRef(new Set<string>())
|
||||
const publishInFlightRef = useRef(false)
|
||||
@ -73,28 +69,63 @@ export function useAgentConfigureSync({
|
||||
)
|
||||
|
||||
const { mutateAsync: saveComposerDraft } = useMutation(
|
||||
consoleQuery.agent.byAgentId.composer.put.mutationOptions(),
|
||||
consoleQuery.agent.byAgentId.composer.put.mutationOptions({
|
||||
context: {
|
||||
silent: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const { isPending: isPublishingAgent, mutateAsync: publishAgent } = useMutation(
|
||||
consoleQuery.agent.byAgentId.publish.post.mutationOptions(),
|
||||
const { mutateAsync: saveComposerDraftOnPageClose } = useMutation(
|
||||
consoleQuery.agent.byAgentId.composer.put.mutationOptions({
|
||||
context: {
|
||||
keepalive: true,
|
||||
silent: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const { mutateAsync: publishAgent } = useMutation(
|
||||
consoleQuery.agent.byAgentId.publish.post.mutationOptions({
|
||||
context: {
|
||||
silent: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const applySavedDraft = useCallback(
|
||||
({
|
||||
draftBaseline,
|
||||
draftKey,
|
||||
saveSequence,
|
||||
}: {
|
||||
draftBaseline: AgentSoulConfigFormState
|
||||
draftKey: string
|
||||
saveSequence: number
|
||||
}) => {
|
||||
if (saveSequence < latestAppliedSaveSequenceRef.current) return
|
||||
|
||||
latestAppliedSaveSequenceRef.current = saveSequence
|
||||
setSavedDraft(draftBaseline)
|
||||
lastAutosavedDraftKeyRef.current = draftKey
|
||||
},
|
||||
[setSavedDraft],
|
||||
)
|
||||
|
||||
const saveComposer = useSerialAsyncCallback(
|
||||
async ({
|
||||
configSnapshot,
|
||||
draftBaseline,
|
||||
publish = false,
|
||||
silent = true,
|
||||
}: {
|
||||
configSnapshot: AgentSoulConfig
|
||||
draftBaseline: AgentSoulConfigFormState
|
||||
publish?: boolean
|
||||
silent?: boolean
|
||||
}) => {
|
||||
const savedDraftKey = JSON.stringify(configSnapshot)
|
||||
const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({
|
||||
input: { params: { agent_id: agentId } },
|
||||
})
|
||||
const saveSequence = ++nextSaveSequenceRef.current
|
||||
try {
|
||||
const composerState = await saveComposerDraft({
|
||||
await saveComposerDraft({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
@ -104,32 +135,93 @@ export function useAgentConfigureSync({
|
||||
agent_soul: configSnapshot,
|
||||
},
|
||||
})
|
||||
queryClient.setQueryData(
|
||||
consoleQuery.agent.byAgentId.composer.get.queryKey({
|
||||
input: { params: { agent_id: agentId } },
|
||||
}),
|
||||
composerState,
|
||||
)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: agentDetailQueryKey,
|
||||
})
|
||||
} catch {
|
||||
// Autosave is silent and keeps the local draft intact; explicit commands must stop at this boundary.
|
||||
if (!silent) {
|
||||
toast.error(tCommon(($) => $['api.actionFailed']))
|
||||
throw new Error('Failed to save agent composer draft.')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
setOriginalDraft(draftBaseline)
|
||||
setDraftSavedAt(Date.now())
|
||||
lastAutosavedDraftKeyRef.current = savedDraftKey
|
||||
applySavedDraft({
|
||||
draftBaseline,
|
||||
draftKey: savedDraftKey,
|
||||
saveSequence,
|
||||
})
|
||||
|
||||
if (publish) {
|
||||
await publishAgent({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.byAgentId.versions.get.key(),
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
const saveComposerOnPageClose = useCallback(
|
||||
async ({
|
||||
configSnapshot,
|
||||
draftBaseline,
|
||||
draftKey,
|
||||
}: {
|
||||
configSnapshot: AgentSoulConfig
|
||||
draftBaseline: AgentSoulConfigFormState
|
||||
draftKey: string
|
||||
}) => {
|
||||
const saveSequence = ++nextSaveSequenceRef.current
|
||||
try {
|
||||
await saveComposerDraftOnPageClose({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
body: {
|
||||
variant: 'agent_app',
|
||||
save_strategy: 'save_to_current_version',
|
||||
agent_soul: configSnapshot,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
applySavedDraft({
|
||||
draftBaseline,
|
||||
draftKey,
|
||||
saveSequence,
|
||||
})
|
||||
return true
|
||||
},
|
||||
[agentId, applySavedDraft, saveComposerDraftOnPageClose],
|
||||
)
|
||||
|
||||
const { isPending: isPublishing, mutateAsync: runPublishTransaction } = useMutation(
|
||||
mutationOptions({
|
||||
mutationKey: ['agent-configure', agentId, 'publish'],
|
||||
mutationFn: async ({
|
||||
configSnapshot,
|
||||
draftBaseline,
|
||||
}: {
|
||||
configSnapshot: AgentSoulConfig
|
||||
draftBaseline: AgentSoulConfigFormState
|
||||
}) => {
|
||||
await saveComposer({
|
||||
configSnapshot,
|
||||
draftBaseline,
|
||||
publish: true,
|
||||
silent: false,
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const latestDraftSaveRef = useRef<() => void>(() => undefined)
|
||||
latestDraftSaveRef.current = () => {
|
||||
const draft = store.get(agentComposerDraftAtom)
|
||||
@ -165,41 +257,46 @@ export function useAgentConfigureSync({
|
||||
draftBaseline: draft,
|
||||
silent: false,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(tCommon(($) => $['api.actionFailed']))
|
||||
throw error
|
||||
} finally {
|
||||
explicitlySavingDraftKeysRef.current.delete(draftKey)
|
||||
}
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store])
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store, tCommon])
|
||||
|
||||
const saveDirtyDraftOnPageClose = useCallback(() => {
|
||||
if (!enabledRef.current || publishInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
const saveDirtyDraftOnPageClose = useCallback(
|
||||
(allowInFlightDuplicate = false) => {
|
||||
if (!enabledRef.current) return
|
||||
|
||||
const draft = store.get(agentComposerDraftAtom)
|
||||
if (!store.get(isAgentComposerDirtyAtom)) {
|
||||
return
|
||||
}
|
||||
const draft = store.get(agentComposerDraftAtom)
|
||||
if (!store.get(isAgentComposerDirtyAtom)) {
|
||||
return
|
||||
}
|
||||
|
||||
const configSnapshot = getAgentSoulDraft()
|
||||
const draftKey = JSON.stringify(configSnapshot)
|
||||
if (
|
||||
lastAutosavedDraftKeyRef.current === draftKey ||
|
||||
pageCloseSavingDraftKeyRef.current === draftKey ||
|
||||
explicitlySavingDraftKeysRef.current.has(draftKey)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const configSnapshot = getAgentSoulDraft()
|
||||
const draftKey = JSON.stringify(configSnapshot)
|
||||
if (
|
||||
lastAutosavedDraftKeyRef.current === draftKey ||
|
||||
pageCloseSavingDraftKeyRef.current === draftKey ||
|
||||
(!allowInFlightDuplicate && explicitlySavingDraftKeysRef.current.has(draftKey))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
debouncedSaveDraft.cancel?.()
|
||||
pageCloseSavingDraftKeyRef.current = draftKey
|
||||
void saveComposer({
|
||||
configSnapshot,
|
||||
draftBaseline: draft,
|
||||
}).finally(() => {
|
||||
if (pageCloseSavingDraftKeyRef.current === draftKey)
|
||||
pageCloseSavingDraftKeyRef.current = undefined
|
||||
})
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store])
|
||||
debouncedSaveDraft.cancel?.()
|
||||
pageCloseSavingDraftKeyRef.current = draftKey
|
||||
void saveComposerOnPageClose({
|
||||
configSnapshot,
|
||||
draftBaseline: draft,
|
||||
draftKey,
|
||||
}).finally(() => {
|
||||
if (pageCloseSavingDraftKeyRef.current === draftKey)
|
||||
pageCloseSavingDraftKeyRef.current = undefined
|
||||
})
|
||||
},
|
||||
[debouncedSaveDraft, getAgentSoulDraft, saveComposerOnPageClose, store],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return store.sub(agentComposerDraftAtom, () => {
|
||||
@ -207,7 +304,7 @@ export function useAgentConfigureSync({
|
||||
const agentSoulDraftKey = JSON.stringify(agentSoulDraft)
|
||||
const isDirty = store.get(isAgentComposerDirtyAtom)
|
||||
|
||||
if (!enabledRef.current || !isDirty) {
|
||||
if (!enabledRef.current || publishInFlightRef.current || !isDirty) {
|
||||
if (!isDirty) debouncedSaveDraft.cancel?.()
|
||||
return
|
||||
}
|
||||
@ -222,10 +319,10 @@ export function useAgentConfigureSync({
|
||||
|
||||
useEffect(() => {
|
||||
const saveDraftWhenPageHidden = () => {
|
||||
if (document.visibilityState === 'hidden') saveDirtyDraftOnPageClose()
|
||||
if (document.visibilityState === 'hidden') saveDirtyDraftOnPageClose(true)
|
||||
}
|
||||
const saveDraftBeforeUnload = () => {
|
||||
saveDirtyDraftOnPageClose()
|
||||
saveDirtyDraftOnPageClose(true)
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', saveDraftWhenPageHidden)
|
||||
@ -244,7 +341,7 @@ export function useAgentConfigureSync({
|
||||
}, [saveDirtyDraftOnPageClose])
|
||||
|
||||
const publishDraft = useCallback(async () => {
|
||||
if (publishInFlightRef.current) return
|
||||
if (!enabledRef.current || publishInFlightRef.current) return
|
||||
|
||||
const draft = store.get(agentComposerDraftAtom)
|
||||
const configSnapshot = formStateToAgentSoulConfig({
|
||||
@ -267,45 +364,12 @@ export function useAgentConfigureSync({
|
||||
}
|
||||
|
||||
publishInFlightRef.current = true
|
||||
setIsPublishInFlight(true)
|
||||
try {
|
||||
debouncedSaveDraft.cancel?.()
|
||||
const saved = await saveComposer({
|
||||
await runPublishTransaction({
|
||||
configSnapshot,
|
||||
draftBaseline: draft,
|
||||
silent: false,
|
||||
})
|
||||
if (!saved) return
|
||||
|
||||
await publishAgent({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
body: {},
|
||||
})
|
||||
queryClient.setQueryData(
|
||||
consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }),
|
||||
(agentDetail) => {
|
||||
if (!agentDetail) return agentDetail
|
||||
|
||||
return {
|
||||
...agentDetail,
|
||||
active_config_is_published: true,
|
||||
}
|
||||
},
|
||||
)
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.byAgentId.composer.get.queryKey({
|
||||
input: { params: { agent_id: agentId } },
|
||||
}),
|
||||
})
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.byAgentId.versions.get.key(),
|
||||
})
|
||||
setOriginalConfig(configSnapshot)
|
||||
const publishedDraft = draft
|
||||
setOriginalDraft(publishedDraft)
|
||||
setPublishedDraft(publishedDraft)
|
||||
trackEvent('app_published_time', {
|
||||
action_mode: 'app',
|
||||
app_id: agentId,
|
||||
@ -313,28 +377,25 @@ export function useAgentConfigureSync({
|
||||
app_mode: 'agent-v2',
|
||||
})
|
||||
toast.success(tCommon(($) => $['api.actionSuccess']))
|
||||
} catch (error) {
|
||||
toast.error(tCommon(($) => $['api.actionFailed']))
|
||||
throw error
|
||||
} finally {
|
||||
publishInFlightRef.current = false
|
||||
setIsPublishInFlight(false)
|
||||
if (enabledRef.current && store.get(isAgentComposerDirtyAtom)) debouncedSaveDraft()
|
||||
}
|
||||
}, [
|
||||
agentId,
|
||||
agentName,
|
||||
debouncedSaveDraft,
|
||||
getKnowledgeValidationMessage,
|
||||
publishAgent,
|
||||
queryClient,
|
||||
saveComposer,
|
||||
setOriginalConfig,
|
||||
setOriginalDraft,
|
||||
setPublishedDraft,
|
||||
runPublishTransaction,
|
||||
store,
|
||||
tCommon,
|
||||
])
|
||||
|
||||
return {
|
||||
draftSavedAt,
|
||||
isPublishing: isPublishInFlight || isPublishingAgent,
|
||||
isPublishing,
|
||||
publishDraft,
|
||||
saveDraft,
|
||||
}
|
||||
|
||||
@ -129,6 +129,12 @@ const createComposerState = (
|
||||
id: 'snapshot-1',
|
||||
version: 1,
|
||||
},
|
||||
draft: {
|
||||
agent_id: 'agent-1',
|
||||
draft_type: 'draft',
|
||||
id: 'draft-1',
|
||||
updated_at: 1710000100,
|
||||
},
|
||||
agent: {
|
||||
active_config_snapshot_id: 'snapshot-1',
|
||||
description: 'Agent description',
|
||||
@ -156,6 +162,12 @@ const createAgentPublishResponse = (
|
||||
version: 1,
|
||||
},
|
||||
active_config_snapshot_id: 'snapshot-1',
|
||||
draft: {
|
||||
agent_id: 'agent-1',
|
||||
draft_type: 'draft',
|
||||
id: 'draft-1',
|
||||
updated_at: 1710000200,
|
||||
},
|
||||
result: 'success',
|
||||
...overrides,
|
||||
})
|
||||
@ -966,10 +978,43 @@ describe('consoleQuery agent mutation defaults', () => {
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
const composerQueryKey = consoleQuery.agent.byAgentId.composer.get.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
},
|
||||
})
|
||||
queryClient.setQueryData(
|
||||
composerQueryKey,
|
||||
createComposerState({
|
||||
active_config_snapshot: {
|
||||
id: 'snapshot-previous',
|
||||
version: 1,
|
||||
},
|
||||
agent_soul: {
|
||||
config_note: 'Keep the cached composer state',
|
||||
schema_version: 1,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const publishResponse = createAgentPublishResponse({
|
||||
active_config_snapshot: {
|
||||
id: 'snapshot-2',
|
||||
version: 2,
|
||||
},
|
||||
active_config_snapshot_id: 'snapshot-2',
|
||||
draft: {
|
||||
agent_id: 'agent-1',
|
||||
draft_type: 'draft',
|
||||
id: 'draft-1',
|
||||
updated_at: 1710000300,
|
||||
},
|
||||
})
|
||||
|
||||
const mutationOptions = consoleQuery.agent.byAgentId.publish.post.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
createAgentPublishResponse(),
|
||||
publishResponse,
|
||||
{
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
@ -990,16 +1035,40 @@ describe('consoleQuery agent mutation defaults', () => {
|
||||
queryKey: consoleQuery.agent.inviteOptions.get.key(),
|
||||
})
|
||||
expect(queryClient.getQueryData(inviteOptionsQueryKey)).toBeUndefined()
|
||||
expect(queryClient.getQueryData(composerQueryKey)).toEqual(
|
||||
expect.objectContaining({
|
||||
active_config_is_published: true,
|
||||
active_config_snapshot: publishResponse.active_config_snapshot,
|
||||
agent_soul: {
|
||||
config_note: 'Keep the cached composer state',
|
||||
schema_version: 1,
|
||||
},
|
||||
draft: publishResponse.draft,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should invalidate roster list but keep invite options stable after saving an agent draft', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
const composerQueryKey = consoleQuery.agent.byAgentId.composer.get.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
},
|
||||
})
|
||||
const savedComposerState = createComposerState({
|
||||
agent_soul: {
|
||||
config_note: 'Saved composer state',
|
||||
schema_version: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const mutationOptions = consoleQuery.agent.byAgentId.composer.put.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
createComposerState(),
|
||||
savedComposerState,
|
||||
{
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
@ -1022,6 +1091,7 @@ describe('consoleQuery agent mutation defaults', () => {
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.agent.inviteOptions.get.key(),
|
||||
})
|
||||
expect(queryClient.getQueryData(composerQueryKey)).toEqual(savedComposerState)
|
||||
})
|
||||
|
||||
it('should invalidate invite option lists after deleting an agent', async () => {
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import type { AgentAppPagination } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type {
|
||||
AgentAppComposerResponse,
|
||||
AgentAppPagination,
|
||||
} from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
|
||||
import type { consoleRouterContract } from '@dify/contracts/console'
|
||||
@ -662,7 +665,17 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
|
||||
composer: {
|
||||
put: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_composerState, variables, _onMutateResult, context) => {
|
||||
onSuccess: (composerState, variables, _onMutateResult, context) => {
|
||||
context.client.setQueryData(
|
||||
consoleQuery.agent.byAgentId.composer.get.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: variables.params.agent_id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
composerState,
|
||||
)
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
@ -681,7 +694,33 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
|
||||
publish: {
|
||||
post: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_publishResult, _variables, _onMutateResult, context) => {
|
||||
onSuccess: (publishResult, variables, _onMutateResult, context) => {
|
||||
context.client.setQueryData<AgentAppComposerResponse>(
|
||||
consoleQuery.agent.byAgentId.composer.get.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: variables.params.agent_id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
(composerState) => {
|
||||
if (!composerState) return composerState
|
||||
|
||||
return {
|
||||
...composerState,
|
||||
active_config_is_published: true,
|
||||
active_config_snapshot: publishResult.active_config_snapshot,
|
||||
agent: {
|
||||
...composerState.agent,
|
||||
active_config_snapshot_id: publishResult.active_config_snapshot_id,
|
||||
},
|
||||
draft:
|
||||
publishResult.draft === undefined
|
||||
? composerState.draft
|
||||
: publishResult.draft,
|
||||
}
|
||||
},
|
||||
)
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user