chore: support preview mode if not in community version (#39399)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Joel 2026-07-24 11:23:01 +08:00 committed by GitHub
parent db29caff2b
commit 34e2205efa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 2976 additions and 372 deletions

View File

@ -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())

View File

@ -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 },

View File

@ -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<void>
}
const Answer: FC<AnswerProps> = ({
answerActionPosition,
item,
question,
index,
@ -185,6 +188,7 @@ const Answer: FC<AnswerProps> = ({
>
{!responding && contentIsEmpty && !hasAgentContent && (
<Operation
answerActionPosition={answerActionPosition}
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - humanInputFormContainerWidth - 4}
contentWidth={humanInputFormContainerWidth}
@ -245,6 +249,7 @@ const Answer: FC<AnswerProps> = ({
>
{!responding && (
<Operation
answerActionPosition={answerActionPosition}
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - contentWidth - 4}
contentWidth={contentWidth}
@ -321,6 +326,7 @@ const Answer: FC<AnswerProps> = ({
>
{!responding && (
<Operation
answerActionPosition={answerActionPosition}
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - contentWidth - 4}
contentWidth={contentWidth}

View File

@ -23,6 +23,7 @@ import NewAudioButton from '@/app/components/base/new-audio-button'
import { useChatContext } from '../context'
type OperationProps = {
answerActionPosition?: AnswerActionPosition
item: ChatItem
question: string
index: number
@ -33,6 +34,8 @@ type OperationProps = {
noChatInput?: boolean
}
export type AnswerActionPosition = 'auto' | 'below'
type FeedbackTooltipProps = {
content: ReactNode
children: ReactElement
@ -69,6 +72,7 @@ const FeedbackTooltip = ({ content, children }: FeedbackTooltipProps) => {
}
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 (
<>

View File

@ -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 = {

View File

@ -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<ChatProps> = ({
answerActionPosition,
isTryApp,
readonly = false,
appData,
@ -214,6 +217,7 @@ const Chat: FC<ChatProps> = ({
const isLast = item.id === chatList.at(-1)?.id
return (
<Answer
answerActionPosition={answerActionPosition}
appData={appData}
key={item.id}
item={item}

View File

@ -147,6 +147,7 @@ export type Metadata = {
export type MessageEnd = {
id: string
conversation_id: string
metadata: Metadata
files?: FileResponse[]
}

View File

@ -213,6 +213,26 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
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', () => {

View File

@ -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
}

View File

@ -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(
<ReactFlowProvider>
<Workflow nodes={baseNodes} edges={baseEdges} isCollaborationEnabled>
<ReactFlowEdgeBootstrap nodes={baseNodes} edges={baseEdges} />
</Workflow>
</ReactFlowProvider>,
)
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()

View File

@ -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<WorkflowProps> = 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<WorkflowSliceShape['pendingComment']>) => {

View File

@ -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<typeof import('@/config')>()
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<string | undefined>()
useImperativeHandle(props.controllerRef, () => ({ stop: mocks.stopBuildChat }))
mocks.completeBuildConversation = () =>
props.onConversationComplete?.('build-conversation-new', 'workflow-run-1')
return (
<div role="region" aria-label="build-chat">
@ -120,21 +143,19 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-cha
<button
type="button"
onClick={() => {
void props.onSaveDraftBeforeRun?.().then((agentSoulConfig) => {
setSentPrompt(agentSoulConfig?.prompt?.system_prompt)
setMessageSent(true)
props.onConversationIdChange?.('build-conversation-new')
})
void props.onSaveDraftBeforeRun?.().then(
(agentSoulConfig) => {
setSentPrompt(agentSoulConfig?.prompt?.system_prompt)
setMessageSent(true)
props.onConversationIdChange?.('build-conversation-new')
},
() => undefined,
)
}}
>
send build message
</button>
<button
type="button"
onClick={() =>
props.onConversationComplete?.('build-conversation-new', 'workflow-run-1')
}
>
<button type="button" onClick={() => mocks.completeBuildConversation?.()}>
complete build conversation
</button>
<button
@ -152,6 +173,30 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-cha
}
})
vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/preview-chat', () => ({
AgentPreviewChat: (props: {
answerActionPosition?: 'auto' | 'below'
conversationId?: string | null
onConversationIdChange?: (conversationId: string) => void
onSaveDraftBeforeRun?: () => Promise<unknown>
}) => (
<div role="region" aria-label="preview-chat">
<span>{`preview:${props.conversationId ?? 'none'}`}</span>
<span>{`actionPosition:${props.answerActionPosition ?? 'auto'}`}</span>
<button
type="button"
onClick={() => {
void props.onSaveDraftBeforeRun?.().then(() => {
props.onConversationIdChange?.('preview-conversation-new')
})
}}
>
send preview message
</button>
</div>
),
}))
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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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({

View File

@ -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 (
<ScopeProvider
key={composerSessionKey}
atoms={agentConfigureScopedAtoms}
atoms={workflowInlineAgentConfigureScopedAtoms}
name="WorkflowInlineAgentConfigure"
>
<WorkflowInlineAgentConfigureWorkspaceComposerScope
@ -226,11 +232,13 @@ function WorkflowInlineAgentConfigureWorkspaceComposerScope({
agentSoulConfig: AgentSoulConfig
}) {
const soulSourceOverride = useAtomValue(agentConfigureSoulSourceOverrideAtom)
const rightPanelMode = useAtomValue(agentConfigureRightPanelModeAtom)
const setSoulSourceOverride = useSetAtom(agentConfigureSoulSourceOverrideAtom)
const buildDraft = useAgentConfigureBuildDraftData({
agentId,
activeVersionId: activeConfigSnapshot?.id,
composerAgentSoulConfig: agentSoulConfig,
isBuildMode: rightPanelMode === 'build',
isViewingVersion: false,
normalAgentSoulConfig: agentSoulConfig,
setSoulSourceOverride,
@ -296,22 +304,29 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
buildDraft: ReturnType<typeof useAgentConfigureBuildDraftData>
}) {
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<string | null>(
null,
)
const [workflowRunId, setWorkflowRunId] = useState<string | null>(null)
const rightPanelChatControllerRef = useRef<AgentPreviewChatController>(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={
<AgentConfigurePreviewSurface
background={<AgentBuildPanelBackground visible />}
background={<AgentBuildPanelBackground visible={rightPanelMode === 'build'} />}
header={
<AgentPreviewHeader
mode="build"
previewEnabled={false}
mode={rightPanelMode}
previewEnabled={previewEnabled}
isChatFeaturesOpen={false}
onModeChange={() => undefined}
onModeChange={changeRightPanelMode}
onToggleChatFeatures={() => undefined}
onOpenWorkingDirectory={workingDirectoryPanel.openWorkingDirectory}
onRefresh={restartCurrentChat}
@ -667,6 +762,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
chat={
<AgentConfigureRightPanelChat
agentId={agentId}
answerActionPosition="below"
agentIcon={composerState?.agent?.icon}
agentIconBackground={composerState?.agent?.icon_background}
agentIconType={
@ -677,51 +773,67 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
agentName={composerState?.agent?.name}
agentSoulConfig={buildDraft.agentSoulConfig}
clearChatList={clearPreviewChat}
controllerRef={rightPanelChatControllerRef}
conversationIds={conversationIds}
draftType="debug_build"
mode={rightPanelChatMode}
mode={rightPanelMode}
onClearChatListChange={setClearPreviewChat}
onConversationComplete={(mode, completedConversationId, completedWorkflowRunId) => {
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}
<AgentConfigureClearSessionConfirmDialog
open={showSwitchToPreviewConfirm}
title={tAgent(($) => $['agentDetail.configure.switchToPreviewConfirm.title'])}
onOpenChange={setShowSwitchToPreviewConfirm}
onConfirm={confirmSwitchToPreview}
/>
</>
}
/>
)
}

View File

@ -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<string, unknown> }>()
composerPutMutationFn.mockReturnValueOnce(saveDeferred.promise)
const { result, store, unmount } = renderUseAgentConfigureSync()
act(() => {
store.set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
prompt: 'Switching prompt',
})
})
let saveDraftPromise!: Promise<void>
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()

View File

@ -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<typeof useAgentConfigureData>
previewEnabled: boolean
rightPanelMode: AgentConfigureRightPanelMode
onComposerRebase: () => void
onRightPanelModeChange: (mode: AgentConfigureRightPanelMode) => void | Promise<unknown>
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<string | null>(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 <AgentConfigurePageLoading label={t(($) => $['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<typeof useAgentConfigureData>
isViewingVersion: boolean
previewEnabled: boolean
rightPanelMode: AgentConfigureRightPanelMode
sessionController: ReturnType<typeof useAgentConfigureSessionController>
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 (
<ScopeProvider
@ -179,11 +224,17 @@ function AgentConfigurePageComposerSession({
agentIconType={agentIconType}
buildDraft={buildDraft}
configureData={configureData}
isRefreshingDebugConversation={isRefreshingDebugConversation}
isRefreshingDebugConversation={
isRefreshingBuildConversation || isRefreshingPreviewConversation
}
isViewingVersion={isViewingVersion}
previewEnabled={previewEnabled}
rightPanelMode={rightPanelMode}
sessionController={sessionController}
onComposerRebase={onComposerRebase}
onRefreshDebugConversation={refreshDebugConversation}
onRefreshDebugConversationAsync={refreshDebugConversationAsync}
onRefreshPreviewConversation={refreshPreviewConversation}
onSelectVersion={onSelectVersion}
/>
</AgentComposerProvider>
@ -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<typeof useAgentConfigureData>
isRefreshingDebugConversation: boolean
isViewingVersion: boolean
previewEnabled: boolean
rightPanelMode: AgentConfigureRightPanelMode
sessionController: ReturnType<typeof useAgentConfigureSessionController>
onComposerRebase: () => void
onRefreshDebugConversation: () => void
onRefreshDebugConversationAsync: () => Promise<unknown>
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<string | null>(
null,
)
const rightPanelChatControllerRef = useRef<AgentPreviewChatController>(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 (
<AgentConfigureWorkspace
aria-busy={agentQuery.isFetching}
aria-busy={agentQuery.isFetching || isEnteringBuildMode}
leftPanel={
<AgentOrchestratePanel
agentId={agentId}
@ -336,11 +453,16 @@ function AgentConfigurePageComposerContent({
textGenerationModelList={textGenerationModelList}
draftSavedAt={draftSavedAt}
isPublishing={isPublishing}
readOnly={isViewingVersion || buildDraft.isActive}
readOnly={
isViewingVersion ||
buildDraft.isActive ||
buildDraftActionsDisabled ||
isEnteringBuildMode
}
selectedVersionSnapshot={isViewingVersion ? activeConfigSnapshot : undefined}
isBuildDraftActive={buildDraft.isActive}
buildDraftChangedKeys={buildDraft.changedKeys}
showPublishBar={!buildDraft.isActive}
showPublishBar={!buildDraft.isActive && !isEnteringBuildMode}
workflowReferencesEnabled={agentQuery.isSuccess}
bottomAction={
showBuildDraftBar ? (
@ -378,9 +500,9 @@ function AgentConfigurePageComposerContent({
header={
<AgentPreviewHeader
mode={rightPanelChatMode}
previewEnabled={false}
previewEnabled={previewEnabled}
isChatFeaturesOpen={showChatFeatures}
onModeChange={setRightPanelMode}
onModeChange={changeRightPanelMode}
onToggleChatFeatures={() => setShowChatFeatures((open) => !open)}
onOpenWorkingDirectory={() => {
setShowPreviewVersions(false)
@ -392,20 +514,27 @@ function AgentConfigurePageComposerContent({
/>
}
chat={
<AgentConfigureRightPanelChat
agentId={agentId}
agentIcon={agentQuery.data?.icon}
agentIconBackground={agentQuery.data?.icon_background}
agentIconType={agentIconType}
agentName={agentQuery.data?.name}
agentSoulConfig={buildDraft.agentSoulConfig}
clearChatList={clearPreviewChat}
conversationIds={conversationIds}
draftType={rightPanelChatMode === 'build' ? 'debug_build' : undefined}
mode={rightPanelChatMode}
onClearChatListChange={setClearPreviewChat}
onConversationComplete={(mode, completedConversationId) => {
if (mode === 'build') {
buildDraft.isPending || isEnteringBuildMode ? (
<Loading type="app" />
) : (
<AgentConfigureRightPanelChat
agentId={agentId}
agentIcon={agentQuery.data?.icon}
agentIconBackground={agentQuery.data?.icon_background}
agentIconType={agentIconType}
agentName={agentQuery.data?.name}
agentSoulConfig={buildDraft.agentSoulConfig}
clearChatList={clearPreviewChat}
controllerRef={rightPanelChatControllerRef}
conversationIds={conversationIds}
mode={rightPanelChatMode}
speechToTextDraftType={
rightPanelChatMode === 'build' && buildDraft.isActive ? 'debug_build' : 'draft'
}
onClearChatListChange={setClearPreviewChat}
onConversationComplete={(mode, completedConversationId) => {
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)}
/>
<AgentConfigureClearSessionConfirmDialog
open={showSwitchToPreviewConfirm}
title={t(($) => $['agentDetail.configure.switchToPreviewConfirm.title'])}
onOpenChange={setShowSwitchToPreviewConfirm}
onConfirm={confirmSwitchToPreview}
/>
</>
}
/>

View File

@ -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<boolean | void>
}) {
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 (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger render={children} />
<AlertDialogContent className="w-100">
<AlertDialog open={open} onOpenChange={handleOpenChange}>
{children && <AlertDialogTrigger render={children} />}
<AlertDialogContent backdropProps={{ forceRender: true }} className="w-100">
<div className="flex flex-col gap-1 p-6 pb-0">
<AlertDialogTitle className="title-md-semi-bold text-text-primary">
{t(($) => $['agentDetail.configure.clearSessionConfirm.title'])}
{title ?? t(($) => $['agentDetail.configure.clearSessionConfirm.title'])}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t(($) => $['agentDetail.configure.clearSessionConfirm.description'])}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-6">
<AlertDialogCancelButton disabled={confirmDisabled}>
<AlertDialogCancelButton disabled={confirmDisabled || isConfirming}>
{tCommon(($) => $['operation.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={confirmDisabled} onClick={handleConfirm}>
<AlertDialogConfirmButton
disabled={confirmDisabled || isConfirming}
loading={isConfirming}
onClick={handleConfirm}
>
{tCommon(($) => $['operation.confirm'])}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -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<AgentChatRuntimeProps, 'draftType'> & { 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(<AgentBuildChat {...commonProps} />)
expect(runtimePropsMock).toHaveBeenCalledWith(
expect.objectContaining({
draftType: 'debug_build',
sendMessage: sendBuildChatMessage,
}),
)
})
it('should wire Preview chat to the Preview request implementation', () => {
render(<AgentPreviewChat {...commonProps} />)
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(<AgentPreviewChat {...commonProps} agentName="Research Agent" />)
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()
})
})

View File

@ -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<typeof import('@/config')>()
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 && (
<button type="button" aria-label="sandbox notice info">
{footerNoticeTooltip}
</button>
)}
</div>
)
},
@ -250,6 +274,7 @@ function renderPreviewChat(props?: Partial<ComponentProps<typeof AgentChatRuntim
clearChatList={false}
inputPlaceholder="Message agent"
renderEmptyState={() => 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<AgentPreviewChatController>()
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,
})

View File

@ -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' }),

View File

@ -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)
}

View File

@ -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 (
<AgentChatRuntime
{...props}
draftType="debug_build"
inputPlaceholder={t(($) => $['agentDetail.configure.build.inputPlaceholder'])}
inputAutoFocus={false}
sendButtonLabel={t(($) => $['agentDetail.configure.build.startBuild'])}
sendMessage={sendBuildChatMessage}
renderEmptyState={() => <AgentBuildChatEmptyState />}
/>
)

View File

@ -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<typeof useChat>['handleSend']
export type AgentChatMessageRequest = {
agentId: string
callbacks: Parameters<AgentChatHandleSend>[2]
data: Parameters<AgentChatHandleSend>[1]
handleSend: AgentChatHandleSend
}
export type AgentChatMessageSender = (
request: AgentChatMessageRequest,
) => ReturnType<AgentChatHandleSend>
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<AgentPreviewChatController>
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<unknown>
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<typeof handleSend>[1], {
onGetConversationMessages: async (conversationId) => {
return queryClient.fetchQuery({
...consoleQuery.agent.byAgentId.chatMessages.get.queryOptions({
input: {
params: {
agent_id: agentId,
sendMessage({
agentId,
data: data as Parameters<typeof handleSend>[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 (
<Chat
answerActionPosition={answerActionPosition}
config={config}
speechToTextTarget={speechToTextTarget}
onBeforeSpeechToText={onBeforeSpeechToText}

View File

@ -1,7 +1,9 @@
'use client'
import type { AgentIconType, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
import type { ReactNode } from 'react'
import type { ReactNode, Ref } from 'react'
import type { AgentChatMessageSender, AgentPreviewChatController } from './chat-conversation'
import type { AnswerActionPosition } from '@/app/components/base/chat/chat/answer/operation'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Loading from '@/app/components/base/loading'
@ -19,18 +21,22 @@ export type AgentChatRuntimeEmptyStateProps = {
export type AgentChatRuntimeProps = {
agentId: string
answerActionPosition?: AnswerActionPosition
agentIcon?: string | null
agentIconBackground?: string | null
agentIconType?: AgentIconType | null
agentName?: string
agentSoulConfig?: AgentSoulConfig
clearChatList: boolean
controllerRef?: Ref<AgentPreviewChatController>
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}

View File

@ -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<AgentPreviewChatController>
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 = (
<ChatInputArea
@ -136,7 +157,7 @@ export function AgentPreviewChatSession({
isResponding={isEmptyChat ? undefined : isResponding}
sendButtonLabel={isEmptyChat ? sendButtonLabel : undefined}
footerNotice={showSandboxNotice ? sandboxNotice : undefined}
footerNoticeTooltip={showSandboxNotice ? sandboxNoticeTooltip : undefined}
footerNoticeTooltip={showSandboxNotice && IS_CE_EDITION ? sandboxNoticeTooltip : undefined}
/>
)
@ -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}

View File

@ -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 = (
<button
type="button"
disabled={refreshDisabled}
onClick={mode === 'preview' ? onRefresh : undefined}
className="flex size-6 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
aria-label={t(($) => $['agentDetail.configure.preview.restart'])}
>
<span aria-hidden className="i-custom-vender-other-replay-line size-4" />
</button>
)
return (
<div className="relative z-1 flex h-12 shrink-0 items-center justify-between gap-3 px-4 py-2">
@ -191,16 +202,13 @@ export function AgentPreviewHeader({
</div>
<div className="flex shrink-0 items-center">
<div className="flex items-center gap-2">
<AgentConfigureClearSessionConfirmDialog onConfirm={onRefresh}>
<button
type="button"
disabled={refreshDisabled}
className="flex size-6 items-center justify-center rounded-md p-0.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
aria-label={t(($) => $['agentDetail.configure.preview.restart'])}
>
<span aria-hidden className="i-custom-vender-other-replay-line size-4" />
</button>
</AgentConfigureClearSessionConfirmDialog>
{mode === 'preview' ? (
restartButton
) : (
<AgentConfigureClearSessionConfirmDialog onConfirm={onRefresh}>
{restartButton}
</AgentConfigureClearSessionConfirmDialog>
)}
{mode === 'build' && showWorkingDirectoryAction && (
<button
type="button"

View File

@ -0,0 +1,10 @@
import type { AgentChatMessageRequest } from './chat-conversation'
export function sendPreviewChatMessage({
agentId,
callbacks,
data,
handleSend,
}: AgentChatMessageRequest) {
return handleSend(`agent/${agentId}/chat-messages`, data, callbacks)
}

View File

@ -4,8 +4,12 @@ import type { AgentChatRuntimeEmptyStateProps, AgentChatRuntimeProps } from './c
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { AgentChatRuntime } from './chat-runtime'
import { sendPreviewChatMessage } from './preview-chat-request'
type AgentPreviewChatProps = Omit<AgentChatRuntimeProps, 'inputPlaceholder' | 'renderEmptyState'>
type AgentPreviewChatProps = Omit<
AgentChatRuntimeProps,
'draftType' | 'inputPlaceholder' | 'renderEmptyState' | 'sendMessage'
>
function AgentPreviewChatEmptyState({
agentIcon,
@ -30,9 +34,7 @@ function AgentPreviewChatEmptyState({
className="bg-background-default"
/>
<div className="mt-3 max-w-full truncate system-md-medium text-text-secondary">
{t(($) => $['agentDetail.configure.preview.empty.title'], {
name: agentName || t(($) => $['agentDetail.configure.preview.empty.defaultAgentName']),
})}
{agentName || t(($) => $['agentDetail.configure.preview.empty.defaultAgentName'])}
</div>
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
{t(($) => $['agentDetail.configure.preview.empty.description'])}
@ -57,6 +59,7 @@ export function AgentPreviewChat(props: AgentPreviewChatProps) {
inputPlaceholder={t(($) => $['agentDetail.configure.preview.inputPlaceholder'], {
name: agentName,
})}
sendMessage={sendPreviewChatMessage}
renderEmptyState={(emptyStateProps) => <AgentPreviewChatEmptyState {...emptyStateProps} />}
/>
)

View File

@ -1,12 +1,20 @@
'use client'
import type { AgentConfigureRightPanelMode } from './state'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { Suspense, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CE_EDITION } from '@/config'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { LicenseStatus } from '@/features/system-features/constants'
import { AgentConfigureComposerScope } from './components/composer-session'
import { AgentConfigurePageLoading } from './components/page-loading'
import { useAgentConfigureData } from './hooks'
import {
AGENT_CONFIGURE_RIGHT_PANEL_MODES,
agentConfigureComposerRebaseRevisionAtom,
agentConfigureScopedAtoms,
agentConfigureSelectedVersionIdAtom,
@ -14,28 +22,62 @@ import {
rebaseAgentConfigureComposerAtom,
} from './state'
const agentConfigureModeQueryParser = parseAsStringLiteral(
AGENT_CONFIGURE_RIGHT_PANEL_MODES,
).withOptions({ history: 'replace' })
type AgentConfigurePageProps = {
agentId: string
}
export function AgentConfigurePage({ agentId }: AgentConfigurePageProps) {
const { t } = useTranslation('agentV2')
const loadingLabel = t(($) => $['agentDetail.sections.configure'])
return (
<ScopeProvider key={agentId} atoms={agentConfigureScopedAtoms} name="AgentConfigure">
<AgentConfigurePageContent agentId={agentId} />
</ScopeProvider>
<Suspense fallback={<AgentConfigurePageLoading label={loadingLabel} />}>
<ScopeProvider key={agentId} atoms={agentConfigureScopedAtoms} name="AgentConfigure">
<AgentConfigurePageContent agentId={agentId} loadingLabel={loadingLabel} />
</ScopeProvider>
</Suspense>
)
}
function AgentConfigurePageContent({ agentId }: AgentConfigurePageProps) {
const { t } = useTranslation('agentV2')
function AgentConfigurePageContent({
agentId,
loadingLabel,
}: AgentConfigurePageProps & { loadingLabel: string }) {
const [modeInUrl, setModeInUrl] = useQueryState('mode', agentConfigureModeQueryParser)
const selectedVersionId = useAtomValue(agentConfigureSelectedVersionIdAtom)
const composerRebaseRevision = useAtomValue(agentConfigureComposerRebaseRevisionAtom)
const rebaseComposer = useSetAtom(rebaseAgentConfigureComposerAtom)
const selectVersion = useSetAtom(agentConfigureSelectVersionAtom)
const configureData = useAgentConfigureData(agentId, selectedVersionId)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const previewEnabled =
!IS_CE_EDITION ||
systemFeatures.license.status === LicenseStatus.ACTIVE ||
systemFeatures.license.status === LicenseStatus.EXPIRING
const requestedMode = modeInUrl ?? 'build'
const rightPanelMode = previewEnabled ? requestedMode : 'build'
const changeRightPanelMode = useCallback(
(nextMode: AgentConfigureRightPanelMode) => {
if (nextMode === 'preview' && !previewEnabled) return
return setModeInUrl(nextMode)
},
[previewEnabled, setModeInUrl],
)
useEffect(() => {
if (modeInUrl === rightPanelMode) return
// oxlint-disable-next-line eslint-react/set-state-in-effect -- The URL is external state and must mirror the effective mode after parsing and license gating.
void setModeInUrl(rightPanelMode, { history: 'replace' })
}, [modeInUrl, rightPanelMode, setModeInUrl])
if (configureData.isPending) {
return <AgentConfigurePageLoading label={t(($) => $['agentDetail.sections.configure'])} />
return <AgentConfigurePageLoading label={loadingLabel} />
}
return (
@ -43,7 +85,10 @@ function AgentConfigurePageContent({ agentId }: AgentConfigurePageProps) {
agentId={agentId}
composerRebaseRevision={composerRebaseRevision}
configureData={configureData}
previewEnabled={previewEnabled}
rightPanelMode={rightPanelMode}
onComposerRebase={rebaseComposer}
onRightPanelModeChange={changeRightPanelMode}
onSelectVersion={selectVersion}
/>
)

View File

@ -1,6 +1,8 @@
import { atom } from 'jotai'
export type AgentConfigureRightPanelMode = 'build' | 'preview'
export const AGENT_CONFIGURE_RIGHT_PANEL_MODES = ['build', 'preview'] as const
export type AgentConfigureRightPanelMode = (typeof AGENT_CONFIGURE_RIGHT_PANEL_MODES)[number]
export type AgentConfigureConversationIds = Record<AgentConfigureRightPanelMode, string | null>
export type AgentConfigureSoulSource = 'draft' | 'build-draft' | 'view-version'
@ -16,12 +18,6 @@ export const agentConfigureConversationIdsAtom = atom<AgentConfigureConversation
preview: null,
})
export const agentConfigureRightPanelChatModeAtom = atom((get): AgentConfigureRightPanelMode => {
const mode = get(agentConfigureRightPanelModeAtom)
return mode === 'preview' ? 'build' : mode
})
export const agentConfigureSelectVersionAtom = atom(null, (_get, set, versionId: string | null) => {
set(agentConfigureSoulSourceOverrideAtom, versionId ? 'view-version' : null)
set(agentConfigureSelectedVersionIdAtom, versionId)
@ -67,5 +63,9 @@ export const agentConfigureScopedAtoms = [
agentConfigureSoulSourceOverrideAtom,
agentConfigureShowChatFeaturesAtom,
agentConfigureShowPreviewVersionsAtom,
] as const
export const workflowInlineAgentConfigureScopedAtoms = [
...agentConfigureScopedAtoms,
agentConfigureRightPanelModeAtom,
] as const

View File

@ -35,38 +35,55 @@ export function usePrepareAgentBuildDraftBeforeRun({
const { mutateAsync: checkoutBuildDraft, isPending: isCheckingOutBuildDraft } =
checkoutBuildDraftMutation
const checkoutBuildDraftFromNormalDraft = useCallback(
async (force: boolean) => {
if (!agentId) return
const buildDraft = await checkoutBuildDraft({
params: {
agent_id: agentId,
},
body: {
force,
},
})
queryClient.setQueryData(buildDraftQueryOptions.queryKey, buildDraft)
rebaseComposerDraft?.(buildDraft.agent_soul as AgentSoulConfig | undefined)
setSoulSourceOverride?.('build-draft')
return buildDraft.agent_soul as AgentSoulConfig | undefined
},
[
agentId,
buildDraftQueryOptions.queryKey,
checkoutBuildDraft,
queryClient,
rebaseComposerDraft,
setSoulSourceOverride,
],
)
const prepareBuildDraftBeforeRun = useCallback(async () => {
if (!agentId) return
if (isBuildDraftActive) return buildDraftAgentSoulConfig
await saveDraft()
const buildDraft = await checkoutBuildDraft({
params: {
agent_id: agentId,
},
body: {
force: false,
},
})
queryClient.setQueryData(buildDraftQueryOptions.queryKey, buildDraft)
rebaseComposerDraft?.(buildDraft.agent_soul as AgentSoulConfig | undefined)
setSoulSourceOverride?.('build-draft')
return buildDraft.agent_soul as AgentSoulConfig | undefined
if (isBuildDraftActive) return buildDraftAgentSoulConfig
return checkoutBuildDraftFromNormalDraft(false)
}, [
agentId,
buildDraftAgentSoulConfig,
buildDraftQueryOptions.queryKey,
checkoutBuildDraft,
checkoutBuildDraftFromNormalDraft,
isBuildDraftActive,
queryClient,
rebaseComposerDraft,
saveDraft,
setSoulSourceOverride,
])
const forceCheckoutBuildDraft = useCallback(
() => checkoutBuildDraftFromNormalDraft(true),
[checkoutBuildDraftFromNormalDraft],
)
return {
forceCheckoutBuildDraft,
isCheckingOutBuildDraft,
prepareBuildDraftBeforeRun,
}

View File

@ -141,6 +141,7 @@ export function useAgentConfigureBuildDraftData({
agentId,
activeVersionId,
composerAgentSoulConfig,
isBuildMode,
isViewingVersion,
normalAgentSoulConfig,
setSoulSourceOverride,
@ -149,6 +150,7 @@ export function useAgentConfigureBuildDraftData({
agentId: string
activeVersionId: string | null | undefined
composerAgentSoulConfig?: AgentSoulConfig
isBuildMode: boolean
isViewingVersion: boolean
normalAgentSoulConfig?: AgentSoulConfig
setSoulSourceOverride: (source: AgentConfigureSoulSource | null) => void
@ -178,7 +180,10 @@ export function useAgentConfigureBuildDraftData({
const buildDraftQuery = useQuery({
...buildDraftQueryOptions,
enabled:
!isViewingVersion && soulSourceOverride !== 'draft' && soulSourceOverride !== 'view-version',
isBuildMode &&
!isViewingVersion &&
soulSourceOverride !== 'draft' &&
soulSourceOverride !== 'view-version',
queryFn: async (context) => {
try {
const queryOptions = shouldSilenceBuildDraftCheckRef.current
@ -205,10 +210,12 @@ export function useAgentConfigureBuildDraftData({
refetch: refetchBuildDraft,
} = buildDraftQuery
const buildDraftNotFound = isNotFoundResponse(buildDraftError)
const soulSource: AgentConfigureSoulSource = isViewingVersion
const resolvedSoulSource: AgentConfigureSoulSource = isViewingVersion
? 'view-version'
: (soulSourceOverride ??
(!buildDraftNotFound && !!buildDraftData && !isBuildDraftError ? 'build-draft' : 'draft'))
const hasActiveBuildDraft = resolvedSoulSource === 'build-draft'
const soulSource = !isBuildMode && hasActiveBuildDraft ? 'draft' : resolvedSoulSource
const isBuildDraftActive = soulSource === 'build-draft'
const buildDraftAgentSoulConfig = buildDraftData?.agent_soul as AgentSoulConfig | undefined
const visibleAgentSoulConfig = isBuildDraftActive
@ -244,11 +251,14 @@ export function useAgentConfigureBuildDraftData({
? `build-draft:${buildDraftDataUpdatedAt}`
: activeVersionId,
agentSoulConfig: visibleAgentSoulConfig,
buildDraftAgentSoulConfig,
changedKeys: buildDraftChangeSummary.changedKeys,
changeSummary: buildDraftChangeSummary,
changesCount: buildDraftChangeSummary.changesCount,
hasActiveBuildDraft,
isActive: isBuildDraftActive,
isPending:
isBuildMode &&
!isViewingVersion &&
soulSourceOverride !== 'draft' &&
soulSourceOverride !== 'view-version' &&
@ -288,6 +298,7 @@ export function useAgentConfigureBuildDraftActions({
const queryClient = useQueryClient()
const buildDraftRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const buildDraftRefreshGenerationRef = useRef(0)
const forceCheckoutBeforeNextBuildRunRef = useRef(false)
const buildDraftQueryOptions = consoleQuery.agent.byAgentId.buildDraft.get.queryOptions({
input: {
params: {
@ -313,14 +324,15 @@ export function useAgentConfigureBuildDraftActions({
applyBuildDraftMutation
const { mutateAsync: discardBuildDraftRequest, isPending: isDiscardingBuildDraft } =
discardBuildDraftMutation
const { prepareBuildDraftBeforeRun } = usePrepareAgentBuildDraftBeforeRun({
agentId,
buildDraftAgentSoulConfig,
isBuildDraftActive: isActive,
rebaseComposerDraft,
saveDraft,
setSoulSourceOverride,
})
const { forceCheckoutBuildDraft, prepareBuildDraftBeforeRun } =
usePrepareAgentBuildDraftBeforeRun({
agentId,
buildDraftAgentSoulConfig,
isBuildDraftActive: isActive,
rebaseComposerDraft,
saveDraft,
setSoulSourceOverride,
})
const cancelBuildDraftRefresh = useCallback(() => {
buildDraftRefreshGenerationRef.current += 1
@ -332,8 +344,37 @@ export function useAgentConfigureBuildDraftActions({
const prepareBuildDraftRun = useCallback(async () => {
cancelBuildDraftRefresh()
return prepareBuildDraftBeforeRun()
}, [cancelBuildDraftRefresh, prepareBuildDraftBeforeRun])
if (!forceCheckoutBeforeNextBuildRunRef.current) return prepareBuildDraftBeforeRun()
await saveDraft()
try {
const buildDraft = await forceCheckoutBuildDraft()
forceCheckoutBeforeNextBuildRunRef.current = false
return buildDraft
} catch (error) {
toast.error(tCommon(($) => $['api.actionFailed']))
throw error
}
}, [
cancelBuildDraftRefresh,
forceCheckoutBuildDraft,
prepareBuildDraftBeforeRun,
saveDraft,
tCommon,
])
const startFreshBuildSession = useCallback(async () => {
cancelBuildDraftRefresh()
try {
await resetBuildChatSession()
forceCheckoutBeforeNextBuildRunRef.current = true
setSoulSourceOverride('draft')
return true
} catch {
toast.error(tCommon(($) => $['api.actionFailed']))
return false
}
}, [cancelBuildDraftRefresh, resetBuildChatSession, setSoulSourceOverride, tCommon])
const refreshBuildDraftAfterBuildChat = useCallback(
(onRefreshed?: () => void) => {
@ -420,8 +461,10 @@ export function useAgentConfigureBuildDraftActions({
})
await exitBuildDraftMode(false)
toast.success(tCommon(($) => $['api.actionSuccess']))
return true
} catch {
toast.error(tCommon(($) => $['api.actionFailed']))
return false
}
}
@ -439,5 +482,6 @@ export function useAgentConfigureBuildDraftActions({
isDiscardingBuildDraft,
prepareBuildDraftBeforeRun: prepareBuildDraftRun,
refreshBuildDraftAfterBuildChat,
startFreshBuildSession,
}
}

View File

@ -0,0 +1,284 @@
'use client'
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
import type { AgentConfigureRightPanelMode } from './state'
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
type PendingDraftSaveResult = { status: 'saved' } | { status: 'failed'; error: unknown }
export function useAgentConfigureSessionController({
buildDraftAgentSoulConfig,
hasActiveBuildDraft,
isBuildDraftActive,
mode,
normalAgentSoulConfig,
onModeChange,
}: {
buildDraftAgentSoulConfig?: AgentSoulConfig
hasActiveBuildDraft: boolean
isBuildDraftActive: boolean
mode: AgentConfigureRightPanelMode
normalAgentSoulConfig?: AgentSoulConfig
onModeChange: (mode: AgentConfigureRightPanelMode) => void | Promise<unknown>
}) {
const [buildCallbackRevision, setBuildCallbackRevision] = useState(0)
const [buildDraftActionsDisabled, setBuildDraftActionsDisabled] = useState(false)
const [hasStartedBuildChat, setHasStartedBuildChat] = useState(false)
const [isEnteringBuildMode, setIsEnteringBuildMode] = useState(false)
const [showSwitchToPreviewConfirm, setShowSwitchToPreviewConfirm] = useState(false)
const buildCallbackGeneration = useMemo(
() => Symbol(`agent-build-session:${mode}:${buildCallbackRevision}`),
[buildCallbackRevision, mode],
)
const buildCallbackGenerationRef = useRef(buildCallbackGeneration)
const buildCallbacksEnabledRef = useRef(mode === 'build')
const modeRef = useRef(mode)
const pendingBuildModeTransitionRef = useRef<Promise<void> | null>(null)
const pendingBuildDraftPreparationRef = useRef<Promise<unknown> | null>(null)
const pendingPreviewDraftSaveRef = useRef<Promise<PendingDraftSaveResult> | null>(null)
const rotateBuildCallbackGeneration = useCallback((enabled: boolean) => {
buildCallbackGenerationRef.current = Symbol('invalid-agent-build-session')
buildCallbacksEnabledRef.current = enabled
setBuildCallbackRevision((revision) => revision + 1)
}, [])
useLayoutEffect(() => {
buildCallbackGenerationRef.current = buildCallbackGeneration
}, [buildCallbackGeneration])
useLayoutEffect(() => {
if (modeRef.current === mode) return
modeRef.current = mode
buildCallbacksEnabledRef.current = mode === 'build'
}, [mode])
const isBuildCallbackCurrent = useCallback(
(generation: symbol) =>
buildCallbacksEnabledRef.current &&
modeRef.current === 'build' &&
buildCallbackGenerationRef.current === generation,
[],
)
const registerPreviewDraftSave = useCallback((draftSave: Promise<unknown>) => {
pendingPreviewDraftSaveRef.current = draftSave.then<
PendingDraftSaveResult,
PendingDraftSaveResult
>(
() => ({ status: 'saved' }),
(error: unknown) => ({ status: 'failed', error }),
)
}, [])
const waitForPendingPreviewDraftSave = useCallback(async () => {
const pendingDraftSave = pendingPreviewDraftSaveRef.current
if (!pendingDraftSave) return
const result = await pendingDraftSave
if (result.status === 'failed') throw result.error
if (pendingPreviewDraftSaveRef.current === pendingDraftSave)
pendingPreviewDraftSaveRef.current = null
}, [])
const waitForPendingBuildDraftPreparation = useCallback(async () => {
const pendingBuildDraftPreparation = pendingBuildDraftPreparationRef.current
if (!pendingBuildDraftPreparation) return
await pendingBuildDraftPreparation.catch(() => undefined)
}, [])
const runBuildPreparation = useCallback(
async <T>({
generation,
markBuildChatStarted = false,
prepare,
}: {
generation: symbol
markBuildChatStarted?: boolean
prepare: () => Promise<T>
}) => {
if (!isBuildCallbackCurrent(generation))
throw new Error('The Build session is no longer active.')
if (markBuildChatStarted) {
setHasStartedBuildChat(true)
setBuildDraftActionsDisabled(true)
}
const preparation = (async () => {
const result = await prepare()
if (!isBuildCallbackCurrent(generation))
throw new Error('The Build session is no longer active.')
return result
})()
pendingBuildDraftPreparationRef.current = preparation
try {
return await preparation
} catch (error) {
if (markBuildChatStarted && isBuildCallbackCurrent(generation)) {
setBuildDraftActionsDisabled(false)
setHasStartedBuildChat(false)
}
throw error
} finally {
if (pendingBuildDraftPreparationRef.current === preparation)
pendingBuildDraftPreparationRef.current = null
}
},
[isBuildCallbackCurrent],
)
const finishBuildAction = useCallback(
(generation: symbol) => {
if (!isBuildCallbackCurrent(generation)) return
setBuildDraftActionsDisabled(false)
},
[isBuildCallbackCurrent],
)
const resetBuildSessionState = useCallback(() => {
const keepBuildCallbacksEnabled =
buildCallbacksEnabledRef.current && modeRef.current === 'build'
rotateBuildCallbackGeneration(keepBuildCallbacksEnabled)
setBuildDraftActionsDisabled(false)
setHasStartedBuildChat(false)
}, [rotateBuildCallbackGeneration])
const resetBuildSession = useCallback(
async (onResetBuildSession: () => Promise<void>) => {
try {
await onResetBuildSession()
} finally {
resetBuildSessionState()
}
},
[resetBuildSessionState],
)
const discardBuildDraftAndSwitchToPreview = useCallback(
async (discardBuildDraft: () => Promise<boolean>, stopBuildChat: () => void) => {
rotateBuildCallbackGeneration(false)
stopBuildChat()
await waitForPendingBuildDraftPreparation()
const discarded = await discardBuildDraft()
if (!discarded) {
rotateBuildCallbackGeneration(true)
return false
}
modeRef.current = 'preview'
onModeChange('preview')
return true
},
[onModeChange, rotateBuildCallbackGeneration, waitForPendingBuildDraftPreparation],
)
const changeMode = useCallback(
(
nextMode: AgentConfigureRightPanelMode,
{
discardBuildDraft,
rebaseComposerDraft,
savePreviewDraft,
startFreshBuildSession,
stopBuildChat,
}: {
discardBuildDraft: () => Promise<boolean>
rebaseComposerDraft: (agentSoulConfig?: AgentSoulConfig) => void
savePreviewDraft: () => Promise<unknown>
startFreshBuildSession?: () => Promise<boolean>
stopBuildChat: () => void
},
) => {
if (nextMode === modeRef.current) return
const isLeavingBuildMode = modeRef.current === 'build' && nextMode === 'preview'
if (isLeavingBuildMode && (hasActiveBuildDraft || hasStartedBuildChat)) {
setShowSwitchToPreviewConfirm(true)
return
}
if (isLeavingBuildMode && pendingBuildDraftPreparationRef.current) {
void discardBuildDraftAndSwitchToPreview(discardBuildDraft, stopBuildChat)
return
}
if (isLeavingBuildMode) rotateBuildCallbackGeneration(false)
const nextUsesBuildDraft = nextMode === 'build' && hasActiveBuildDraft
const isEnteringBuildMode = modeRef.current === 'preview' && nextMode === 'build'
if (isEnteringBuildMode) {
if (startFreshBuildSession) {
if (pendingBuildModeTransitionRef.current) return
setIsEnteringBuildMode(true)
const transition = (async () => {
try {
const started = await startFreshBuildSession()
if (!started || modeRef.current !== 'preview') return
modeRef.current = 'build'
rotateBuildCallbackGeneration(true)
try {
await onModeChange(nextMode)
} catch (error) {
modeRef.current = 'preview'
rotateBuildCallbackGeneration(false)
throw error
}
} catch {}
})()
pendingBuildModeTransitionRef.current = transition
void transition.finally(() => {
if (pendingBuildModeTransitionRef.current === transition)
pendingBuildModeTransitionRef.current = null
setIsEnteringBuildMode(false)
})
return
}
modeRef.current = 'build'
rotateBuildCallbackGeneration(true)
registerPreviewDraftSave(savePreviewDraft())
if (nextUsesBuildDraft) rebaseComposerDraft(buildDraftAgentSoulConfig)
onModeChange(nextMode)
return
}
if (nextUsesBuildDraft !== isBuildDraftActive) {
rebaseComposerDraft(nextUsesBuildDraft ? buildDraftAgentSoulConfig : normalAgentSoulConfig)
}
modeRef.current = nextMode
onModeChange(nextMode)
},
[
buildDraftAgentSoulConfig,
discardBuildDraftAndSwitchToPreview,
hasActiveBuildDraft,
hasStartedBuildChat,
isBuildDraftActive,
normalAgentSoulConfig,
onModeChange,
registerPreviewDraftSave,
rotateBuildCallbackGeneration,
],
)
return {
buildCallbackGeneration,
buildDraftActionsDisabled,
changeMode,
confirmSwitchToPreview: discardBuildDraftAndSwitchToPreview,
finishBuildAction,
isEnteringBuildMode,
isBuildCallbackCurrent,
resetBuildSession,
resetBuildSessionState,
runBuildPreparation,
setShowSwitchToPreviewConfirm,
showSwitchToPreviewConfirm,
waitForPendingPreviewDraftSave,
}
}

View File

@ -52,6 +52,7 @@ export function useAgentConfigureSync({
const enabledRef = useRef(enabled)
const lastAutosavedDraftKeyRef = useRef<string | undefined>(undefined)
const pageCloseSavingDraftKeyRef = useRef<string | undefined>(undefined)
const explicitlySavingDraftKeysRef = useRef(new Set<string>())
const publishInFlightRef = useRef(false)
baseConfigRef.current = baseConfig
@ -153,11 +154,17 @@ export function useAgentConfigureSync({
debouncedSaveDraft.cancel?.()
if (!store.get(isAgentComposerDirtyAtom) && !hasEffectiveModelChange) return
await saveComposer({
configSnapshot,
draftBaseline: draft,
silent: false,
})
const draftKey = JSON.stringify(configSnapshot)
explicitlySavingDraftKeysRef.current.add(draftKey)
try {
await saveComposer({
configSnapshot,
draftBaseline: draft,
silent: false,
})
} finally {
explicitlySavingDraftKeysRef.current.delete(draftKey)
}
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store])
const saveDirtyDraftOnPageClose = useCallback(() => {
@ -174,7 +181,8 @@ export function useAgentConfigureSync({
const draftKey = JSON.stringify(configSnapshot)
if (
lastAutosavedDraftKeyRef.current === draftKey ||
pageCloseSavingDraftKeyRef.current === draftKey
pageCloseSavingDraftKeyRef.current === draftKey ||
explicitlySavingDraftKeysRef.current.has(draftKey)
) {
return
}

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "تحميل مهارة",
"agentDetail.configure.skills.upload.warning.files": "إذا كنت تحتاج فقط إلى استخدام ملفات Markdown، فحمّلها إلى قسم الملفات وأشر إليها في الموجّه الخاص بك.",
"agentDetail.configure.skills.upload.warning.specification": "يجب أن تتوافق المهارات التي يتم تحميلها مع <specificationLink>مواصفات Agent Skills</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "التبديل إلى وضع المعاينة؟",
"agentDetail.configure.title": "تكوين",
"agentDetail.configure.tools.add": "إضافة أداة",
"agentDetail.configure.tools.addMenu.cliTool.badge": "للمطورين",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Skill hochladen",
"agentDetail.configure.skills.upload.warning.files": "Wenn du nur Markdown-Dateien verwenden möchtest, lade sie unter „Dateien“ hoch und verweise in deinem Prompt darauf.",
"agentDetail.configure.skills.upload.warning.specification": "Hochgeladene Skills müssen der <specificationLink>Agent-Skills-Spezifikation</specificationLink> entsprechen.",
"agentDetail.configure.switchToPreviewConfirm.title": "In den Vorschaumodus wechseln?",
"agentDetail.configure.title": "Konfigurieren",
"agentDetail.configure.tools.add": "Tool hinzufügen",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Für Entwickler",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Upload skill",
"agentDetail.configure.skills.upload.warning.files": "If you only need to use Markdown files, upload them to Files and reference them in your prompt.",
"agentDetail.configure.skills.upload.warning.specification": "Uploaded skills must follow the <specificationLink>Agent Skills specification</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "Switch to Preview mode?",
"agentDetail.configure.title": "Configure",
"agentDetail.configure.tools.add": "Add tool",
"agentDetail.configure.tools.addMenu.cliTool.badge": "For developers",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Subir habilidad",
"agentDetail.configure.skills.upload.warning.files": "Si solo necesitas usar archivos Markdown, súbelos a Archivos y haz referencia a ellos en tu prompt.",
"agentDetail.configure.skills.upload.warning.specification": "Las habilidades subidas deben cumplir la <specificationLink>especificación de Agent Skills</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "¿Cambiar al modo de vista previa?",
"agentDetail.configure.title": "Configurar",
"agentDetail.configure.tools.add": "Agregar herramienta",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Para desarrolladores",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "بارگذاری مهارت",
"agentDetail.configure.skills.upload.warning.files": "اگر فقط می‌خواهید از فایل‌های Markdown استفاده کنید، آن‌ها را در بخش فایل‌ها بارگذاری و در پرامپت خود ارجاع دهید.",
"agentDetail.configure.skills.upload.warning.specification": "مهارت‌های بارگذاری‌شده باید از <specificationLink>مشخصات Agent Skills</specificationLink> پیروی کنند.",
"agentDetail.configure.switchToPreviewConfirm.title": "به حالت پیش‌نمایش بروید؟",
"agentDetail.configure.title": "پیکربندی",
"agentDetail.configure.tools.add": "افزودن ابزار",
"agentDetail.configure.tools.addMenu.cliTool.badge": "برای توسعه‌دهندگان",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Téléverser une compétence",
"agentDetail.configure.skills.upload.warning.files": "Si vous souhaitez uniquement utiliser des fichiers Markdown, téléversez-les dans Fichiers et référencez-les dans votre prompt.",
"agentDetail.configure.skills.upload.warning.specification": "Les compétences téléversées doivent respecter la <specificationLink>spécification Agent Skills</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "Passer en mode aperçu ?",
"agentDetail.configure.title": "Configurer",
"agentDetail.configure.tools.add": "Ajouter un outil",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Pour les développeurs",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "कौशल अपलोड करें",
"agentDetail.configure.skills.upload.warning.files": "यदि आपको केवल Markdown फ़ाइलों का उपयोग करना है, तो उन्हें फ़ाइलें में अपलोड करें और अपने प्रॉम्प्ट में उनका संदर्भ दें।",
"agentDetail.configure.skills.upload.warning.specification": "अपलोड किए गए कौशल को <specificationLink>Agent Skills विनिर्देश</specificationLink> का पालन करना चाहिए।",
"agentDetail.configure.switchToPreviewConfirm.title": "प्रीव्यू मोड पर स्विच करें?",
"agentDetail.configure.title": "कॉन्फ़िगर करें",
"agentDetail.configure.tools.add": "उपकरण जोड़ें",
"agentDetail.configure.tools.addMenu.cliTool.badge": "डेवलपर्स के लिए",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Unggah keterampilan",
"agentDetail.configure.skills.upload.warning.files": "Jika Anda hanya perlu menggunakan file Markdown, unggah ke File dan rujuk file tersebut dalam prompt Anda.",
"agentDetail.configure.skills.upload.warning.specification": "Keterampilan yang diunggah harus mengikuti <specificationLink>spesifikasi Agent Skills</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "Beralih ke mode Pratinjau?",
"agentDetail.configure.title": "Konfigurasi",
"agentDetail.configure.tools.add": "Tambahkan alat",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Untuk pengembang",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Carica abilità",
"agentDetail.configure.skills.upload.warning.files": "Se devi usare solo file Markdown, caricali in File e richiamali nel tuo prompt.",
"agentDetail.configure.skills.upload.warning.specification": "Le abilità caricate devono rispettare la <specificationLink>specifica Agent Skills</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "Passare alla modalità Anteprima?",
"agentDetail.configure.title": "Configura",
"agentDetail.configure.tools.add": "Aggiungi strumento",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Per sviluppatori",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "スキルをアップロード",
"agentDetail.configure.skills.upload.warning.files": "Markdown ファイルのみを使用する場合は、「ファイル」にアップロードし、プロンプト内で参照してください。",
"agentDetail.configure.skills.upload.warning.specification": "アップロードするスキルは <specificationLink>Agent Skills 仕様</specificationLink>に準拠する必要があります。",
"agentDetail.configure.switchToPreviewConfirm.title": "プレビューモードに切り替えますか?",
"agentDetail.configure.title": "設定",
"agentDetail.configure.tools.add": "ツールを追加",
"agentDetail.configure.tools.addMenu.cliTool.badge": "開発者向け",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "스킬 업로드",
"agentDetail.configure.skills.upload.warning.files": "Markdown 파일만 사용하려면 파일에 업로드하고 프롬프트에서 참조하세요.",
"agentDetail.configure.skills.upload.warning.specification": "업로드한 스킬은 <specificationLink>Agent Skills 사양</specificationLink>을 준수해야 합니다.",
"agentDetail.configure.switchToPreviewConfirm.title": "미리보기 모드로 전환할까요?",
"agentDetail.configure.title": "구성",
"agentDetail.configure.tools.add": "도구 추가",
"agentDetail.configure.tools.addMenu.cliTool.badge": "개발자용",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Skill uploaden",
"agentDetail.configure.skills.upload.warning.files": "Als je alleen Markdown-bestanden wilt gebruiken, upload ze dan naar Bestanden en verwijs ernaar in je prompt.",
"agentDetail.configure.skills.upload.warning.specification": "Geüploade skills moeten voldoen aan de <specificationLink>Agent Skills-specificatie</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "Overschakelen naar de voorbeeldmodus?",
"agentDetail.configure.title": "Configureren",
"agentDetail.configure.tools.add": "Tool toevoegen",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Voor ontwikkelaars",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Prześlij umiejętność",
"agentDetail.configure.skills.upload.warning.files": "Jeśli chcesz używać tylko plików Markdown, prześlij je do sekcji Pliki i odwołaj się do nich w swoim prompcie.",
"agentDetail.configure.skills.upload.warning.specification": "Przesyłane umiejętności muszą być zgodne ze <specificationLink>specyfikacją Agent Skills</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "Przełączyć się do trybu podglądu?",
"agentDetail.configure.title": "Konfiguruj",
"agentDetail.configure.tools.add": "Dodaj narzędzie",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Dla deweloperów",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Enviar habilidade",
"agentDetail.configure.skills.upload.warning.files": "Se você só precisa usar arquivos Markdown, envie-os para Arquivos e faça referência a eles no seu prompt.",
"agentDetail.configure.skills.upload.warning.specification": "As habilidades enviadas devem seguir a <specificationLink>especificação Agent Skills</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "Mudar para o modo de visualização?",
"agentDetail.configure.title": "Configurar",
"agentDetail.configure.tools.add": "Adicionar ferramenta",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Para desenvolvedores",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Încarcă abilitate",
"agentDetail.configure.skills.upload.warning.files": "Dacă trebuie doar să folosești fișiere Markdown, încarcă-le în Fișiere și menționează-le în promptul tău.",
"agentDetail.configure.skills.upload.warning.specification": "Abilitățile încărcate trebuie să respecte <specificationLink>specificația Agent Skills</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "Comuți la modul Previzualizare?",
"agentDetail.configure.title": "Configurare",
"agentDetail.configure.tools.add": "Adaugă instrument",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Pentru dezvoltatori",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Загрузить навык",
"agentDetail.configure.skills.upload.warning.files": "Если вам нужно использовать только файлы Markdown, загрузите их в раздел «Файлы» и укажите ссылки на них в своем промпте.",
"agentDetail.configure.skills.upload.warning.specification": "Загружаемые навыки должны соответствовать <specificationLink>спецификации Agent Skills</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "Перейти в режим предпросмотра?",
"agentDetail.configure.title": "Настроить",
"agentDetail.configure.tools.add": "Добавить инструмент",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Для разработчиков",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Naloži veščino",
"agentDetail.configure.skills.upload.warning.files": "Če želite uporabljati samo datoteke Markdown, jih naložite v razdelek Datoteke in se nanje sklicujte v svojem pozivu.",
"agentDetail.configure.skills.upload.warning.specification": "Naložene veščine morajo upoštevati <specificationLink>specifikacijo Agent Skills</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "Preklopiti v način predogleda?",
"agentDetail.configure.title": "Konfiguriraj",
"agentDetail.configure.tools.add": "Dodaj orodje",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Za razvijalce",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "อัปโหลดทักษะ",
"agentDetail.configure.skills.upload.warning.files": "หากต้องการใช้เฉพาะไฟล์ Markdown ให้อัปโหลดไปยังส่วนไฟล์และอ้างอิงไฟล์เหล่านั้นในพรอมต์ของคุณ",
"agentDetail.configure.skills.upload.warning.specification": "ทักษะที่อัปโหลดต้องเป็นไปตาม<specificationLink>ข้อกำหนด Agent Skills</specificationLink>",
"agentDetail.configure.switchToPreviewConfirm.title": "สลับไปยังโหมดแสดงตัวอย่าง?",
"agentDetail.configure.title": "กำหนดค่า",
"agentDetail.configure.tools.add": "เพิ่มเครื่องมือ",
"agentDetail.configure.tools.addMenu.cliTool.badge": "สำหรับนักพัฒนา",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Beceri yükle",
"agentDetail.configure.skills.upload.warning.files": "Yalnızca Markdown dosyalarını kullanmanız gerekiyorsa bunları Dosyalar bölümüne yükleyin ve isteminizde referans verin.",
"agentDetail.configure.skills.upload.warning.specification": "Yüklenen beceriler <specificationLink>Agent Skills spesifikasyonuna</specificationLink> uygun olmalıdır.",
"agentDetail.configure.switchToPreviewConfirm.title": "Önizleme moduna geçilsin mi?",
"agentDetail.configure.title": "Yapılandır",
"agentDetail.configure.tools.add": "Araç ekle",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Geliştiriciler için",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Завантажити навичку",
"agentDetail.configure.skills.upload.warning.files": "Якщо потрібно використовувати лише файли Markdown, завантажте їх у розділ «Файли» та посилайтеся на них у своєму промпті.",
"agentDetail.configure.skills.upload.warning.specification": "Завантажувані навички мають відповідати <specificationLink>специфікації Agent Skills</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "Перейти в режим попереднього перегляду?",
"agentDetail.configure.title": "Налаштувати",
"agentDetail.configure.tools.add": "Додати інструмент",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Для розробників",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "Tải lên kỹ năng",
"agentDetail.configure.skills.upload.warning.files": "Nếu bạn chỉ cần sử dụng tệp Markdown, hãy tải chúng lên Tệp và tham chiếu chúng trong prompt của bạn.",
"agentDetail.configure.skills.upload.warning.specification": "Kỹ năng được tải lên phải tuân theo <specificationLink>đặc tả Agent Skills</specificationLink>.",
"agentDetail.configure.switchToPreviewConfirm.title": "Chuyển sang chế độ Xem trước?",
"agentDetail.configure.title": "Cấu hình",
"agentDetail.configure.tools.add": "Thêm công cụ",
"agentDetail.configure.tools.addMenu.cliTool.badge": "Dành cho nhà phát triển",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "上传 Skill",
"agentDetail.configure.skills.upload.warning.files": "如果只需要使用 Markdown 文件,请将它们上传到「文件」,并在你的提示词中引用。",
"agentDetail.configure.skills.upload.warning.specification": "上传的 Skill 需遵循 <specificationLink>Agent Skills 规范</specificationLink>。",
"agentDetail.configure.switchToPreviewConfirm.title": "切换到预览模式?",
"agentDetail.configure.title": "配置",
"agentDetail.configure.tools.add": "添加工具",
"agentDetail.configure.tools.addMenu.cliTool.badge": "开发者适用",

View File

@ -226,6 +226,7 @@
"agentDetail.configure.skills.upload.title": "上傳 Skill",
"agentDetail.configure.skills.upload.warning.files": "如果只需要使用 Markdown 檔案,請將它們上傳到「檔案」,並在你的提示詞中引用。",
"agentDetail.configure.skills.upload.warning.specification": "上傳的 Skill 需遵循 <specificationLink>Agent Skills 規範</specificationLink>。",
"agentDetail.configure.switchToPreviewConfirm.title": "切換到預覽模式?",
"agentDetail.configure.title": "設定",
"agentDetail.configure.tools.add": "新增工具",
"agentDetail.configure.tools.addMenu.cliTool.badge": "開發者適用",

View File

@ -458,6 +458,47 @@ describe('ssePost and sseGet', () => {
expect(onCompleted).toHaveBeenCalledWith(true, 'Error: stream lost')
expect(toast.error).toHaveBeenCalledWith('Error: stream lost')
})
it('should not notify when the stream reader is aborted', async () => {
const onError = vi.fn()
const onCompleted = vi.fn()
const mockReader = {
read: vi
.fn()
.mockRejectedValueOnce(new DOMException('BodyStreamBuffer was aborted', 'AbortError')),
}
const response = {
status: 200,
ok: true,
body: {
getReader: () => mockReader,
},
} as unknown as Response
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(response)
await ssePost(
'/chat-messages',
{
body: {
query: 'hello',
},
},
{
onError,
onCompleted,
},
)
await waitFor(() => {
expect(onError).toHaveBeenCalledWith(
'AbortError: BodyStreamBuffer was aborted',
'stream_read_error',
)
})
expect(onCompleted).toHaveBeenCalledWith(true, 'AbortError: BodyStreamBuffer was aborted')
expect(toast.error).not.toHaveBeenCalled()
})
})
describe('HTTP methods', () => {

View File

@ -49,6 +49,17 @@ import { getWebAppPassport } from './webapp-auth'
const TIME_OUT = 100000
const isAbortError = (error: unknown) => {
if (typeof error === 'string') return error === 'AbortError' || error.startsWith('AbortError:')
return (
typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError'
)
}
const shouldNotifyStreamError = (error: unknown) =>
!isAbortError(error) && !String(error).includes('TypeError: Cannot assign to read only property')
export type IOnDataMoreInfo = {
event?: string
conversationId?: string
@ -618,12 +629,8 @@ export const ssePost = async (
(str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
if (moreInfo.errorMessage) {
onError?.(moreInfo.errorMessage, moreInfo.errorCode)
// TypeError: Cannot assign to read only property ... will happen in page leave, so it should be ignored.
if (
moreInfo.errorMessage !== 'AbortError: The user aborted a request.' &&
!moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property')
)
toast.error(moreInfo.errorMessage)
// These errors can happen when a stream is intentionally stopped or its page is left.
if (shouldNotifyStreamError(moreInfo.errorMessage)) toast.error(moreInfo.errorMessage)
return
}
onData?.(str, isFirstMessage, moreInfo)
@ -664,11 +671,7 @@ export const ssePost = async (
})
.catch((e) => {
const errorMessage = String(e)
if (
errorMessage !== 'AbortError: The user aborted a request.' &&
!errorMessage.includes('TypeError: Cannot assign to read only property')
)
toast.error(errorMessage)
if (shouldNotifyStreamError(e)) toast.error(errorMessage)
onError?.(errorMessage)
})
}
@ -781,12 +784,8 @@ export const sseGet = async (
(str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
if (moreInfo.errorMessage) {
onError?.(moreInfo.errorMessage, moreInfo.errorCode)
// TypeError: Cannot assign to read only property ... will happen in page leave, so it should be ignored.
if (
moreInfo.errorMessage !== 'AbortError: The user aborted a request.' &&
!moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property')
)
toast.error(moreInfo.errorMessage)
// These errors can happen when a stream is intentionally stopped or its page is left.
if (shouldNotifyStreamError(moreInfo.errorMessage)) toast.error(moreInfo.errorMessage)
return
}
onData?.(str, isFirstMessage, moreInfo)
@ -827,11 +826,7 @@ export const sseGet = async (
})
.catch((e) => {
const errorMessage = String(e)
if (
errorMessage !== 'AbortError: The user aborted a request.' &&
!errorMessage.includes('TypeError: Cannot assign to read only property')
)
toast.error(errorMessage)
if (shouldNotifyStreamError(e)) toast.error(errorMessage)
onError?.(errorMessage)
})
}