chore: inline agent in agent node

This commit is contained in:
Joel 2026-06-25 18:16:03 +08:00
parent 0d7d1704f7
commit 855ec62fb2
7 changed files with 247 additions and 48 deletions

View File

@ -348,4 +348,52 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
}),
}))
})
it('still saves manually when inline agent autosave is disabled', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
const { result } = renderWorkflowHook(() => useWorkflowInlineAgentConfigureSync({
nodeId: 'node-1',
baseConfig: {
schema_version: 1,
},
autoSaveEnabled: false,
enabled: true,
}), {
queryClient,
hooksStoreProps: {
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
},
},
})
await act(async () => {
await result.current.saveDraft()
})
expect(mockComposerMutationFn).toHaveBeenCalledWith({
params: {
app_id: 'app-1',
node_id: 'node-1',
},
body: expect.objectContaining({
variant: 'workflow',
save_strategy: 'node_job_only',
agent_soul: expect.objectContaining({
schema_version: 1,
}),
}),
}, expect.any(Object))
})
})

View File

@ -20,6 +20,7 @@ const {
mockSetInputs,
mockStoreState,
mockUseAgentRosterDetail,
mockWorkflowInlineAgentDetailRefetch,
mockUseWorkflowInlineAgentDetail,
mockUseNodeCrud,
} = vi.hoisted(() => ({
@ -48,6 +49,7 @@ const {
setOpenInlineAgentPanelNodeId: vi.fn(),
},
mockUseAgentRosterDetail: vi.fn(),
mockWorkflowInlineAgentDetailRefetch: vi.fn(),
mockUseWorkflowInlineAgentDetail: vi.fn(),
mockUseNodeCrud: vi.fn(),
}))
@ -188,12 +190,39 @@ vi.mock('../components/agent-orchestrate-drawer-panel', () => ({
inlineComposerState?: unknown
isInline: boolean
nodeId: string
onClose?: () => void
onSaved?: (binding: {
agent_id?: string | null
binding_type: 'inline_agent' | 'roster_agent'
current_snapshot_id?: string | null
}) => void
onSaveInlineToRoster?: () => void
open: boolean
}) => {
mockOrchestrateDrawerPanelProps.push(props)
return (
<div role="region" aria-label="inline-orchestrate-panel" />
<div role="region" aria-label="inline-orchestrate-panel">
<button
type="button"
onClick={props.onSaveInlineToRoster}
>
Inline workspace more
</button>
<button
type="button"
onClick={() => {
props.onSaved?.({
binding_type: 'inline_agent',
agent_id: 'inline-agent-1',
current_snapshot_id: 'latest-snapshot-2',
})
props.onClose?.()
}}
>
Save inline modal
</button>
</div>
)
},
}))
@ -339,6 +368,8 @@ describe('agent/panel', () => {
},
}
: undefined,
isFetching: false,
refetch: mockWorkflowInlineAgentDetailRefetch,
}))
})
@ -586,6 +617,7 @@ describe('agent/panel', () => {
fireEvent.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }))
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node')
expect(mockWorkflowInlineAgentDetailRefetch).toHaveBeenCalled()
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
rerender(
@ -612,7 +644,7 @@ describe('agent/panel', () => {
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
})
it('opens save-to-roster action from the inline drawer menu and rebinds to the saved roster agent', () => {
it('opens save-to-roster action from the inline workspace menu and rebinds to the saved roster agent', () => {
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
render(
<AgentV2Panel
@ -629,8 +661,8 @@ describe('agent/panel', () => {
)
const panel = screen.getByRole('dialog', { name: 'Workflow Agent 1' })
fireEvent.click(within(panel).getByRole('button', { name: 'workflow.nodes.agent.roster.more' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'agentV2.roster.saveToRoster' }))
expect(within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.more' })).not.toBeInTheDocument()
fireEvent.click(within(panel).getByRole('button', { name: 'Inline workspace more' }))
fireEvent.click(screen.getByRole('button', { name: 'Save inline agent to roster', hidden: true }))
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith(undefined)
@ -651,6 +683,43 @@ describe('agent/panel', () => {
)
})
it('updates the inline binding snapshot from the modal save response before closing', () => {
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
render(
<AgentV2Panel
id="agent-node"
data={createData({
agent_binding: {
binding_type: 'inline_agent',
agent_id: 'inline-agent-1',
current_snapshot_id: 'snapshot-1',
},
})}
panelProps={panelProps}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Save inline modal' }))
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
{
id: 'agent-node',
data: expect.objectContaining({
agent_binding: {
binding_type: 'inline_agent',
agent_id: 'inline-agent-1',
current_snapshot_id: 'latest-snapshot-2',
},
}),
},
expect.objectContaining({
sync: true,
notRefreshWhenSyncError: true,
}),
)
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith(undefined)
})
it('does not show start from scratch for an existing inline agent binding', () => {
render(
<AgentV2Panel
@ -673,7 +742,11 @@ describe('agent/panel', () => {
it('opens the inline panel while workflow composer state is still loading', () => {
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
mockUseWorkflowInlineAgentDetail.mockReturnValue({ data: undefined })
mockUseWorkflowInlineAgentDetail.mockReturnValue({
data: undefined,
isFetching: true,
refetch: mockWorkflowInlineAgentDetailRefetch,
})
const { container } = render(
<AgentV2Panel

View File

@ -1,4 +1,4 @@
import type { AgentSoulConfig } from '@dify/contracts/api/console/apps/types.gen'
import type { AgentSoulConfig, WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { DefaultModelResponse } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { debounce } from 'es-toolkit/compat'
@ -54,6 +54,7 @@ export function useWorkflowInlineAgentConfigureSync({
nodeId,
baseConfig,
currentModel,
autoSaveEnabled = true,
enabled,
}: {
nodeId: string
@ -63,6 +64,7 @@ export function useWorkflowInlineAgentConfigureSync({
model: string
plugin_id?: string
}
autoSaveEnabled?: boolean
enabled: boolean
}) {
const queryClient = useQueryClient()
@ -89,7 +91,7 @@ export function useWorkflowInlineAgentConfigureSync({
currentModel: currentModelRef.current,
}), [store])
const saveComposer = useSerialAsyncCallback(async (configSnapshot: AgentSoulConfig) => {
const saveComposer = useSerialAsyncCallback(async (configSnapshot: AgentSoulConfig): Promise<WorkflowAgentComposerResponse | undefined> => {
if (!configsMap?.flowId || configsMap.flowType !== FlowType.appFlow)
return
@ -121,6 +123,7 @@ export function useWorkflowInlineAgentConfigureSync({
setOriginalDraft(agentSoulConfigToFormState(composerState.agent_soul))
setDraftSavedAt(Date.now())
lastAutosavedDraftKeyRef.current = savedDraftKey
return composerState
})
const latestDraftSaveRef = useRef<() => void>(() => undefined)
@ -137,7 +140,7 @@ export function useWorkflowInlineAgentConfigureSync({
return
debouncedSaveDraft.cancel?.()
await saveComposer(getAgentSoulDraft())
return saveComposer(getAgentSoulDraft())
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer])
useEffect(() => {
@ -147,6 +150,7 @@ export function useWorkflowInlineAgentConfigureSync({
if (
!enabledRef.current
|| !autoSaveEnabled
|| !store.get(isAgentComposerDirtyAtom)
|| lastAutosavedDraftKeyRef.current === agentSoulDraftKey
) {
@ -155,13 +159,14 @@ export function useWorkflowInlineAgentConfigureSync({
debouncedSaveDraft()
})
}, [debouncedSaveDraft, getAgentSoulDraft, store])
}, [autoSaveEnabled, debouncedSaveDraft, getAgentSoulDraft, store])
useEffect(() => {
return () => {
debouncedSaveDraft.flush?.()
if (autoSaveEnabled)
debouncedSaveDraft.flush?.()
}
}, [debouncedSaveDraft])
}, [autoSaveEnabled, debouncedSaveDraft])
return {
draftSavedAt,

View File

@ -1,8 +1,14 @@
'use client'
import type { AgentConfigSnapshotSummaryResponse, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
import type { WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { AgentComposerBindingResponse, WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useAtom } from 'jotai'
import { useState } from 'react'
@ -29,6 +35,8 @@ type AgentOrchestrateDrawerPanelProps = {
isInline: boolean
nodeId: string
onClose?: () => void
onSaved?: (binding: AgentComposerBindingResponse) => void
onSaveInlineToRoster?: () => void
open: boolean
}
@ -122,8 +130,11 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
inlineComposerState,
nodeId,
onClose,
onSaved,
onSaveInlineToRoster,
open,
}: AgentOrchestrateDrawerPanelProps) {
const { t } = useTranslation()
const [clearChatList, setClearChatList] = useState(false)
const [conversationId, setConversationId] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
@ -137,6 +148,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
nodeId,
baseConfig: agentSoulConfig,
currentModel,
autoSaveEnabled: false,
enabled: open && !!agentSoulConfig,
})
const previewAgentSoulConfig = useAgentPreviewSoulConfig(agentSoulConfig as AgentSoulConfig | undefined)
@ -161,7 +173,17 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
setIsSaving(true)
try {
await saveDraft()
const composerState = await saveDraft()
const binding = composerState?.binding
if (
binding?.binding_type !== 'inline_agent'
|| !binding.agent_id
|| !binding.current_snapshot_id
) {
return
}
onSaved?.(binding)
onClose?.()
}
finally {
@ -188,6 +210,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
<WorkflowInlineAgentConfigureActionBar
isSaving={isSaving}
onCancel={() => onClose?.()}
onSaveInlineToRoster={onSaveInlineToRoster}
onSave={() => {
void handleSave()
}}
@ -217,6 +240,16 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
setClearChatList(true)
}}
showChatFeaturesAction={false}
trailingAction={(
<button
type="button"
onClick={onClose}
className="flex size-8 items-center justify-center rounded-lg 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"
aria-label={t('operation.close', { ns: 'common' })}
>
<span aria-hidden className="i-ri-close-line size-4" />
</button>
)}
/>
)}
chat={(
@ -232,7 +265,6 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
draftType="debug_build"
onClearChatListChange={setClearChatList}
onConversationIdChange={setConversationId}
onSaveDraftBeforeRun={saveDraft}
/>
)}
/>
@ -244,10 +276,12 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
function WorkflowInlineAgentConfigureActionBar({
isSaving,
onCancel,
onSaveInlineToRoster,
onSave,
}: {
isSaving: boolean
onCancel: () => void
onSaveInlineToRoster?: () => void
onSave: () => void
}) {
const { t } = useTranslation('common')
@ -268,16 +302,28 @@ function WorkflowInlineAgentConfigureActionBar({
<div className="flex h-4 items-start px-1">
<div className="h-full w-px bg-divider-regular" />
</div>
<Button
type="button"
variant="secondary"
size="medium"
className="px-2"
disabled={isSaving}
aria-label={t('operation.more')}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={(
<Button
type="button"
variant="secondary"
size="medium"
className="px-2"
disabled={isSaving || !onSaveInlineToRoster}
aria-label={t('operation.more')}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</Button>
)}
/>
<DropdownMenuContent placement="top" sideOffset={4} popupClassName="min-w-44 w-max">
<DropdownMenuItem className="gap-2 whitespace-nowrap" onClick={onSaveInlineToRoster}>
<span aria-hidden className="i-ri-inbox-archive-line size-4 shrink-0 text-text-tertiary" />
<span>{t('roster.saveToRoster', { ns: 'agentV2' })}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="primary"

View File

@ -3,7 +3,7 @@ import type { AgentRosterNodeData } from '@/app/components/workflow/block-select
import type { AppIconType } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import {
Drawer,
DrawerCloseButton,
@ -247,13 +247,11 @@ function AgentRosterDrawer({
function AgentRosterInlineConfigureDialog({
agent,
children,
onSaveInlineToRoster,
open,
onClose,
}: {
agent: AgentRosterDisplayData
children?: ReactNode
onSaveInlineToRoster?: () => void
open: boolean
onClose: () => void
}) {
@ -269,23 +267,6 @@ function AgentRosterInlineConfigureDialog({
disablePointerDismissal
>
<DialogContent className="h-[min(760px,calc(100dvh-32px))] w-[min(1120px,calc(100vw-32px))] max-w-none overflow-hidden p-0">
<DialogCloseButton className="z-10" />
{onSaveInlineToRoster && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t(`${i18nPrefix}.roster.more`, { ns: 'workflow' })}
className="absolute top-3 right-12 z-10 flex size-6 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44 w-max">
<DropdownMenuItem className="gap-2 whitespace-nowrap" onClick={onSaveInlineToRoster}>
<span aria-hidden className="i-ri-inbox-archive-line size-4 shrink-0 text-text-tertiary" />
<span>{t('roster.saveToRoster', { ns: 'agentV2' })}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<DialogTitle className="sr-only">
{agent.name}
</DialogTitle>
@ -441,7 +422,6 @@ export function AgentRosterField({
? (
<AgentRosterInlineConfigureDialog
agent={agent}
onSaveInlineToRoster={onSaveInlineToRoster}
open={panelOpen}
onClose={() => setPanelOpen(false)}
>

View File

@ -58,10 +58,12 @@ export function AgentV2Panel({
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.copyFromRoster.post.mutationOptions(),
)
const isAgentPanelOpen = isInlineAgentReady || isInlineAgentPending ? isInlineAgentPanelOpen : isRosterAgentPanelOpen
const isInlineAgentLoading = isInlineAgentPending || (isInlineAgentReady && !inlineAgent)
const isInlineAgentDetailFetchingForPanel = isInlineAgentPanelOpen && inlineAgentQuery.isFetching
const isInlineAgentLoading = isInlineAgentPending || (isInlineAgentReady && (!inlineAgent || isInlineAgentDetailFetchingForPanel))
const isAgentBindingPending = isInlineAgentPending || isCreatingInlineAgent
const canStartFromScratch = inputs.agent_binding?.binding_type !== 'inline_agent'
const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent
const inlineComposerStateForPanel = isInlineAgentDetailFetchingForPanel ? undefined : inlineAgentQuery.data
const displayedAgent = rosterAgentQuery.data ?? (isInlineAgentPending || isInlineAgentReady
? {
id: inlineAgentId ?? id,
@ -234,6 +236,37 @@ export function AgentV2Panel({
)
}, [handleNodeDataUpdateWithSyncDraft, id])
const handleInlineAgentSaved = useCallback((binding: AgentComposerBindingResponse) => {
if (
binding.binding_type !== 'inline_agent'
|| !binding.agent_id
|| !binding.current_snapshot_id
) {
return
}
const newInputs = produce(inputsRef.current, (draft) => {
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
delete draft._openInlineAgentPanel
draft.agent_binding = {
binding_type: 'inline_agent',
agent_id: binding.agent_id,
current_snapshot_id: binding.current_snapshot_id,
}
})
inputsRef.current = newInputs
handleNodeDataUpdateWithSyncDraft(
{
id,
data: newInputs,
},
{
sync: true,
notRefreshWhenSyncError: true,
},
)
}, [handleNodeDataUpdateWithSyncDraft, id])
const handleStartFromScratch = useCallback(() => {
setIsRosterAgentPanelOpen(false)
setIsInlineAgentPanelOpenedFromTrigger(false)
@ -269,6 +302,9 @@ export function AgentV2Panel({
setIsInlineAgentPanelOpenedFromTrigger(true)
setOpenInlineAgentPanelNodeId(open ? id : undefined)
if (open && isInlineAgentReady)
void inlineAgentQuery.refetch()
if (open && isInlineAgentPending && !isCreatingInlineAgent) {
createInlineAgentBinding(id, {
onSuccess: handleInlineAgentBindingCreated,
@ -278,7 +314,7 @@ export function AgentV2Panel({
}
setIsRosterAgentPanelOpen(open)
}, [createInlineAgentBinding, handleInlineAgentBindingCreated, id, isCreatingInlineAgent, isInlineAgentPending, isInlineAgentReady, setOpenInlineAgentPanelNodeId])
}, [createInlineAgentBinding, handleInlineAgentBindingCreated, id, inlineAgentQuery, isCreatingInlineAgent, isInlineAgentPending, isInlineAgentReady, setOpenInlineAgentPanelNodeId])
const handleDeclaredOutputsChange = useCallback((outputs: ReturnType<typeof getAgentV2DeclaredOutputs>, agentTask?: string) => {
const previousOutputs = getAgentV2DeclaredOutputs(inputsRef.current)
@ -338,10 +374,12 @@ export function AgentV2Panel({
<WorkflowInlineAgentConfigureWorkspace
agentId={inlineAgentId ?? undefined}
appId={appId}
inlineComposerState={inlineAgentQuery.data}
inlineComposerState={inlineComposerStateForPanel}
isInline
nodeId={id}
onClose={() => handleAgentPanelOpenChange(false)}
onSaved={handleInlineAgentSaved}
onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined}
open={isAgentPanelOpen}
/>
)

View File

@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { SegmentedControl, SegmentedControlDivider, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { useTranslation } from 'react-i18next'
@ -36,6 +37,7 @@ export function AgentPreviewHeader({
onRefresh,
refreshDisabled,
showChatFeaturesAction = true,
trailingAction,
}: {
mode: AgentConfigureRightPanelMode
previewEnabled: boolean
@ -46,6 +48,7 @@ export function AgentPreviewHeader({
onRefresh: () => void
refreshDisabled?: boolean
showChatFeaturesAction?: boolean
trailingAction?: ReactNode
}) {
const { t } = useTranslation('agentV2')
const docLink = useDocLink()
@ -149,6 +152,12 @@ export function AgentPreviewHeader({
</button>
</>
)}
{trailingAction && (
<>
<SegmentedControlDivider className="mx-3" />
{trailingAction}
</>
)}
</div>
</div>
)