diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index d664a2af12a..554cd5ad0fb 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -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 diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index f0cef4d317d..0b1a3ca65eb 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -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, diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 0465f66c4fb..51ad70efccd 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -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 diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 27e1d3f3a81..3d44c8bdad7 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -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 } diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 1d6d03c735d..d84e5a0dde1 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -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, }) diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index e61dc4a4179..92a5c263531 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -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 } diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index c99c6be0c0c..ecb37ae578f 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -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, }) diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx index 723c6569ec7..c44cfebe668 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx @@ -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() + + return { + ...actual, + useMutation: () => ({ + isPending: mockCopyFromRosterState.isPending, + mutate: mockCopyFromRosterMutate, + }), + } +}) + vi.mock('lexical', async (importOriginal) => { const actual = await importOriginal() @@ -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 + ? ( +
+ +
+ ) + : 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( + , + ) + + 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( { 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( + , + ) + + 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( { 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', diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx new file mode 100644 index 00000000000..645511b81ac --- /dev/null +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx @@ -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 + ? ( +
+ {`${initialEmoji?.icon}:${initialEmoji?.background}`} + +
+ ) + : 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( + , + ) + + 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), + })) + }) +}) diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx index b95617ff4a3..9de817cd109 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx @@ -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 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 (
- -
-
-
+ {showInlineActions && ( + <> + + + + + + + + {t('roster.saveToRoster', { ns: 'agentV2' })} + + + +
+
+
+ + )} @@ -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 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} diff --git a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx new file mode 100644 index 00000000000..26e21843046 --- /dev/null +++ b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx @@ -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(() => 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 ( + <> + + + +
+ + {t('roster.saveToRosterDialog.title')} + + + {t('roster.saveToRosterDialog.description')} + +
+ + key={formKey} + className="min-h-0 flex-1" + onFormSubmit={handleSubmit} + > + setIconPickerOpen(true)} + onNameChange={setName} + onRoleChange={setRole} + /> +
+ + +
+ +
+
+ { + setAgentIcon(icon) + setIsIconChanged(true) + }} + /> + + ) +} diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx index 1a377d69341..6948ec55cd4 100644 --- a/web/app/components/workflow/nodes/agent-v2/panel.tsx +++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx @@ -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} /> +
{ +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', diff --git a/web/i18n/ar-TN/agent-v-2.json b/web/i18n/ar-TN/agent-v-2.json index cd8d3ccd7aa..3c545272666 100644 --- a/web/i18n/ar-TN/agent-v-2.json +++ b/web/i18n/ar-TN/agent-v-2.json @@ -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": "تطبيق الوكيل", diff --git a/web/i18n/de-DE/agent-v-2.json b/web/i18n/de-DE/agent-v-2.json index 51f1628d7b0..89a23f86864 100644 --- a/web/i18n/de-DE/agent-v-2.json +++ b/web/i18n/de-DE/agent-v-2.json @@ -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", diff --git a/web/i18n/en-US/agent-v-2.json b/web/i18n/en-US/agent-v-2.json index 54d627e33b5..1ca7a6b8720 100644 --- a/web/i18n/en-US/agent-v-2.json +++ b/web/i18n/en-US/agent-v-2.json @@ -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", diff --git a/web/i18n/es-ES/agent-v-2.json b/web/i18n/es-ES/agent-v-2.json index 8d6c991bd8e..3df96abe479 100644 --- a/web/i18n/es-ES/agent-v-2.json +++ b/web/i18n/es-ES/agent-v-2.json @@ -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", diff --git a/web/i18n/fa-IR/agent-v-2.json b/web/i18n/fa-IR/agent-v-2.json index c556885587e..38cda19f7d8 100644 --- a/web/i18n/fa-IR/agent-v-2.json +++ b/web/i18n/fa-IR/agent-v-2.json @@ -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": "برنامه عامل", diff --git a/web/i18n/fr-FR/agent-v-2.json b/web/i18n/fr-FR/agent-v-2.json index aa79a2c6d91..f26ba147067 100644 --- a/web/i18n/fr-FR/agent-v-2.json +++ b/web/i18n/fr-FR/agent-v-2.json @@ -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", diff --git a/web/i18n/hi-IN/agent-v-2.json b/web/i18n/hi-IN/agent-v-2.json index 574065d1385..e88bbd77616 100644 --- a/web/i18n/hi-IN/agent-v-2.json +++ b/web/i18n/hi-IN/agent-v-2.json @@ -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": "एजेंट ऐप", diff --git a/web/i18n/id-ID/agent-v-2.json b/web/i18n/id-ID/agent-v-2.json index 61b99927cc5..99c2e1aa047 100644 --- a/web/i18n/id-ID/agent-v-2.json +++ b/web/i18n/id-ID/agent-v-2.json @@ -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", diff --git a/web/i18n/it-IT/agent-v-2.json b/web/i18n/it-IT/agent-v-2.json index e0519649b29..ad680ebf72d 100644 --- a/web/i18n/it-IT/agent-v-2.json +++ b/web/i18n/it-IT/agent-v-2.json @@ -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", diff --git a/web/i18n/ja-JP/agent-v-2.json b/web/i18n/ja-JP/agent-v-2.json index c572609eee1..b8aadd9f6b5 100644 --- a/web/i18n/ja-JP/agent-v-2.json +++ b/web/i18n/ja-JP/agent-v-2.json @@ -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": "エージェントアプリ", diff --git a/web/i18n/ko-KR/agent-v-2.json b/web/i18n/ko-KR/agent-v-2.json index 24a0b44c404..00cef5c5270 100644 --- a/web/i18n/ko-KR/agent-v-2.json +++ b/web/i18n/ko-KR/agent-v-2.json @@ -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": "에이전트 앱", diff --git a/web/i18n/nl-NL/agent-v-2.json b/web/i18n/nl-NL/agent-v-2.json index 19c9aa3afd9..dbb49db2db2 100644 --- a/web/i18n/nl-NL/agent-v-2.json +++ b/web/i18n/nl-NL/agent-v-2.json @@ -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", diff --git a/web/i18n/pl-PL/agent-v-2.json b/web/i18n/pl-PL/agent-v-2.json index 1d31614b0c1..fde194e7be7 100644 --- a/web/i18n/pl-PL/agent-v-2.json +++ b/web/i18n/pl-PL/agent-v-2.json @@ -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", diff --git a/web/i18n/pt-BR/agent-v-2.json b/web/i18n/pt-BR/agent-v-2.json index ee0d1f9d38e..e05cdfa8173 100644 --- a/web/i18n/pt-BR/agent-v-2.json +++ b/web/i18n/pt-BR/agent-v-2.json @@ -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", diff --git a/web/i18n/ro-RO/agent-v-2.json b/web/i18n/ro-RO/agent-v-2.json index 9f10bf9b286..0b3a3129cd9 100644 --- a/web/i18n/ro-RO/agent-v-2.json +++ b/web/i18n/ro-RO/agent-v-2.json @@ -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", diff --git a/web/i18n/ru-RU/agent-v-2.json b/web/i18n/ru-RU/agent-v-2.json index 6830bd58e1b..213ec4b9c43 100644 --- a/web/i18n/ru-RU/agent-v-2.json +++ b/web/i18n/ru-RU/agent-v-2.json @@ -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": "Агентское приложение", diff --git a/web/i18n/sl-SI/agent-v-2.json b/web/i18n/sl-SI/agent-v-2.json index 9642ceba1f4..dd2fe8dc2ca 100644 --- a/web/i18n/sl-SI/agent-v-2.json +++ b/web/i18n/sl-SI/agent-v-2.json @@ -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", diff --git a/web/i18n/th-TH/agent-v-2.json b/web/i18n/th-TH/agent-v-2.json index 60a9f8a47ae..3149177c559 100644 --- a/web/i18n/th-TH/agent-v-2.json +++ b/web/i18n/th-TH/agent-v-2.json @@ -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": "แอปตัวแทน", diff --git a/web/i18n/tr-TR/agent-v-2.json b/web/i18n/tr-TR/agent-v-2.json index 6d7fc8c4bae..9f8ad50a6b9 100644 --- a/web/i18n/tr-TR/agent-v-2.json +++ b/web/i18n/tr-TR/agent-v-2.json @@ -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ı", diff --git a/web/i18n/uk-UA/agent-v-2.json b/web/i18n/uk-UA/agent-v-2.json index 43f7c98ed82..77a268718b3 100644 --- a/web/i18n/uk-UA/agent-v-2.json +++ b/web/i18n/uk-UA/agent-v-2.json @@ -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": "Агентний застосунок", diff --git a/web/i18n/vi-VN/agent-v-2.json b/web/i18n/vi-VN/agent-v-2.json index 2a09a98c212..296e74e0346 100644 --- a/web/i18n/vi-VN/agent-v-2.json +++ b/web/i18n/vi-VN/agent-v-2.json @@ -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", diff --git a/web/i18n/zh-Hans/agent-v-2.json b/web/i18n/zh-Hans/agent-v-2.json index 2243db1037f..e27e4f15bd7 100644 --- a/web/i18n/zh-Hans/agent-v-2.json +++ b/web/i18n/zh-Hans/agent-v-2.json @@ -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": "智能体应用", diff --git a/web/i18n/zh-Hant/agent-v-2.json b/web/i18n/zh-Hant/agent-v-2.json index 92e81406751..699c84ab299 100644 --- a/web/i18n/zh-Hant/agent-v-2.json +++ b/web/i18n/zh-Hant/agent-v-2.json @@ -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": "智能體應用", diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index 414cf37d325..f37c58d13a0 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -46,6 +46,7 @@ const createApiBasedExtension = (overrides: Partial = type AgentMutationResponse = Parameters['onSuccess']>>[0] type AgentComposerMutationResponse = Parameters['onSuccess']>>[0] +type WorkflowAgentComposerMutationResponse = Parameters['onSuccess']>>[0] const createAgent = (overrides: Partial = {}): AgentMutationResponse => ({ ...overrides, @@ -81,6 +82,40 @@ const createComposerState = (overrides: Partial = ...overrides, }) +const createWorkflowComposerState = (overrides: Partial = {}): 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() diff --git a/web/service/client.ts b/web/service/client.ts index 73c23f60da8..255b139c193 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -347,6 +347,73 @@ export const consoleClient: JsonifiedClient = 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: {