From 34e2205efaf61bac53649ea38705d5de36497748 Mon Sep 17 00:00:00 2001 From: Joel Date: Fri, 24 Jul 2026 11:23:01 +0800 Subject: [PATCH] chore: support preview mode if not in community version (#39399) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../base/chat/chat/__tests__/hooks.spec.tsx | 100 ++ .../chat/answer/__tests__/operation.spec.tsx | 7 + .../base/chat/chat/answer/index.tsx | 6 + .../base/chat/chat/answer/operation.tsx | 9 +- web/app/components/base/chat/chat/hooks.ts | 10 +- web/app/components/base/chat/chat/index.tsx | 4 + web/app/components/base/chat/chat/type.ts | 1 + .../__tests__/use-nodes-sync-draft.spec.ts | 28 +- .../hooks/use-nodes-sync-draft.ts | 2 - .../__tests__/workflow-edge-events.spec.tsx | 37 +- web/app/components/workflow/index.tsx | 42 +- .../agent-orchestrate-panel-content.spec.tsx | 364 +++++- .../agent-orchestrate-panel-content.tsx | 252 ++-- .../configure/__tests__/page.spec.tsx | 1116 ++++++++++++++++- .../use-agent-configure-sync.spec.tsx | 31 + .../configure/components/composer-session.tsx | 258 +++- .../confirm-clear-session-dialog.tsx | 52 +- .../__tests__/chat-mode-routing.spec.tsx | 68 + .../preview/__tests__/chat.spec.tsx | 127 +- .../preview/__tests__/header.spec.tsx | 15 +- .../components/preview/build-chat-request.ts | 10 + .../components/preview/build-chat.tsx | 5 +- .../components/preview/chat-conversation.tsx | 103 +- .../components/preview/chat-runtime.tsx | 16 +- .../components/preview/chat-session.tsx | 35 +- .../configure/components/preview/header.tsx | 28 +- .../preview/preview-chat-request.ts | 10 + .../components/preview/preview-chat.tsx | 11 +- .../agent-v2/agent-detail/configure/page.tsx | 57 +- .../agent-v2/agent-detail/configure/state.ts | 14 +- .../configure/use-agent-build-draft-run.ts | 55 +- .../use-agent-configure-build-draft.ts | 68 +- .../use-agent-configure-session-controller.ts | 284 +++++ .../configure/use-agent-configure-sync.ts | 20 +- web/i18n/ar-TN/agent-v-2.json | 1 + web/i18n/de-DE/agent-v-2.json | 1 + web/i18n/en-US/agent-v-2.json | 1 + web/i18n/es-ES/agent-v-2.json | 1 + web/i18n/fa-IR/agent-v-2.json | 1 + web/i18n/fr-FR/agent-v-2.json | 1 + web/i18n/hi-IN/agent-v-2.json | 1 + web/i18n/id-ID/agent-v-2.json | 1 + web/i18n/it-IT/agent-v-2.json | 1 + web/i18n/ja-JP/agent-v-2.json | 1 + web/i18n/ko-KR/agent-v-2.json | 1 + web/i18n/nl-NL/agent-v-2.json | 1 + web/i18n/pl-PL/agent-v-2.json | 1 + web/i18n/pt-BR/agent-v-2.json | 1 + web/i18n/ro-RO/agent-v-2.json | 1 + web/i18n/ru-RU/agent-v-2.json | 1 + web/i18n/sl-SI/agent-v-2.json | 1 + web/i18n/th-TH/agent-v-2.json | 1 + web/i18n/tr-TR/agent-v-2.json | 1 + web/i18n/uk-UA/agent-v-2.json | 1 + web/i18n/vi-VN/agent-v-2.json | 1 + web/i18n/zh-Hans/agent-v-2.json | 1 + web/i18n/zh-Hant/agent-v-2.json | 1 + web/service/base.spec.ts | 41 + web/service/base.ts | 39 +- 59 files changed, 2976 insertions(+), 372 deletions(-) create mode 100644 web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat-mode-routing.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/components/preview/build-chat-request.ts create mode 100644 web/features/agent-v2/agent-detail/configure/components/preview/preview-chat-request.ts create mode 100644 web/features/agent-v2/agent-detail/configure/use-agent-configure-session-controller.ts diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx index e990c6d0df4..354978bde8e 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -320,6 +320,106 @@ describe('useChat', () => { }) describe('handleSend', () => { + it('should complete with the conversation id from message_end', async () => { + let callbacks: HookCallbacks + const onGetConversationMessages = vi.fn().mockResolvedValue({ data: [] }) + const onConversationComplete = vi.fn() + vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => { + callbacks = options as HookCallbacks + }) + const { result } = renderHook(() => + useChat(undefined, undefined, undefined, undefined, undefined, undefined, undefined, { + isNewAgent: true, + }), + ) + + act(() => { + result.current.handleSend( + 'test-url', + { query: 'preview message' }, + { + onGetConversationMessages, + onConversationComplete, + }, + ) + }) + + await act(async () => { + callbacks.onMessageEnd({ + id: 'preview-message', + conversation_id: 'preview-conversation', + metadata: {}, + }) + await callbacks.onCompleted() + }) + + expect(onGetConversationMessages).toHaveBeenCalledWith( + 'preview-conversation', + expect.any(Function), + ) + expect(onConversationComplete).toHaveBeenCalledWith('preview-conversation', undefined) + }) + + it('should send with the latest externally selected conversation', () => { + const { result, rerender } = renderHook( + ({ conversationId }) => + useChat(undefined, undefined, undefined, undefined, undefined, undefined, conversationId), + { + initialProps: { + conversationId: 'build-conversation' as string | undefined, + }, + }, + ) + + rerender({ + conversationId: 'preview-conversation', + }) + + act(() => { + result.current.handleSend('test-url', { query: 'preview message' }, {}) + }) + + expect(ssePost).toHaveBeenCalledWith( + 'test-url', + expect.objectContaining({ + body: expect.objectContaining({ + conversation_id: 'preview-conversation', + }), + }), + expect.any(Object), + ) + }) + + it('should not reuse a previous conversation when the selected session has no id', () => { + const { result, rerender } = renderHook( + ({ conversationId }: { conversationId?: string }) => + useChat(undefined, undefined, undefined, undefined, undefined, undefined, conversationId), + { + initialProps: { + conversationId: 'build-conversation' as string | undefined, + }, + }, + ) + + rerender({ + conversationId: undefined, + }) + + act(() => { + result.current.handleSend('test-url', { query: 'new preview message' }, {}) + }) + + expect(ssePost).toHaveBeenCalledWith( + 'test-url', + expect.objectContaining({ + body: expect.objectContaining({ + conversation_id: '', + }), + }), + expect.any(Object), + ) + }) + it('should block send if already responding', async () => { const { result } = renderHook(() => useChat()) diff --git a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx index 32a6af71fed..a6ddf6f05d6 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx @@ -198,6 +198,7 @@ vi.mock('react-i18next', async () => { }) type OperationProps = { + answerActionPosition?: 'auto' | 'below' item: ChatItem question: string index: number @@ -854,6 +855,12 @@ describe('Operation', () => { expect(bar.style.left).toBeFalsy() }) + it('should position below when requested even if there is room on the right', () => { + renderOperation({ ...baseProps, answerActionPosition: 'below', maxSize: 500 }) + const bar = screen.getByTestId('operation-bar') + expect(bar.style.left).toBeFalsy() + }) + it('should calculate width correctly for all features combined', () => { mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true }, diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx index 1c28228d965..13f5e7e83dc 100644 --- a/web/app/components/base/chat/chat/answer/index.tsx +++ b/web/app/components/base/chat/chat/answer/index.tsx @@ -1,6 +1,7 @@ import type { FC, ReactNode } from 'react' import type { ChatConfig, ChatItem } from '../../types' import type { HumanInputFormSubmitData } from './human-input-content/type' +import type { AnswerActionPosition } from './operation' import type { AppData } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' import { memo, useCallback, useEffect, useRef, useState } from 'react' @@ -23,6 +24,7 @@ import SuggestedQuestions from './suggested-questions' import WorkflowProcessItem from './workflow-process' type AnswerProps = { + answerActionPosition?: AnswerActionPosition item: ChatItem question: string index: number @@ -44,6 +46,7 @@ type AnswerProps = { onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise } const Answer: FC = ({ + answerActionPosition, item, question, index, @@ -185,6 +188,7 @@ const Answer: FC = ({ > {!responding && contentIsEmpty && !hasAgentContent && ( = ({ > {!responding && ( = ({ > {!responding && ( { } function Operation({ + answerActionPosition = 'auto', item, question, index, @@ -204,7 +208,10 @@ function Operation({ showPromptLog, ]) - const positionRight = useMemo(() => operationWidth < maxSize, [operationWidth, maxSize]) + const positionRight = useMemo( + () => answerActionPosition === 'auto' && operationWidth < maxSize, + [answerActionPosition, operationWidth, maxSize], + ) return ( <> diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index b78999fe461..13a7c70613c 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -281,9 +281,9 @@ export const useChat = ( }, []) useEffect(() => { - initialConversationIdRef.current = initialConversationId ?? '' - if (initialConversationId && !conversationIdRef.current) - conversationIdRef.current = initialConversationId + const nextConversationId = initialConversationId ?? '' + initialConversationIdRef.current = nextConversationId + conversationIdRef.current = nextConversationId }, [initialConversationId]) /** Find the target node by bfs and then operate on it */ @@ -551,6 +551,8 @@ export const useChat = ( }) }, onMessageEnd: (messageEnd) => { + if (options.isNewAgent && messageEnd.conversation_id) + conversationIdRef.current = messageEnd.conversation_id updateChatTreeNode(messageId, (responseItem) => { if (messageEnd.metadata?.annotation_reply) { responseItem.annotation = { @@ -1199,6 +1201,8 @@ export const useChat = ( }) }, onMessageEnd: (messageEnd) => { + if (options.isNewAgent && messageEnd.conversation_id) + conversationIdRef.current = messageEnd.conversation_id if (messageEnd.metadata?.annotation_reply) { responseItem.id = messageEnd.id responseItem.annotation = { diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx index 2735c606e37..6511e87707d 100644 --- a/web/app/components/base/chat/chat/index.tsx +++ b/web/app/components/base/chat/chat/index.tsx @@ -2,6 +2,7 @@ import type { FC, ReactNode } from 'react' import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context' import type { ChatConfig, ChatItem, Feedback, OnRegenerate, OnSend } from '../types' import type { HumanInputFormSubmitData } from './answer/human-input-content/type' +import type { AnswerActionPosition } from './answer/operation' import type { InputForm } from './type' import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types' import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types' @@ -22,6 +23,7 @@ import TryToAsk from './try-to-ask' import { useChatLayout } from './use-chat-layout' export type ChatProps = { + answerActionPosition?: AnswerActionPosition isTryApp?: boolean readonly?: boolean appData?: AppData @@ -89,6 +91,7 @@ export type ChatProps = { } const Chat: FC = ({ + answerActionPosition, isTryApp, readonly = false, appData, @@ -214,6 +217,7 @@ const Chat: FC = ({ const isLast = item.id === chatList.at(-1)?.id return ( expect(callbacks.onSettled).toHaveBeenCalled() }) + it('should treat unavailable workflow data as a skipped sync instead of an error', async () => { + workflowStoreState = { + ...workflowStoreState, + isWorkflowDataLoaded: false, + } + const callbacks = { + onError: vi.fn(), + onSettled: vi.fn(), + } + + const { result } = renderUseNodesSyncDraft() + await act(async () => { + await expect(result.current.doSyncWorkflowDraft(false, callbacks)).resolves.toBeNull() + }) + + expect(mockSyncWorkflowDraft).not.toHaveBeenCalled() + expect(callbacks.onError).not.toHaveBeenCalled() + expect(callbacks.onSettled).toHaveBeenCalled() + }) + it('should not include source_workflow_id in draft sync payloads', async () => { const { result } = renderUseNodesSyncDraft() @@ -568,16 +588,22 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => mockCollaborationIsConnected.mockReturnValue(true) mockCollaborationGetIsLeader.mockReturnValue(true) mockCollaborationCanPersistLocalGraph.mockReturnValue(false) + const callbacks = { + onError: vi.fn(), + onSettled: vi.fn(), + } const { result } = renderUseNodesSyncDraft() let syncResult: unknown await act(async () => { - syncResult = await result.current.doSyncWorkflowDraft(false) + syncResult = await result.current.doSyncWorkflowDraft(false, callbacks) }) expect(syncResult).toBeNull() expect(mockSyncWorkflowDraft).not.toHaveBeenCalled() + expect(callbacks.onError).not.toHaveBeenCalled() + expect(callbacks.onSettled).toHaveBeenCalled() }) it('should skip keepalive sync on page close when current user is collaboration follower', () => { diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 343dcb01944..6767f77c628 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -151,14 +151,12 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { if (getNodesReadOnly()) return null if (isCollaborationEnabled && !collaborationManager.canPersistLocalGraph()) { - callback?.onError?.() callback?.onSettled?.() return null } const baseParams = getPostParams() if (!baseParams) { - callback?.onError?.() callback?.onSettled?.() return null } diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index 2b572022e88..6e188a05b80 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -629,7 +629,10 @@ describe('Workflow edge event wiring', () => { }, ) - const { unmount } = renderSubject({ isCollaborationEnabled: true }) + const { unmount } = renderSubject({ + initialStoreState: { isWorkflowDataLoaded: true }, + isCollaborationEnabled: true, + }) unmount() @@ -638,10 +641,40 @@ describe('Workflow edge event wiring', () => { }) }) + it('should skip the unmount save before workflow data has loaded', () => { + const { unmount } = renderSubject() + + unmount() + + expect(workflowHookMocks.handleSyncWorkflowDraft).not.toHaveBeenCalled() + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it('should not save the draft when the workflow rerenders', () => { + const { rerender } = renderSubject({ + initialStoreState: { isWorkflowDataLoaded: true }, + isCollaborationEnabled: false, + }) + + rerender( + + + + + , + ) + + expect(workflowHookMocks.handleSyncWorkflowDraft).not.toHaveBeenCalled() + expect(toastErrorMock).not.toHaveBeenCalled() + }) + it('should skip the unmount save when the current collaborator is not the draft leader', () => { collaborationBridge.canFlushGraphOnPageClose.mockReturnValue(false) - const { unmount } = renderSubject({ isCollaborationEnabled: true }) + const { unmount } = renderSubject({ + initialStoreState: { isWorkflowDataLoaded: true }, + isCollaborationEnabled: true, + }) unmount() diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index c7bd4c1e181..aae1f8bf018 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -22,7 +22,17 @@ import { toast } from '@langgenius/dify-ui/toast' import { useEventListener } from 'ahooks' import { isEqual } from 'es-toolkit/predicate' import { setAutoFreeze } from 'immer' -import { Fragment, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + Fragment, + memo, + Suspense, + useCallback, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, +} from 'react' import { useTranslation } from 'react-i18next' import ReactFlow, { Background, @@ -365,22 +375,26 @@ export const Workflow: FC = memo( } }, []) + const syncWorkflowDraftOnUnmount = useEffectEvent(() => { + if (!workflowStore.getState().isWorkflowDataLoaded) return + if (isCollaborationEnabled && !collaborationManager.canFlushGraphOnPageClose()) return + + handleSyncWorkflowDraft(true, true, { + onError: () => { + toast.error( + t(($) => $['common.draftSaveFailed'], { ns: 'workflow' }), + { + timeout: 0, + }, + ) + }, + }) + }) useEffect(() => { return () => { - if (isCollaborationEnabled && !collaborationManager.canFlushGraphOnPageClose()) return - - handleSyncWorkflowDraft(true, true, { - onError: () => { - toast.error( - t(($) => $['common.draftSaveFailed'], { ns: 'workflow' }), - { - timeout: 0, - }, - ) - }, - }) + syncWorkflowDraftOnUnmount() } - }, [handleSyncWorkflowDraft, isCollaborationEnabled, t]) + }, []) const handlePendingCommentPositionChange = useCallback( (position: NonNullable) => { diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx index da6ecce2805..d4e5fac2ea6 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx @@ -2,14 +2,16 @@ import type { AgentSoulConfig, WorkflowAgentComposerResponse, } from '@dify/contracts/api/console/apps/types.gen' -import type { ReactNode } from 'react' +import type { ReactNode, Ref } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { FlowType } from '@/types/common' import { WorkflowInlineAgentConfigureWorkspace } from '../agent-orchestrate-panel-content' const mocks = vi.hoisted(() => ({ checkoutBuildDraft: vi.fn(), + completeBuildConversation: undefined as (() => void) | undefined, deleteBuildDraft: vi.fn(), loadBuildDraft: vi.fn(), applyBuildDraft: vi.fn(), @@ -18,10 +20,27 @@ const mocks = vi.hoisted(() => ({ saveBuildDraft: vi.fn(), saveAgentSoulConfig: vi.fn(), saveDraft: vi.fn(), + stopBuildChat: vi.fn(), uploadAgentSandboxFile: vi.fn(), uploadWorkflowSandboxFile: vi.fn(), })) +const editionState = vi.hoisted(() => ({ + isSelfHosted: false, + licenseStatus: 'none', +})) + +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + get IS_CE_EDITION() { + return editionState.isSelfHosted + }, + } +}) + const permission = vi.hoisted(() => ({ canManageAgents: true })) vi.mock('@/features/agent-v2/permissions', () => ({ @@ -99,11 +118,12 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-bac })) vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-chat', async () => { - const { useState } = await import('react') + const { useImperativeHandle, useState } = await import('react') return { AgentBuildChat: (props: { conversationId?: string | null + controllerRef?: Ref<{ stop: () => void }> onConversationComplete?: (conversationId: string, workflowRunId?: string) => void onConversationIdChange?: (conversationId: string) => void onSendInterrupted?: () => void @@ -111,6 +131,9 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-cha }) => { const [messageSent, setMessageSent] = useState(false) const [sentPrompt, setSentPrompt] = useState() + useImperativeHandle(props.controllerRef, () => ({ stop: mocks.stopBuildChat })) + mocks.completeBuildConversation = () => + props.onConversationComplete?.('build-conversation-new', 'workflow-run-1') return (
@@ -120,21 +143,19 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-cha - +
+ ), +})) + vi.mock('@/app/components/workflow/nodes/agent-v2/agent-soul-config', () => ({ useWorkflowInlineAgentConfigureSync: () => ({ draftSavedAt: undefined, @@ -162,6 +207,14 @@ vi.mock('@/app/components/workflow/nodes/agent-v2/agent-soul-config', () => ({ vi.mock('@/service/client', () => ({ consoleClient: { + systemFeatures: { + get: () => + Promise.resolve({ + license: { + status: editionState.licenseStatus, + }, + }), + }, agent: { byAgentId: { sandbox: { @@ -194,6 +247,11 @@ vi.mock('@/service/client', () => ({ }, }, consoleQuery: { + systemFeatures: { + get: { + queryKey: () => ['system-features'], + }, + }, agent: { byAgentId: { get: { @@ -414,9 +472,21 @@ function createInlineComposerState({ } as WorkflowAgentComposerResponse } +function createDeferredPromise() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + + return { promise, resolve } +} + describe('WorkflowInlineAgentConfigureWorkspace', () => { beforeEach(() => { vi.clearAllMocks() + mocks.completeBuildConversation = undefined + editionState.isSelfHosted = false + editionState.licenseStatus = 'none' permission.canManageAgents = true mocks.loadBuildDraft.mockRejectedValue(new Response(null, { status: 404 })) mocks.checkoutBuildDraft.mockResolvedValue({ @@ -498,6 +568,274 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) } + describe('Right panel mode', () => { + it('should switch an inline agent between build and preview without creating a build draft from preview', async () => { + const user = userEvent.setup() + renderWorkspace() + + const previewButton = await screen.findByRole('button', { + name: 'agentV2.agentDetail.configure.rightPanel.preview', + }) + expect(previewButton).toBeEnabled() + + await user.click(previewButton) + + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent('preview:none') + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent( + 'actionPosition:below', + ) + expect(screen.queryByRole('region', { name: 'build-chat' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'send preview message' })) + + await waitFor(() => expect(mocks.saveDraft).toHaveBeenCalled()) + expect(mocks.saveBuildDraft).not.toHaveBeenCalled() + + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.rightPanel.build', + }), + ) + + expect(screen.getByRole('region', { name: 'build-chat' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'preview-chat' })).not.toBeInTheDocument() + }) + + it('should refresh only the inline preview conversation when restarting preview mode', async () => { + const user = userEvent.setup() + renderWorkspace({ + inlineComposerState: createInlineComposerState({ + debugConversationHasMessages: true, + debugConversationId: 'inline-build-conversation-1', + debugConversationMessageCount: 1, + }), + }) + + await user.click( + await screen.findByRole('button', { + name: 'agentV2.agentDetail.configure.rightPanel.preview', + }), + ) + await user.click(screen.getByRole('button', { name: 'send preview message' })) + await waitFor(() => { + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent( + 'preview:preview-conversation-new', + ) + }) + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.preview.restart', + }), + ) + + await waitFor(() => { + expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + body: { + draft_type: 'draft', + }, + }, + expect.any(Object), + ) + }) + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent('preview:none') + + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.rightPanel.build', + }), + ) + + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:inline-build-conversation-1', + ) + }) + + it('should discard an active inline build session before switching to preview', async () => { + const user = userEvent.setup() + renderWorkspace() + + await user.click(await screen.findByRole('button', { name: 'send build message' })) + await waitFor(() => + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes'), + ) + + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.rightPanel.preview', + }), + ) + + expect( + await screen.findByRole('alertdialog', { + name: 'agentV2.agentDetail.configure.switchToPreviewConfirm.title', + }), + ).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'build-chat', hidden: true })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => expect(mocks.deleteBuildDraft).toHaveBeenCalled()) + expect(mocks.stopBuildChat).toHaveBeenCalledTimes(1) + const stopBuildChatCallOrder = mocks.stopBuildChat.mock.invocationCallOrder[0] + const deleteBuildDraftCallOrder = mocks.deleteBuildDraft.mock.invocationCallOrder[0] + if (stopBuildChatCallOrder === undefined || deleteBuildDraftCallOrder === undefined) + throw new Error('Expected Build stop and draft deletion calls.') + expect(stopBuildChatCallOrder).toBeLessThan(deleteBuildDraftCallOrder) + expect(screen.getByRole('region', { name: 'preview-chat' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'build-chat' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'send preview message' })) + await waitFor(() => { + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent( + 'preview:preview-conversation-new', + ) + }) + expect( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.preview.restart', + }), + ).toBeEnabled() + }) + + it('should wait for an in-flight inline build draft save and ignore its result after switching to preview', async () => { + const user = userEvent.setup() + const buildDraftSave = createDeferredPromise<{ + agent_soul: AgentSoulConfig + draft: object + variant: 'agent_app' + }>() + mocks.saveBuildDraft.mockReturnValue(buildDraftSave.promise) + renderWorkspace() + + await user.click(await screen.findByRole('button', { name: 'send build message' })) + await waitFor(() => expect(mocks.saveBuildDraft).toHaveBeenCalledTimes(1)) + + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.rightPanel.preview', + }), + ) + await user.click( + await screen.findByRole('button', { + name: 'common.operation.confirm', + }), + ) + + expect(mocks.deleteBuildDraft).not.toHaveBeenCalled() + + await act(async () => { + buildDraftSave.resolve({ + agent_soul: { + schema_version: 1, + prompt: { + system_prompt: 'Late build draft prompt.', + }, + }, + draft: {}, + variant: 'agent_app', + }) + }) + + await waitFor(() => expect(mocks.deleteBuildDraft).toHaveBeenCalledTimes(1)) + expect(screen.getByRole('region', { name: 'preview-chat' })).toBeInTheDocument() + expect(screen.getByLabelText('local composer draft')).toHaveValue('Help with workflow tasks.') + }) + + it('should ignore a late inline build completion after leaving and re-entering build mode', async () => { + const user = userEvent.setup() + renderWorkspace() + + await user.click(await screen.findByRole('button', { name: 'send build message' })) + await waitFor(() => + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes'), + ) + const completeBuildConversation = mocks.completeBuildConversation + if (!completeBuildConversation) throw new Error('Expected a Build completion callback.') + + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.rightPanel.preview', + }), + ) + await user.click( + await screen.findByRole('button', { + name: 'common.operation.confirm', + }), + ) + await screen.findByRole('region', { name: 'preview-chat' }) + + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.rightPanel.build', + }), + ) + await screen.findByRole('region', { name: 'build-chat' }) + const buildDraftLoadCount = mocks.loadBuildDraft.mock.calls.length + + vi.useFakeTimers() + try { + await act(async () => { + completeBuildConversation() + await vi.advanceTimersByTimeAsync(1000) + }) + expect(mocks.loadBuildDraft).toHaveBeenCalledTimes(buildDraftLoadCount) + } finally { + vi.useRealTimers() + } + }) + + it('should confirm before discarding an existing inline build draft', async () => { + const user = userEvent.setup() + mocks.loadBuildDraft.mockResolvedValue({ + agent_soul: { + schema_version: 1, + prompt: { + system_prompt: 'Existing build draft prompt.', + }, + }, + draft: {}, + variant: 'agent_app', + }) + renderWorkspace() + + await user.click( + await screen.findByRole('button', { + name: 'agentV2.agentDetail.configure.rightPanel.preview', + }), + ) + + expect( + await screen.findByRole('alertdialog', { + name: 'agentV2.agentDetail.configure.switchToPreviewConfirm.title', + }), + ).toBeInTheDocument() + expect(mocks.deleteBuildDraft).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => expect(mocks.deleteBuildDraft).toHaveBeenCalledTimes(1)) + expect(screen.getByRole('region', { name: 'preview-chat' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'build-chat' })).not.toBeInTheDocument() + }) + + it('should keep preview disabled for an inline agent in community edition', async () => { + editionState.isSelfHosted = true + renderWorkspace() + + expect( + await screen.findByRole('button', { + name: 'agentV2.agentDetail.configure.rightPanel.preview', + }), + ).toBeDisabled() + expect(screen.getByRole('region', { name: 'build-chat' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'preview-chat' })).not.toBeInTheDocument() + }) + }) + describe('Working Directory', () => { it('should show save-to-roster in the configure header menu without rendering the old action bar', async () => { renderWorkspace({ diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx index 28247caba35..377bf683245 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx @@ -9,6 +9,7 @@ import type { AgentComposerBindingResponse, WorkflowAgentComposerResponse, } from '@dify/contracts/api/console/apps/types.gen' +import type { AgentPreviewChatController } from '@/features/agent-v2/agent-detail/configure/components/preview/chat-conversation' import { DropdownMenu, DropdownMenuContent, @@ -19,7 +20,7 @@ import { toast } from '@langgenius/dify-ui/toast' import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useAtom, useAtomValue, useStore as useJotaiStore, useSetAtom } from 'jotai' import { ScopeProvider } from 'jotai-scope' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -27,6 +28,7 @@ import { useDefaultModel, useTextGenerationCurrentProviderAndModelAndModelList, } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { IS_CE_EDITION } from '@/config' import { agentSoulConfigToFormState, formStateToAgentSoulConfig, @@ -37,6 +39,7 @@ import { rebaseAgentComposerDraftAtom, } from '@/features/agent-v2/agent-composer/store' import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model' +import { AgentConfigureClearSessionConfirmDialog } from '@/features/agent-v2/agent-detail/configure/components/confirm-clear-session-dialog' import { AgentOrchestratePanel } from '@/features/agent-v2/agent-detail/configure/components/orchestrate' import { AgentBuildDraftBar } from '@/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar' import { AgentBuildPanelBackground } from '@/features/agent-v2/agent-detail/configure/components/preview/build-background' @@ -52,17 +55,20 @@ import { } from '@/features/agent-v2/agent-detail/configure/components/workspace' import { agentConfigureConversationIdsAtom, - agentConfigureRightPanelChatModeAtom, - agentConfigureScopedAtoms, + agentConfigureRightPanelModeAtom, agentConfigureSoulSourceOverrideAtom, resetAgentConfigureConversationAtom, setAgentConfigureConversationIdAtom, + workflowInlineAgentConfigureScopedAtoms, } from '@/features/agent-v2/agent-detail/configure/state' import { useAgentConfigureBuildDraftActions, useAgentConfigureBuildDraftData, } from '@/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft' +import { useAgentConfigureSessionController } from '@/features/agent-v2/agent-detail/configure/use-agent-configure-session-controller' import { useCanManageAgents } from '@/features/agent-v2/permissions' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { LicenseStatus } from '@/features/system-features/constants' import { consoleQuery } from '@/service/client' import { FlowType } from '@/types/common' import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config' @@ -202,7 +208,7 @@ export function WorkflowInlineAgentConfigureWorkspace( return ( }) { const { t } = useTranslation('common') + const { t: tAgent } = useTranslation('agentV2') const queryClient = useQueryClient() const jotaiStore = useJotaiStore() + const setBuildDraftSoulSourceOverride = buildDraft.setSoulSourceOverride + const { data: systemFeatures } = useQuery(systemFeaturesQueryOptions()) const composerState = inlineComposerState - const [buildDraftActionsDisabled, setBuildDraftActionsDisabled] = useState(false) const [clearPreviewChat, setClearPreviewChat] = useState(false) const [completedBuildConversationId, setCompletedBuildConversationId] = useState( null, ) const [workflowRunId, setWorkflowRunId] = useState(null) + const rightPanelChatControllerRef = useRef(null) const appId = flowType === FlowType.appFlow ? flowId : undefined const conversationIds = useAtomValue(agentConfigureConversationIdsAtom) - const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom) + const [rightPanelMode, setRightPanelMode] = useAtom(agentConfigureRightPanelModeAtom) + const previewEnabled = + !IS_CE_EDITION || + systemFeatures?.license.status === LicenseStatus.ACTIVE || + systemFeatures?.license.status === LicenseStatus.EXPIRING const workingDirectoryPanel = useAgentWorkingDirectoryPanel({ agentId, appId, - conversationId: conversationIds[rightPanelChatMode], + conversationId: conversationIds[rightPanelMode], nodeId, workflowRunId, }) @@ -412,8 +427,10 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ ) const { mutateAsync: refreshDebugConversationRequestAsync, - isPending: isRefreshingDebugConversation, + isPending: isRefreshingBuildConversation, } = refreshDebugConversationMutation + const { mutate: refreshPreviewConversationRequest, isPending: isRefreshingPreviewConversation } = + useMutation(consoleQuery.agent.byAgentId.debugConversation.refresh.post.mutationOptions()) const refreshDebugConversationInput = useCallback( () => ({ params: { @@ -425,7 +442,21 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ const refreshDebugConversationAsync = useCallback(() => { return refreshDebugConversationRequestAsync(refreshDebugConversationInput()) }, [refreshDebugConversationInput, refreshDebugConversationRequestAsync]) - const resetBuildChatSession = useCallback(async () => { + const refreshPreviewConversationInput = useCallback( + () => ({ + params: { + agent_id: agentId, + }, + body: { + draft_type: 'draft' as const, + }, + }), + [agentId], + ) + const refreshPreviewConversation = useCallback(() => { + refreshPreviewConversationRequest(refreshPreviewConversationInput()) + }, [refreshPreviewConversationInput, refreshPreviewConversationRequest]) + const resetBuildChatState = useCallback(async () => { await refreshDebugConversationAsync().catch(() => undefined) setCompletedBuildConversationId(null) setConversationId({ mode: 'build', conversationId: null }) @@ -441,6 +472,31 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ }, [rebaseComposerDraft], ) + const sessionController = useAgentConfigureSessionController({ + buildDraftAgentSoulConfig: buildDraft.buildDraftAgentSoulConfig, + hasActiveBuildDraft: buildDraft.hasActiveBuildDraft, + isBuildDraftActive: buildDraft.isActive, + mode: rightPanelMode, + normalAgentSoulConfig: agentSoulConfig, + onModeChange: setRightPanelMode, + }) + const { + buildCallbackGeneration, + buildDraftActionsDisabled, + changeMode, + confirmSwitchToPreview: confirmSessionSwitchToPreview, + finishBuildAction, + isBuildCallbackCurrent, + resetBuildSession: resetSessionController, + runBuildPreparation, + setShowSwitchToPreviewConfirm, + showSwitchToPreviewConfirm, + waitForPendingPreviewDraftSave, + } = sessionController + const resetBuildSession = useCallback( + () => resetSessionController(resetBuildChatState), + [resetBuildChatState, resetSessionController], + ) const buildDraftActions = useAgentConfigureBuildDraftActions({ agentId, buildDraftAgentSoulConfig: buildDraft.agentSoulConfig, @@ -453,8 +509,9 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ agent_soul: agentSoulConfig, }, }), - resetBuildChatSession, + resetBuildChatSession: resetBuildSession, saveDraft: async () => { + await waitForPendingPreviewDraftSave() await saveDraft() }, setSoulSourceOverride: buildDraft.setSoulSourceOverride, @@ -470,7 +527,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ const { mutateAsync: saveBuildDraft } = useMutation( consoleQuery.agent.byAgentId.buildDraft.put.mutationOptions(), ) - const discardBuildDraftMutation = useMutation( + const { mutateAsync: discardBuildDraft, isPending: isDiscardingBuildDraft } = useMutation( consoleQuery.agent.byAgentId.buildDraft.delete.mutationOptions(), ) const getInlineAgentSoulDraft = useCallback( @@ -483,6 +540,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ [agentSoulConfig, currentModel, jotaiStore], ) const prepareInlineBuildDraftBeforeRun = useCallback(async () => { + await waitForPendingPreviewDraftSave() cancelBuildDraftRefresh() const configSnapshot = getInlineAgentSoulDraft() const savedComposerState = await saveDraft() @@ -497,15 +555,13 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ agent_soul: preparedAgentSoulConfig, }, }) - const savedBuildAgentSoulConfig = buildDraftState.agent_soul ?? preparedAgentSoulConfig queryClient.setQueryData(buildDraftQueryOptions.queryKey, buildDraftState) rebaseComposerDraftFromSoulConfig(savedBuildAgentSoulConfig) - buildDraft.setSoulSourceOverride('build-draft') + setBuildDraftSoulSourceOverride('build-draft') return savedBuildAgentSoulConfig }, [ agentId, - buildDraft, buildDraftQueryOptions.queryKey, cancelBuildDraftRefresh, getInlineAgentSoulDraft, @@ -513,6 +569,8 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ rebaseComposerDraftFromSoulConfig, saveBuildDraft, saveDraft, + setBuildDraftSoulSourceOverride, + waitForPendingPreviewDraftSave, ]) const applyInlineBuildDraft = async () => { cancelBuildDraftRefresh() @@ -521,15 +579,13 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ if (!buildDraft.agentSoulConfig) return const savedComposerState = await saveAgentSoulConfig(buildDraft.agentSoulConfig) - await discardBuildDraftMutation - .mutateAsync({ - params: { - agent_id: agentId, - }, - }) - .catch(() => undefined) - await resetBuildChatSession().catch(() => undefined) - buildDraft.setSoulSourceOverride('draft') + await discardBuildDraft({ + params: { + agent_id: agentId, + }, + }).catch(() => undefined) + await resetBuildSession().catch(() => undefined) + setBuildDraftSoulSourceOverride('draft') queryClient.removeQueries({ queryKey: buildDraftQueryOptions.queryKey, }) @@ -543,50 +599,89 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ setIsApplyingInlineBuildDraft(false) } } - const discardInlineBuildDraft = async () => { + const discardInlineBuildDraft = useCallback(async () => { cancelBuildDraftRefresh() try { - await discardBuildDraftMutation.mutateAsync({ + await discardBuildDraft({ params: { agent_id: agentId, }, }) - await resetBuildChatSession().catch(() => undefined) - buildDraft.setSoulSourceOverride('draft') + await resetBuildSession().catch(() => undefined) + setBuildDraftSoulSourceOverride('draft') queryClient.removeQueries({ queryKey: buildDraftQueryOptions.queryKey, }) rebaseComposerDraftFromSoulConfig(agentSoulConfig) toast.success(t(($) => $['api.actionSuccess'])) + return true } catch { toast.error(t(($) => $['api.actionFailed'])) + return false } - } + }, [ + agentId, + agentSoulConfig, + buildDraftQueryOptions.queryKey, + cancelBuildDraftRefresh, + discardBuildDraft, + queryClient, + rebaseComposerDraftFromSoulConfig, + resetBuildSession, + setBuildDraftSoulSourceOverride, + t, + ]) + const stopBuildChat = useCallback(() => { + rightPanelChatControllerRef.current?.stop() + }, []) + const changeRightPanelMode = useCallback( + (nextMode: typeof rightPanelMode) => + changeMode(nextMode, { + discardBuildDraft: discardInlineBuildDraft, + rebaseComposerDraft: rebaseComposerDraftFromSoulConfig, + savePreviewDraft: saveDraft, + stopBuildChat, + }), + [ + changeMode, + discardInlineBuildDraft, + rebaseComposerDraftFromSoulConfig, + saveDraft, + stopBuildChat, + ], + ) + const confirmSwitchToPreview = useCallback( + () => confirmSessionSwitchToPreview(discardInlineBuildDraft, stopBuildChat), + [confirmSessionSwitchToPreview, discardInlineBuildDraft, stopBuildChat], + ) const hasRestartCurrentChatTarget = - (inlineComposerState?.debug_conversation_has_messages ?? false) || buildDraft.isActive + rightPanelMode === 'build' + ? (inlineComposerState?.debug_conversation_has_messages ?? false) || buildDraft.isActive + : !!conversationIds.preview const isRestartCurrentChatDisabled = !hasRestartCurrentChatTarget || buildDraftActionsDisabled || isApplyingInlineBuildDraft || - discardBuildDraftMutation.isPending || - isRefreshingDebugConversation + isDiscardingBuildDraft || + isRefreshingBuildConversation || + isRefreshingPreviewConversation const buildConversationHasAgentResponse = !!conversationIds.build && (conversationIds.build === completedBuildConversationId || (conversationIds.build === inlineComposerState?.debug_conversation_id && (inlineComposerState?.debug_conversation_has_messages ?? false))) - const showWorkingDirectoryAction = - rightPanelChatMode === 'build' && buildConversationHasAgentResponse + const showWorkingDirectoryAction = rightPanelMode === 'build' && buildConversationHasAgentResponse const restartCurrentChat = () => { if (isRestartCurrentChatDisabled) return - if (buildDraft.isActive) { + if (rightPanelMode === 'build' && buildDraft.isActive) { void discardInlineBuildDraft() return } - void refreshDebugConversationAsync().catch(() => undefined) - resetConversation(rightPanelChatMode) + if (rightPanelMode === 'build') void refreshDebugConversationAsync().catch(() => undefined) + else refreshPreviewConversation() + resetConversation(rightPanelMode) setClearPreviewChat(true) } @@ -614,7 +709,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ changesCount={buildDraft.changesCount} disabled={buildDraftActionsDisabled} isApplying={isApplyingInlineBuildDraft} - isDiscarding={discardBuildDraftMutation.isPending} + isDiscarding={isDiscardingBuildDraft} onApply={() => { void applyInlineBuildDraft() }} @@ -639,13 +734,13 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ } rightPanel={ } + background={} header={ undefined} + onModeChange={changeRightPanelMode} onToggleChatFeatures={() => undefined} onOpenWorkingDirectory={workingDirectoryPanel.openWorkingDirectory} onRefresh={restartCurrentChat} @@ -667,6 +762,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ chat={ { - if (mode === 'build') { - setCompletedBuildConversationId(completedConversationId) - setWorkflowRunId(completedWorkflowRunId ?? completedConversationId) - invalidateAgentWorkingDirectoryFiles({ - agentId, - appId, - conversationId: completedConversationId, - nodeId, - queryClient, - workflowRunId: completedWorkflowRunId ?? completedConversationId, - }) - buildDraftActions.refreshBuildDraftAfterBuildChat(() => - setBuildDraftActionsDisabled(false), - ) - } + if (mode !== 'build' || !isBuildCallbackCurrent(buildCallbackGeneration)) return + + setCompletedBuildConversationId(completedConversationId) + setWorkflowRunId(completedWorkflowRunId ?? completedConversationId) + invalidateAgentWorkingDirectoryFiles({ + agentId, + appId, + conversationId: completedConversationId, + nodeId, + queryClient, + workflowRunId: completedWorkflowRunId ?? completedConversationId, + }) + buildDraftActions.refreshBuildDraftAfterBuildChat(() => + finishBuildAction(buildCallbackGeneration), + ) }} onConversationIdChange={(mode, conversationId) => { + if (mode === 'build' && !isBuildCallbackCurrent(buildCallbackGeneration)) return setConversationId({ mode, conversationId }) }} onWorkflowRunIdChange={(nextWorkflowRunId) => { + if (!isBuildCallbackCurrent(buildCallbackGeneration)) return if (nextWorkflowRunId) setWorkflowRunId(nextWorkflowRunId) }} - onSaveDraftBeforeRun={async () => { - setBuildDraftActionsDisabled(true) - setWorkflowRunId(null) - try { - return await prepareInlineBuildDraftBeforeRun() - } catch (error) { - setBuildDraftActionsDisabled(false) - throw error - } - }} + onSaveDraftBeforeRun={ + rightPanelMode === 'build' + ? () => { + setWorkflowRunId(null) + return runBuildPreparation({ + generation: buildCallbackGeneration, + markBuildChatStarted: true, + prepare: prepareInlineBuildDraftBeforeRun, + }) + } + : async () => { + await saveDraft() + } + } onSendInterrupted={() => { - setBuildDraftActionsDisabled(false) + finishBuildAction(buildCallbackGeneration) }} /> } /> } - sidePanels={workingDirectoryPanel.panel} + sidePanels={ + <> + {workingDirectoryPanel.panel} + $['agentDetail.configure.switchToPreviewConfirm.title'])} + onOpenChange={setShowSwitchToPreviewConfirm} + onConfirm={confirmSwitchToPreview} + /> + + } /> ) } diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx index 3cf2b1022b0..648e7fb7c65 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx @@ -1,8 +1,14 @@ -import type { ReactNode } from 'react' +import type { ReactNode, Ref } from 'react' import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { ScopeProvider } from 'jotai-scope' +import { useState } from 'react' +import { renderWithNuqs as render } from '@/test/nuqs-testing' +import { AgentConfigureComposerScope } from '../components/composer-session' +import { useAgentConfigureData } from '../hooks' import { AgentConfigurePage } from '../page' +import { agentConfigureScopedAtoms } from '../state' const mocks = vi.hoisted(() => ({ applyBuildDraft: vi.fn(), @@ -10,11 +16,14 @@ const mocks = vi.hoisted(() => ({ discardBuildDraft: vi.fn(), finalizeBuildChat: vi.fn(), refreshDebugConversation: vi.fn(), + saveComposerDraft: vi.fn(), + stopBuildChat: vi.fn(), + completeBuildConversation: undefined as (() => void) | undefined, queryState: { agent: { data: { debug_conversation_has_messages: true, - debug_conversation_id: 'debug-conversation-old', + debug_conversation_id: 'debug-conversation-old' as string | null, debug_conversation_message_count: 1, icon: 'agent', icon_background: '#E0F2FE', @@ -56,6 +65,7 @@ const mocks = vi.hoisted(() => ({ const toastMock = vi.hoisted(() => ({ error: vi.fn(), + success: vi.fn(), })) const modelHooksState = vi.hoisted(() => ({ @@ -67,6 +77,13 @@ const modelHooksState = vi.hoisted(() => ({ } as { provider: { provider: string }; model: string } | undefined, })) +const editionState = vi.hoisted(() => ({ + isSelfHosted: false, + isSystemFeaturesPending: false, + licenseStatus: 'none', + systemFeaturesPendingPromise: new Promise(() => {}), +})) + function createDeferredPromise() { let resolve!: (value: T) => void const promise = new Promise((promiseResolve) => { @@ -89,6 +106,38 @@ function expectFirstMockCallBefore( expect(firstCallOrder).toBeLessThan(secondCallOrder) } +function clearBuildConversation() { + mocks.queryState.agent = { + ...mocks.queryState.agent, + data: { + ...mocks.queryState.agent.data, + debug_conversation_has_messages: false, + debug_conversation_id: null, + debug_conversation_message_count: 0, + }, + } +} + +function AgentConfigureComposerScopeHarness() { + const [rightPanelMode, setRightPanelMode] = useState<'build' | 'preview'>('preview') + const configureData = useAgentConfigureData('agent-1', null) + + return ( + + + + ) +} + vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal() @@ -101,6 +150,16 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { if (queryKey === 'composer') return mocks.queryState.composer if (queryKey === 'version') return mocks.queryState.version if (queryKey === 'build-draft') return mocks.queryState.buildDraft + if (queryKey === 'system-features') { + return { + data: { + license: { + status: editionState.licenseStatus, + }, + }, + isPending: false, + } + } return { data: undefined, @@ -110,6 +169,17 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { isSuccess: false, } }), + useSuspenseQuery: vi.fn(() => { + if (editionState.isSystemFeaturesPending) throw editionState.systemFeaturesPendingPromise + + return { + data: { + license: { + status: editionState.licenseStatus, + }, + }, + } + }), } }) @@ -117,8 +187,24 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: toastMock, })) +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + get IS_CE_EDITION() { + return editionState.isSelfHosted + }, + } +}) + vi.mock('@/service/client', () => ({ consoleQuery: { + systemFeatures: { + get: { + queryKey: () => ['system-features'], + }, + }, agent: { get: { key: () => ['agents'], @@ -162,7 +248,7 @@ vi.mock('@/service/client', () => ({ queryKey: () => ['composer'], }, put: { - mutationOptions: () => ({ mutationFn: vi.fn() }), + mutationOptions: () => ({ mutationFn: mocks.saveComposerDraft }), }, }, buildDraft: { @@ -217,7 +303,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('../components/orchestrate', async () => { - const { useAtomValue } = await import('jotai') + const { useAtomValue, useSetAtom } = await import('jotai') const { agentComposerPromptAtom } = await import('@/features/agent-v2/agent-composer/store-modules/prompt') @@ -232,6 +318,7 @@ vi.mock('../components/orchestrate', async () => { showPublishBar?: boolean }) => { const prompt = useAtomValue(agentComposerPromptAtom) + const setPrompt = useSetAtom(agentComposerPromptAtom) return (
@@ -239,6 +326,9 @@ vi.mock('../components/orchestrate', async () => { {`readonly:${props.readOnly ? 'yes' : 'no'}`} {`publish:${props.showPublishBar ? 'yes' : 'no'}`} {`prompt:${prompt}`} + @@ -279,23 +369,32 @@ vi.mock('../components/orchestrate/build-draft-bar', () => ({ })) vi.mock('../components/preview/build-chat', async () => { - const { useState } = await import('react') + const { useImperativeHandle, useState } = await import('react') return { AgentBuildChat: (props: { clearChatList?: boolean conversationId?: string | null + controllerRef?: Ref<{ stop: () => void }> onConversationComplete?: (conversationId: string) => void onConversationIdChange?: (conversationId: string) => void + onBeforeSpeechToText?: () => Promise onSaveDraftBeforeRun?: () => Promise + speechToTextDraftType?: 'draft' | 'debug_build' }) => { const [messageSent, setMessageSent] = useState(false) + useImperativeHandle(props.controllerRef, () => ({ stop: mocks.stopBuildChat })) + mocks.completeBuildConversation = () => { + props.onConversationIdChange?.('build-conversation-new') + props.onConversationComplete?.('build-conversation-new') + } return (
{`build:${props.conversationId ?? 'none'}`} {`clear:${props.clearChatList ? 'yes' : 'no'}`} {`sent:${messageSent ? 'yes' : 'no'}`} + {`speech:${props.speechToTextDraftType ?? 'debug_build'}`} - +
@@ -331,18 +430,33 @@ vi.mock('../components/preview/build-chat', async () => { vi.mock('../components/preview/preview-chat', () => ({ AgentPreviewChat: (props: { conversationId?: string | null + draftType?: 'debug_build' + onSaveDraftBeforeRun?: () => Promise onConversationIdChange?: (conversationId: string) => void - }) => ( -
- {`preview:${props.conversationId ?? 'none'}`} - -
- ), + }) => { + return ( +
+ {`preview:${props.conversationId ?? 'none'}`} + {`draftType:${props.draftType ?? 'draft'}`} + + +
+ ) + }, })) vi.mock('../components/preview/chat-features-panel', () => ({ @@ -410,6 +524,10 @@ vi.mock('../components/preview/versions-panel', () => ({ describe('AgentConfigurePage', () => { beforeEach(() => { vi.clearAllMocks() + mocks.completeBuildConversation = undefined + editionState.isSelfHosted = false + editionState.isSystemFeaturesPending = false + editionState.licenseStatus = 'none' modelHooksState.defaultTextGenerationModel = { provider: { provider: 'langgenius/openai/openai', @@ -422,6 +540,7 @@ describe('AgentConfigurePage', () => { debug_conversation_message_count: 0, }) mocks.finalizeBuildChat.mockResolvedValue({ result: 'success' }) + mocks.saveComposerDraft.mockResolvedValue({ agent_soul: {} }) mocks.applyBuildDraft.mockResolvedValue({ result: 'success', draft: {} }) mocks.checkoutBuildDraft.mockResolvedValue({ variant: 'agent_app', @@ -476,6 +595,22 @@ describe('AgentConfigurePage', () => { }) describe('Loading state', () => { + it('should keep the configure loading UI while system features are loading', () => { + editionState.isSystemFeaturesPending = true + + render( + + + , + ) + + const configureSection = screen.getByRole('region', { + name: 'agentV2.agentDetail.sections.configure', + }) + expect(configureSection).toHaveAttribute('aria-busy', 'true') + expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + }) + it('should show the page loading indicator instead of skeleton panels while composer data is pending', () => { const queryClient = new QueryClient() @@ -494,6 +629,72 @@ describe('AgentConfigurePage', () => { expect(screen.queryByRole('region', { name: 'orchestrate-panel' })).not.toBeInTheDocument() }) + it('should initialize the composer from the active build draft after its pending check', () => { + const queryClient = new QueryClient() + mocks.queryState.composer = { + data: { + agent_soul: { + prompt: { + system_prompt: 'draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + mocks.queryState.buildDraft = { + data: undefined as unknown, + dataUpdatedAt: 0, + error: null, + isFetching: true, + isError: false, + isPending: true, + isSuccess: false, + refetch: vi.fn(), + } + + const view = render( + + + , + { searchParams: '?mode=build' }, + ) + + expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'orchestrate-panel' })).not.toBeInTheDocument() + + mocks.queryState.buildDraft = { + data: { + agent_soul: { + prompt: { + system_prompt: 'build prompt', + }, + }, + draft: {}, + variant: 'agent_app', + }, + dataUpdatedAt: 1, + error: null, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + view.rerender( + + + , + ) + + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'prompt:build prompt', + ) + }) + it('should expose a single configure workspace region after loading', () => { const queryClient = new QueryClient() mocks.queryState.composer = { @@ -522,7 +723,637 @@ describe('AgentConfigurePage', () => { }) describe('Right panel mode', () => { - it('should keep preview disabled and stay in build mode', async () => { + it('should restore preview mode from the page URL', () => { + mocks.queryState.composer = { + data: {}, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + render( + + + , + { searchParams: '?mode=preview' }, + ) + + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent('preview:none') + expect(screen.queryByRole('region', { name: 'build-chat' })).not.toBeInTheDocument() + }) + + it('should switch modes without confirmation before Build chat starts', async () => { + const user = userEvent.setup() + mocks.queryState.composer = { + data: {}, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + const { onUrlUpdate } = render( + + + , + { searchParams: '?source=shared-link' }, + ) + + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('mode')).toBe('build') + }) + + await user.click(screen.getByRole('button', { name: 'preview mode' })) + + let urlUpdate = onUrlUpdate.mock.calls.at(-1)?.[0] + expect(urlUpdate?.searchParams.get('mode')).toBe('preview') + expect(urlUpdate?.searchParams.get('source')).toBe('shared-link') + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'build mode' })) + + urlUpdate = onUrlUpdate.mock.calls.at(-1)?.[0] + expect(urlUpdate?.searchParams.get('mode')).toBe('build') + expect(urlUpdate?.searchParams.get('source')).toBe('shared-link') + }) + + it('should confirm before discarding an existing build draft and switching to preview', async () => { + const user = userEvent.setup() + mocks.queryState.composer = { + data: { + agent_soul: { + prompt: { + system_prompt: 'draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + mocks.queryState.buildDraft = { + data: { + agent_soul: { + prompt: { + system_prompt: 'build prompt', + }, + }, + draft: {}, + variant: 'agent_app', + }, + dataUpdatedAt: 1, + error: null, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + const { onUrlUpdate } = render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'preview mode' })) + + expect( + screen.getByRole('alertdialog', { + name: 'agentV2.agentDetail.configure.switchToPreviewConfirm.title', + }), + ).toBeInTheDocument() + expect(mocks.discardBuildDraft).not.toHaveBeenCalled() + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('mode')).toBe('build') + + await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => expect(mocks.discardBuildDraft).toHaveBeenCalledTimes(1)) + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('mode')).toBe('preview') + }) + }) + + it('should confirm and discard the build session before switching to preview', async () => { + const user = userEvent.setup() + const discardBuildDraft = createDeferredPromise<{ result: string }>() + const refetchBuildDraft = vi.fn().mockResolvedValue({}) + mocks.discardBuildDraft.mockReturnValue(discardBuildDraft.promise) + mocks.queryState.composer = { + data: { + agent_soul: { + prompt: { + system_prompt: 'draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + mocks.queryState.buildDraft = { + data: { + agent_soul: { + prompt: { + system_prompt: 'build prompt', + }, + }, + draft: {}, + variant: 'agent_app', + }, + dataUpdatedAt: 1, + error: null, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: refetchBuildDraft, + } + + const { onUrlUpdate } = render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'send build message' })) + await waitFor(() => { + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes') + }) + await user.click(screen.getByRole('button', { name: 'preview mode' })) + + expect( + screen.getByRole('alertdialog', { + name: 'agentV2.agentDetail.configure.switchToPreviewConfirm.title', + }), + ).toBeInTheDocument() + expect( + screen.getByText('agentV2.agentDetail.configure.clearSessionConfirm.description'), + ).toBeInTheDocument() + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('mode')).toBe('build') + expect(mocks.discardBuildDraft).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + expect(screen.getByRole('region', { name: 'build-chat' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'preview mode' })) + const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' }) + await user.click(confirmButton) + + expect(confirmButton).toHaveAttribute('aria-disabled', 'true') + expect(mocks.stopBuildChat).toHaveBeenCalledTimes(1) + expect(mocks.discardBuildDraft).toHaveBeenCalledTimes(1) + expectFirstMockCallBefore(mocks.stopBuildChat, mocks.discardBuildDraft) + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('mode')).toBe('build') + + const completeBuildConversation = mocks.completeBuildConversation + if (!completeBuildConversation) throw new Error('Expected a Build completion callback.') + + vi.useFakeTimers() + try { + await act(async () => { + completeBuildConversation() + await vi.advanceTimersByTimeAsync(1000) + }) + expect(refetchBuildDraft).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + + discardBuildDraft.resolve({ result: 'success' }) + + await waitFor(() => { + expect(toastMock.success).toHaveBeenCalledWith('common.api.actionSuccess') + }) + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('mode')).toBe('preview') + }) + expect(screen.getByRole('region', { name: 'preview-chat' })).toBeInTheDocument() + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + + vi.useFakeTimers() + try { + await act(async () => { + completeBuildConversation() + await vi.advanceTimersByTimeAsync(1000) + }) + expect(refetchBuildDraft).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + + await user.click(screen.getByRole('button', { name: 'build mode' })) + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') + }) + + it('should stay in build mode when discarding the build session fails', async () => { + const user = userEvent.setup() + const refetchBuildDraft = vi.fn().mockResolvedValue({}) + mocks.discardBuildDraft.mockRejectedValueOnce(new Error('discard failed')) + mocks.queryState.composer = { + data: { + agent_soul: { + prompt: { + system_prompt: 'draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + mocks.queryState.buildDraft = { + data: { + agent_soul: { + prompt: { + system_prompt: 'build prompt', + }, + }, + draft: {}, + variant: 'agent_app', + }, + dataUpdatedAt: 1, + error: null, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: refetchBuildDraft, + } + + const { onUrlUpdate } = render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'send build message' })) + await waitFor(() => { + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes') + }) + await user.click(screen.getByRole('button', { name: 'preview mode' })) + const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' }) + await user.click(confirmButton) + + await waitFor(() => { + expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed') + }) + expect(confirmButton).not.toHaveAttribute('aria-disabled', 'true') + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('mode')).toBe('build') + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + + const completeBuildConversation = mocks.completeBuildConversation + if (!completeBuildConversation) throw new Error('Expected a Build completion callback.') + + vi.useFakeTimers() + try { + await act(async () => { + completeBuildConversation() + await vi.advanceTimersByTimeAsync(1000) + }) + expect(refetchBuildDraft).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('should reset Build on mode switch, then save and force checkout when starting chat', async () => { + const user = userEvent.setup() + const queryClient = new QueryClient() + const draftSave = createDeferredPromise<{ agent_soul: object }>() + const forcedBuildDraft = { + agent_soul: { + prompt: { + system_prompt: 'fresh build prompt', + }, + }, + draft: {}, + variant: 'agent_app', + } + mocks.saveComposerDraft.mockReturnValue(draftSave.promise) + mocks.checkoutBuildDraft.mockImplementation(async () => { + mocks.queryState.buildDraft = { + data: forcedBuildDraft, + dataUpdatedAt: 2, + error: null, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + return forcedBuildDraft + }) + mocks.queryState.composer = { + data: { + agent_soul: { + model: { + model: 'gpt-4o-mini', + model_provider: 'langgenius/openai/openai', + model_settings: undefined, + plugin_id: 'langgenius/openai', + }, + prompt: { + system_prompt: 'draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + mocks.queryState.buildDraft = { + data: { + agent_soul: { + prompt: { + system_prompt: 'stale build prompt', + }, + }, + draft: {}, + variant: 'agent_app', + }, + dataUpdatedAt: 1, + error: null, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'build mode' })) + + await waitFor(() => { + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') + }) + expect(mocks.refreshDebugConversation).toHaveBeenCalledTimes(1) + expect(mocks.saveComposerDraft).not.toHaveBeenCalled() + expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'prompt:draft prompt', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:no', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'publish:yes', + ) + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('speech:draft') + + await user.click(screen.getByRole('button', { name: 'edit prompt' })) + await user.click(screen.getByRole('button', { name: 'send build message' })) + + expect(mocks.saveComposerDraft).toHaveBeenCalledTimes(1) + expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:yes', + ) + + draftSave.resolve({ agent_soul: {} }) + + await waitFor(() => { + expect(mocks.checkoutBuildDraft).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + body: { + force: true, + }, + }, + expect.any(Object), + ) + }) + expectFirstMockCallBefore(mocks.refreshDebugConversation, mocks.saveComposerDraft) + expectFirstMockCallBefore(mocks.saveComposerDraft, mocks.checkoutBuildDraft) + expectFirstMockCallBefore(mocks.refreshDebugConversation, mocks.checkoutBuildDraft) + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'prompt:fresh build prompt', + ) + }) + + it('should stay in Build without checkout or send when saving before chat fails', async () => { + const user = userEvent.setup() + const queryClient = new QueryClient() + mocks.saveComposerDraft.mockRejectedValue(new Error('save failed')) + mocks.queryState.composer = { + data: { + agent_soul: { + model: { + model: 'gpt-4o-mini', + model_provider: 'langgenius/openai/openai', + model_settings: undefined, + plugin_id: 'langgenius/openai', + }, + prompt: { + system_prompt: 'draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + mocks.queryState.buildDraft = { + data: { + agent_soul: { + prompt: { + system_prompt: 'build prompt', + }, + }, + draft: {}, + variant: 'agent_app', + }, + dataUpdatedAt: 1, + error: null, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'build mode' })) + + await waitFor(() => { + expect(screen.getByRole('region', { name: 'build-chat' })).toBeInTheDocument() + }) + expect(mocks.saveComposerDraft).not.toHaveBeenCalled() + expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'edit prompt' })) + await user.click(screen.getByRole('button', { name: 'send build message' })) + + await waitFor(() => { + expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed') + }) + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:no') + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'prompt:edited draft prompt', + ) + expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() + }) + + it('should stay in Preview when resetting the Build conversation fails', async () => { + const user = userEvent.setup() + mocks.refreshDebugConversation.mockRejectedValueOnce(new Error('refresh failed')) + mocks.queryState.composer = { + data: { + agent_soul: {}, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'build mode' })) + + await waitFor(() => { + expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed') + }) + expect(screen.getByRole('region', { name: 'preview-chat' })).toBeInTheDocument() + expect(mocks.saveComposerDraft).not.toHaveBeenCalled() + expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() + }) + + it('should stay in Build without sending when force checkout fails at chat start', async () => { + const user = userEvent.setup() + mocks.checkoutBuildDraft.mockRejectedValueOnce(new Error('checkout failed')) + mocks.queryState.composer = { + data: { + agent_soul: {}, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'build mode' })) + + await waitFor(() => { + expect(screen.getByRole('region', { name: 'build-chat' })).toBeInTheDocument() + }) + expect(mocks.refreshDebugConversation).toHaveBeenCalled() + expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'send build message' })) + + await waitFor(() => { + expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed') + }) + expect(mocks.checkoutBuildDraft).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + body: { + force: true, + }, + }, + expect.any(Object), + ) + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:no') + + await user.click(screen.getByRole('button', { name: 'send build message' })) + + await waitFor(() => { + expect(mocks.checkoutBuildDraft).toHaveBeenCalledTimes(2) + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes') + }) + }) + + it('should run preview with the shared chat API without entering build draft mode outside community edition', async () => { + const user = userEvent.setup() + const queryClient = new QueryClient() + clearBuildConversation() + mocks.queryState.composer = { + data: {}, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + render( + + + , + ) + + const previewButton = screen.getByRole('button', { name: 'preview mode' }) + expect(previewButton).toBeEnabled() + + await user.click(previewButton) + expect(screen.getByRole('button', { name: 'restart preview' })).toBeDisabled() + + await user.click(screen.getByRole('button', { name: 'send preview message' })) + + await waitFor(() => { + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent( + 'preview:preview-conversation-new', + ) + }) + expect(screen.getByRole('button', { name: 'restart preview' })).toBeEnabled() + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent( + 'draftType:draft', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:no', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:no', + ) + expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument() + expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() + expect(mocks.refreshDebugConversation).not.toHaveBeenCalled() + }) + + it('should refresh only the preview conversation when restarting preview mode', async () => { const user = userEvent.setup() const queryClient = new QueryClient() mocks.queryState.composer = { @@ -540,21 +1371,36 @@ describe('AgentConfigurePage', () => { , ) - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( - 'build:debug-conversation-old', - ) - expect(screen.queryByRole('region', { name: 'preview-chat' })).not.toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'save build conversation' })) - - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( - 'build:build-conversation-new', - ) - expect(mocks.refreshDebugConversation).not.toHaveBeenCalled() - + await user.click(screen.getByRole('button', { name: 'preview mode' })) + await user.click(screen.getByRole('button', { name: 'save preview conversation' })) + await waitFor(() => { + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent( + 'preview:preview-conversation-new', + ) + }) await user.click(screen.getByRole('button', { name: 'restart preview' })) - expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( + await waitFor(() => { + expect(mocks.refreshDebugConversation).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + body: { + draft_type: 'draft', + }, + }, + expect.any(Object), + ) + }) + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent('preview:none') + + await user.click(screen.getByRole('button', { name: 'build mode' })) + + await waitFor(() => { + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') + }) + expect(mocks.refreshDebugConversation).toHaveBeenLastCalledWith( { params: { agent_id: 'agent-1', @@ -562,16 +1408,6 @@ describe('AgentConfigurePage', () => { }, expect.any(Object), ) - - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') - - const previewButton = screen.getByRole('button', { name: 'preview mode' }) - expect(previewButton).toBeDisabled() - - await user.click(previewButton) - - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') - expect(screen.queryByRole('region', { name: 'preview-chat' })).not.toBeInTheDocument() }) it('should not keep a stale clear command after the composer content remounts', async () => { @@ -646,9 +1482,71 @@ describe('AgentConfigurePage', () => { expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('clear:no') }) - it('should keep preview disabled', async () => { + it('should clear preview conversation state after the configure page remounts', async () => { const user = userEvent.setup() - const queryClient = new QueryClient() + clearBuildConversation() + mocks.queryState.composer = { + data: {}, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + const view = render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'preview mode' })) + await user.click(screen.getByRole('button', { name: 'save preview conversation' })) + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent( + 'preview:preview-conversation-new', + ) + + view.unmount() + render( + + + , + { searchParams: '?mode=preview' }, + ) + + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent('preview:none') + }) + + it('should keep preview disabled in community edition', async () => { + editionState.isSelfHosted = true + mocks.queryState.composer = { + data: {}, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + const { onUrlUpdate } = render( + + + , + { searchParams: '?mode=preview' }, + ) + + expect(screen.getByRole('button', { name: 'preview mode' })).toBeDisabled() + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( + 'build:debug-conversation-old', + ) + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('mode')).toBe('build') + }) + }) + + it('should enable preview for a self-hosted enterprise license', () => { + editionState.isSelfHosted = true + editionState.licenseStatus = 'active' mocks.queryState.composer = { data: {}, isFetching: false, @@ -659,22 +1557,91 @@ describe('AgentConfigurePage', () => { } render( - + , + { searchParams: '?mode=preview' }, ) - const previewButton = screen.getByRole('button', { name: 'preview mode' }) - expect(previewButton).toBeDisabled() + expect(screen.getByRole('button', { name: 'preview mode' })).toBeEnabled() + expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent('preview:none') + }) - await user.click(previewButton) - - expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent( - 'build:debug-conversation-old', + it('should hide the stale build draft until chat starts and leave Build without discarding it', async () => { + const user = userEvent.setup() + clearBuildConversation() + mocks.queryState.composer = { + data: { + agent_soul: { + prompt: { + system_prompt: 'draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + mocks.queryState.buildDraft = { + data: { + agent_soul: { + prompt: { + system_prompt: 'build prompt', + }, + }, + draft: {}, + variant: 'agent_app', + }, + dataUpdatedAt: 1, + error: null, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + render( + + + , + { searchParams: '?mode=preview' }, ) - expect( - screen.queryByRole('region', { name: 'preview-chat', hidden: true }), - ).not.toBeInTheDocument() + + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'prompt:draft prompt', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:no', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'buildDraft:no', + ) + expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'build mode' })) + + await waitFor(() => { + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'prompt:draft prompt', + ) + }) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).not.toHaveTextContent( + 'prompt:build prompt', + ) + expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument() + expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'preview mode' })) + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + expect(mocks.discardBuildDraft).not.toHaveBeenCalled() + expect(screen.getByRole('region', { name: 'preview-chat' })).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'prompt:draft prompt', + ) + expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument() }) it('should disable restart when the debug conversation has no messages', async () => { @@ -713,6 +1680,41 @@ describe('AgentConfigurePage', () => { expect(mocks.refreshDebugConversation).not.toHaveBeenCalled() }) + it('should switch to preview without confirmation after restarting a build session', async () => { + const user = userEvent.setup() + mocks.checkoutBuildDraft.mockRejectedValueOnce(new Error('checkout failed')) + mocks.queryState.composer = { + data: { + agent_soul: {}, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + const { onUrlUpdate } = render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'send build message' })) + await waitFor(() => expect(mocks.checkoutBuildDraft).toHaveBeenCalledTimes(1)) + await waitFor(() => + expect(screen.getByRole('button', { name: 'restart preview' })).toBeEnabled(), + ) + + await user.click(screen.getByRole('button', { name: 'restart preview' })) + await user.click(screen.getByRole('button', { name: 'preview mode' })) + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('mode')).toBe('preview') + }) + }) + it('should stay in normal draft mode when build draft returns 404 even if a debug conversation exists', () => { const queryClient = new QueryClient() mocks.queryState.composer = { diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx index 25fec02876a..e8e340327c8 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx @@ -376,6 +376,37 @@ describe('useAgentConfigureSync', () => { ) }) + it('should not save the same draft again when Configure unmounts during an explicit save', async () => { + const saveDeferred = createDeferredPromise<{ agent_soul: Record }>() + composerPutMutationFn.mockReturnValueOnce(saveDeferred.promise) + const { result, store, unmount } = renderUseAgentConfigureSync() + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Switching prompt', + }) + }) + + let saveDraftPromise!: Promise + act(() => { + saveDraftPromise = result.current.saveDraft() + }) + await act(async () => { + await Promise.resolve() + }) + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + + unmount() + await act(async () => { + saveDeferred.resolve({ agent_soul: {} }) + await saveDraftPromise + await Promise.resolve() + }) + + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + }) + it('should include Agent Soul files when autosaving file changes', async () => { const { store } = renderUseAgentConfigureSync() diff --git a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx index c12368c0ba9..38aa85e7f17 100644 --- a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx @@ -6,12 +6,15 @@ import type { AgentSoulConfig, } from '@dify/contracts/api/console/agent/types.gen' import type { useAgentConfigureData } from '../hooks' +import type { AgentConfigureRightPanelMode } from '../state' +import type { AgentPreviewChatController } from './preview/chat-conversation' import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useAtomValue, useSetAtom } from 'jotai' import { ScopeProvider } from 'jotai-scope' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions' import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' import { rebaseAgentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store' @@ -19,8 +22,6 @@ import { consoleQuery } from '@/service/client' import { useAgentConfigureModelOptions } from '../hooks' import { agentConfigureConversationIdsAtom, - agentConfigureRightPanelChatModeAtom, - agentConfigureRightPanelModeAtom, agentConfigureShowChatFeaturesAtom, agentConfigureShowPreviewVersionsAtom, agentConfigureSoulSourceOverrideAtom, @@ -31,7 +32,9 @@ import { useAgentConfigureBuildDraftActions, useAgentConfigureBuildDraftData, } from '../use-agent-configure-build-draft' +import { useAgentConfigureSessionController } from '../use-agent-configure-session-controller' import { useAgentConfigureSync } from '../use-agent-configure-sync' +import { AgentConfigureClearSessionConfirmDialog } from './confirm-clear-session-dialog' import { AgentOrchestratePanel } from './orchestrate' import { AgentBuildDraftBar } from './orchestrate/build-draft-bar' import { AgentConfigurePageLoading } from './page-loading' @@ -50,34 +53,51 @@ export function AgentConfigureComposerScope({ agentId, composerRebaseRevision, configureData, + previewEnabled, + rightPanelMode, onComposerRebase, + onRightPanelModeChange, onSelectVersion, }: { agentId: string composerRebaseRevision: number configureData: ReturnType + previewEnabled: boolean + rightPanelMode: AgentConfigureRightPanelMode onComposerRebase: () => void + onRightPanelModeChange: (mode: AgentConfigureRightPanelMode) => void | Promise onSelectVersion: (versionId: string | null) => void }) { const { t } = useTranslation('agentV2') const { composerQuery, selectedVersionId, activeVersionId, agentSoulConfig } = configureData const soulSourceOverride = useAtomValue(agentConfigureSoulSourceOverrideAtom) const setSoulSourceOverride = useSetAtom(agentConfigureSoulSourceOverrideAtom) + const initializedComposerAgentIdRef = useRef(null) const isViewingVersion = !!selectedVersionId const buildDraft = useAgentConfigureBuildDraftData({ agentId, activeVersionId, composerAgentSoulConfig: composerQuery.data?.agent_soul, + isBuildMode: rightPanelMode === 'build', isViewingVersion, normalAgentSoulConfig: agentSoulConfig, setSoulSourceOverride, soulSourceOverride, }) + const sessionController = useAgentConfigureSessionController({ + buildDraftAgentSoulConfig: buildDraft.buildDraftAgentSoulConfig, + hasActiveBuildDraft: buildDraft.hasActiveBuildDraft, + isBuildDraftActive: buildDraft.isActive, + mode: rightPanelMode, + normalAgentSoulConfig: agentSoulConfig, + onModeChange: onRightPanelModeChange, + }) - if (buildDraft.isPending) { + if (buildDraft.isPending && initializedComposerAgentIdRef.current !== agentId) { return $['agentDetail.sections.configure'])} /> } + initializedComposerAgentIdRef.current = agentId const composerSessionKey = `${agentId}:${activeVersionId ?? selectedVersionId ?? 'draft'}:${composerRebaseRevision}` return ( @@ -87,6 +107,9 @@ export function AgentConfigureComposerScope({ composerSessionKey={composerSessionKey} configureData={configureData} isViewingVersion={isViewingVersion} + previewEnabled={previewEnabled} + rightPanelMode={rightPanelMode} + sessionController={sessionController} onComposerRebase={onComposerRebase} onSelectVersion={onSelectVersion} /> @@ -99,6 +122,9 @@ function AgentConfigurePageComposerSession({ composerSessionKey, configureData, isViewingVersion, + previewEnabled, + rightPanelMode, + sessionController, onComposerRebase, onSelectVersion, }: { @@ -107,6 +133,9 @@ function AgentConfigurePageComposerSession({ composerSessionKey: string configureData: ReturnType isViewingVersion: boolean + previewEnabled: boolean + rightPanelMode: AgentConfigureRightPanelMode + sessionController: ReturnType onComposerRebase: () => void onSelectVersion: (versionId: string | null) => void }) { @@ -139,8 +168,10 @@ function AgentConfigurePageComposerSession({ const { mutate: refreshDebugConversationRequest, mutateAsync: refreshDebugConversationRequestAsync, - isPending: isRefreshingDebugConversation, + isPending: isRefreshingBuildConversation, } = refreshDebugConversationMutation + const { mutate: refreshPreviewConversationRequest, isPending: isRefreshingPreviewConversation } = + useMutation(consoleQuery.agent.byAgentId.debugConversation.refresh.post.mutationOptions()) const refreshDebugConversationInput = useCallback( () => ({ params: { @@ -155,6 +186,20 @@ function AgentConfigurePageComposerSession({ const refreshDebugConversationAsync = useCallback(() => { return refreshDebugConversationRequestAsync(refreshDebugConversationInput()) }, [refreshDebugConversationInput, refreshDebugConversationRequestAsync]) + const refreshPreviewConversationInput = useCallback( + () => ({ + params: { + agent_id: agentId, + }, + body: { + draft_type: 'draft' as const, + }, + }), + [agentId], + ) + const refreshPreviewConversation = useCallback(() => { + refreshPreviewConversationRequest(refreshPreviewConversationInput()) + }, [refreshPreviewConversationInput, refreshPreviewConversationRequest]) return ( @@ -198,9 +249,13 @@ function AgentConfigurePageComposerContent({ configureData, isRefreshingDebugConversation, isViewingVersion, + previewEnabled, + rightPanelMode, + sessionController, onComposerRebase, onRefreshDebugConversation, onRefreshDebugConversationAsync, + onRefreshPreviewConversation, onSelectVersion, }: { agentId: string @@ -209,9 +264,13 @@ function AgentConfigurePageComposerContent({ configureData: ReturnType isRefreshingDebugConversation: boolean isViewingVersion: boolean + previewEnabled: boolean + rightPanelMode: AgentConfigureRightPanelMode + sessionController: ReturnType onComposerRebase: () => void onRefreshDebugConversation: () => void onRefreshDebugConversationAsync: () => Promise + onRefreshPreviewConversation: () => void onSelectVersion: (versionId: string | null) => void }) { const { @@ -223,14 +282,15 @@ function AgentConfigurePageComposerContent({ activeConfigSnapshot, agentSoulConfig, } = configureData + const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') - const [buildDraftActionsDisabled, setBuildDraftActionsDisabled] = useState(false) const [clearPreviewChat, setClearPreviewChat] = useState(false) const [completedBuildConversationId, setCompletedBuildConversationId] = useState( null, ) + const rightPanelChatControllerRef = useRef(null) const conversationIds = useAtomValue(agentConfigureConversationIdsAtom) - const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom) + const rightPanelChatMode = rightPanelMode const workingDirectoryPanel = useAgentWorkingDirectoryPanel({ agentId, conversationId: conversationIds[rightPanelChatMode], @@ -239,13 +299,12 @@ function AgentConfigurePageComposerContent({ const showPreviewVersions = useAtomValue(agentConfigureShowPreviewVersionsAtom) const resetConversation = useSetAtom(resetAgentConfigureConversationAtom) const setConversationId = useSetAtom(setAgentConfigureConversationIdAtom) - const setRightPanelMode = useSetAtom(agentConfigureRightPanelModeAtom) const setShowChatFeatures = useSetAtom(agentConfigureShowChatFeaturesAtom) const setShowPreviewVersions = useSetAtom(agentConfigureShowPreviewVersionsAtom) const rebaseComposerDraft = useSetAtom(rebaseAgentComposerDraftAtom) const queryClient = useQueryClient() const showBuildDraftBar = buildDraft.isActive - const resetBuildChatSession = useCallback(async () => { + const resetBuildChatState = useCallback(async () => { try { await onRefreshDebugConversationAsync() } finally { @@ -271,6 +330,29 @@ function AgentConfigurePageComposerContent({ currentModel, enabled: composerQuery.isSuccess && !selectedVersionId && !buildDraft.isActive, }) + const { + buildCallbackGeneration, + buildDraftActionsDisabled, + changeMode, + confirmSwitchToPreview: confirmSessionSwitchToPreview, + finishBuildAction, + isEnteringBuildMode, + isBuildCallbackCurrent, + resetBuildSession: resetSessionController, + resetBuildSessionState, + runBuildPreparation, + setShowSwitchToPreviewConfirm, + showSwitchToPreviewConfirm, + waitForPendingPreviewDraftSave, + } = sessionController + const resetBuildSession = useCallback( + () => resetSessionController(resetBuildChatState), + [resetBuildChatState, resetSessionController], + ) + const saveDraftBeforeBuildRun = useCallback(async () => { + await waitForPendingPreviewDraftSave() + await saveDraft() + }, [saveDraft, waitForPendingPreviewDraftSave]) const buildDraftActions = useAgentConfigureBuildDraftActions({ agentId, buildDraftAgentSoulConfig: buildDraft.agentSoulConfig, @@ -279,11 +361,36 @@ function AgentConfigurePageComposerContent({ rebaseComposerDraft: rebaseComposerDraftFromSoulConfig, refetchBuildDraft: buildDraft.refetch, refetchComposer: composerQuery.refetch, - resetBuildChatSession, - saveDraft, + resetBuildChatSession: resetBuildSession, + saveDraft: saveDraftBeforeBuildRun, onComposerRebased: onComposerRebase, setSoulSourceOverride: buildDraft.setSoulSourceOverride, }) + const stopBuildChat = useCallback(() => { + rightPanelChatControllerRef.current?.stop() + }, []) + const changeRightPanelMode = useCallback( + (nextMode: AgentConfigureRightPanelMode) => + changeMode(nextMode, { + discardBuildDraft: buildDraftActions.discardBuildDraft, + rebaseComposerDraft: rebaseComposerDraftFromSoulConfig, + savePreviewDraft: saveDraft, + startFreshBuildSession: buildDraftActions.startFreshBuildSession, + stopBuildChat, + }), + [ + buildDraftActions.discardBuildDraft, + buildDraftActions.startFreshBuildSession, + changeMode, + rebaseComposerDraftFromSoulConfig, + saveDraft, + stopBuildChat, + ], + ) + const confirmSwitchToPreview = useCallback( + () => confirmSessionSwitchToPreview(buildDraftActions.discardBuildDraft, stopBuildChat), + [buildDraftActions.discardBuildDraft, confirmSessionSwitchToPreview, stopBuildChat], + ) const selectVersion = useCallback( (versionId: string | null) => { onSelectVersion(versionId) @@ -297,10 +404,15 @@ function AgentConfigurePageComposerContent({ const isRestartCurrentChatDisabled = !hasRestartCurrentChatTarget || buildDraftActionsDisabled || + isEnteringBuildMode || isRefreshingDebugConversation || buildDraftActions.isApplyingBuildDraft || buildDraftActions.isDiscardingBuildDraft - const isChatFeaturesReadOnly = (isViewingVersion && versionQuery.isPending) || buildDraft.isActive + const isChatFeaturesReadOnly = + isEnteringBuildMode || + buildDraftActionsDisabled || + (isViewingVersion && versionQuery.isPending) || + buildDraft.isActive const buildConversationHasAgentResponse = !!conversationIds.build && (conversationIds.build === completedBuildConversationId || @@ -316,7 +428,12 @@ function AgentConfigurePageComposerContent({ return } - if (rightPanelChatMode === 'build') onRefreshDebugConversation() + if (rightPanelChatMode === 'build') { + resetBuildSessionState() + onRefreshDebugConversation() + } else { + onRefreshPreviewConversation() + } resetConversation(rightPanelChatMode) setClearPreviewChat(true) @@ -324,7 +441,7 @@ function AgentConfigurePageComposerContent({ return ( setShowChatFeatures((open) => !open)} onOpenWorkingDirectory={() => { setShowPreviewVersions(false) @@ -392,20 +514,27 @@ function AgentConfigurePageComposerContent({ /> } chat={ - { - if (mode === 'build') { + buildDraft.isPending || isEnteringBuildMode ? ( + + ) : ( + { + if (mode !== 'build' || !isBuildCallbackCurrent(buildCallbackGeneration)) return + setCompletedBuildConversationId(completedConversationId) invalidateAgentWorkingDirectoryFiles({ agentId, @@ -413,40 +542,37 @@ function AgentConfigurePageComposerContent({ queryClient, }) buildDraftActions.refreshBuildDraftAfterBuildChat(() => - setBuildDraftActionsDisabled(false), + finishBuildAction(buildCallbackGeneration), ) + }} + onConversationIdChange={(mode, conversationId) => { + if (mode === 'build' && !isBuildCallbackCurrent(buildCallbackGeneration)) return + setConversationId({ mode, conversationId }) + }} + onBeforeSpeechToText={ + rightPanelChatMode === 'build' ? saveDraftBeforeBuildRun : saveDraft } - }} - onConversationIdChange={(mode, conversationId) => { - setConversationId({ mode, conversationId }) - }} - onBeforeSpeechToText={ - rightPanelChatMode === 'build' - ? buildDraftActions.prepareBuildDraftBeforeRun - : saveDraft - } - onSaveDraftBeforeRun={ - rightPanelChatMode === 'build' - ? async () => { - if (!currentModel?.provider || !currentModel.model) { - toast.error(tCommon(($) => $['modelProvider.selectModel'])) - throw new Error('Agent model is required.') - } + onSaveDraftBeforeRun={ + rightPanelChatMode === 'build' + ? async () => { + if (!currentModel?.provider || !currentModel.model) { + toast.error(tCommon(($) => $['modelProvider.selectModel'])) + throw new Error('Agent model is required.') + } - setBuildDraftActionsDisabled(true) - try { - return await buildDraftActions.prepareBuildDraftBeforeRun() - } catch (error) { - setBuildDraftActionsDisabled(false) - throw error + return runBuildPreparation({ + generation: buildCallbackGeneration, + markBuildChatStarted: true, + prepare: buildDraftActions.prepareBuildDraftBeforeRun, + }) } - } - : saveDraft - } - onSendInterrupted={() => { - if (rightPanelChatMode === 'build') setBuildDraftActionsDisabled(false) - }} - /> + : saveDraft + } + onSendInterrupted={() => { + if (rightPanelChatMode === 'build') finishBuildAction(buildCallbackGeneration) + }} + /> + ) } /> } @@ -467,6 +593,12 @@ function AgentConfigurePageComposerContent({ disabled={isChatFeaturesReadOnly} onClose={() => setShowChatFeatures(false)} /> + $['agentDetail.configure.switchToPreviewConfirm.title'])} + onOpenChange={setShowSwitchToPreviewConfirm} + onConfirm={confirmSwitchToPreview} + /> } /> diff --git a/web/features/agent-v2/agent-detail/configure/components/confirm-clear-session-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/confirm-clear-session-dialog.tsx index 86743278684..8a7cf40f201 100644 --- a/web/features/agent-v2/agent-detail/configure/components/confirm-clear-session-dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/confirm-clear-session-dialog.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ReactElement } from 'react' +import type { ReactElement, ReactNode } from 'react' import { AlertDialog, AlertDialogActions, @@ -17,38 +17,64 @@ import { useTranslation } from 'react-i18next' export function AgentConfigureClearSessionConfirmDialog({ children, confirmDisabled = false, + open: controlledOpen, + title, + onOpenChange, onConfirm, }: { - children: ReactElement + children?: ReactElement confirmDisabled?: boolean - onConfirm: () => void + open?: boolean + title?: ReactNode + onOpenChange?: (open: boolean) => void + onConfirm: () => boolean | void | Promise }) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') - const [open, setOpen] = useState(false) + const [uncontrolledOpen, setUncontrolledOpen] = useState(false) + const [isConfirming, setIsConfirming] = useState(false) + const open = controlledOpen ?? uncontrolledOpen + const setOpen = (nextOpen: boolean) => { + if (controlledOpen === undefined) setUncontrolledOpen(nextOpen) + onOpenChange?.(nextOpen) + } + const handleOpenChange = (nextOpen: boolean) => { + if (!isConfirming) setOpen(nextOpen) + } - const handleConfirm = () => { - onConfirm() - setOpen(false) + const handleConfirm = async () => { + setIsConfirming(true) + try { + const shouldClose = await onConfirm() + if (shouldClose !== false) setOpen(false) + } catch { + // Keep the dialog open so the user can retry the failed action. + } finally { + setIsConfirming(false) + } } return ( - - - + + {children && } +
- {t(($) => $['agentDetail.configure.clearSessionConfirm.title'])} + {title ?? t(($) => $['agentDetail.configure.clearSessionConfirm.title'])} {t(($) => $['agentDetail.configure.clearSessionConfirm.description'])}
- + {tCommon(($) => $['operation.cancel'])} - + {tCommon(($) => $['operation.confirm'])} diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat-mode-routing.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat-mode-routing.spec.tsx new file mode 100644 index 00000000000..16347d2e8a2 --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat-mode-routing.spec.tsx @@ -0,0 +1,68 @@ +import type { AgentChatMessageSender } from '../chat-conversation' +import type { AgentChatRuntimeProps } from '../chat-runtime' +import { screen } from '@testing-library/react' +import { render } from '@/test/console/render' +import { AgentBuildChat } from '../build-chat' +import { sendBuildChatMessage } from '../build-chat-request' +import { AgentPreviewChat } from '../preview-chat' +import { sendPreviewChatMessage } from '../preview-chat-request' + +const runtimePropsMock = vi.hoisted(() => vi.fn()) + +vi.mock('../chat-runtime', () => ({ + AgentChatRuntime: ( + props: Pick & { sendMessage: AgentChatMessageSender }, + ) => { + runtimePropsMock(props) + return null + }, +})) + +const commonProps = { + agentId: 'agent-1', + clearChatList: false, + onClearChatListChange: vi.fn(), +} + +describe('Agent chat mode request routing', () => { + beforeEach(() => { + runtimePropsMock.mockClear() + }) + + it('should wire Build chat to the Build request implementation', () => { + render() + + expect(runtimePropsMock).toHaveBeenCalledWith( + expect.objectContaining({ + draftType: 'debug_build', + sendMessage: sendBuildChatMessage, + }), + ) + }) + + it('should wire Preview chat to the Preview request implementation', () => { + render() + + expect(runtimePropsMock).toHaveBeenCalledWith( + expect.objectContaining({ + sendMessage: sendPreviewChatMessage, + }), + ) + expect(runtimePropsMock.mock.calls.at(-1)?.[0]).not.toHaveProperty('draftType') + }) + + it('should show only the agent name in the Preview empty-state title', () => { + render() + + const renderEmptyState = runtimePropsMock.mock.calls.at(-1)?.[0].renderEmptyState + render( + renderEmptyState({ + agentName: 'Research Agent', + hasInstructions: true, + }), + ) + + expect(screen.getByText('Research Agent')).toBeInTheDocument() + expect(screen.queryByText('Preview Research Agent')).not.toBeInTheDocument() + }) +}) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx index 3910d61db88..886d6c1ce54 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx @@ -1,10 +1,11 @@ import type { ComponentProps, ReactNode } from 'react' +import type { AgentPreviewChatController } from '../chat-conversation' import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createStore, Provider as JotaiProvider } from 'jotai' -import { useState } from 'react' +import { createRef, useState } from 'react' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model' import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt' @@ -12,7 +13,9 @@ import { consoleQuery } from '@/service/client' import { render } from '@/test/console/render' import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture' import { TransferMethod } from '@/types/app' +import { sendBuildChatMessage } from '../build-chat-request' import { AgentChatRuntime } from '../chat-runtime' +import { sendPreviewChatMessage } from '../preview-chat-request' const useChatMock = vi.hoisted(() => vi.fn()) const handleSendMock = vi.hoisted(() => vi.fn()) @@ -25,6 +28,18 @@ const sendResultRef = vi.hoisted(() => ({ const chatMessagesGetMock = vi.hoisted(() => vi.fn()) const suggestedQuestionsGetMock = vi.hoisted(() => vi.fn()) const stopPostMock = vi.hoisted(() => vi.fn()) +const editionState = vi.hoisted(() => ({ isCommunity: true })) + +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + get IS_CE_EDITION() { + return editionState.isCommunity + }, + } +}) vi.mock('@/next/dynamic', async () => { const { useState } = await import('react') @@ -32,6 +47,7 @@ vi.mock('@/next/dynamic', async () => { return { default: () => function MockChat(props: { + answerActionPosition?: 'auto' | 'below' onSend: (message: string) => unknown onStopResponding: () => void sendButtonLabel?: string @@ -62,6 +78,7 @@ vi.mock('@/next/dynamic', async () => { aria-label="chat" data-send-button-label={props.sendButtonLabel ?? ''} data-send-button-loading={String(!!props.sendButtonLoading)} + data-answer-action-position={props.answerActionPosition ?? 'auto'} data-show-prompt-log={String(!!props.showPromptLog)} data-footer-notice={props.footerNotice ?? ''} data-no-chat-input={String(!!props.noChatInput)} @@ -102,9 +119,11 @@ vi.mock('@/next/dynamic', async () => { vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({ default: function MockChatInputArea({ footerNotice, + footerNoticeTooltip, speechToTextTarget, }: { footerNotice?: ReactNode + footerNoticeTooltip?: ReactNode speechToTextTarget?: SpeechToTextTarget }) { const [inputValue, setInputValue] = useState('') @@ -126,6 +145,11 @@ vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({ onChange={(event) => setInputValue(event.target.value)} /> {footerNotice} + {footerNoticeTooltip !== undefined && footerNoticeTooltip !== null && ( + + )}
) }, @@ -250,6 +274,7 @@ function renderPreviewChat(props?: Partial null} + sendMessage={sendPreviewChatMessage} onClearChatListChange={vi.fn()} {...props} /> @@ -269,6 +294,7 @@ function RuntimeConversationHarness() { conversationId={conversationId} inputPlaceholder="Message agent" renderEmptyState={() => null} + sendMessage={sendPreviewChatMessage} onClearChatListChange={vi.fn()} onConversationIdChange={setConversationId} /> @@ -284,6 +310,7 @@ function RuntimeClearCommandHarness({ inputPlaceholder }: { inputPlaceholder: st clearChatList={clearChatList} inputPlaceholder={inputPlaceholder} renderEmptyState={() => null} + sendMessage={sendPreviewChatMessage} onClearChatListChange={setClearChatList} /> ) @@ -346,15 +373,58 @@ function renderPreviewChatWithClearCommandHarness() { describe('AgentPreviewChat', () => { beforeEach(() => { + editionState.isCommunity = true useChatMock.mockClear() handleSendMock.mockClear() chatMessagesGetMock.mockResolvedValue({ data: [] }) suggestedQuestionsGetMock.mockResolvedValue({ data: [] }) + stopPostMock.mockClear() stopPostMock.mockResolvedValue({ result: 'success' }) stopCallbackRef.current = undefined sendResultRef.current = undefined }) + it('should keep build and preview chat requests independently replaceable', () => { + const buildHandleSend = vi.fn() + const previewHandleSend = vi.fn() + const callbacks = { + onSendSettled: vi.fn(), + } + const buildData = { + query: 'Build an agent', + draft_type: 'debug_build', + } + const previewData = { + query: 'Preview the agent', + } + + expect(sendBuildChatMessage).not.toBe(sendPreviewChatMessage) + + sendBuildChatMessage({ + agentId: 'agent-1', + callbacks, + data: buildData, + handleSend: buildHandleSend, + }) + sendPreviewChatMessage({ + agentId: 'agent-1', + callbacks, + data: previewData, + handleSend: previewHandleSend, + }) + + expect(buildHandleSend).toHaveBeenCalledWith( + 'agent/agent-1/chat-messages', + buildData, + callbacks, + ) + expect(previewHandleSend).toHaveBeenCalledWith( + 'agent/agent-1/chat-messages', + previewData, + callbacks, + ) + }) + it('should bind Agent preview voice input to the normal Agent draft', () => { renderPreviewChat({ renderEmptyState: () => null, @@ -394,9 +464,30 @@ describe('AgentPreviewChat', () => { ) }) + it('should bind pre-checkout Build voice input to the normal Agent draft', () => { + renderPreviewChat({ + draftType: 'debug_build', + speechToTextDraftType: 'draft', + renderEmptyState: () => null, + }) + + expect(screen.getByRole('group', { name: 'voice input' })).toHaveAttribute( + 'data-speech-draft-type', + 'draft', + ) + expect(screen.getByRole('region', { name: 'chat' })).toHaveAttribute( + 'data-speech-draft-type', + 'draft', + ) + }) + it('should keep answer regeneration available when the chat input is external', () => { renderPreviewChat() + expect(screen.getByRole('region', { name: 'chat' })).toHaveAttribute( + 'data-answer-action-position', + 'auto', + ) expect(screen.getByRole('region', { name: 'chat' })).toHaveAttribute( 'data-no-chat-input', 'true', @@ -620,6 +711,7 @@ describe('AgentPreviewChat', () => { expect(handleSendMock).toHaveBeenCalledWith( 'agent/agent-1/chat-messages', expect.not.objectContaining({ + draft_type: expect.anything(), model_config: expect.anything(), }), expect.objectContaining({ @@ -1015,6 +1107,23 @@ describe('AgentPreviewChat', () => { }) }) + it('should stop the active SSE through the runtime controller', async () => { + const controllerRef = createRef() + renderPreviewChat({ controllerRef }) + + await waitFor(() => expect(controllerRef.current).not.toBeNull()) + act(() => { + controllerRef.current?.stop() + }) + + expect(stopPostMock).toHaveBeenCalledWith({ + params: { + agent_id: 'agent-1', + task_id: 'task-1', + }, + }) + }) + it('should notify the owner once when a stopped send later settles with an error', async () => { const onSendInterrupted = vi.fn() renderPreviewChat({ @@ -1050,6 +1159,7 @@ describe('AgentPreviewChat', () => { it('should send build chat with the debug build draft type', async () => { renderPreviewChat({ draftType: 'debug_build', + sendMessage: sendBuildChatMessage, }) fireEvent.click(screen.getByRole('button', { name: 'send' })) @@ -1085,6 +1195,7 @@ describe('AgentPreviewChat', () => { expect( screen.getByText('agentV2.agentDetail.configure.preview.sandboxNotice'), ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'sandbox notice info' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'send' })) @@ -1095,6 +1206,19 @@ describe('AgentPreviewChat', () => { }) }) + it('should hide only the sandbox notice infotip outside community edition', () => { + editionState.isCommunity = false + + renderPreviewChat({ + renderEmptyState: () => null, + }) + + expect( + screen.getByText('agentV2.agentDetail.configure.preview.sandboxNotice'), + ).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'sandbox notice info' })).not.toBeInTheDocument() + }) + it('should send build chat inputs from the prepared build draft snapshot', async () => { const saveDraftBeforeRun = vi.fn().mockResolvedValue({ app_variables: [ @@ -1125,6 +1249,7 @@ describe('AgentPreviewChat', () => { ], }, draftType: 'debug_build', + sendMessage: sendBuildChatMessage, onSaveDraftBeforeRun: saveDraftBeforeRun, }) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/header.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/header.spec.tsx index f498bdc4d5f..ef4b6b4716c 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/header.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/header.spec.tsx @@ -62,10 +62,23 @@ describe('AgentPreviewHeader', () => { expect(onRefresh).toHaveBeenCalledTimes(1) }) + it('should emit refresh without confirmation in preview mode', async () => { + const user = userEvent.setup() + const onRefresh = vi.fn() + renderHeader({ mode: 'preview', onRefresh }) + + await user.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.restart' }), + ) + + expect(onRefresh).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + }) + it('should not emit refresh when the restart button is disabled', async () => { const user = userEvent.setup() const onRefresh = vi.fn() - renderHeader({ mode: 'build', onRefresh, refreshDisabled: true }) + renderHeader({ mode: 'preview', onRefresh, refreshDisabled: true }) await user.click( screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.restart' }), diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/build-chat-request.ts b/web/features/agent-v2/agent-detail/configure/components/preview/build-chat-request.ts new file mode 100644 index 00000000000..963c5edf6df --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/preview/build-chat-request.ts @@ -0,0 +1,10 @@ +import type { AgentChatMessageRequest } from './chat-conversation' + +export function sendBuildChatMessage({ + agentId, + callbacks, + data, + handleSend, +}: AgentChatMessageRequest) { + return handleSend(`agent/${agentId}/chat-messages`, data, callbacks) +} diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx index e2c1a827898..7643b8d0ab1 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx @@ -3,6 +3,7 @@ import type { AgentChatRuntimeProps } from './chat-runtime' import { useTranslation } from 'react-i18next' import { CommunityEditionTip } from '../community-edition-tip' +import { sendBuildChatMessage } from './build-chat-request' import { AgentChatRuntime } from './chat-runtime' const buildIconGridCellOpacities = [ @@ -23,7 +24,7 @@ const buildIconGridCells = buildIconGridCellOpacities.map((opacity, index) => ({ type AgentBuildChatProps = Omit< AgentChatRuntimeProps, - 'inputPlaceholder' | 'renderEmptyState' | 'sendButtonLabel' + 'draftType' | 'inputPlaceholder' | 'renderEmptyState' | 'sendButtonLabel' | 'sendMessage' > function AgentBuildChatEmptyState() { @@ -72,9 +73,11 @@ export function AgentBuildChat(props: AgentBuildChatProps) { return ( $['agentDetail.configure.build.inputPlaceholder'])} inputAutoFocus={false} sendButtonLabel={t(($) => $['agentDetail.configure.build.startBuild'])} + sendMessage={sendBuildChatMessage} renderEmptyState={() => } /> ) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/chat-conversation.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/chat-conversation.tsx index 50a1a46189a..09c81228c95 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/chat-conversation.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/chat-conversation.tsx @@ -3,6 +3,7 @@ import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import type { Ref } from 'react' import type { AgentPreviewChatConfig } from './chat-config' +import type { AnswerActionPosition } from '@/app/components/base/chat/chat/answer/operation' import type { InputForm } from '@/app/components/base/chat/chat/type' import type { ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types' import type { FileEntity } from '@/app/components/base/file-uploader/types' @@ -44,6 +45,19 @@ const fetchAgentSuggestedQuestions = (agentId: string, messageId: string) => { }) } +type AgentChatHandleSend = ReturnType['handleSend'] + +export type AgentChatMessageRequest = { + agentId: string + callbacks: Parameters[2] + data: Parameters[1] + handleSend: AgentChatHandleSend +} + +export type AgentChatMessageSender = ( + request: AgentChatMessageRequest, +) => ReturnType + export type AgentPreviewChatRuntimeState = { isEmptyChat: boolean isResponding: boolean @@ -52,11 +66,13 @@ export type AgentPreviewChatRuntimeState = { export type AgentPreviewChatController = { send: OnSend + stop: () => void } export function AgentPreviewChatConversation({ ref, agentId, + answerActionPosition, agentSoulConfig, clearChatList, config, @@ -67,6 +83,7 @@ export function AgentPreviewChatConversation({ inputs, inputsForm, sendButtonLabel, + sendMessage, speechToTextTarget, onBeforeSpeechToText, onClearChatListChange, @@ -79,6 +96,7 @@ export function AgentPreviewChatConversation({ }: { ref: Ref agentId: string + answerActionPosition?: AnswerActionPosition agentSoulConfig?: AgentSoulConfig clearChatList: boolean config: AgentPreviewChatConfig @@ -89,6 +107,7 @@ export function AgentPreviewChatConversation({ inputs: Inputs inputsForm: InputForm[] sendButtonLabel?: string + sendMessage: AgentChatMessageSender speechToTextTarget: SpeechToTextTarget onBeforeSpeechToText?: () => Promise onClearChatListChange: (clearChatList: boolean) => void @@ -178,44 +197,49 @@ export function AgentPreviewChatConversation({ if (files?.length && supportVision) data.files = files - handleSend(`agent/${agentId}/chat-messages`, data as Parameters[1], { - onGetConversationMessages: async (conversationId) => { - return queryClient.fetchQuery({ - ...consoleQuery.agent.byAgentId.chatMessages.get.queryOptions({ - input: { - params: { - agent_id: agentId, + sendMessage({ + agentId, + data: data as Parameters[1], + handleSend, + callbacks: { + onGetConversationMessages: async (conversationId) => { + return queryClient.fetchQuery({ + ...consoleQuery.agent.byAgentId.chatMessages.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, + query: { + conversation_id: conversationId, + }, }, - query: { - conversation_id: conversationId, - }, - }, - }), - staleTime: 0, - }) - }, - onGetSuggestedQuestions: (responseItemId) => - fetchAgentSuggestedQuestions(agentId, responseItemId), - onUnhandledEvent: (event) => { - if (event.event !== 'error' || typeof event.message !== 'string') return + }), + staleTime: 0, + }) + }, + onGetSuggestedQuestions: (responseItemId) => + fetchAgentSuggestedQuestions(agentId, responseItemId), + onUnhandledEvent: (event) => { + if (event.event !== 'error' || typeof event.message !== 'string') return - return { - conversationId: - typeof event.conversation_id === 'string' ? event.conversation_id : undefined, - messageId: typeof event.message_id === 'string' ? event.message_id : undefined, - errorMessage: event.message, - errorCode: typeof event.code === 'string' ? event.code : undefined, - } - }, - onConversationComplete: (completedConversationId, workflowRunId) => { - if (completedConversationId && completedConversationId !== conversationId) - onCurrentSessionConversationIdChange(completedConversationId) - onConversationIdChange?.(completedConversationId) - onConversationComplete?.(completedConversationId, workflowRunId) - }, - onSendSettled: (hasError) => { - setIsSendPending(false) - if (hasError) notifySendInterrupted() + return { + conversationId: + typeof event.conversation_id === 'string' ? event.conversation_id : undefined, + messageId: typeof event.message_id === 'string' ? event.message_id : undefined, + errorMessage: event.message, + errorCode: typeof event.code === 'string' ? event.code : undefined, + } + }, + onConversationComplete: (completedConversationId, workflowRunId) => { + if (completedConversationId && completedConversationId !== conversationId) + onCurrentSessionConversationIdChange(completedConversationId) + onConversationIdChange?.(completedConversationId) + onConversationComplete?.(completedConversationId, workflowRunId) + }, + onSendSettled: (hasError) => { + setIsSendPending(false) + if (hasError) notifySendInterrupted() + }, }, }) sendStarted = true @@ -241,6 +265,7 @@ export function AgentPreviewChatConversation({ onCurrentSessionConversationIdChange, onSaveDraftBeforeRun, queryClient, + sendMessage, textGenerationModelList, ], ) @@ -269,7 +294,10 @@ export function AgentPreviewChatConversation({ ) const isEmptyChat = chatList.length === 0 const sendButtonLoading = isEmptyChat && !!sendButtonLabel && (isSendPending || isResponding) - useImperativeHandle(ref, () => ({ send: doSend }), [doSend]) + useImperativeHandle(ref, () => ({ send: doSend, stop: doStopResponding }), [ + doSend, + doStopResponding, + ]) useLayoutEffect(() => { onRuntimeStateChange({ isEmptyChat, @@ -280,6 +308,7 @@ export function AgentPreviewChatConversation({ return ( conversationId?: string | null draftType?: 'debug_build' + speechToTextDraftType?: 'draft' | 'debug_build' inputPlaceholder: string inputAutoFocus?: boolean sendButtonLabel?: string renderEmptyState: (props: AgentChatRuntimeEmptyStateProps) => ReactNode + sendMessage: AgentChatMessageSender onClearChatListChange: (clearChatList: boolean) => void onConversationComplete?: (conversationId: string, workflowRunId?: string) => void onConversationIdChange?: (conversationId: string) => void @@ -42,18 +48,22 @@ export type AgentChatRuntimeProps = { export function AgentChatRuntime({ agentId, + answerActionPosition, agentIcon, agentIconBackground, agentIconType, agentName, agentSoulConfig, clearChatList, + controllerRef, conversationId, draftType, + speechToTextDraftType, inputPlaceholder, inputAutoFocus, sendButtonLabel, renderEmptyState, + sendMessage, onClearChatListChange, onConversationComplete, onConversationIdChange, @@ -117,19 +127,23 @@ export function AgentChatRuntime({ key={inputSessionKey} conversationSessionKey={conversationSessionKey} agentId={agentId} + answerActionPosition={answerActionPosition} agentIcon={agentIcon} agentIconBackground={agentIconBackground} agentIconType={agentIconType} agentName={agentName} agentSoulConfig={agentSoulConfig} clearChatList={clearChatList} + controllerRef={controllerRef} conversationId={conversationId} draftType={draftType} + speechToTextDraftType={speechToTextDraftType} initialChatTree={initialChatTree} inputPlaceholder={inputPlaceholder} inputAutoFocus={inputAutoFocus} sendButtonLabel={sendButtonLabel} renderEmptyState={renderEmptyState} + sendMessage={sendMessage} onClearChatListChange={handleClearChatListChange} onConversationComplete={onConversationComplete} onConversationIdChange={onConversationIdChange} diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/chat-session.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/chat-session.tsx index 538277339ea..af1ea49507a 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/chat-session.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/chat-session.tsx @@ -1,17 +1,22 @@ 'use client' import type { AgentIconType, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' -import type { ReactNode } from 'react' -import type { AgentPreviewChatController, AgentPreviewChatRuntimeState } from './chat-conversation' -import type { AgentChatRuntimeEmptyStateProps } from './chat-runtime' +import type { ReactNode, Ref } from 'react' +import type { + AgentChatMessageSender, + AgentPreviewChatController, + AgentPreviewChatRuntimeState, +} from './chat-conversation' +import type { AgentChatRuntimeEmptyStateProps, AgentChatRuntimeProps } from './chat-runtime' import type { ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types' import { cn } from '@langgenius/dify-ui/cn' import { useAtomValue } from 'jotai' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ChatInputArea from '@/app/components/base/chat/chat/chat-input-area' +import { IS_CE_EDITION } from '@/config' import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model' import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt' import { buildChatConfig, getAgentSoulInputs, getAgentSoulInputsForm } from './chat-config' @@ -20,19 +25,23 @@ import { AgentPreviewChatConversation } from './chat-conversation' export function AgentPreviewChatSession({ conversationSessionKey, agentId, + answerActionPosition, agentIcon, agentIconBackground, agentIconType, agentName, agentSoulConfig, clearChatList, + controllerRef, conversationId, draftType, + speechToTextDraftType, initialChatTree, inputPlaceholder, inputAutoFocus, sendButtonLabel, renderEmptyState, + sendMessage, onClearChatListChange, onConversationComplete, onConversationIdChange, @@ -43,19 +52,23 @@ export function AgentPreviewChatSession({ }: { conversationSessionKey: string agentId: string + answerActionPosition?: AgentChatRuntimeProps['answerActionPosition'] agentIcon?: string | null agentIconBackground?: string | null agentIconType?: AgentIconType | null agentName?: string agentSoulConfig?: AgentSoulConfig clearChatList: boolean + controllerRef?: Ref conversationId?: string | null draftType?: 'debug_build' + speechToTextDraftType?: 'draft' | 'debug_build' initialChatTree: ChatItemInTree[] inputPlaceholder: string inputAutoFocus?: boolean sendButtonLabel?: string renderEmptyState: (props: AgentChatRuntimeEmptyStateProps) => ReactNode + sendMessage: AgentChatMessageSender onClearChatListChange: (clearChatList: boolean) => void onConversationComplete?: (conversationId: string, workflowRunId?: string) => void onConversationIdChange?: (conversationId: string) => void @@ -105,6 +118,14 @@ export function AgentPreviewChatSession({ ) => conversationRef.current?.send(message, files, isRegenerate, parentAnswer), [], ) + useImperativeHandle( + controllerRef, + () => ({ + send: handleInputSend, + stop: () => conversationRef.current?.stop(), + }), + [handleInputSend], + ) const { isEmptyChat, isResponding, isSendPending } = runtimeState const hasInstructions = !!config.pre_prompt.trim() const sendButtonLoading = isEmptyChat && !!sendButtonLabel && (isSendPending || isResponding) @@ -114,7 +135,7 @@ export function AgentPreviewChatSession({ const speechToTextTarget: SpeechToTextTarget = { type: 'agent', agentId, - draftType: draftType ?? 'draft', + draftType: speechToTextDraftType ?? draftType ?? 'draft', } const chatInputNode = ( ) @@ -147,6 +168,7 @@ export function AgentPreviewChatSession({ key={conversationSessionKey} ref={conversationRef} agentId={agentId} + answerActionPosition={answerActionPosition} agentSoulConfig={agentSoulConfig} clearChatList={clearChatList} config={config} @@ -157,6 +179,7 @@ export function AgentPreviewChatSession({ inputs={inputs} inputsForm={inputsForm} sendButtonLabel={sendButtonLabel} + sendMessage={sendMessage} speechToTextTarget={speechToTextTarget} onBeforeSpeechToText={onBeforeSpeechToText} onClearChatListChange={onClearChatListChange} diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx index 123d1aa89c9..084db8fe25d 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx @@ -139,6 +139,17 @@ export function AgentPreviewHeader({ const previewDisabledTip = t(($) => $['agentDetail.configure.rightPanel.previewDisabledTip']) const learnMoreLabel = t(($) => $['agentDetail.configure.rightPanel.learnMore']) const modeTip = `${buildLabel}. ${buildTipBody} ${learnMoreLabel} ${previewLabel}. ${previewTipBody}` + const restartButton = ( + + ) return (
@@ -191,16 +202,13 @@ export function AgentPreviewHeader({
- - - + {mode === 'preview' ? ( + restartButton + ) : ( + + {restartButton} + + )} {mode === 'build' && showWorkingDirectoryAction && (