feat(agent-v2): wire workflow agent copy and roster save

This commit is contained in:
yyh 2026-06-23 21:31:49 +08:00
parent 673d84073b
commit 7ef6c13d0d
No known key found for this signature in database
38 changed files with 1024 additions and 14 deletions

View File

@ -298,6 +298,10 @@ class AgentComposerAgentResponse(ResponseModel):
id: str
name: str
description: str
role: str | None = None
icon_type: str | None = None
icon: str | None = None
icon_background: str | None = None
scope: AgentScope
status: AgentStatus
active_config_snapshot_id: str | None = None

View File

@ -1450,6 +1450,10 @@ class AgentComposerService:
"id": agent.id,
"name": agent.name,
"description": agent.description,
"role": agent.role,
"icon_type": agent.icon_type,
"icon": agent.icon,
"icon_background": agent.icon_background,
"scope": agent.scope.value,
"status": agent.status.value,
"active_config_snapshot_id": agent.active_config_snapshot_id,

View File

@ -370,13 +370,27 @@ def test_serialize_workflow_state_changes_lock_and_save_options(monkeypatch: pyt
node_id="node-1",
node_job_config='{"workflow_prompt":"do work"}',
)
agent = Agent(id="agent-1", name="Analyst", description="", scope=AgentScope.ROSTER, status=AgentStatus.ACTIVE)
agent = Agent(
id="agent-1",
name="Analyst",
description="Clarifies tenders",
role="Tender Analyst",
icon_type="emoji",
icon="robot",
icon_background="#F5F3FF",
scope=AgentScope.ROSTER,
status=AgentStatus.ACTIVE,
)
version = AgentConfigSnapshot(id="version-1", version=1, config_snapshot='{"prompt":{"system_prompt":"x"}}')
monkeypatch.setattr(AgentComposerService, "calculate_impact", lambda **kwargs: {"workflow_node_count": 1})
state = AgentComposerService._serialize_workflow_state(binding=binding, agent=agent, version=version)
assert state["soul_lock"]["locked"] is True
assert state["agent"]["role"] == "Tender Analyst"
assert state["agent"]["icon_type"] == "emoji"
assert state["agent"]["icon"] == "robot"
assert state["agent"]["icon_background"] == "#F5F3FF"
assert "save_as_new_version" in state["save_options"]
assert state["agent_soul"]["app_features"] == {}
# Stage 4 §10.1 (D-3): binding with no declared_outputs → response surfaces

View File

@ -502,8 +502,12 @@ export type AgentConfigSnapshotSummaryResponse = {
export type AgentComposerAgentResponse = {
active_config_snapshot_id?: string | null
description: string
icon?: string | null
icon_background?: string | null
icon_type?: string | null
id: string
name: string
role?: string | null
scope: AgentScope
status: AgentStatus
}

View File

@ -926,8 +926,12 @@ export const zAgentInviteOptionsResponse = z.object({
export const zAgentComposerAgentResponse = z.object({
active_config_snapshot_id: z.string().nullish(),
description: z.string(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
id: z.string(),
name: z.string(),
role: z.string().nullish(),
scope: zAgentScope,
status: zAgentStatus,
})

View File

@ -1788,8 +1788,12 @@ export type AgentConfigSnapshotSummaryResponse = {
export type AgentComposerAgentResponse = {
active_config_snapshot_id?: string | null
description: string
icon?: string | null
icon_background?: string | null
icon_type?: string | null
id: string
name: string
role?: string | null
scope: AgentScope
status: AgentStatus
}

View File

@ -2499,8 +2499,12 @@ export const zAgentStatus = z.enum(['active', 'archived'])
export const zAgentComposerAgentResponse = z.object({
active_config_snapshot_id: z.string().nullish(),
description: z.string(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
id: z.string(),
name: z.string(),
role: z.string().nullish(),
scope: zAgentScope,
status: zAgentStatus,
})

View File

@ -14,6 +14,8 @@ const {
mockInsertNodes,
mockOrchestrateDrawerPanelProps,
mockPromptEditorProps,
mockCopyFromRosterMutate,
mockCopyFromRosterState,
mockCreateInlineAgentBinding,
mockSetInputs,
mockStoreState,
@ -34,9 +36,14 @@ const {
open: boolean
}>,
mockPromptEditorProps: [] as PromptEditorProps[],
mockCopyFromRosterMutate: vi.fn(),
mockCopyFromRosterState: {
isPending: false,
},
mockCreateInlineAgentBinding: vi.fn(),
mockSetInputs: vi.fn(),
mockStoreState: {
appId: 'app-1',
openInlineAgentPanelNodeId: undefined as string | undefined,
setOpenInlineAgentPanelNodeId: vi.fn(),
},
@ -80,6 +87,18 @@ vi.mock('@lexical/react/LexicalComposerContext', () => ({
}],
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: () => ({
isPending: mockCopyFromRosterState.isPending,
mutate: mockCopyFromRosterMutate,
}),
}
})
vi.mock('lexical', async (importOriginal) => {
const actual = await importOriginal<typeof import('lexical')>()
@ -165,6 +184,41 @@ vi.mock('../components/agent-orchestrate-drawer-panel', () => ({
},
}))
vi.mock('../components/save-inline-agent-to-roster-dialog', () => ({
SaveInlineAgentToRosterDialog: ({
open,
onSaved,
}: {
open: boolean
onSaved: (binding: {
agent_id?: string | null
binding_type: 'inline_agent' | 'roster_agent'
current_snapshot_id?: string | null
id: string
node_id: string
workflow_id: string
}) => void
}) => open
? (
<div role="dialog" aria-label="save-inline-agent-to-roster">
<button
type="button"
onClick={() => onSaved({
id: 'binding-1',
binding_type: 'roster_agent',
agent_id: 'saved-roster-agent',
current_snapshot_id: 'saved-snapshot',
workflow_id: 'workflow-1',
node_id: 'agent-node',
})}
>
Save inline agent to roster
</button>
</div>
)
: null,
}))
vi.mock('../../_base/hooks/use-available-var-list', () => ({
default: () => ({
availableVars: [{
@ -215,7 +269,32 @@ describe('agent/panel', () => {
vi.clearAllMocks()
mockPromptEditorProps.length = 0
mockOrchestrateDrawerPanelProps.length = 0
mockStoreState.appId = 'app-1'
mockStoreState.openInlineAgentPanelNodeId = undefined
mockCopyFromRosterState.isPending = false
mockCopyFromRosterMutate.mockImplementation((_variables, options?: {
onSuccess?: (composerState: {
binding: {
agent_id: string
binding_type: 'inline_agent'
current_snapshot_id: string
id: string
node_id: string
workflow_id: string
}
}) => void
}) => {
options?.onSuccess?.({
binding: {
id: 'binding-1',
binding_type: 'inline_agent',
agent_id: 'inline-copy-agent',
current_snapshot_id: 'inline-copy-snapshot',
workflow_id: 'workflow-1',
node_id: 'agent-node',
},
})
})
mockCreateInlineAgentBinding.mockImplementation(() => {})
mockUseNodeCrud.mockImplementation((_id: string, data: AgentV2NodeType) => ({
inputs: data,
@ -304,6 +383,52 @@ describe('agent/panel', () => {
expect(screen.queryByRole('dialog', { name: 'Nadia' })).not.toBeInTheDocument()
})
it('copies a roster agent from the drawer into an inline agent for this node', () => {
render(
<AgentV2Panel
id="agent-node"
data={createData()}
panelProps={panelProps}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }))
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' }))
expect(mockCopyFromRosterMutate).toHaveBeenCalledWith(
{
params: {
app_id: 'app-1',
node_id: 'agent-node',
},
body: {
source_agent_id: 'agent-1',
},
},
expect.objectContaining({
onSuccess: expect.any(Function),
}),
)
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node')
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
{
id: 'agent-node',
data: expect.objectContaining({
agent_binding: {
binding_type: 'inline_agent',
agent_id: 'inline-copy-agent',
current_snapshot_id: 'inline-copy-snapshot',
},
_openInlineAgentPanel: true,
}),
},
expect.objectContaining({
sync: true,
notRefreshWhenSyncError: true,
}),
)
})
it('renders a required roster state when no roster agent is selected', () => {
render(
<AgentV2Panel
@ -442,6 +567,45 @@ 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', () => {
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}
/>,
)
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
fireEvent.click(within(panel).getByRole('button', { name: 'workflow.nodes.agent.roster.more' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'agentV2.roster.saveToRoster' }))
fireEvent.click(screen.getByRole('button', { name: 'Save inline agent to roster' }))
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith(undefined)
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
{
id: 'agent-node',
data: expect.objectContaining({
agent_binding: {
binding_type: 'roster_agent',
agent_id: 'saved-roster-agent',
},
}),
},
expect.objectContaining({
sync: true,
notRefreshWhenSyncError: true,
}),
)
})
it('does not show start from scratch for an existing inline agent binding', () => {
render(
<AgentV2Panel
@ -482,7 +646,9 @@ describe('agent/panel', () => {
expect(mockUseWorkflowInlineAgentDetail).toHaveBeenCalledWith('agent-node', 'inline-agent-1')
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
expect(screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })).toBeInTheDocument()
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
expect(panel).toBeInTheDocument()
expect(within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.more' })).not.toBeInTheDocument()
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
expect(mockOrchestrateDrawerPanelProps.at(-1)).toMatchObject({
agentId: 'inline-agent-1',

View File

@ -0,0 +1,174 @@
import type { AgentComposerAgentResponse } from '@dify/contracts/api/console/apps/types.gen'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SaveInlineAgentToRosterDialog } from '../save-inline-agent-to-roster-dialog'
const mutationMock = vi.hoisted(() => ({
isPending: false,
mutate: vi.fn(),
}))
const toastMock = vi.hoisted(() => ({
success: vi.fn(),
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({
isPending: mutationMock.isPending,
mutate: mutationMock.mutate,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMock,
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
__esModule: true,
default: ({
initialEmoji,
onSelect,
open,
}: {
initialEmoji?: { icon: string, background: string }
onSelect: (payload: { type: 'emoji', icon: string, background: string }) => void
open: boolean
}) => open
? (
<div>
<span>{`${initialEmoji?.icon}:${initialEmoji?.background}`}</span>
<button
type="button"
onClick={() => onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })}
>
Select brain icon
</button>
</div>
)
: null,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
byAppId: {
workflows: {
draft: {
nodes: {
byNodeId: {
agentComposer: {
saveToRoster: {
post: {
mutationOptions: vi.fn(() => ({})),
},
},
},
},
},
},
},
},
},
},
}))
const inlineAgent: AgentComposerAgentResponse = {
active_config_snapshot_id: 'snapshot-1',
description: 'Drafts tender clarifications.',
icon: '🤖',
icon_background: '#F5F3FF',
icon_type: 'emoji',
id: 'inline-agent-1',
name: 'Inline Tender Agent',
role: 'Tender Analyst',
scope: 'workflow_only',
status: 'active',
}
const renderDialog = () => {
const onOpenChange = vi.fn()
const onSaved = vi.fn()
render(
<SaveInlineAgentToRosterDialog
appId="app-1"
formKey={1}
initialAgent={inlineAgent}
nodeId="node-1"
open
onOpenChange={onOpenChange}
onSaved={onSaved}
/>,
)
return { onOpenChange, onSaved }
}
describe('SaveInlineAgentToRosterDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mutationMock.isPending = false
})
it('initializes form fields from the inline agent metadata', async () => {
const user = userEvent.setup()
renderDialog()
const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' })
expect(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' })).toHaveValue('Inline Tender Agent')
expect(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' })).toHaveValue('Tender Analyst')
expect(within(dialog).getByPlaceholderText('agentV2.roster.createForm.descriptionPlaceholder')).toHaveValue('Drafts tender clarifications.')
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
expect(mutationMock.mutate).toHaveBeenCalledWith({
params: {
app_id: 'app-1',
node_id: 'node-1',
},
body: {
variant: 'workflow',
save_strategy: 'save_to_roster',
new_agent_name: 'Inline Tender Agent',
description: 'Drafts tender clarifications.',
role: 'Tender Analyst',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1]
expect(mutationOptions).not.toHaveProperty('onError')
})
it('initializes the icon picker from the inline agent and submits changed icon fields', async () => {
const user = userEvent.setup()
renderDialog()
const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' })
await user.click(within(dialog).getByRole('button', { name: 'agentV2.roster.saveToRosterForm.changeIcon' }))
expect(screen.getByText('🤖:#F5F3FF')).toBeInTheDocument()
await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' }))
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
expect(mutationMock.mutate).toHaveBeenCalledWith({
params: {
app_id: 'app-1',
node_id: 'node-1',
},
body: {
variant: 'workflow',
save_strategy: 'save_to_roster',
new_agent_name: 'Inline Tender Agent',
description: 'Drafts tender clarifications.',
role: 'Tender Analyst',
icon_type: 'emoji',
icon: '🧠',
icon_background: '#E0F2FE',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
})
})

View File

@ -12,6 +12,12 @@ import {
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import {
Popover,
@ -91,23 +97,30 @@ function AgentRosterDrawer({
showAccessIcon = true,
showConsoleLink = true,
showDetailActions = true,
isCopyPending = false,
onMakeCopy,
onSaveInlineToRoster,
onClose,
}: {
agent: AgentRosterDisplayData
children?: ReactNode
isInlineSetup?: boolean
isCopyPending?: boolean
mode?: AgentRosterDrawerMode
open: boolean
portalContainerRef: RefObject<HTMLDivElement | null>
showAccessIcon?: boolean
showConsoleLink?: boolean
showDetailActions?: boolean
onMakeCopy?: () => void
onSaveInlineToRoster?: () => void
onClose: () => void
}) {
const { t } = useTranslation()
const isSetup = mode === 'setup'
const title = isInlineSetup ? t(`${i18nPrefix}.roster.inlineSetup.name`, { ns: 'workflow' }) : agent.name
const description = isSetup ? t(`${i18nPrefix}.roster.inlineSetup.description`, { ns: 'workflow' }) : agent.role
const showInlineActions = isInlineSetup && !!onSaveInlineToRoster
return (
<Drawer
@ -160,16 +173,27 @@ function AgentRosterDrawer({
</div>
</div>
<div className="flex shrink-0 items-center gap-1 py-1">
<button
type="button"
aria-label={t(`${i18nPrefix}.roster.more`, { ns: 'workflow' })}
className="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"
>
<span aria-hidden className="i-ri-more-fill size-4" />
</button>
<div className="flex h-3.5 items-start px-1">
<div className="h-full w-px bg-divider-regular" />
</div>
{showInlineActions && (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t(`${i18nPrefix}.roster.more`, { ns: 'workflow' })}
className="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>
<div className="flex h-3.5 items-start px-1">
<div className="h-full w-px bg-divider-regular" />
</div>
</>
)}
<DrawerCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="size-6 rounded-md"
@ -193,6 +217,8 @@ function AgentRosterDrawer({
variant="secondary"
size="medium"
className="min-w-0 flex-1 gap-1.5 px-3"
loading={isCopyPending}
onClick={onMakeCopy}
>
<span aria-hidden className="i-ri-file-copy-2-line size-4 shrink-0" />
<span className="truncate">
@ -222,6 +248,7 @@ export function AgentRosterField({
agentId,
canOpenPanel = true,
isPanelOpen,
isPanelCopyPending = false,
isPending = false,
isLoading = false,
isInlineSetup = false,
@ -230,13 +257,16 @@ export function AgentRosterField({
showPanelDetailActions = true,
portalContainerRef,
onChange,
onMakeCopy,
onPanelOpenChange,
onSaveInlineToRoster,
onStartFromScratch,
}: {
agent?: AgentRosterDisplayData
agentId?: string
canOpenPanel?: boolean
isPanelOpen?: boolean
isPanelCopyPending?: boolean
isLoading?: boolean
isInlineSetup?: boolean
isPending?: boolean
@ -245,7 +275,9 @@ export function AgentRosterField({
showPanelDetailActions?: boolean
portalContainerRef: RefObject<HTMLDivElement | null>
onChange: (agent: AgentRosterNodeData) => void
onMakeCopy?: () => void
onPanelOpenChange?: (open: boolean) => void
onSaveInlineToRoster?: () => void
onStartFromScratch?: () => void
}) {
const { t } = useTranslation()
@ -358,6 +390,9 @@ export function AgentRosterField({
portalContainerRef={portalContainerRef}
showAccessIcon={!isInlineSetup}
showDetailActions={showPanelDetailActions}
isCopyPending={isPanelCopyPending}
onMakeCopy={onMakeCopy}
onSaveInlineToRoster={onSaveInlineToRoster}
onClose={() => setPanelOpen(false)}
>
{panelBody}

View File

@ -0,0 +1,166 @@
'use client'
import type { AgentComposerAgentResponse, AgentComposerBindingResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { AgentFormValues, AgentIconSelection } from '@/features/agent-v2/roster/components/agent-form'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Form } from '@langgenius/dify-ui/form'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import { createAgentIconSelection, defaultAgentIcon } from '@/features/agent-v2/roster/components/agent-form'
import { AgentFormFields } from '@/features/agent-v2/roster/components/agent-form-fields'
import { consoleQuery } from '@/service/client'
type SaveInlineAgentToRosterDialogProps = {
appId?: string
formKey: number
initialAgent?: AgentComposerAgentResponse | null
nodeId: string
open: boolean
onOpenChange: (open: boolean) => void
onSaved: (binding: AgentComposerBindingResponse) => void
}
export function SaveInlineAgentToRosterDialog({
appId,
formKey,
initialAgent,
nodeId,
open,
onOpenChange,
onSaved,
}: SaveInlineAgentToRosterDialogProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const [name, setName] = useState(initialAgent?.name ?? '')
const [description, setDescription] = useState(initialAgent?.description ?? '')
const [role, setRole] = useState(initialAgent?.role ?? '')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [isIconChanged, setIsIconChanged] = useState(false)
const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => initialAgent
? createAgentIconSelection(initialAgent)
: defaultAgentIcon)
const saveToRosterMutation = useMutation(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(),
)
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
setName(initialAgent?.name ?? '')
setDescription(initialAgent?.description ?? '')
setRole(initialAgent?.role ?? '')
setIsIconChanged(false)
setAgentIcon(initialAgent
? createAgentIconSelection(initialAgent)
: defaultAgentIcon)
}
else {
setIconPickerOpen(false)
}
onOpenChange(nextOpen)
}
const handleSubmit = (formValues: AgentFormValues) => {
if (saveToRosterMutation.isPending)
return
if (!appId)
return
const trimmedName = formValues.name?.trim() ?? ''
const trimmedRole = formValues.role?.trim() ?? ''
saveToRosterMutation.mutate({
params: {
app_id: appId,
node_id: nodeId,
},
body: {
variant: 'workflow',
save_strategy: 'save_to_roster',
new_agent_name: trimmedName,
description: formValues.description?.trim() ?? '',
role: trimmedRole,
...(isIconChanged
? {
icon_type: agentIcon.type,
icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon,
icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined,
}
: {}),
},
}, {
onSuccess: (composerState) => {
const binding = composerState.binding
if (binding?.binding_type !== 'roster_agent' || !binding.agent_id)
return
toast.success(t('roster.saveToRosterSuccess'))
onSaved(binding)
handleOpenChange(false)
},
})
}
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!">
<DialogCloseButton />
<div className="shrink-0 pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('roster.saveToRosterDialog.title')}
</DialogTitle>
<DialogDescription className="sr-only">
{t('roster.saveToRosterDialog.description')}
</DialogDescription>
</div>
<Form<AgentFormValues>
key={formKey}
className="min-h-0 flex-1"
onFormSubmit={handleSubmit}
>
<AgentFormFields
description={description}
icon={agentIcon}
iconAriaLabel={t('roster.saveToRosterForm.changeIcon')}
name={name}
role={role}
onDescriptionChange={setDescription}
onIconClick={() => setIconPickerOpen(true)}
onNameChange={setName}
onRoleChange={setRole}
/>
<div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6">
<Button type="button" className="min-w-18" onClick={() => handleOpenChange(false)} disabled={saveToRosterMutation.isPending}>
{tCommon('operation.cancel')}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-18"
loading={saveToRosterMutation.isPending}
>
{tCommon('operation.save')}
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
<AppIconPicker
open={iconPickerOpen}
initialEmoji={agentIcon.type === 'emoji'
? { icon: agentIcon.icon, background: agentIcon.background }
: undefined}
onOpenChange={setIconPickerOpen}
onSelect={(icon) => {
setAgentIcon(icon)
setIsIconChanged(true)
}}
/>
</>
)
}

View File

@ -1,6 +1,8 @@
import type { AgentComposerBindingResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { AgentRosterNodeData } from '../../block-selector/types'
import type { NodePanelProps } from '../../types'
import type { AgentV2NodeType } from './types'
import { useMutation } from '@tanstack/react-query'
import { produce } from 'immer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -10,12 +12,14 @@ import {
} from '@/app/components/base/prompt-editor/plugins/agent-output-block/utils'
import { useNodeDataUpdate } from '@/app/components/workflow/hooks'
import { useStore } from '@/app/components/workflow/store'
import { consoleQuery } from '@/service/client'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { AgentAdvancedSettings } from './components/agent-advanced-settings'
import { AgentOrchestrateDrawerPanel } from './components/agent-orchestrate-drawer-panel'
import { AgentOutputVariables } from './components/agent-output-variables'
import { AgentRosterField } from './components/agent-roster-field'
import { AgentTaskField } from './components/agent-task-field'
import { SaveInlineAgentToRosterDialog } from './components/save-inline-agent-to-roster-dialog'
import { useAgentRosterDetail, useCreateInlineAgentBinding, useWorkflowInlineAgentDetail } from './hooks'
import { getAgentV2DeclaredOutputs } from './output-variables'
import { hasValidInlineAgentBinding } from './types'
@ -30,6 +34,8 @@ export function AgentV2Panel({
const promptOutputNamesRef = useRef(extractAgentOutputNames(inputs.agent_task || ''))
const [isRosterAgentPanelOpen, setIsRosterAgentPanelOpen] = useState(false)
const [isInlineAgentPanelOpenedFromTrigger, setIsInlineAgentPanelOpenedFromTrigger] = useState(false)
const [isSaveToRosterDialogOpen, setIsSaveToRosterDialogOpen] = useState(false)
const [saveToRosterSessionKey, setSaveToRosterSessionKey] = useState(0)
const { handleNodeDataUpdate, handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const openInlineAgentPanelNodeId = useStore(state => state.openInlineAgentPanelNodeId)
const setOpenInlineAgentPanelNodeId = useStore(state => state.setOpenInlineAgentPanelNodeId)
@ -45,10 +51,17 @@ export function AgentV2Panel({
const inlineAgentQuery = useWorkflowInlineAgentDetail(id, inlineAgentId)
const { createInlineAgentBinding, isCreatingInlineAgent } = useCreateInlineAgentBinding()
const inlineAgent = inlineAgentQuery.data?.agent
const {
isPending: isCopyingFromRoster,
mutate: copyFromRoster,
} = useMutation(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.copyFromRoster.post.mutationOptions(),
)
const isAgentPanelOpen = isInlineAgentReady || isInlineAgentPending ? isInlineAgentPanelOpen : isRosterAgentPanelOpen
const isInlineAgentLoading = isInlineAgentPending || (isInlineAgentReady && !inlineAgent)
const isAgentBindingPending = isInlineAgentPending || isCreatingInlineAgent
const canStartFromScratch = inputs.agent_binding?.binding_type !== 'inline_agent'
const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent
const displayedAgent = rosterAgentQuery.data ?? (isInlineAgentPending || isInlineAgentReady
? {
id: inlineAgentId ?? id,
@ -113,6 +126,91 @@ export function AgentV2Panel({
)
}, [handleNodeDataUpdateWithSyncDraft, id, inputs, setOpenInlineAgentPanelNodeId])
const handleMakeRosterCopy = useCallback(() => {
if (!appId || !rosterAgentId || isCopyingFromRoster)
return
copyFromRoster({
params: {
app_id: appId,
node_id: id,
},
body: {
source_agent_id: rosterAgentId,
},
}, {
onSuccess: (composerState) => {
const binding = composerState.binding
if (
binding?.binding_type !== 'inline_agent'
|| !binding.agent_id
|| !binding.current_snapshot_id
) {
return
}
setIsRosterAgentPanelOpen(false)
setIsInlineAgentPanelOpenedFromTrigger(true)
setOpenInlineAgentPanelNodeId(id)
const newInputs = produce(inputsRef.current, (draft) => {
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
draft.agent_binding = {
binding_type: 'inline_agent',
agent_id: binding.agent_id,
current_snapshot_id: binding.current_snapshot_id,
}
draft._openInlineAgentPanel = true
})
inputsRef.current = newInputs
handleNodeDataUpdateWithSyncDraft(
{
id,
data: newInputs,
},
{
sync: true,
notRefreshWhenSyncError: true,
},
)
},
})
}, [appId, copyFromRoster, handleNodeDataUpdateWithSyncDraft, id, isCopyingFromRoster, rosterAgentId, setOpenInlineAgentPanelNodeId])
const handleSaveInlineToRosterOpen = useCallback(() => {
setSaveToRosterSessionKey(key => key + 1)
setIsSaveToRosterDialogOpen(true)
}, [])
const handleInlineSavedToRoster = useCallback((binding: AgentComposerBindingResponse) => {
if (binding.binding_type !== 'roster_agent' || !binding.agent_id)
return
setOpenInlineAgentPanelNodeId(undefined)
setIsInlineAgentPanelOpenedFromTrigger(false)
setIsRosterAgentPanelOpen(true)
const newInputs = produce(inputsRef.current, (draft) => {
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
delete draft._openInlineAgentPanel
draft.agent_binding = {
binding_type: 'roster_agent',
agent_id: binding.agent_id!,
}
})
inputsRef.current = newInputs
handleNodeDataUpdateWithSyncDraft(
{
id,
data: newInputs,
},
{
sync: true,
notRefreshWhenSyncError: true,
},
)
}, [handleNodeDataUpdateWithSyncDraft, id, setOpenInlineAgentPanelNodeId])
const handleStartFromScratch = useCallback(() => {
setIsRosterAgentPanelOpen(false)
setIsInlineAgentPanelOpenedFromTrigger(false)
@ -219,6 +317,7 @@ export function AgentV2Panel({
canOpenPanel
isInlineSetup={isInlineAgentReady || isInlineAgentPending}
isLoading={isInlineAgentLoading}
isPanelCopyPending={isCopyingFromRoster}
isPanelOpen={isAgentPanelOpen}
isPending={isAgentBindingPending}
panelBody={isAgentPanelOpen && displayedAgent
@ -237,9 +336,21 @@ export function AgentV2Panel({
portalContainerRef={drawerPortalContainerRef}
showPanelDetailActions={!isInlineAgentReady && !isInlineAgentPending}
onChange={handleRosterChange}
onMakeCopy={rosterAgentId ? handleMakeRosterCopy : undefined}
onPanelOpenChange={handleAgentPanelOpenChange}
onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined}
onStartFromScratch={canStartFromScratch ? handleStartFromScratch : undefined}
/>
<SaveInlineAgentToRosterDialog
key={saveToRosterSessionKey}
appId={appId}
formKey={saveToRosterSessionKey}
initialAgent={inlineAgent}
nodeId={id}
open={isSaveToRosterDialogOpen}
onOpenChange={setIsSaveToRosterDialogOpen}
onSaved={handleInlineSavedToRoster}
/>
</div>
<div
aria-disabled={isInlineAgentPending}

View File

@ -1,4 +1,3 @@
import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
export type AgentFormValues = {
@ -19,7 +18,13 @@ export const defaultAgentIcon = {
background: '#F5F3FF',
} satisfies AppIconSelection
export const createAgentIconSelection = (agent: AgentAppPartial): AgentIconSelection => {
type AgentIconSource = {
icon?: string | null
icon_background?: string | null
icon_type?: string | null
}
export const createAgentIconSelection = (agent: AgentIconSource): AgentIconSelection => {
if (agent.icon_type === 'image' && agent.icon) {
return {
type: 'image',

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "البدء من الصفر",
"roster.references.label": "سير العمل الذي يستخدم {{name}}",
"roster.references.trigger": "سير العمل الذي يستخدم {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "البحث عن الوكلاء",
"roster.searchPlaceholder": "ابحث عن الوكلاء بالاسم…",
"roster.sources.agent_app": "تطبيق الوكيل",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Von Grund auf starten",
"roster.references.label": "Workflows, die {{name}} verwenden",
"roster.references.trigger": "Workflows, die {{name}} verwenden",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Agenten suchen",
"roster.searchPlaceholder": "Agenten nach Namen suchen…",
"roster.sources.agent_app": "Agent-App",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Start from Scratch",
"roster.references.label": "Workflows using {{name}}",
"roster.references.trigger": "Workflows using {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Search agents",
"roster.searchPlaceholder": "Search agents by name…",
"roster.sources.agent_app": "Agent app",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Empezar desde cero",
"roster.references.label": "Flujos de trabajo que usan {{name}}",
"roster.references.trigger": "Flujos de trabajo que usan {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Buscar agentes",
"roster.searchPlaceholder": "Buscar agentes por nombre…",
"roster.sources.agent_app": "Aplicación de agente",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "از صفر شروع کنید",
"roster.references.label": "گردش‌های کار با استفاده از {{name}}",
"roster.references.trigger": "گردش‌های کار با استفاده از {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "جستجوی عوامل",
"roster.searchPlaceholder": "جستجوی عوامل بر اساس نام…",
"roster.sources.agent_app": "برنامه عامل",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Partir de zéro",
"roster.references.label": "Workflows utilisant {{name}}",
"roster.references.trigger": "Workflows utilisant {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Rechercher des agents",
"roster.searchPlaceholder": "Rechercher des agents par nom…",
"roster.sources.agent_app": "Application agent",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "शुरुआत से आरंभ करें",
"roster.references.label": "{{name}} का उपयोग करने वाले वर्कफ़्लो",
"roster.references.trigger": "{{name}} का उपयोग करने वाले वर्कफ़्लो",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "एजेंट खोजें",
"roster.searchPlaceholder": "नाम से एजेंट खोजें…",
"roster.sources.agent_app": "एजेंट ऐप",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Mulai dari Awal",
"roster.references.label": "Alur kerja yang menggunakan {{name}}",
"roster.references.trigger": "Alur kerja yang menggunakan {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Cari agen",
"roster.searchPlaceholder": "Cari agen berdasarkan nama…",
"roster.sources.agent_app": "Aplikasi agen",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Inizia da zero",
"roster.references.label": "Workflow che usano {{name}}",
"roster.references.trigger": "Workflow che usano {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Cerca agenti",
"roster.searchPlaceholder": "Cerca agenti per nome…",
"roster.sources.agent_app": "App agente",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "ゼロから始める",
"roster.references.label": "{{name}} を使用するワークフロー",
"roster.references.trigger": "{{name}} を使用するワークフロー",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "エージェントを検索",
"roster.searchPlaceholder": "エージェントを名前で検索…",
"roster.sources.agent_app": "エージェントアプリ",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "처음부터 시작",
"roster.references.label": "{{name}}을(를) 사용하는 워크플로",
"roster.references.trigger": "{{name}}을(를) 사용하는 워크플로",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "에이전트 검색",
"roster.searchPlaceholder": "이름으로 에이전트 검색…",
"roster.sources.agent_app": "에이전트 앱",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Vanaf nul beginnen",
"roster.references.label": "Workflows die {{name}} gebruiken",
"roster.references.trigger": "Workflows die {{name}} gebruiken",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Agents zoeken",
"roster.searchPlaceholder": "Agents op naam zoeken…",
"roster.sources.agent_app": "Agent-app",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Zacznij od zera",
"roster.references.label": "Workflow używające {{name}}",
"roster.references.trigger": "Workflow używające {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Szukaj agentów",
"roster.searchPlaceholder": "Wyszukaj agentów po nazwie…",
"roster.sources.agent_app": "Aplikacja agenta",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Começar do zero",
"roster.references.label": "Workflows que usam {{name}}",
"roster.references.trigger": "Workflows que usam {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Pesquisar agentes",
"roster.searchPlaceholder": "Pesquisar agentes pelo nome…",
"roster.sources.agent_app": "Aplicativo agente",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Începeți de la zero",
"roster.references.label": "Workflow-uri care folosesc {{name}}",
"roster.references.trigger": "Workflow-uri care folosesc {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Căutați agenți",
"roster.searchPlaceholder": "Căutați agenți după nume…",
"roster.sources.agent_app": "Aplicație agent",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Начать с нуля",
"roster.references.label": "Рабочие процессы, использующие {{name}}",
"roster.references.trigger": "Рабочие процессы, использующие {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Поиск агентов",
"roster.searchPlaceholder": "Поиск агентов по имени…",
"roster.sources.agent_app": "Агентское приложение",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Začni iz nič",
"roster.references.label": "Poteki dela, ki uporabljajo {{name}}",
"roster.references.trigger": "Poteki dela, ki uporabljajo {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Iskanje agentov",
"roster.searchPlaceholder": "Iskanje agentov po imenu…",
"roster.sources.agent_app": "Aplikacija agenta",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "เริ่มต้นจากศูนย์",
"roster.references.label": "เวิร์กโฟลว์ที่ใช้ {{name}}",
"roster.references.trigger": "เวิร์กโฟลว์ที่ใช้ {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "ค้นหาตัวแทน",
"roster.searchPlaceholder": "ค้นหาตัวแทนตามชื่อ…",
"roster.sources.agent_app": "แอปตัวแทน",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Sıfırdan Başla",
"roster.references.label": "{{name}} kullanan iş akışları",
"roster.references.trigger": "{{name}} kullanan iş akışları",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Ajanları ara",
"roster.searchPlaceholder": "Ajanları ada göre ara…",
"roster.sources.agent_app": "Ajan uygulaması",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Почати з нуля",
"roster.references.label": "Робочі процеси, що використовують {{name}}",
"roster.references.trigger": "Робочі процеси, що використовують {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Пошук агентів",
"roster.searchPlaceholder": "Пошук агентів за ім'ям…",
"roster.sources.agent_app": "Агентний застосунок",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "Bắt đầu từ đầu",
"roster.references.label": "Quy trình làm việc đang dùng {{name}}",
"roster.references.trigger": "Quy trình làm việc đang dùng {{name}}",
"roster.saveToRoster": "Save to Agent Console",
"roster.saveToRosterDialog.description": "Save this inline setup as a reusable roster agent.",
"roster.saveToRosterDialog.title": "Save to Agent Console",
"roster.saveToRosterForm.changeIcon": "Change roster agent icon",
"roster.saveToRosterSuccess": "Agent saved to roster.",
"roster.searchLabel": "Tìm kiếm tác nhân",
"roster.searchPlaceholder": "Tìm kiếm tác nhân theo tên…",
"roster.sources.agent_app": "Ứng dụng tác nhân",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "从空白开始",
"roster.references.label": "使用 {{name}} 的工作流",
"roster.references.trigger": "使用 {{name}} 的工作流",
"roster.saveToRoster": "保存到智能体控制台",
"roster.saveToRosterDialog.description": "将此内联配置保存为可复用的 Roster 智能体。",
"roster.saveToRosterDialog.title": "保存到智能体控制台",
"roster.saveToRosterForm.changeIcon": "更换 Roster 智能体图标",
"roster.saveToRosterSuccess": "智能体已保存到 Roster。",
"roster.searchLabel": "搜索智能体",
"roster.searchPlaceholder": "按名称搜索智能体…",
"roster.sources.agent_app": "智能体应用",

View File

@ -381,6 +381,11 @@
"roster.nodeSelector.startFromScratch": "從空白開始",
"roster.references.label": "使用 {{name}} 的工作流程",
"roster.references.trigger": "使用 {{name}} 的工作流程",
"roster.saveToRoster": "儲存到智慧體控制台",
"roster.saveToRosterDialog.description": "將此內聯設定儲存為可重用的 Roster 智慧體。",
"roster.saveToRosterDialog.title": "儲存到智慧體控制台",
"roster.saveToRosterForm.changeIcon": "更換 Roster 智慧體圖示",
"roster.saveToRosterSuccess": "智慧體已儲存到 Roster。",
"roster.searchLabel": "搜尋智能體",
"roster.searchPlaceholder": "依名稱搜尋智能體…",
"roster.sources.agent_app": "智能體應用",

View File

@ -46,6 +46,7 @@ const createApiBasedExtension = (overrides: Partial<ApiBasedExtensionResponse> =
type AgentMutationResponse = Parameters<NonNullable<ReturnType<typeof ConsoleQuery.agent.post.mutationOptions>['onSuccess']>>[0]
type AgentComposerMutationResponse = Parameters<NonNullable<ReturnType<typeof ConsoleQuery.agent.byAgentId.composer.put.mutationOptions>['onSuccess']>>[0]
type WorkflowAgentComposerMutationResponse = Parameters<NonNullable<ReturnType<typeof ConsoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions>['onSuccess']>>[0]
const createAgent = (overrides: Partial<AgentMutationResponse> = {}): AgentMutationResponse => ({
...overrides,
@ -81,6 +82,40 @@ const createComposerState = (overrides: Partial<AgentComposerMutationResponse> =
...overrides,
})
const createWorkflowComposerState = (overrides: Partial<WorkflowAgentComposerMutationResponse> = {}): WorkflowAgentComposerMutationResponse => ({
agent: {
active_config_snapshot_id: 'snapshot-1',
description: 'Agent description',
id: 'agent-1',
name: 'Agent',
scope: 'roster',
status: 'active',
},
agent_soul: {
schema_version: 1,
},
binding: {
agent_id: 'agent-1',
binding_type: 'roster_agent',
current_snapshot_id: 'snapshot-1',
id: 'binding-1',
node_id: 'node-1',
workflow_id: 'workflow-1',
},
node_job: {
mode: 'tell_agent_what_to_do',
schema_version: 1,
workflow_prompt: '',
},
save_options: ['node_job_only', 'save_as_new_agent'],
soul_lock: {
can_unlock: false,
locked: true,
},
variant: 'workflow',
...overrides,
})
// Scenario: base URL selection and warnings.
describe('getBaseURL', () => {
beforeEach(() => {
@ -222,6 +257,104 @@ describe('consoleQuery agent mutation defaults', () => {
})
})
it('should cache workflow composer state after copying a roster agent into an inline agent', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
const composerState = createWorkflowComposerState({
binding: {
agent_id: 'inline-agent-1',
binding_type: 'inline_agent',
current_snapshot_id: 'inline-snapshot-1',
id: 'binding-1',
node_id: 'node-1',
workflow_id: 'workflow-1',
},
})
const mutationOptions = consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.copyFromRoster.post.mutationOptions()
await mutationOptions.onSuccess?.(
composerState,
{
params: {
app_id: 'app-1',
node_id: 'node-1',
},
body: {
source_agent_id: 'roster-agent-1',
},
},
undefined,
createMutationContext(queryClient),
)
expect(queryClient.getQueryData(consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
input: {
params: {
app_id: 'app-1',
node_id: 'node-1',
},
},
}))).toEqual(composerState)
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: consoleQuery.agent.get.key(),
})
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: consoleQuery.agent.inviteOptions.get.key(),
})
})
it('should cache workflow composer state and invalidate roster lists after saving inline agent to roster', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
const composerState = createWorkflowComposerState()
const mutationOptions = consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions()
await mutationOptions.onSuccess?.(
composerState,
{
params: {
app_id: 'app-1',
node_id: 'node-1',
},
body: {
variant: 'workflow',
save_strategy: 'save_to_roster',
new_agent_name: 'Saved Agent',
description: 'Agent description',
role: 'Assistant',
},
},
undefined,
createMutationContext(queryClient),
)
expect(queryClient.getQueryData(consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
input: {
params: {
app_id: 'app-1',
node_id: 'node-1',
},
},
}))).toEqual(composerState)
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.agent.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.agent.inviteOptions.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.agent.byAgentId.get.queryKey({
input: {
params: {
agent_id: 'agent-1',
},
},
}),
})
})
it('should invalidate invite option lists after updating an agent', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()

View File

@ -347,6 +347,73 @@ export const consoleClient: JsonifiedClient<ContractRouterClient<typeof consoleR
export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQueryUtils(consoleClient, {
path: ['console'],
experimental_defaults: {
apps: {
byAppId: {
workflows: {
draft: {
nodes: {
byNodeId: {
agentComposer: {
copyFromRoster: {
post: {
mutationOptions: {
onSuccess: (composerState, variables, _onMutateResult, context) => {
context.client.setQueryData(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
input: {
params: variables.params,
},
}),
composerState,
)
},
},
},
},
saveToRoster: {
post: {
mutationOptions: {
onSuccess: (composerState, variables, _onMutateResult, context) => {
context.client.setQueryData(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
input: {
params: variables.params,
},
}),
composerState,
)
context.client.invalidateQueries({
queryKey: consoleQuery.agent.get.key(),
})
context.client.invalidateQueries({
queryKey: consoleQuery.agent.inviteOptions.get.key(),
})
const agentId = composerState.binding?.binding_type === 'roster_agent'
? composerState.binding.agent_id
: undefined
if (agentId) {
context.client.invalidateQueries({
queryKey: consoleQuery.agent.byAgentId.get.queryKey({
input: {
params: {
agent_id: agentId,
},
},
}),
})
}
},
},
},
},
},
},
},
},
},
},
},
agent: {
post: {
mutationOptions: {