feat: add agent version restore (#42567)

This commit is contained in:
Joel
2026-09-20 07:23:37 +00:00
committed by GitHub
parent dec1658200
commit 8dd33b88d8
37 changed files with 853 additions and 141 deletions
-5
View File
@@ -5082,11 +5082,6 @@
"count": 4
}
},
"web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts": {
"@tanstack/query/prefer-query-options": {
"count": 1
}
},
"web/features/agent-v2/agent-detail/logs/__tests__/page.spec.tsx": {
"@tanstack/query/prefer-query-options": {
"count": 1
@@ -535,10 +535,26 @@ vi.mock('../components/preview/header', () => ({
}))
vi.mock('../components/preview/versions-panel', () => ({
AgentPreviewVersionsPanel: (props: { onSelectVersion: (versionId: string) => void }) => (
<button type="button" onClick={() => props.onSelectVersion('snapshot-2')}>
select version
</button>
AgentPreviewVersionsPanel: (props: {
onBeforeRestore?: () => Promise<void>
onSelectVersion: (versionId: string | null) => void
onVersionRestored?: () => Promise<void>
}) => (
<>
<button type="button" onClick={() => props.onSelectVersion('snapshot-2')}>
select version
</button>
<button
type="button"
onClick={async () => {
await props.onBeforeRestore?.()
await props.onVersionRestored?.()
props.onSelectVersion(null)
}}
>
restore from version menu
</button>
</>
),
}))
@@ -2859,6 +2875,64 @@ describe('AgentConfigurePage', () => {
expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument()
})
it('should save pending draft edits before restoring from the version menu and rebase the composer', async () => {
const user = userEvent.setup()
const queryClient = createQueryClient()
const draftSave = createDeferredPromise<{ agent_soul: object }>()
mocks.saveComposerDraft.mockReturnValue(draftSave.promise)
const refetchComposer = vi.fn(async () => {
mocks.queryState.composer = {
...mocks.queryState.composer,
data: {
agent_soul: {
prompt: { system_prompt: 'restored prompt' },
},
},
}
return {}
})
mocks.queryState.composer = {
data: {
agent_soul: {
prompt: { system_prompt: 'draft prompt' },
},
},
isFetching: false,
isError: false,
isPending: false,
isSuccess: true,
refetch: refetchComposer,
}
render(
<QueryClientProvider client={queryClient}>
<AgentConfigurePage agentId="agent-1" />
</QueryClientProvider>,
)
await user.click(screen.getByRole('button', { name: 'edit prompt' }))
await user.click(screen.getByRole('button', { name: 'open versions' }))
await user.click(screen.getByRole('button', { name: 'restore from version menu' }))
expect(mocks.saveComposerDraft).toHaveBeenCalledTimes(1)
expect(refetchComposer).not.toHaveBeenCalled()
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent(
'prompt:edited draft prompt',
)
draftSave.resolve({ agent_soul: {} })
await waitFor(() => {
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent(
'prompt:restored prompt',
)
})
expect(refetchComposer).toHaveBeenCalledTimes(1)
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent(
'readonly:no',
)
})
it('should rebase the composer from the restored version', async () => {
const user = userEvent.setup()
const queryClient = createQueryClient()
@@ -0,0 +1,101 @@
import type { PropsWithChildren } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useAgentConfigureBuildDraftData } from '../use-agent-configure-build-draft'
const mocks = vi.hoisted(() => ({
request: vi.fn(),
toastError: vi.fn(),
}))
vi.mock('@/app/notifications', () => ({
toast: { error: mocks.toastError },
}))
vi.mock('@/service/console', () => ({
consoleQuery: {
agent: {
byAgentId: {
buildDraft: {
get: {
queryOptions: ({ context }: { context?: { silent?: boolean } }) => ({
queryKey: ['agent', 'agent-1', 'build-draft'],
queryFn: async () => {
try {
return await mocks.request()
} catch (error) {
if (!context?.silent) mocks.toastError('Request failed')
throw error
}
},
}),
},
},
},
},
},
}))
function setup() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const setSoulSourceOverride = vi.fn()
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
const hook = renderHook(
() =>
useAgentConfigureBuildDraftData({
agentId: 'agent-1',
activeVersionId: 'version-1',
isBuildMode: true,
isViewingVersion: false,
setSoulSourceOverride,
soulSourceOverride: null,
}),
{ wrapper },
)
return { ...hook, setSoulSourceOverride }
}
describe('build draft refresh errors', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.request.mockReset()
})
it('returns to the normal draft without a toast when a later refresh finds no build draft', async () => {
mocks.request.mockResolvedValueOnce({ draft: { id: 'build-draft-1' } })
const { result, setSoulSourceOverride } = setup()
await waitFor(() => expect(result.current.isActive).toBe(true))
const notFound = new Response(null, { status: 404 })
mocks.request.mockRejectedValueOnce(notFound)
await act(async () => {
const refreshed = await result.current.refetch()
expect(refreshed.error).toBe(notFound)
})
await waitFor(() => expect(result.current.soulSource).toBe('draft'))
expect(setSoulSourceOverride).toHaveBeenCalledWith('draft')
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('still notifies when refreshing the build draft fails unexpectedly', async () => {
mocks.request.mockResolvedValueOnce({ draft: { id: 'build-draft-1' } })
const { result, setSoulSourceOverride } = setup()
await waitFor(() => expect(result.current.isActive).toBe(true))
const serverError = new Response(null, { status: 500 })
mocks.request.mockRejectedValueOnce(serverError)
await act(async () => {
const refreshed = await result.current.refetch()
expect(refreshed.error).toBe(serverError)
})
expect(mocks.toastError).toHaveBeenCalledTimes(1)
expect(setSoulSourceOverride).not.toHaveBeenCalled()
})
})
@@ -628,6 +628,22 @@ function AgentConfigurePageComposerContent({
agentId={agentId}
activeVersionId={activeVersionId}
onSelectVersion={selectVersion}
restoreDisabled={
buildDraft.isActive ||
buildDraftActionsDisabled ||
isEnteringBuildMode ||
isPublishing
}
onBeforeRestore={async () => {
if (!isViewingVersion) {
await waitForPendingPreviewDraftSave()
await saveDraft()
}
}}
onVersionRestored={async () => {
await composerQuery.refetch()
onComposerRebase()
}}
onClose={() => setShowPreviewVersions(false)}
/>
)}
@@ -14,11 +14,26 @@ import {
agentComposerSavedDraftAtom,
} from '@/features/agent-v2/agent-composer/store'
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
import { consoleQuery } from '@/service/console'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { AgentConfigurePublishBar } from '../publish-bar'
type PublishHandler = NonNullable<ComponentProps<typeof AgentConfigurePublishBar>['onPublish']>
type PublishMock = Mock<PublishHandler>
const restoreAccess = vi.hoisted(() => ({
canRestore: true,
plan: 'professional',
onUrlUpdate: vi.fn(),
}))
vi.mock('@/features/agent-v2/permissions', () => ({
useAgentPermissions: () => ({ canReleaseAndVersion: restoreAccess.canRestore }),
}))
vi.mock('@/features/system-features/state', async () => {
const { atom } = await import('jotai')
return { deploymentEditionAtom: atom('CLOUD') }
})
const hotkeyRegistrations = vi.hoisted(
() =>
new Map<
@@ -84,8 +99,24 @@ vi.mock('@/hooks/use-timestamp', () => ({
}),
}))
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: ['system-features'],
queryFn: async () => ({ deployment_edition: 'CLOUD' }),
}),
}))
vi.mock('@/service/console', () => ({
consoleQuery: {
features: {
get: {
queryOptions: (options: Record<string, unknown>) => ({
...options,
queryKey: ['features'],
queryFn: async () => ({ billing: { subscription: { plan: restoreAccess.plan } } }),
}),
},
},
agent: {
byAgentId: {
get: {
@@ -137,7 +168,10 @@ vi.mock('@/service/console', () => ({
byVersionId: {
restore: {
post: {
mutationOptions: () => ({ mutationFn: restoreVersionMutation }),
mutationOptions: (options: Record<string, unknown>) => ({
...options,
mutationFn: restoreVersionMutation,
}),
},
},
},
@@ -239,6 +273,10 @@ function renderPublishBar({
mutations: { retry: false },
},
})
queryClient.setQueryData<unknown>(consoleQuery.features.get.queryOptions({}).queryKey, {
billing: { subscription: { plan: restoreAccess.plan } },
})
const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ onUrlUpdate: restoreAccess.onUrlUpdate })
const store = createStore()
store.set(agentComposerPromptAtom, prompt)
setupStore?.(store)
@@ -270,20 +308,22 @@ function renderPublishBar({
}
const renderPublishBarTree = (nextProps?: { isPublishing?: boolean }) => (
<QueryClientProvider client={queryClient}>
<JotaiProvider store={store}>
<AgentConfigurePublishBar
agentId="agent-1"
agentName="Iris"
isPublishing={nextProps?.isPublishing ?? isPublishing}
selectedVersionSnapshot={selectedVersionSnapshot}
onPublish={onPublish}
onExitVersions={onExitVersions}
onOpenVersions={vi.fn()}
onVersionRestored={onVersionRestored}
/>
</JotaiProvider>
</QueryClientProvider>
<NuqsWrapper>
<QueryClientProvider client={queryClient}>
<JotaiProvider store={store}>
<AgentConfigurePublishBar
agentId="agent-1"
agentName="Iris"
isPublishing={nextProps?.isPublishing ?? isPublishing}
selectedVersionSnapshot={selectedVersionSnapshot}
onPublish={onPublish}
onExitVersions={onExitVersions}
onOpenVersions={vi.fn()}
onVersionRestored={onVersionRestored}
/>
</JotaiProvider>
</QueryClientProvider>
</NuqsWrapper>
)
const view = render(renderPublishBarTree())
@@ -301,6 +341,8 @@ describe('AgentConfigurePublishBar', () => {
beforeEach(() => {
vi.clearAllMocks()
hotkeyRegistrations.clear()
restoreAccess.canRestore = true
restoreAccess.plan = 'professional'
restoreVersionMutation.mockResolvedValue({
active_config_snapshot_id: 'snapshot-2',
result: 'success',
@@ -393,8 +435,13 @@ describe('AgentConfigurePublishBar', () => {
expect(screen.getByText('Stable version')).toBeInTheDocument()
expect(screen.getByText('agentV2.agentDetail.versionHistory.viewOnly')).toBeInTheDocument()
expect(screen.getByText('formatted:1710000000 · Alice')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: 'agentV2.agentDetail.versionHistory.restore' }),
const user = userEvent.setup()
await user.click(
screen.getByRole('button', { name: /agentV2.agentDetail.versionHistory.restore/ }),
)
expect(restoreVersionMutation).not.toHaveBeenCalled()
await user.click(
within(await screen.findByRole('alertdialog')).getByRole('button', { name: /restore/i }),
)
await waitFor(() => {
@@ -413,6 +460,75 @@ describe('AgentConfigurePublishBar', () => {
expect(toastMock.success).toHaveBeenCalledWith('common.api.actionSuccess')
})
it('shows the agent upgrade dialog before opening pricing on the sandbox plan', async () => {
restoreAccess.plan = 'sandbox'
const user = userEvent.setup()
const { onExitVersions, onVersionRestored } = renderPublishBar({
selectedVersionSnapshot: activeConfigSnapshot,
})
await user.click(
screen.getByRole('button', { name: /agentV2.agentDetail.versionHistory.restore/ }),
)
const dialog = await screen.findByRole('dialog', { name: 'billing.upgrade.agentRestore.title' })
expect(dialog).toHaveAccessibleDescription('billing.upgrade.agentRestore.description')
expect(restoreAccess.onUrlUpdate).not.toHaveBeenCalled()
expect(restoreVersionMutation).not.toHaveBeenCalled()
await user.click(
within(dialog).getByRole('button', { name: 'billing.triggerLimitModal.upgrade' }),
)
expect(restoreAccess.onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({ queryString: '?pricing=open' }),
)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(restoreVersionMutation).not.toHaveBeenCalled()
expect(onExitVersions).not.toHaveBeenCalled()
expect(onVersionRestored).not.toHaveBeenCalled()
})
it('prevents duplicate restoration and cancellation while the request is pending', async () => {
const deferred = createDeferredPromise()
restoreVersionMutation.mockImplementationOnce(async () => {
await deferred.promise
return { active_config_snapshot_id: 'snapshot-2', result: 'success' }
})
const user = userEvent.setup()
const { onExitVersions } = renderPublishBar({ selectedVersionSnapshot: activeConfigSnapshot })
await user.click(
screen.getByRole('button', { name: /agentV2.agentDetail.versionHistory.restore/ }),
)
const dialog = await screen.findByRole('alertdialog')
const confirm = within(dialog).getByRole('button', { name: /restore/i })
await user.click(confirm)
await waitFor(() => expect(confirm).toHaveAttribute('aria-disabled', 'true'))
expect(within(dialog).getByRole('button', { name: /cancel/i })).toBeDisabled()
await user.click(confirm)
expect(restoreVersionMutation).toHaveBeenCalledTimes(1)
expect(onExitVersions).not.toHaveBeenCalled()
await act(async () => {
deferred.resolve()
await deferred.promise
})
await waitFor(() => expect(onExitVersions).toHaveBeenCalledTimes(1))
})
it('keeps the selected version open when restoration fails', async () => {
restoreVersionMutation.mockRejectedValueOnce(new Error('Restore failed'))
const user = userEvent.setup()
const { onExitVersions, onVersionRestored } = renderPublishBar({
selectedVersionSnapshot: activeConfigSnapshot,
})
await user.click(
screen.getByRole('button', { name: /agentV2.agentDetail.versionHistory.restore/ }),
)
await user.click(
within(await screen.findByRole('alertdialog')).getByRole('button', { name: /restore/i }),
)
await waitFor(() => expect(restoreVersionMutation).toHaveBeenCalledTimes(1))
expect(onExitVersions).not.toHaveBeenCalled()
expect(onVersionRestored).not.toHaveBeenCalled()
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
})
it('should render saved time from the latest draft save timestamp', () => {
renderPublishBar({ draftSavedAt: 1710000100000 })
@@ -12,7 +12,7 @@ import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -21,6 +21,8 @@ import { isAgentComposerDirtyAtom } from '@/features/agent-v2/agent-composer/sto
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import useTimestamp from '@/hooks/use-timestamp'
import { consoleQuery } from '@/service/console'
import { AgentVersionRestoreDialogs } from '../../preview/versions-panel/restore-dialogs'
import { useAgentVersionRestore } from '../../preview/versions-panel/use-agent-version-restore'
import { AgentPublishImpactDetails } from './publish-impact-details'
const PUBLISH_AGENT_HOTKEY = 'Mod+Shift+P' satisfies Hotkey
@@ -133,57 +135,15 @@ export function AgentConfigurePublishBar({
enabled: publishIsAvailable && !isPublishing && !selectedVersionSnapshot,
})
useQuery(workflowReferencesQueryOptions)
const restoreVersionMutation = useMutation(
consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions(),
)
const restore = useAgentVersionRestore({
agentId,
onRestored: async () => {
await onVersionRestored?.()
onExitVersions?.()
},
})
const canPublish = publishIsAvailable && !isPublishing
const handleRestoreVersion = (versionId: string) => {
if (restoreVersionMutation.isPending) return
restoreVersionMutation.mutate(
{
params: {
agent_id: agentId,
version_id: versionId,
},
},
{
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: consoleQuery.agent.byAgentId.get.queryKey({
input: {
params: {
agent_id: agentId,
},
},
}),
}),
queryClient.invalidateQueries({
queryKey: consoleQuery.agent.byAgentId.composer.get.queryKey({
input: {
params: {
agent_id: agentId,
},
},
}),
}),
queryClient.invalidateQueries({
queryKey: consoleQuery.agent.byAgentId.versions.get.key(),
}),
])
await onVersionRestored?.()
onExitVersions?.()
toast.success(tCommon(($) => $['api.actionSuccess']))
},
onError: () => {
toast.error(tCommon(($) => $['api.actionFailed']))
},
},
)
}
const handlePublish = async () => {
if (!canPublish) return
@@ -237,12 +197,27 @@ export function AgentConfigurePublishBar({
if (selectedVersionSnapshot) {
return (
<AgentVersionRestoreBar
version={selectedVersionSnapshot}
isRestoring={restoreVersionMutation.isPending}
onExitVersions={onExitVersions}
onRestoreVersion={handleRestoreVersion}
/>
<>
<AgentVersionRestoreBar
version={selectedVersionSnapshot}
isRestoring={restore.isPending}
restoreDisabled={restore.disabled}
onExitVersions={onExitVersions}
onRestoreVersion={
restore.canRestore ? () => restore.requestRestore(selectedVersionSnapshot) : undefined
}
/>
<AgentVersionRestoreDialogs
version={restore.version}
isUpgradeOpen={restore.isUpgradeOpen}
onUpgradeClose={restore.closeUpgrade}
isConfirmOpen={restore.isConfirmOpen}
onConfirmOpenChange={restore.onConfirmOpenChange}
isPending={restore.isPending}
disabled={restore.disabled}
onConfirm={restore.confirmRestore}
/>
</>
)
}
@@ -420,11 +395,13 @@ function PublishBarActions({
function AgentVersionRestoreBar({
version,
isRestoring = false,
restoreDisabled,
onExitVersions,
onRestoreVersion,
}: {
version: AgentConfigSnapshotSummaryResponse
isRestoring?: boolean
restoreDisabled?: boolean
onExitVersions?: () => void
onRestoreVersion?: (versionId: string) => void
}) {
@@ -461,9 +438,9 @@ function AgentVersionRestoreBar({
<Button
type="button"
variant="primary"
disabled={!onRestoreVersion}
disabled={!onRestoreVersion || restoreDisabled}
loading={isRestoring}
className="h-8 rounded-lg px-3"
className="shrink-0"
onClick={() => onRestoreVersion?.(version.id)}
>
{t(($) => $['agentDetail.versionHistory.restore'])}
@@ -1,21 +1,26 @@
import type { AgentConfigSnapshotSummaryResponse } from '@dify/contracts/api/console/agent/types.gen'
import { fireEvent, screen } from '@testing-library/react'
import type { ReactElement } from 'react'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import { renderWithAccountProfile as renderAccountProfile } from '@/test/console/account-profile'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { AgentPreviewVersionsPanel } from '../versions-panel'
const exportState = vi.hoisted(() => ({
canExport: true,
canRestore: true,
restoreVersion: vi.fn(async (_input: unknown) => ({ result: 'success' })),
isExporting: false,
edition: 'CLOUD',
plan: 'professional' as string | undefined,
exportAppDsl: vi.fn(),
setPricing: vi.fn(),
onUrlUpdate: vi.fn(),
}))
vi.mock('@/features/agent-v2/permissions', () => ({
useAgentPermissions: () => ({
canImportExportDSL: exportState.canExport,
canReleaseAndVersion: exportState.canRestore,
agentQuery: { data: { app_id: 'app-1', name: 'My agent' } },
}),
}))
@@ -32,10 +37,10 @@ vi.mock('jotai', async (importOriginal) => ({
useAtomValue: () => exportState.edition,
}))
vi.mock('nuqs', async (importOriginal) => ({
...(await importOriginal<typeof import('nuqs')>()),
useQueryState: () => [null, exportState.setPricing],
}))
function render(ui: ReactElement, options?: Parameters<typeof renderAccountProfile>[1]) {
const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ onUrlUpdate: exportState.onUrlUpdate })
return renderAccountProfile(<NuqsWrapper>{ui}</NuqsWrapper>, options)
}
const versions: AgentConfigSnapshotSummaryResponse[] = [
{
@@ -79,6 +84,13 @@ vi.mock('@/hooks/use-timestamp', () => ({
}),
}))
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: ['system-features'],
queryFn: async () => ({ deployment_edition: 'CLOUD' }),
}),
}))
vi.mock('@/service/console', () => ({
consoleQuery: {
features: { get: { queryOptions: () => ({ queryKey: ['features'] }) } },
@@ -91,9 +103,22 @@ vi.mock('@/service/console', () => ({
},
agent: {
byAgentId: {
get: { queryKey: () => ['agent'] },
composer: { get: { queryKey: () => ['composer'] } },
versions: {
byVersionId: {
restore: {
post: {
mutationOptions: (options: Record<string, unknown>) => ({
...options,
mutationFn: exportState.restoreVersion,
}),
},
},
},
get: {
queryOptions: () => ({ queryKey: ['agent-versions'] }),
key: () => ['agent-versions'],
},
},
},
@@ -105,6 +130,8 @@ describe('AgentPreviewVersionsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
exportState.canExport = true
exportState.canRestore = true
exportState.restoreVersion.mockResolvedValue({ result: 'success' })
exportState.isExporting = false
exportState.edition = 'CLOUD'
exportState.plan = 'professional'
@@ -137,8 +164,9 @@ describe('AgentPreviewVersionsPanel', () => {
expect(onSelectVersion).not.toHaveBeenCalled()
})
it('hides version actions without export permission', () => {
it('hides version actions without export or restore permission', () => {
exportState.canExport = false
exportState.canRestore = false
renderPanel()
expect(screen.queryByRole('button', { name: /moreActions/ })).not.toBeInTheDocument()
})
@@ -149,7 +177,9 @@ describe('AgentPreviewVersionsPanel', () => {
renderPanel()
await user.click(screen.getByRole('button', { name: /moreActions.*Initial release/ }))
await user.click(screen.getByRole('menuitem', { name: /export/i }))
expect(exportState.setPricing).toHaveBeenCalledWith('open')
expect(exportState.onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({ queryString: '?pricing=open' }),
)
expect(exportState.exportAppDsl).not.toHaveBeenCalled()
})
@@ -181,6 +211,138 @@ describe('AgentPreviewVersionsPanel', () => {
})
})
describe('Version restore', () => {
const renderPanel = (onBeforeRestore?: () => void | Promise<void>) => {
const onSelectVersion = vi.fn()
const onVersionRestored = vi.fn().mockResolvedValue(undefined)
render(
<AgentPreviewVersionsPanel
agentId="agent-1"
activeVersionId="version-2"
onSelectVersion={onSelectVersion}
onVersionRestored={onVersionRestored}
onBeforeRestore={onBeforeRestore}
onClose={vi.fn()}
/>,
)
return { onSelectVersion, onVersionRestored }
}
const openRestore = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole('button', { name: /moreActions.*Initial release/ }))
await user.click(screen.getByRole('menuitem', { name: /restore/i }))
}
it('restores the version whose menu was opened only after confirmation, then returns to the refreshed draft', async () => {
const user = userEvent.setup()
const { onSelectVersion, onVersionRestored } = renderPanel()
await openRestore(user)
const dialog = await screen.findByRole('alertdialog')
expect(exportState.restoreVersion).not.toHaveBeenCalled()
expect(onSelectVersion).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: /restore/i }))
await waitFor(() => expect(onSelectVersion).toHaveBeenCalledWith(null))
expect(exportState.restoreVersion.mock.calls[0]?.[0]).toEqual({
params: { agent_id: 'agent-1', version_id: 'version-0' },
})
expect(onVersionRestored.mock.invocationCallOrder[0]).toBeLessThan(
onSelectVersion.mock.invocationCallOrder[0]!,
)
})
it('does not restore when saving the existing draft fails', async () => {
const user = userEvent.setup()
const onBeforeRestore = vi.fn().mockRejectedValue(new Error('Draft save failed'))
const { onSelectVersion, onVersionRestored } = renderPanel(onBeforeRestore)
await openRestore(user)
await user.click(
within(await screen.findByRole('alertdialog')).getByRole('button', { name: /restore/i }),
)
await waitFor(() => expect(onBeforeRestore).toHaveBeenCalledTimes(1))
expect(exportState.restoreVersion).not.toHaveBeenCalled()
expect(onSelectVersion).not.toHaveBeenCalled()
expect(onVersionRestored).not.toHaveBeenCalled()
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
})
it('cancels restoration without changing the selected version', async () => {
const user = userEvent.setup()
const { onSelectVersion } = renderPanel()
await openRestore(user)
await user.click(
within(await screen.findByRole('alertdialog')).getByRole('button', { name: /cancel/i }),
)
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(exportState.restoreVersion).not.toHaveBeenCalled()
expect(onSelectVersion).not.toHaveBeenCalled()
})
it('dismisses the agent upgrade dialog without restoring or opening pricing on the Cloud sandbox plan', async () => {
exportState.plan = 'sandbox'
const user = userEvent.setup()
renderPanel()
await openRestore(user)
const dialog = await screen.findByRole('dialog', {
name: 'billing.upgrade.agentRestore.title',
})
expect(dialog).toHaveAccessibleDescription('billing.upgrade.agentRestore.description')
expect(exportState.onUrlUpdate).not.toHaveBeenCalled()
expect(exportState.restoreVersion).not.toHaveBeenCalled()
await user.click(
within(dialog).getByRole('button', { name: 'billing.triggerLimitModal.dismiss' }),
)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(exportState.onUrlUpdate).not.toHaveBeenCalled()
expect(exportState.restoreVersion).not.toHaveBeenCalled()
})
it.each(['COMMUNITY', 'ENTERPRISE'])(
'allows restoration on %s without a Cloud plan',
async (edition) => {
exportState.edition = edition
exportState.plan = undefined
const user = userEvent.setup()
renderPanel()
await openRestore(user)
await user.click(
within(await screen.findByRole('alertdialog')).getByRole('button', { name: /restore/i }),
)
await waitFor(() => expect(exportState.restoreVersion).toHaveBeenCalledTimes(1))
expect(exportState.onUrlUpdate).not.toHaveBeenCalled()
},
)
it('disables restoration until the Cloud plan is loaded', async () => {
exportState.plan = undefined
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: /moreActions.*Initial release/ }))
const restore = screen.getByRole('menuitem', { name: /restore/i })
expect(restore).toHaveAttribute('aria-disabled', 'true')
await user.click(restore)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(exportState.restoreVersion).not.toHaveBeenCalled()
})
it('offers restore independently of export permission', async () => {
exportState.canExport = false
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: /moreActions.*Initial release/ }))
expect(screen.getByRole('menuitem', { name: /restore/i })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: /export/i })).not.toBeInTheDocument()
})
it('hides restore without release permission while allowing export', async () => {
exportState.canRestore = false
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', { name: /moreActions.*Initial release/ }))
expect(screen.queryByRole('menuitem', { name: /restore/i })).not.toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /export/i })).toBeInTheDocument()
})
})
describe('Version selection', () => {
it('should notify the selected version when a version row is clicked', () => {
const handleSelectVersion = vi.fn()
@@ -17,6 +17,8 @@ import { deploymentEditionAtom } from '@/features/system-features/state'
import { consoleQuery } from '@/service/console'
import { CurrentDraftItem } from './current-draft-item'
import { VersionFilter } from './filter'
import { AgentVersionRestoreDialogs } from './restore-dialogs'
import { useAgentVersionRestore } from './use-agent-version-restore'
import { VersionFilterEmpty } from './version-filter-empty'
import { VersionItem } from './version-item'
@@ -24,6 +26,9 @@ type AgentPreviewVersionsPanelProps = {
agentId: string
activeVersionId?: string | null
onSelectVersion: (versionId: string | null) => void
onBeforeRestore?: () => void | Promise<void>
restoreDisabled?: boolean
onVersionRestored?: () => void | Promise<void>
onClose: () => void
}
@@ -32,7 +37,19 @@ export function AgentPreviewVersionsPanel({
activeVersionId,
onSelectVersion,
onClose,
onVersionRestored,
onBeforeRestore,
restoreDisabled,
}: AgentPreviewVersionsPanelProps) {
const restore = useAgentVersionRestore({
agentId,
onBeforeRestore,
disabled: restoreDisabled,
onRestored: async () => {
await onVersionRestored?.()
onSelectVersion(null)
},
})
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const { t: tWorkflow } = useTranslation('workflow')
@@ -140,6 +157,8 @@ export function AgentPreviewVersionsPanel({
isLast={index === filteredVersions.length - 1}
onSelect={onSelectVersion}
onExport={canImportExportDSL ? handleExport : undefined}
onRestore={restore.canRestore ? restore.requestRestore : undefined}
restoreDisabled={restore.disabled}
exportDisabled={exportDisabled}
showUpgrade={shouldShowUpgrade}
/>
@@ -147,6 +166,16 @@ export function AgentPreviewVersionsPanel({
</div>
)}
</div>
<AgentVersionRestoreDialogs
version={restore.version}
isUpgradeOpen={restore.isUpgradeOpen}
onUpgradeClose={restore.closeUpgrade}
isConfirmOpen={restore.isConfirmOpen}
onConfirmOpenChange={restore.onConfirmOpenChange}
isPending={restore.isPending}
disabled={restore.disabled}
onConfirm={restore.confirmRestore}
/>
</aside>
)
}
@@ -0,0 +1,76 @@
import type { AgentConfigSnapshotSummaryResponse } from '@dify/contracts/api/console/agent/types.gen'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { useTranslation } from 'react-i18next'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
type AgentVersionRestoreDialogsProps = {
version: AgentConfigSnapshotSummaryResponse | null
isUpgradeOpen: boolean
onUpgradeClose: () => void
isConfirmOpen: boolean
onConfirmOpenChange: (open: boolean) => void
isPending: boolean
disabled: boolean
onConfirm: () => void
}
export function AgentVersionRestoreDialogs({
version,
isUpgradeOpen,
onUpgradeClose,
isConfirmOpen,
onConfirmOpenChange,
isPending,
disabled,
onConfirm,
}: AgentVersionRestoreDialogsProps) {
const { t } = useTranslation('agentV2')
const versionLabel = version
? version.version_note ||
t(($) => $['agentDetail.versionHistory.versionName'], { version: version.version })
: ''
return (
<>
<PlanUpgradeModal
show={isUpgradeOpen}
onClose={onUpgradeClose}
title={t(($) => $['upgrade.agentRestore.title'], { ns: 'billing' })}
description={t(($) => $['upgrade.agentRestore.description'], { ns: 'billing' })}
/>
<AlertDialog open={isConfirmOpen} onOpenChange={onConfirmOpenChange}>
<AlertDialogContent>
<div className="flex flex-col gap-2 p-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{`${t(($) => $['agentDetail.versionHistory.restore'])} ${versionLabel}`}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-secondary">
{t(($) => $['versionHistory.restorationTip'], { ns: 'workflow' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary" disabled={isPending}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="default"
disabled={disabled}
loading={isPending}
onClick={onConfirm}
>
{t(($) => $['agentDetail.versionHistory.restore'])}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,104 @@
import type { AgentConfigSnapshotSummaryResponse } from '@dify/contracts/api/console/agent/types.gen'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/notifications'
import { useAgentPermissions } from '@/features/agent-v2/permissions'
import { deploymentEditionAtom } from '@/features/system-features/state'
import { consoleQuery } from '@/service/console'
export function useAgentVersionRestore({
agentId,
onBeforeRestore,
disabled: externallyDisabled = false,
onRestored,
}: {
agentId: string
disabled?: boolean
onBeforeRestore?: () => void | Promise<void>
onRestored?: () => void | Promise<void>
}) {
const { t } = useTranslation('agentV2')
const queryClient = useQueryClient()
const { canReleaseAndVersion: canRestore } = useAgentPermissions(agentId)
const deploymentEdition = useAtomValue(deploymentEditionAtom)
const { data: plan } = useQuery(
consoleQuery.features.get.queryOptions({
enabled: deploymentEdition === 'CLOUD',
select: (data) => data.billing.subscription.plan,
}),
)
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false)
const [version, setVersion] = useState<AgentConfigSnapshotSummaryResponse | null>(null)
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const showUpgrade = deploymentEdition === 'CLOUD' && plan === 'sandbox'
const disabled =
externallyDisabled || !canRestore || (deploymentEdition === 'CLOUD' && plan === undefined)
const mutation = useMutation(
consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions({
onMutate: async () => {
await onBeforeRestore?.()
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: consoleQuery.agent.byAgentId.get.queryKey({
input: { params: { agent_id: agentId } },
}),
}),
queryClient.invalidateQueries({
queryKey: consoleQuery.agent.byAgentId.composer.get.queryKey({
input: { params: { agent_id: agentId } },
}),
}),
queryClient.invalidateQueries({
queryKey: consoleQuery.agent.byAgentId.versions.get.key(),
}),
])
await onRestored?.()
setIsConfirmOpen(false)
toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }))
},
onError: () => {
toast.error(t(($) => $['api.actionFailed'], { ns: 'common' }))
},
}),
)
const requestRestore = (selectedVersion: AgentConfigSnapshotSummaryResponse) => {
if (disabled || mutation.isPending) return
if (showUpgrade) {
setIsUpgradeOpen(true)
return
}
setVersion(selectedVersion)
setIsConfirmOpen(true)
}
const confirmRestore = () => {
if (!version || disabled || mutation.isPending) return
if (showUpgrade) {
setIsConfirmOpen(false)
setIsUpgradeOpen(true)
return
}
mutation.mutate({ params: { agent_id: agentId, version_id: version.id } })
}
return {
requestRestore,
isPending: mutation.isPending,
disabled: disabled || mutation.isPending,
canRestore,
showUpgrade,
version,
isUpgradeOpen,
closeUpgrade: () => setIsUpgradeOpen(false),
isConfirmOpen,
onConfirmOpenChange: (open: boolean) => {
if (!mutation.isPending) setIsConfirmOpen(open)
},
confirmRestore,
}
}
@@ -39,6 +39,8 @@ export function VersionItem({
isLast,
onSelect,
onExport,
onRestore,
restoreDisabled,
exportDisabled,
showUpgrade,
}: {
@@ -49,6 +51,8 @@ export function VersionItem({
isLast: boolean
onSelect: (versionId: string) => void
onExport?: (versionId: string) => void
onRestore?: (version: AgentConfigSnapshotSummaryResponse) => void
restoreDisabled?: boolean
exportDisabled?: boolean
showUpgrade?: boolean
}) {
@@ -67,7 +71,7 @@ export function VersionItem({
onClick={() => onSelect(version.id)}
className={cn(
'group relative flex w-full items-start gap-1 rounded-lg py-1 pl-2 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
onExport ? 'pr-8' : 'pr-1.5',
onExport || onRestore ? 'pr-8' : 'pr-1.5',
isActive ? 'bg-state-accent-active' : 'hover:bg-state-base-hover',
)}
>
@@ -96,7 +100,7 @@ export function VersionItem({
<VersionMetadata version={version} />
</div>
</button>
{onExport && (
{(onExport || onRestore) && (
<DropdownMenu>
<DropdownMenuTrigger
render={
@@ -110,19 +114,36 @@ export function VersionItem({
}
/>
<DropdownMenuContent placement="bottom-end" className="min-w-40">
<DropdownMenuItem
disabled={exportDisabled}
onClick={() => onExport(version.id)}
className="gap-2"
>
<span aria-hidden className="i-ri-file-download-line size-4 shrink-0" />
<span className="flex-1">{t(($) => $.exportApp, { ns: 'app' })}</span>
{showUpgrade && (
<PremiumBadge size="s">
{t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}
</PremiumBadge>
)}
</DropdownMenuItem>
{onRestore && (
<DropdownMenuItem
disabled={restoreDisabled}
onClick={() => onRestore(version)}
className="gap-2"
>
<span aria-hidden className="i-ri-history-line size-4 shrink-0" />
<span className="flex-1">{tWorkflow(($) => $['common.restore'])}</span>
{showUpgrade && (
<PremiumBadge size="s">
{t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}
</PremiumBadge>
)}
</DropdownMenuItem>
)}
{onExport && (
<DropdownMenuItem
disabled={exportDisabled}
onClick={() => onExport(version.id)}
className="gap-2"
>
<span aria-hidden className="i-ri-file-download-line size-4 shrink-0" />
<span className="flex-1">{t(($) => $.exportApp, { ns: 'app' })}</span>
{showUpgrade && (
<PremiumBadge size="s">
{t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}
</PremiumBadge>
)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -11,7 +11,7 @@ import type {
AgentFileNode,
AgentSoulConfigFormState,
} from '@/features/agent-v2/agent-composer/form-state'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import isEqual from 'fast-deep-equal'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -156,51 +156,42 @@ export function useAgentConfigureBuildDraftData({
setSoulSourceOverride: (source: AgentConfigureSoulSource | null) => void
soulSourceOverride: AgentConfigureSoulSource | null
}) {
const shouldSilenceBuildDraftCheckRef = useRef(true)
const { t } = useTranslation('common')
const buildDraftQueryInput = {
params: {
agent_id: agentId,
},
}
const buildDraftQueryOptions = consoleQuery.agent.byAgentId.buildDraft.get.queryOptions({
input: {
params: buildDraftQueryInput.params,
},
context: {},
})
const silentBuildDraftQueryOptions = consoleQuery.agent.byAgentId.buildDraft.get.queryOptions({
input: {
params: buildDraftQueryInput.params,
},
context: {
silent: true,
},
queryKey: buildDraftQueryOptions.queryKey,
})
const buildDraftQuery = useQuery({
...buildDraftQueryOptions,
enabled:
isBuildMode &&
!isViewingVersion &&
soulSourceOverride !== 'draft' &&
soulSourceOverride !== 'view-version',
queryFn: async (context) => {
try {
const queryOptions = shouldSilenceBuildDraftCheckRef.current
? silentBuildDraftQueryOptions
: buildDraftQueryOptions
shouldSilenceBuildDraftCheckRef.current = false
return await queryOptions.queryFn(context)
} catch (error) {
if (isNotFoundResponse(error)) setSoulSourceOverride('draft')
throw error
}
},
retry: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
})
const buildDraftQuery = useQuery(
queryOptions({
...buildDraftQueryOptions,
enabled:
isBuildMode &&
!isViewingVersion &&
soulSourceOverride !== 'draft' &&
soulSourceOverride !== 'view-version',
queryFn: async (context) => {
try {
return await buildDraftQueryOptions.queryFn(context)
} catch (error) {
if (isNotFoundResponse(error)) setSoulSourceOverride('draft')
else toast.error(t(($) => $['api.actionFailed']))
throw error
}
},
retry: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}),
)
const {
data: buildDraftData,
dataUpdatedAt: buildDraftDataUpdatedAt,
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "أحداث المشغل",
"upgrade.addChunks.description": "لقد وصلت إلى الحد الأقصى لإضافة الأجزاء لهذا الخطة.",
"upgrade.addChunks.title": "قم بالترقية لمواصلة إضافة المقاطع",
"upgrade.agentRestore.description": "استعادة إصدارات تطبيق الوكيل غير متاحة في خطتك الحالية.",
"upgrade.agentRestore.title": "قم بالترقية لاستعادة إصدارات تطبيق الوكيل",
"upgrade.uploadMultipleFiles.description": "ارفع عدة مستندات دفعة واحدة وزِد الحد الأقصى لحجم كل ملف إلى 50 MB.",
"upgrade.uploadMultipleFiles.title": "قم بالترقية لإتاحة رفع الملفات دفعة واحدة والملفات الأكبر حجمًا",
"upgrade.uploadMultiplePages.description": "لقد وصلت إلى حد التحميل — يمكن اختيار ورفع مستند واحد فقط في كل مرة على الخطة الحالية الخاصة بك.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "Tətikləmə hadisələri",
"upgrade.addChunks.description": "Bu plan üzrə fraqment əlavə etmə limitinə çatmısınız.",
"upgrade.addChunks.title": "Fraqment əlavə etməyə davam etmək üçün planın yüksəldilməsi",
"upgrade.agentRestore.description": "Cari planınızda agent tətbiqinin versiyalarının bərpası əlçatan deyil.",
"upgrade.agentRestore.title": "Agent tətbiqinin versiyalarını bərpa etmək üçün planın yüksəldilməsi",
"upgrade.uploadMultipleFiles.description": "Bir neçə sənədi eyni anda yüklə və hər faylın maksimum ölçüsünü 50 MB-a qədər artır.",
"upgrade.uploadMultipleFiles.title": "Toplu yükləmə və daha böyük fayllar üçün planın yüksəldilməsi",
"upgrade.uploadMultiplePages.description": "Yükləmə limitinə çatmısınız: cari planınızda eyni anda yalnız bir sənəd seçib yükləyə bilərsiniz.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "AUSLÖSEEREIGNISSE",
"upgrade.addChunks.description": "Sie haben das Limit für das Hinzufügen von Abschnitten in diesem Tarif erreicht.",
"upgrade.addChunks.title": "Upgraden, um weiterhin Abschnitte hinzuzufügen",
"upgrade.agentRestore.description": "Die Wiederherstellung von Versionen der Agenten-App ist in Ihrem aktuellen Tarif nicht verfügbar.",
"upgrade.agentRestore.title": "Upgraden, um Versionen der Agenten-App wiederherzustellen",
"upgrade.uploadMultipleFiles.description": "Laden Sie mehrere Dokumente gleichzeitig hoch und erhöhen Sie die maximale Größe pro Datei auf 50 MB.",
"upgrade.uploadMultipleFiles.title": "Upgrade durchführen, um Batch-Uploads und größere Dateien freizuschalten",
"upgrade.uploadMultiplePages.description": "Sie haben das Upload-Limit erreicht – in Ihrem aktuellen Tarif kann jeweils nur ein Dokument ausgewählt und hochgeladen werden.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "TRIGGER EVENTS",
"upgrade.addChunks.description": "You’ve reached the limit of adding chunks for this plan.",
"upgrade.addChunks.title": "Upgrade to continue adding chunks",
"upgrade.agentRestore.description": "Agent app version restoration is not available on your current plan.",
"upgrade.agentRestore.title": "Upgrade to restore agent app versions",
"upgrade.uploadMultipleFiles.description": "Upload multiple documents at once and increase the maximum size per file to 50 MB.",
"upgrade.uploadMultipleFiles.title": "Upgrade to unlock batch uploads and larger files",
"upgrade.uploadMultiplePages.description": "You’ve reached the upload limit — only one document can be selected and uploaded at a time on your current plan.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "EVENTOS DESENCADENANTES",
"upgrade.addChunks.description": "Has alcanzado el límite de agregar fragmentos para este plan.",
"upgrade.addChunks.title": "Actualiza para seguir agregando fragmentos",
"upgrade.agentRestore.description": "La restauración de versiones de la aplicación de agente no está disponible en tu plan actual.",
"upgrade.agentRestore.title": "Actualiza para restaurar versiones de la aplicación de agente",
"upgrade.uploadMultipleFiles.description": "Sube varios documentos a la vez y aumenta el tamaño máximo por archivo a 50 MB.",
"upgrade.uploadMultipleFiles.title": "Mejora tu plan para desbloquear las cargas por lotes y los archivos de mayor tamaño",
"upgrade.uploadMultiplePages.description": "Has alcanzado el límite de carga: solo se puede seleccionar y subir un documento a la vez en tu plan actual.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "رویدادهای محرک",
"upgrade.addChunks.description": "شما به حد اضافه کردن بخش‌ها برای این طرح رسیده‌اید.",
"upgrade.addChunks.title": "برای ادامه افزودن بخش‌ها ارتقا دهید",
"upgrade.agentRestore.description": "بازیابی نسخه‌های برنامهٔ عامل در طرح فعلی شما در دسترس نیست.",
"upgrade.agentRestore.title": "برای بازیابی نسخه‌های برنامهٔ عامل ارتقا دهید",
"upgrade.uploadMultipleFiles.description": "چند سند را به‌طور هم‌زمان بارگذاری کنید و حداکثر اندازه هر فایل را به 50 MB افزایش دهید.",
"upgrade.uploadMultipleFiles.title": "برای فعال‌کردن بارگذاری گروهی و فایل‌های بزرگ‌تر، ارتقا دهید",
"upgrade.uploadMultiplePages.description": "شما به حد آپلود رسیده‌اید — در طرح فعلی خود تنها می‌توانید یک سند را در هر بار انتخاب و آپلود کنید.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "ÉVÉNEMENTS DÉCLENCHEURS",
"upgrade.addChunks.description": "Vous avez atteint la limite d'ajout de morceaux pour ce plan.",
"upgrade.addChunks.title": "Mettez à niveau pour continuer à ajouter des morceaux",
"upgrade.agentRestore.description": "La restauration des versions de l’application d’agent n'est pas disponible avec votre plan actuel.",
"upgrade.agentRestore.title": "Mettez à niveau pour restaurer les versions de l’application d’agent",
"upgrade.uploadMultipleFiles.description": "Importez plusieurs documents à la fois et augmentez la taille maximale par fichier à 50 MB.",
"upgrade.uploadMultipleFiles.title": "Passez à une offre supérieure pour débloquer les importations groupées et les fichiers plus volumineux",
"upgrade.uploadMultiplePages.description": "Vous avez atteint la limite de téléchargement — un seul document peut être sélectionné et téléchargé à la fois avec votre abonnement actuel.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "ट्रिगर घटनाएँ",
"upgrade.addChunks.description": "आप इस योजना के लिए टुकड़े जोड़ने की सीमा तक पहुँच चुके हैं।",
"upgrade.addChunks.title": "अधिक चंक्स जोड़ने के लिए अपग्रेड करें",
"upgrade.agentRestore.description": "आपकी वर्तमान योजना में एजेंट ऐप संस्करण पुनर्स्थापना उपलब्ध नहीं है।",
"upgrade.agentRestore.title": "एजेंट ऐप संस्करणों को पुनर्स्थापित करने के लिए अपग्रेड करें",
"upgrade.uploadMultipleFiles.description": "एक साथ कई दस्तावेज़ अपलोड करें और प्रति फ़ाइल अधिकतम साइज़ सीमा बढ़ाकर 50 MB करें।",
"upgrade.uploadMultipleFiles.title": "बैच अपलोड और बड़ी फ़ाइलों की सुविधा अनलॉक करने के लिए अपग्रेड करें",
"upgrade.uploadMultiplePages.description": "आपने अपलोड की सीमा तक पहुँच लिया है — आपके वर्तमान प्लान पर एक समय में केवल एक ही दस्तावेज़ चुना और अपलोड किया जा सकता है।",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "PERISTIWA PEMICU",
"upgrade.addChunks.description": "Anda telah mencapai batas penambahan potongan untuk paket ini.",
"upgrade.addChunks.title": "Tingkatkan untuk terus menambahkan potongan",
"upgrade.agentRestore.description": "Pemulihan versi aplikasi agen tidak tersedia di paket Anda saat ini.",
"upgrade.agentRestore.title": "Tingkatkan untuk memulihkan versi aplikasi agen",
"upgrade.uploadMultipleFiles.description": "Upload beberapa dokumen sekaligus dan tingkatkan ukuran maksimum per file menjadi 50 MB.",
"upgrade.uploadMultipleFiles.title": "Upgrade untuk membuka upload batch dan file berukuran lebih besar",
"upgrade.uploadMultiplePages.description": "Anda telah mencapai batas unggah — hanya satu dokumen yang dapat dipilih dan diunggah sekaligus dengan paket Anda saat ini.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "EVENTI DI ATTIVAZIONE",
"upgrade.addChunks.description": "Hai raggiunto il limite di aggiunta di blocchi per questo piano.",
"upgrade.addChunks.title": "Aggiorna per continuare ad aggiungere blocchi",
"upgrade.agentRestore.description": "Il ripristino delle versioni dell'app agente non è disponibile nel tuo piano attuale.",
"upgrade.agentRestore.title": "Esegui l'upgrade per ripristinare le versioni dell'app agente",
"upgrade.uploadMultipleFiles.description": "Carica più documenti contemporaneamente e aumenta la dimensione massima per file a 50 MB.",
"upgrade.uploadMultipleFiles.title": "Passa a un piano superiore per sbloccare i caricamenti in batch e i file di dimensioni maggiori",
"upgrade.uploadMultiplePages.description": "Hai raggiunto il limite di caricamento: sul tuo piano attuale può essere selezionato e caricato un solo documento alla volta.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "TRIGGER EVENTS",
"upgrade.addChunks.description": "このプランでは、チャンク追加の上限に達しています。",
"upgrade.addChunks.title": "アップグレードして、チャンクを引き続き追加できるようにしてください。",
"upgrade.agentRestore.description": "現在のプランでは、エージェントアプリのバージョンの復元は利用できません。",
"upgrade.agentRestore.title": "アップグレードしてエージェントアプリのバージョンを復元",
"upgrade.uploadMultipleFiles.description": "複数のドキュメントを一度にアップロードでき、1ファイルあたりのサイズ上限も50 MBに引き上げられます。",
"upgrade.uploadMultipleFiles.title": "アップグレードして一括アップロードと大容量ファイルを利用",
"upgrade.uploadMultiplePages.description": "現在のプランではアップロード上限に達しています。1回の操作で選択・アップロードできるドキュメントは1つのみです。",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "트리거 이벤트",
"upgrade.addChunks.description": "이 요금제에서는 더 이상 청크를 추가할 수 있는 한도에 도달했습니다.",
"upgrade.addChunks.title": "계속해서 조각을 추가하려면 업그레이드하세요",
"upgrade.agentRestore.description": "현재 플랜에서는 에이전트 앱 버전 복원을 사용할 수 없습니다.",
"upgrade.agentRestore.title": "에이전트 앱 버전을 복원하려면 업그레이드하세요",
"upgrade.uploadMultipleFiles.description": "여러 문서를 한 번에 업로드하고 파일당 최대 크기를 50MB로 늘리세요.",
"upgrade.uploadMultipleFiles.title": "업그레이드하여 일괄 업로드와 대용량 파일을 이용하세요",
"upgrade.uploadMultiplePages.description": "업로드 한도에 도달했습니다 — 현재 요금제에서는 한 번에 한 개의 문서만 선택하고 업로드할 수 있습니다.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "ເຫດການຕົວເລີ່ມຕົ້ນ",
"upgrade.addChunks.description": "ທ່ານໄດ້ໃຊ້ງານຮອດຂີດຈຳກັດໃນການເພີ່ມ Chunk ສຳລັບແພັກເກດນີ້ແລ້ວ.",
"upgrade.addChunks.title": "ອັບເກຣດເພື່ອເພີ່ມ Chunk ຕໍ່",
"upgrade.agentRestore.description": "ການກູ້ຄືນເວີຊັນແອັບ Agent ບໍ່ສາມາດໃຊ້ງານໄດ້ໃນແພັກເກດປັດຈຸບັນຂອງທ່ານ.",
"upgrade.agentRestore.title": "ອັບເກຣດເພື່ອກູ້ຄືນເວີຊັນແອັບ Agent",
"upgrade.uploadMultipleFiles.description": "ອັບໂຫລດເອກະສານຫຼາຍອັນພ້ອມກັນເພື່ອປະຢັດເວລາ ແລະ ເພີ່ມປະສິດທິພາບ.",
"upgrade.uploadMultipleFiles.title": "ອັບເກຣດເພື່ອປົດລັອກການອັບໂຫລດເອກະສານແບບເປັນກຸ່ມ",
"upgrade.uploadMultiplePages.description": "ທ່ານຮອດຂີດຈຳກັດການອັບໂຫລດແລ້ວ — ສາມາດເລືອກ ແລະ ອັບໂຫລດເອກະສານໄດ້ເທື່ອລະອັນໃນແພັກເກດປັດຈຸບັນ.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "TRIGGER EVENTS",
"upgrade.addChunks.description": "You’ve reached the limit of adding chunks for this plan.",
"upgrade.addChunks.title": "Upgrade to continue adding chunks",
"upgrade.agentRestore.description": "Het herstellen van versies van de agent-app is niet beschikbaar in je huidige abonnement.",
"upgrade.agentRestore.title": "Upgrade om versies van de agent-app te herstellen",
"upgrade.uploadMultipleFiles.description": "Upload meerdere documenten tegelijk en verhoog de maximale bestandsgrootte naar 50 MB.",
"upgrade.uploadMultipleFiles.title": "Upgrade je abonnement om batchuploads en grotere bestanden te ontgrendelen",
"upgrade.uploadMultiplePages.description": "You’ve reached the upload limit — only one document can be selected and uploaded at a time on your current plan.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "WYDARZENIA WYZWALAJĄCE",
"upgrade.addChunks.description": "Osiągnąłeś limit dodawania fragmentów w tym planie.",
"upgrade.addChunks.title": "Uaktualnij, aby kontynuować dodawanie fragmentów",
"upgrade.agentRestore.description": "Przywracanie wersji aplikacji agenta nie jest dostępne w obecnym planie.",
"upgrade.agentRestore.title": "Ulepsz plan, aby przywracać wersje aplikacji agenta",
"upgrade.uploadMultipleFiles.description": "Przesyłaj wiele dokumentów jednocześnie i zwiększ maksymalny rozmiar pojedynczego pliku do 50 MB.",
"upgrade.uploadMultipleFiles.title": "Przejdź na wyższy plan, aby odblokować przesyłanie zbiorcze i większe pliki",
"upgrade.uploadMultiplePages.description": "Osiągnąłeś limit przesyłania — w ramach obecnego planu można wybrać i przesłać tylko jeden dokument naraz.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "EVENTOS DE GATILHO",
"upgrade.addChunks.description": "Você atingiu o limite de adição de blocos para este plano.",
"upgrade.addChunks.title": "Faça upgrade para continuar adicionando blocos",
"upgrade.agentRestore.description": "A restauração de versões do aplicativo de agente não está disponível no seu plano atual.",
"upgrade.agentRestore.title": "Faça upgrade para restaurar versões do aplicativo de agente",
"upgrade.uploadMultipleFiles.description": "Envie vários documentos de uma só vez e aumente o tamanho máximo por arquivo para 50 MB.",
"upgrade.uploadMultipleFiles.title": "Faça upgrade para liberar uploads em lote e arquivos maiores",
"upgrade.uploadMultiplePages.description": "Você atingiu o limite de upload — apenas um documento pode ser selecionado e enviado por vez no seu plano atual.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "EVENIMENTE DECLANȘATOARE",
"upgrade.addChunks.description": "Ai atins limita de adăugare a segmentelor pentru acest plan.",
"upgrade.addChunks.title": "Actualizează pentru a continua să adaugi segmente",
"upgrade.agentRestore.description": "Restaurarea versiunilor aplicației agent nu este disponibilă în planul tău actual.",
"upgrade.agentRestore.title": "Fă upgrade pentru a restaura versiunile aplicației agent",
"upgrade.uploadMultipleFiles.description": "Încarcă mai multe documente simultan și mărește dimensiunea maximă pentru fiecare fișier la 50 MB.",
"upgrade.uploadMultipleFiles.title": "Treci la un plan superior pentru a debloca încărcările în lot și fișierele mai mari",
"upgrade.uploadMultiplePages.description": "Ați atins limita de încărcare — poate fi selectat și încărcat doar un singur document odată în planul dvs. actual.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "СОБЫТИЯ-ИНИЦИАТОРЫ",
"upgrade.addChunks.description": "Вы достигли предела добавления чанков по этому тарифному плану.",
"upgrade.addChunks.title": "Обновите версию, чтобы продолжить добавление блоков",
"upgrade.agentRestore.description": "Восстановление версий приложения агента недоступно в вашем текущем плане.",
"upgrade.agentRestore.title": "Обновите план, чтобы восстанавливать версии приложения агента",
"upgrade.uploadMultipleFiles.description": "Загружайте несколько документов одновременно и увеличьте максимальный размер одного файла до 50 МБ.",
"upgrade.uploadMultipleFiles.title": "Перейдите на более высокий тариф, чтобы разблокировать пакетную загрузку и файлы большего размера",
"upgrade.uploadMultiplePages.description": "Вы достигли лимита загрузки — на вашем текущем тарифном плане можно выбрать и загрузить только один документ за раз.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "SPROŽITVENI DOGODKI",
"upgrade.addChunks.description": "Dosegli ste omejitev dodajanja delov za ta načrt.",
"upgrade.addChunks.title": "Nadgradite, da nadaljujete z dodajanjem delov",
"upgrade.agentRestore.description": "Obnovitev različic aplikacije agenta v vašem trenutnem paketu ni na voljo.",
"upgrade.agentRestore.title": "Nadgradite za obnovitev različic aplikacije agenta",
"upgrade.uploadMultipleFiles.description": "Naložite več dokumentov hkrati in povečajte največjo velikost posamezne datoteke na 50 MB.",
"upgrade.uploadMultipleFiles.title": "Nadgradite paket, da odklenete paketno nalaganje in večje datoteke",
"upgrade.uploadMultiplePages.description": "Dosegli ste omejitev nalaganja — na vašem trenutnem načrtu je mogoče izbrati in naložiti le en dokument naenkrat.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "เหตุการณ์ทริกเกอร์",
"upgrade.addChunks.description": "คุณได้ถึงขีดจำกัดในการเพิ่มชิ้นส่วนสำหรับแผนนี้แล้ว",
"upgrade.addChunks.title": "อัปเกรดเพื่อเพิ่มชิ้นส่วนต่อ",
"upgrade.agentRestore.description": "การกู้คืนเวอร์ชันแอปเอเจนต์ไม่พร้อมใช้งานในแผนปัจจุบันของคุณ",
"upgrade.agentRestore.title": "อัปเกรดเพื่อกู้คืนเวอร์ชันแอปเอเจนต์",
"upgrade.uploadMultipleFiles.description": "อัปโหลดเอกสารหลายไฟล์พร้อมกัน และเพิ่มขนาดสูงสุดต่อไฟล์เป็น 50 MB",
"upgrade.uploadMultipleFiles.title": "อัปเกรดเพื่อปลดล็อกการอัปโหลดแบบเป็นชุดและไฟล์ขนาดใหญ่ขึ้น",
"upgrade.uploadMultiplePages.description": "คุณได้ถึงขีดจำกัดการอัปโหลดแล้ว — สามารถเลือกและอัปโหลดเอกสารได้เพียงไฟล์เดียวต่อครั้งในแผนปัจจุบันของคุณ",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "TETİKLEYİCİ OLAYLAR",
"upgrade.addChunks.description": "Bu plan için parça ekleme sınırına ulaştınız.",
"upgrade.addChunks.title": "Parçalar eklemeye devam etmek için yükseltin",
"upgrade.agentRestore.description": "Ajan uygulaması sürümü geri yükleme mevcut planınızda kullanılamaz.",
"upgrade.agentRestore.title": "Ajan uygulaması sürümlerini geri yüklemek için yükseltin",
"upgrade.uploadMultipleFiles.description": "Birden fazla belgeyi aynı anda yükleyin ve dosya başına maksimum boyutu 50 MB'a çıkarın.",
"upgrade.uploadMultipleFiles.title": "Toplu yüklemelerin ve daha büyük dosyaların kilidini açmak için yükseltin",
"upgrade.uploadMultiplePages.description": "Yükleme sınırına ulaştınız — mevcut planınızda aynı anda yalnızca bir belge seçip yükleyebilirsiniz.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "ПОДІЇ-ТРИГЕРИ",
"upgrade.addChunks.description": "Ви досягли межі додавання фрагментів для цього плану.",
"upgrade.addChunks.title": "Оновіть, щоб продовжити додавати частини",
"upgrade.agentRestore.description": "Відновлення версій застосунку агента недоступне у вашому поточному плані.",
"upgrade.agentRestore.title": "Оновіть план, щоб відновлювати версії застосунку агента",
"upgrade.uploadMultipleFiles.description": "Завантажуйте кілька документів одночасно та збільште максимальний розмір одного файлу до 50 МБ.",
"upgrade.uploadMultipleFiles.title": "Перейдіть на вищий тариф, щоб розблокувати пакетне завантаження та більші файли",
"upgrade.uploadMultiplePages.description": "Ви досягли ліміту завантаження — на вашому поточному плані можна вибрати та завантажити лише один документ одночасно.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "SỰ KIỆN KÍCH HOẠT",
"upgrade.addChunks.description": "Bạn đã đạt đến giới hạn thêm phần cho gói này.",
"upgrade.addChunks.title": "Nâng cấp để tiếp tục thêm các phần",
"upgrade.agentRestore.description": "Tính năng khôi phục phiên bản ứng dụng tác tử không khả dụng trong gói hiện tại của bạn.",
"upgrade.agentRestore.title": "Nâng cấp để khôi phục các phiên bản ứng dụng tác tử",
"upgrade.uploadMultipleFiles.description": "Tải lên nhiều tài liệu cùng lúc và tăng kích thước tối đa cho mỗi tệp lên 50 MB.",
"upgrade.uploadMultipleFiles.title": "Nâng cấp để mở khóa tính năng tải lên hàng loạt và tệp có dung lượng lớn hơn",
"upgrade.uploadMultiplePages.description": "Bạn đã đạt đến giới hạn tải lên — chỉ có thể chọn và tải lên một tài liệu trong một lần với gói hiện tại của bạn.",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "触发事件额度",
"upgrade.addChunks.description": "您已达到此计划的添加分段上限。",
"upgrade.addChunks.title": "升级以继续添加分段",
"upgrade.agentRestore.description": "当前套餐不支持恢复 Agent 应用版本。",
"upgrade.agentRestore.title": "升级以恢复 Agent 应用版本",
"upgrade.uploadMultipleFiles.description": "一次可上传多个文档,单个文件大小上限也将提升至 50 MB。",
"upgrade.uploadMultipleFiles.title": "升级以解锁批量上传和更大的文件",
"upgrade.uploadMultiplePages.description": "您已达到当前套餐的上传限制 —— 该套餐每次只能选择并上传 1 个文档。",
+2
View File
@@ -160,6 +160,8 @@
"triggerLimitModal.usageTitle": "觸發事件",
"upgrade.addChunks.description": "您已達到此方案可新增區塊的上限。",
"upgrade.addChunks.title": "升級以繼續添加區塊",
"upgrade.agentRestore.description": "目前方案不支援還原 Agent 應用程式版本。",
"upgrade.agentRestore.title": "升級以還原 Agent 應用程式版本",
"upgrade.uploadMultipleFiles.description": "一次上傳多份文件,並將單一檔案的大小上限提高至 50 MB。",
"upgrade.uploadMultipleFiles.title": "升級方案,解鎖批次上傳與更大檔案",
"upgrade.uploadMultiplePages.description": "您已達到上傳限制 — 在您目前的方案下,每次只能選擇並上傳一個文件。",