feat: new agent support import and export in frontend (#39012)

This commit is contained in:
Joel 2026-07-15 17:54:09 +08:00 committed by GitHub
parent dfabe4cb28
commit 9e6a2cd19c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 2071 additions and 328 deletions

View File

@ -0,0 +1,95 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
type CreateAppDropdownProps = {
onCreateBlank: () => void
onCreateTemplate?: () => void
onImportDSL: () => void
}
export function CreateAppDropdown({
onCreateBlank,
onCreateTemplate,
onImportDSL,
}: CreateAppDropdownProps) {
const { t } = useTranslation()
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button
variant="primary"
size="medium"
className="gap-0.5 px-2 whitespace-nowrap shadow-xs shadow-shadow-shadow-3"
>
<span aria-hidden className="i-ri-add-line size-4 shrink-0" />
<span className="pl-1">{t(($) => $['operation.create'], { ns: 'common' })}</span>
<span aria-hidden className="i-ri-arrow-down-s-line size-4 shrink-0" />
</Button>
}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-70 p-0">
<div className="py-1">
<DropdownMenuItem
className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary"
onClick={onCreateBlank}
>
<span
aria-hidden
className="i-ri-sticky-note-add-line size-4 shrink-0 text-text-secondary"
/>
<span className="min-w-0 flex-1 truncate px-1">
{t(($) => $['newApp.startFromBlank'], { ns: 'app' })}
</span>
</DropdownMenuItem>
{onCreateTemplate && (
<DropdownMenuItem
className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary"
onClick={onCreateTemplate}
>
<span
aria-hidden
className="i-ri-apps-2-add-line size-4 shrink-0 text-text-secondary"
/>
<span className="min-w-0 flex-1 truncate px-1">
{t(($) => $['newApp.startFromTemplate'], { ns: 'app' })}
</span>
</DropdownMenuItem>
)}
</div>
<div className="h-px bg-divider-subtle" />
<div className="py-1">
<DropdownMenuItem
className={cn(
'h-auto items-start gap-1 rounded-lg px-2 py-1.5',
'hover:bg-state-base-hover focus:bg-state-base-hover',
)}
onClick={onImportDSL}
>
<span className="flex h-5 shrink-0 items-center py-0.5">
<span aria-hidden className="i-ri-file-upload-line size-4 text-text-secondary" />
</span>
<span className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-1">
<span className="system-md-regular text-text-secondary">
{t(($) => $.importDSL, { ns: 'app' })}
</span>
<span className="system-xs-regular text-text-tertiary">
{t(($) => $['newApp.dropDSLToCreateApp'], { ns: 'app' })}
</span>
</span>
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -13,6 +13,9 @@ const mockImportDSLConfirm = vi.fn()
const mockTrackCreateApp = vi.fn()
const mockHandleCheckPluginDependencies = vi.fn()
const mockGetRedirection = vi.fn()
const mockResolveImportedAppRedirectionTarget = vi.fn(
async (target: Record<string, unknown>) => target,
)
const mockInvalidateAppList = vi.hoisted(() => vi.fn())
const toastMocks = vi.hoisted(() => ({
call: vi.fn(),
@ -139,6 +142,11 @@ vi.mock('@/utils/app-redirection', () => ({
getRedirection: (...args: unknown[]) => mockGetRedirection(...args),
}))
vi.mock('@/utils/imported-app-redirection', () => ({
resolveImportedAppRedirectionTarget: (target: Record<string, unknown>) =>
mockResolveImportedAppRedirectionTarget(target),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: Object.assign((...args: unknown[]) => toastMocks.call(...args), {
success: (...args: unknown[]) => toastMocks.success(...args),
@ -320,8 +328,16 @@ describe('CreateFromDSLModal', () => {
id: 'import-2',
status: DSLImportStatus.COMPLETED_WITH_WARNINGS,
app_id: 'app-2',
app_mode: AppModeEnum.CHAT,
app_mode: AppModeEnum.AGENT,
permission_keys: ['app.acl.view_layout'],
warnings: [
{
code: 'agent_secret_required',
path: 'agent_packages.agent_1.soul.env.secret_refs',
message: "Agent secret 'SEARCH_TOKEN' must be configured.",
details: { name: 'SEARCH_TOKEN' },
},
],
})
render(
@ -344,6 +360,13 @@ describe('CreateFromDSLModal', () => {
mode: DSLImportMode.YAML_CONTENT,
yaml_content: 'app: demo',
})
expect(toastMocks.call).toHaveBeenCalledWith(
expect.stringMatching(/(?:^|\.)newApp\.caution(?=$|:)/),
{
type: 'warning',
description: "Agent secret 'SEARCH_TOKEN' must be configured.",
},
)
})
it('should remove the current file and keep the create shortcut guarded', async () => {
@ -437,6 +460,73 @@ describe('CreateFromDSLModal', () => {
)
})
it('should surface Agent warnings after confirming a pending import', async () => {
vi.useFakeTimers()
mockImportDSL.mockResolvedValue({
id: 'agent-import-pending',
status: DSLImportStatus.PENDING,
imported_dsl_version: '1.0.0',
current_dsl_version: '2.0.0',
})
mockImportDSLConfirm.mockResolvedValue({
status: DSLImportStatus.COMPLETED_WITH_WARNINGS,
app_id: 'agent-app-1',
app_mode: AppModeEnum.AGENT,
warnings: [
{
code: 'agent_tool_authorization_required',
path: 'agent_packages.agent_1.soul.tools.dify_tools.0',
message: "Agent tool 'web_search' requires authorization.",
details: { tool_name: 'web_search' },
},
],
})
mockResolveImportedAppRedirectionTarget.mockResolvedValueOnce({
id: 'agent-app-1',
mode: AppModeEnum.AGENT,
permission_keys: undefined,
bound_agent_id: 'agent-1',
})
render(
<CreateFromDSLModal
show
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
dslUrl="https://example.com/agent.yml"
/>,
)
await act(async () => {
fireEvent.click(getCreateButton())
})
await act(async () => {
vi.advanceTimersByTime(300)
})
await act(async () => {
fireEvent.click(screen.getAllByRole('button', { name: /newApp\.Confirm/ })[0]!)
})
expect(toastMocks.call).toHaveBeenCalledWith(expect.stringMatching(/newApp\.caution/), {
type: 'warning',
description: "Agent tool 'web_search' requires authorization.",
})
expect(mockResolveImportedAppRedirectionTarget).toHaveBeenCalledWith({
id: 'agent-app-1',
mode: AppModeEnum.AGENT,
permission_keys: undefined,
})
expect(mockGetRedirection).toHaveBeenCalledWith(
expect.objectContaining({
id: 'agent-app-1',
mode: AppModeEnum.AGENT,
bound_agent_id: 'agent-1',
}),
mockPush,
expect.any(Object),
)
})
it('should close the DSL mismatch modal when dialog requests close', async () => {
vi.useFakeTimers()
mockImportDSL.mockResolvedValue({

View File

@ -27,6 +27,8 @@ import { importDSL, importDSLConfirm } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'
import { getRedirection } from '@/utils/app-redirection'
import { trackCreateApp } from '@/utils/create-app-tracking'
import { getDSLImportWarningDescription } from '@/utils/dsl-import-warning'
import { resolveImportedAppRedirectionTarget } from '@/utils/imported-app-redirection'
import { CreateFromDSLModalTab } from './types'
import Uploader from './uploader'
@ -123,6 +125,7 @@ const CreateFromDSLModal = ({
imported_dsl_version,
current_dsl_version,
permission_keys,
warnings,
} = response
if (
status === DSLImportStatus.COMPLETED ||
@ -142,7 +145,8 @@ const CreateFromDSLModal = ({
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
description:
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' })
? getDSLImportWarningDescription(warnings) ||
t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' })
: undefined,
},
)
@ -150,7 +154,12 @@ const CreateFromDSLModal = ({
invalidateAppList()
if (app_id) {
await handleCheckPluginDependencies(app_id)
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, {
const redirectionTarget = await resolveImportedAppRedirectionTarget({
id: app_id,
mode: app_mode,
permission_keys,
})
getRedirection(redirectionTarget, push, {
currentUserId,
resourceMaintainer: currentUserId,
workspacePermissionKeys,
@ -199,19 +208,40 @@ const CreateFromDSLModal = ({
import_id: importId,
})
const { status, app_id, app_mode, permission_keys } = response
const { status, app_id, app_mode, permission_keys, warnings } = response
if (status === DSLImportStatus.COMPLETED) {
if (
status === DSLImportStatus.COMPLETED ||
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
) {
trackCreateApp({ source: 'studio_upload', appMode: app_mode })
if (onSuccess) onSuccess()
if (onClose) onClose()
toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' }))
toast(
t(
($) => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'],
{ ns: 'app' },
),
{
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
description:
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
? getDSLImportWarningDescription(warnings) ||
t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' })
: undefined,
},
)
if (app_id) await handleCheckPluginDependencies(app_id)
setNeedRefresh('1')
invalidateAppList()
if (app_id) {
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, {
const redirectionTarget = await resolveImportedAppRedirectionTarget({
id: app_id,
mode: app_mode,
permission_keys,
})
getRedirection(redirectionTarget, push, {
currentUserId,
resourceMaintainer: currentUserId,
workspacePermissionKeys,

View File

@ -2,15 +2,8 @@
import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen'
import type { AppListCategory } from './app-type-filter-shared'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { CreateAppDropdown } from '@/app/components/app/create-app-dropdown'
import { SearchInput } from '@/app/components/base/search-input'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import Link from '@/next/link'
@ -88,74 +81,11 @@ export function AppListHeaderFilters({
{t(($) => $['studio.viewSnippets'], { ns: 'app' })}
</Link>
{showCreateButton && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button
variant="primary"
size="medium"
className="gap-0.5 px-2 whitespace-nowrap shadow-xs shadow-shadow-shadow-3"
>
<span aria-hidden className="i-ri-add-line size-4 shrink-0" />
<span className="pl-1">{t(($) => $['operation.create'], { ns: 'common' })}</span>
<span aria-hidden className="i-ri-arrow-down-s-line size-4 shrink-0" />
</Button>
}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-70 p-0">
<div className="py-1">
<DropdownMenuItem
className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary"
onClick={onCreateBlank}
>
<span
aria-hidden
className="i-ri-sticky-note-add-line size-4 shrink-0 text-text-secondary"
/>
<span className="min-w-0 flex-1 truncate px-1">
{t(($) => $['newApp.startFromBlank'], { ns: 'app' })}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary"
onClick={onCreateTemplate}
>
<span
aria-hidden
className="i-ri-apps-2-add-line size-4 shrink-0 text-text-secondary"
/>
<span className="min-w-0 flex-1 truncate px-1">
{t(($) => $['newApp.startFromTemplate'], { ns: 'app' })}
</span>
</DropdownMenuItem>
</div>
<div className="h-px bg-divider-subtle" />
<div className="py-1">
<DropdownMenuItem
className={cn(
'h-auto items-start gap-1 rounded-lg px-2 py-1.5',
'hover:bg-state-base-hover focus:bg-state-base-hover',
)}
onClick={onImportDSL}
>
<span className="flex h-5 shrink-0 items-center py-0.5">
<span
aria-hidden
className="i-ri-file-upload-line size-4 text-text-secondary"
/>
</span>
<span className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-1">
<span className="system-md-regular text-text-secondary">
{t(($) => $.importDSL, { ns: 'app' })}
</span>
<span className="system-xs-regular text-text-tertiary">
{t(($) => $['newApp.dropDSLToCreateApp'], { ns: 'app' })}
</span>
</span>
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
<CreateAppDropdown
onCreateBlank={onCreateBlank}
onCreateTemplate={onCreateTemplate}
onImportDSL={onImportDSL}
/>
)}
</div>
</div>

View File

@ -115,6 +115,35 @@ describe('useDSLByCanEdit', () => {
)
})
it('should download workflow DSL containing portable Agent packages unchanged', async () => {
const agentDSL = `version: 0.7.0
agent_packages:
agent_1:
schema_version: 1
workflow:
graph:
nodes:
- data:
type: agent
version: '2'
agent_binding:
binding_type: inline_agent
package_ref: agent_1
`
mockExportAppConfig.mockResolvedValue({ data: agentDSL })
const { result } = renderHook(() => useDSLByCanEdit(true))
await act(async () => {
await result.current.handleExportDSL()
})
const [{ data, fileName }] = mockDownloadBlob.mock.calls[0] as [
{ data: Blob; fileName: string },
]
expect(await data.text()).toBe(agentDSL)
expect(fileName).toBe('Workflow App.yml')
})
it('should forward include and workflow id arguments when exporting dsl directly', async () => {
const { result } = renderHook(() => useDSLByCanEdit(true))

View File

@ -2,6 +2,7 @@ import { DSLImportStatus } from '@/models/app'
import { AppModeEnum } from '@/types/app'
import { BlockEnum } from '../types'
import {
getImportNotificationPayload,
getInvalidNodeTypes,
isImportCompleted,
normalizeWorkflowFeatures,
@ -94,6 +95,43 @@ workflow:
expect(isImportCompleted(DSLImportStatus.PENDING)).toBe(false)
})
it('should use distinct Agent warning messages in the import notification', () => {
const t = ((key: (selector: Record<string, string>) => string) =>
key({
'common.importSuccess': 'Import succeeded',
'common.importWarning': 'Caution',
'common.importWarningDetails': 'Some configuration may need attention',
})) as never
const payload = getImportNotificationPayload(DSLImportStatus.COMPLETED_WITH_WARNINGS, t, [
{
code: 'agent_file_omitted',
path: 'agent_packages.agent_1.omitted_assets',
message: "Agent file 'brief.pdf' was not included.",
details: {},
},
{
code: 'agent_file_omitted',
path: 'agent_packages.agent_1.omitted_assets',
message: "Agent file 'brief.pdf' was not included.",
details: {},
},
{
code: 'agent_tool_authorization_required',
path: 'agent_packages.agent_1.soul.tools.dify_tools.0',
message: "Agent tool 'web_search' requires authorization.",
details: {},
},
])
expect(payload).toEqual({
type: 'warning',
message: 'Caution',
children:
"Agent file 'brief.pdf' was not included. · Agent tool 'web_search' requires authorization.",
})
})
it('should normalize workflow features with defaults', () => {
const features = normalizeWorkflowFeatures({
file_upload: {

View File

@ -177,6 +177,42 @@ describe('UpdateDSLModal', () => {
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})
it('should show Agent package warnings returned by a completed import', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-with-agent-warnings',
status: DSLImportStatus.COMPLETED_WITH_WARNINGS,
app_id: 'app-1',
warnings: [
{
code: 'agent_file_omitted',
path: 'agent_packages.agent_1.omitted_assets',
message: "Agent file 'brief.pdf' was not included in the portable package.",
details: { kind: 'file', name: 'brief.pdf' },
},
{
code: 'agent_tool_authorization_required',
path: 'agent_packages.agent_1.soul.tools.dify_tools.0',
message: "Agent tool 'web_search' requires authorization.",
details: { tool_name: 'web_search' },
},
],
})
renderModal()
fireEvent.change(screen.getByTestId('dsl-file-input'), {
target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
await waitFor(() => {
expect(toast.warning).toHaveBeenCalledWith('workflow.common.importWarning', {
description:
"Agent file 'brief.pdf' was not included in the portable package. · Agent tool 'web_search' requires authorization.",
})
})
})
it('should show an error notification when import fails', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-1',
@ -225,6 +261,43 @@ describe('UpdateDSLModal', () => {
expect(mockEmitWorkflowUpdate).toHaveBeenCalledWith('app-1')
})
it('should show Agent package warnings returned after confirming a pending import', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-pending-agent',
status: DSLImportStatus.PENDING,
imported_dsl_version: '0.8.0',
current_dsl_version: '0.7.0',
})
mockImportDSLConfirm.mockResolvedValue({
status: DSLImportStatus.COMPLETED_WITH_WARNINGS,
app_id: 'app-1',
warnings: [
{
code: 'agent_secret_required',
path: 'agent_packages.agent_1.soul.env.secret_refs',
message: "Agent secret 'SEARCH_TOKEN' must be configured.",
details: { name: 'SEARCH_TOKEN' },
},
],
})
renderModal()
fireEvent.change(screen.getByTestId('dsl-file-input'), {
target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
const confirmButton = await screen.findByRole('button', { name: 'app.newApp.Confirm' })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(toast.warning).toHaveBeenCalledWith('workflow.common.importWarning', {
description: "Agent secret 'SEARCH_TOKEN' must be configured.",
})
})
})
it('should open the pending modal after the timeout and allow dismissing it', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-5',

View File

@ -10,7 +10,7 @@ import {
import { FlowType } from '@/types/common'
import { renderWorkflowHook } from '../../../__tests__/workflow-test-env'
import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config'
import { useCreateInlineAgentBinding } from '../hooks'
import { useCreateInlineAgentBinding, useWorkflowInlineAgentDetail } from '../hooks'
const mockDefaultModel = vi.hoisted(() => ({
value: {
@ -47,6 +47,68 @@ const mockComposerMutationOptions = vi.hoisted(() =>
mutationFn: mockComposerMutationFn,
})),
)
const mockSnippetComposerMutationFn = vi.hoisted(() =>
vi.fn(async (variables: unknown) => ({
agent_soul: (
variables as {
body?: {
agent_soul?: unknown
}
}
).body?.agent_soul,
binding: {
binding_type: 'inline_agent',
agent_id: 'snippet-inline-agent-1',
current_snapshot_id: 'snippet-inline-snapshot-1',
},
variables,
})),
)
const mockSnippetComposerMutationOptions = vi.hoisted(() =>
vi.fn(() => ({
mutationFn: mockSnippetComposerMutationFn,
})),
)
const mockAppComposerQueryFn = vi.hoisted(() =>
vi.fn(async (): Promise<{ agent?: { id: string } }> => ({ agent: { id: 'app-agent' } })),
)
const mockSnippetComposerQueryFn = vi.hoisted(() =>
vi.fn(async () => ({ agent: { id: 'snippet-agent' } })),
)
const mockAppComposerQueryOptions = vi.hoisted(() =>
vi.fn(
(options: {
input: symbol | { params: { app_id: string; node_id: string } }
refetchInterval?: (query: {
state: {
data?: {
agent?: unknown
}
}
}) => number | false
}) => {
const { input } = options
return {
queryKey:
typeof input === 'symbol'
? ['workflow-agent-composer-disabled']
: ['workflow-agent-composer', input.params.app_id, input.params.node_id],
queryFn: typeof input === 'symbol' ? input : mockAppComposerQueryFn,
refetchInterval: options.refetchInterval,
}
},
),
)
const mockSnippetComposerQueryOptions = vi.hoisted(() =>
vi.fn(({ input }: { input: symbol | { params: { snippet_id: string; node_id: string } } }) => ({
queryKey:
typeof input === 'symbol'
? ['snippet-agent-composer-disabled']
: ['snippet-agent-composer', input.params.snippet_id, input.params.node_id],
queryFn: typeof input === 'symbol' ? input : mockSnippetComposerQueryFn,
})),
)
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
@ -77,6 +139,7 @@ vi.mock('@/service/client', () => ({
byNodeId: {
agentComposer: {
get: {
queryOptions: mockAppComposerQueryOptions,
queryKey: ({
input,
}: {
@ -93,9 +156,107 @@ vi.mock('@/service/client', () => ({
},
},
},
snippets: {
bySnippetId: {
workflows: {
draft: {
nodes: {
byNodeId: {
agentComposer: {
get: {
queryOptions: mockSnippetComposerQueryOptions,
queryKey: ({
input,
}: {
input: { params: { snippet_id: string; node_id: string } }
}) => ['snippet-agent-composer', input.params.snippet_id, input.params.node_id],
},
put: {
mutationOptions: mockSnippetComposerMutationOptions,
},
},
},
},
},
},
},
},
},
}))
describe('useWorkflowInlineAgentDetail', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads inline agent detail through the snippet composer API', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const { result } = renderWorkflowHook(
() => useWorkflowInlineAgentDetail('node-1', 'snippet-agent'),
{
queryClient,
hooksStoreProps: {
configsMap: {
flowId: 'snippet-1',
flowType: FlowType.snippet,
fileSettings: {} as never,
},
},
},
)
await waitFor(() => expect(result.current.data).toEqual({ agent: { id: 'snippet-agent' } }))
expect(mockSnippetComposerQueryOptions).toHaveBeenCalledWith({
input: {
params: {
snippet_id: 'snippet-1',
node_id: 'node-1',
},
},
})
expect(mockSnippetComposerQueryFn).toHaveBeenCalled()
expect(mockAppComposerQueryFn).not.toHaveBeenCalled()
})
it('polls until a copied inline agent composer is created for the new node', async () => {
mockAppComposerQueryFn.mockResolvedValueOnce({ agent: undefined })
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const { result } = renderWorkflowHook(
() =>
useWorkflowInlineAgentDetail('copied-node', 'source-inline-agent', {
pollUntilReady: true,
}),
{
queryClient,
hooksStoreProps: {
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
},
},
},
)
await waitFor(() => expect(mockAppComposerQueryFn).toHaveBeenCalledTimes(2), {
timeout: 1500,
})
expect(result.current.data).toEqual({ agent: { id: 'app-agent' } })
})
})
describe('useCreateInlineAgentBinding', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -188,6 +349,64 @@ describe('useCreateInlineAgentBinding', () => {
)
})
it('creates inline agent through the snippet composer API', async () => {
const onSuccess = vi.fn()
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
retry: false,
},
},
})
const { result } = renderWorkflowHook(() => useCreateInlineAgentBinding(), {
queryClient,
hooksStoreProps: {
configsMap: {
flowId: 'snippet-1',
flowType: FlowType.snippet,
fileSettings: {} as never,
},
},
})
act(() => {
result.current.createInlineAgentBinding('node-1', { onSuccess })
})
await waitFor(() => expect(mockSnippetComposerMutationFn).toHaveBeenCalled())
expect(mockSnippetComposerMutationFn).toHaveBeenCalledWith(
{
params: {
snippet_id: 'snippet-1',
node_id: 'node-1',
},
body: expect.objectContaining({
variant: 'workflow',
save_strategy: 'node_job_only',
binding: {
binding_type: 'inline_agent',
},
}),
},
expect.any(Object),
)
await waitFor(() =>
expect(onSuccess).toHaveBeenCalledWith({
binding_type: 'inline_agent',
agent_id: 'snippet-inline-agent-1',
current_snapshot_id: 'snippet-inline-snapshot-1',
}),
)
expect(queryClient.getQueryData(['snippet-agent-composer', 'snippet-1', 'node-1'])).toEqual(
expect.objectContaining({
binding: expect.objectContaining({
agent_id: 'snippet-inline-agent-1',
}),
}),
)
expect(mockComposerMutationFn).not.toHaveBeenCalled()
})
it('creates inline agent with a model-less initial soul before the default model loads', async () => {
mockDefaultModel.value = undefined as never
const onSuccess = vi.fn()
@ -399,6 +618,77 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
)
})
it('saves inline agent composer changes through the snippet composer API', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
const { result } = renderWorkflowHook(
() =>
useWorkflowInlineAgentConfigureSync({
nodeId: 'node-1',
baseConfig: {
schema_version: 1,
},
enabled: true,
}),
{
queryClient,
hooksStoreProps: {
configsMap: {
flowId: 'snippet-1',
flowType: FlowType.snippet,
fileSettings: {} as never,
},
},
},
)
act(() => {
getDefaultStore().set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
prompt: 'Snippet inline prompt',
})
})
await act(async () => {
await result.current.saveDraft()
})
expect(mockSnippetComposerMutationFn).toHaveBeenCalledWith(
{
params: {
snippet_id: 'snippet-1',
node_id: 'node-1',
},
body: expect.objectContaining({
variant: 'workflow',
save_strategy: 'node_job_only',
agent_soul: expect.objectContaining({
prompt: expect.objectContaining({
system_prompt: 'Snippet inline prompt',
}),
}),
}),
},
expect.any(Object),
)
expect(queryClient.getQueryData(['snippet-agent-composer', 'snippet-1', 'node-1'])).toEqual(
expect.objectContaining({
agent_soul: expect.objectContaining({
schema_version: 1,
}),
}),
)
expect(mockComposerMutationFn).not.toHaveBeenCalled()
})
it('still saves manually when inline agent autosave is disabled', async () => {
const queryClient = new QueryClient({
defaultOptions: {

View File

@ -4,6 +4,7 @@ import type { PromptEditorProps } from '@/app/components/base/prompt-editor'
import type { NodePanelProps } from '@/app/components/workflow/types'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import { FlowType } from '@/types/common'
import { AgentV2Panel } from '../panel'
const {
@ -17,6 +18,7 @@ const {
mockPromptEditorProps,
mockCopyFromRosterMutate,
mockCopyFromRosterState,
mockConfigsMap,
mockCreateInlineAgentBinding,
mockSetInputs,
mockStoreState,
@ -48,6 +50,10 @@ const {
mockCopyFromRosterState: {
isPending: false,
},
mockConfigsMap: {
flowId: 'app-1',
flowType: 'appFlow' as FlowType,
},
mockCreateInlineAgentBinding: vi.fn(),
mockSetInputs: vi.fn(),
mockStoreState: {
@ -307,6 +313,11 @@ vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowVariableType: () => vi.fn(),
}))
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: (selector: (state: { configsMap: typeof mockConfigsMap }) => unknown) =>
selector({ configsMap: mockConfigsMap }),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState),
}))
@ -335,6 +346,8 @@ describe('agent/panel', () => {
mockStoreState.appId = 'app-1'
mockStoreState.openInlineAgentPanelNodeId = undefined
mockCopyFromRosterState.isPending = false
mockConfigsMap.flowId = 'app-1'
mockConfigsMap.flowType = FlowType.appFlow
mockCopyFromRosterMutate.mockImplementation(
(
_variables,
@ -502,6 +515,33 @@ describe('agent/panel', () => {
)
})
it('copies a roster agent through the snippet composer API', () => {
mockConfigsMap.flowId = 'snippet-1'
mockConfigsMap.flowType = FlowType.snippet
mockStoreState.appId = undefined as never
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: {
snippet_id: 'snippet-1',
node_id: 'agent-node',
},
body: {
source_agent_id: 'agent-1',
},
},
expect.objectContaining({
onSuccess: expect.any(Function),
}),
)
})
it('renders a required roster state when no roster agent is selected', () => {
render(
<AgentV2Panel
@ -653,6 +693,76 @@ describe('agent/panel', () => {
expect(screen.getByText('workflow.nodes.agent.task.label')).toBeInTheDocument()
})
it('does not open copied inline agent configuration before its composer is created', () => {
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
mockUseWorkflowInlineAgentDetail.mockReturnValue({
data: undefined,
isFetching: false,
refetch: mockWorkflowInlineAgentDetailRefetch,
})
const { container, rerender } = render(
<AgentV2Panel
id="agent-node"
data={createData({
agent_binding: {
binding_type: 'inline_agent',
agent_id: 'source-inline-agent',
current_snapshot_id: 'source-inline-snapshot',
},
})}
panelProps={panelProps}
/>,
)
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
expect(
screen.queryByRole('button', {
name: /^workflow\.nodes\.agent\.roster\.openPanel/,
}),
).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
mockUseWorkflowInlineAgentDetail.mockReturnValue({
data: {
agent: {
id: 'cloned-inline-agent',
name: 'Cloned Workflow Agent',
description: '',
scope: 'workflow_only',
status: 'active',
},
binding: {
binding_type: 'inline_agent',
agent_id: 'cloned-inline-agent',
current_snapshot_id: 'cloned-inline-snapshot',
},
},
isFetching: false,
refetch: mockWorkflowInlineAgentDetailRefetch,
})
rerender(
<AgentV2Panel
id="agent-node"
data={createData({
agent_binding: {
binding_type: 'inline_agent',
agent_id: 'source-inline-agent',
current_snapshot_id: 'source-inline-snapshot',
},
})}
panelProps={panelProps}
/>,
)
expect(screen.getByRole('dialog', { name: 'Cloned Workflow Agent' })).toBeInTheDocument()
expect(mockOrchestratePanelContentProps.at(-1)).toMatchObject({
agentId: 'cloned-inline-agent',
nodeId: 'agent-node',
open: true,
})
})
it('uses the detail header when opening an existing inline agent from the roster trigger', () => {
const { rerender } = render(
<AgentV2Panel
@ -878,7 +988,7 @@ describe('agent/panel', () => {
expect(screen.queryByRole('button', { name: 'Start from Scratch' })).not.toBeInTheDocument()
})
it('opens the inline panel while workflow composer state is still loading', () => {
it('keeps the inline panel closed while workflow composer state is still loading', () => {
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
mockUseWorkflowInlineAgentDetail.mockReturnValue({
data: undefined,
@ -902,20 +1012,15 @@ describe('agent/panel', () => {
expect(mockUseWorkflowInlineAgentDetail).toHaveBeenCalledWith('agent-node', 'inline-agent-1')
expect(container.querySelector('[aria-busy="true"]')).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' }),
screen.queryByRole('button', {
name: /^workflow\.nodes\.agent\.roster\.openPanel/,
}),
).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(
screen.queryByRole('region', { name: 'inline-orchestrate-panel' }),
).not.toBeInTheDocument()
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
expect(mockOrchestratePanelContentProps.at(-1)).toMatchObject({
agentId: 'inline-agent-1',
inlineComposerState: undefined,
nodeId: 'agent-node',
open: true,
})
})
it('recovers the inline setup panel open state from the node open marker', () => {

View File

@ -85,9 +85,12 @@ export function useWorkflowInlineAgentConfigureSync({
const enabledRef = useRef(enabled)
const onDraftSavedRef = useRef(onDraftSaved)
const lastAutosavedDraftKeyRef = useRef<string | undefined>(undefined)
const saveComposerMutation = useMutation(
const { mutateAsync: saveAppComposer } = useMutation(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions(),
)
const { mutateAsync: saveSnippetComposer } = useMutation(
consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions(),
)
baseConfigRef.current = baseConfig
currentModelRef.current = currentModel
@ -106,32 +109,62 @@ export function useWorkflowInlineAgentConfigureSync({
const saveComposer = useSerialAsyncCallback(
async (configSnapshot: AgentSoulConfig): Promise<WorkflowAgentComposerResponse | undefined> => {
if (!configsMap?.flowId || configsMap.flowType !== FlowType.appFlow) return
if (
!configsMap?.flowId ||
(configsMap.flowType !== FlowType.appFlow && configsMap.flowType !== FlowType.snippet)
)
return
const savedDraftKey = JSON.stringify(configSnapshot)
const composerState = await saveComposerMutation.mutateAsync({
params: {
app_id: configsMap.flowId,
node_id: nodeId,
},
body: {
variant: 'workflow',
save_strategy: 'node_job_only',
agent_soul: configSnapshot,
},
})
const body = {
variant: 'workflow' as const,
save_strategy: 'node_job_only' as const,
agent_soul: configSnapshot,
}
const composerState =
configsMap.flowType === FlowType.snippet
? await saveSnippetComposer({
params: {
snippet_id: configsMap.flowId,
node_id: nodeId,
},
body,
})
: await saveAppComposer({
params: {
app_id: configsMap.flowId,
node_id: nodeId,
},
body,
})
queryClient.setQueryData(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
input: {
params: {
app_id: configsMap.flowId,
node_id: nodeId,
if (configsMap.flowType === FlowType.snippet) {
queryClient.setQueryData(
consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey(
{
input: {
params: {
snippet_id: configsMap.flowId,
node_id: nodeId,
},
},
},
},
}),
composerState,
)
),
composerState,
)
} else {
queryClient.setQueryData(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
input: {
params: {
app_id: configsMap.flowId,
node_id: nodeId,
},
},
}),
composerState,
)
}
setOriginalConfig(composerState.agent_soul)
setOriginalDraft(agentSoulConfigToFormState(composerState.agent_soul))
setDraftSavedAt(Date.now())

View File

@ -5,6 +5,7 @@ import type {
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { FlowType } from '@/types/common'
import { WorkflowInlineAgentConfigureWorkspace } from '../agent-orchestrate-panel-content'
const mocks = vi.hoisted(() => ({
@ -464,7 +465,8 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
<QueryClientProvider client={queryClient}>
<WorkflowInlineAgentConfigureWorkspace
agentId="agent-1"
appId="app-1"
flowId="app-1"
flowType={FlowType.appFlow}
inlineComposerState={props.inlineComposerState ?? createInlineComposerState()}
nodeId="node-1"
onSaveInlineToRoster={props.onSaveInlineToRoster}
@ -1066,7 +1068,8 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
<QueryClientProvider client={new QueryClient()}>
<WorkflowInlineAgentConfigureWorkspace
agentId="agent-1"
appId="app-1"
flowId="app-1"
flowType={FlowType.appFlow}
inlineComposerState={createInlineComposerState({ snapshotId: 'snapshot-2' })}
nodeId="node-1"
open

View File

@ -1,6 +1,7 @@
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 { FlowType } from '@/types/common'
import { SaveInlineAgentToRosterDialog } from '../save-inline-agent-to-roster-dialog'
const mutationMock = vi.hoisted(() => ({
@ -68,6 +69,25 @@ vi.mock('@/service/client', () => ({
},
},
},
snippets: {
bySnippetId: {
workflows: {
draft: {
nodes: {
byNodeId: {
agentComposer: {
saveToRoster: {
post: {
mutationOptions: vi.fn(() => ({})),
},
},
},
},
},
},
},
},
},
},
}))
@ -90,7 +110,8 @@ const renderDialog = (agent: AgentComposerAgentResponse = inlineAgent) => {
render(
<SaveInlineAgentToRosterDialog
appId="app-1"
flowId="app-1"
flowType={FlowType.appFlow}
formKey={1}
initialAgent={agent}
nodeId="node-1"
@ -155,6 +176,46 @@ describe('SaveInlineAgentToRosterDialog', () => {
expect(mutationOptions).not.toHaveProperty('onError')
})
it('saves the inline agent to roster through the snippet composer API', async () => {
const user = userEvent.setup()
render(
<SaveInlineAgentToRosterDialog
flowId="snippet-1"
flowType={FlowType.snippet}
formKey={1}
initialAgent={inlineAgent}
nodeId="node-1"
open
onOpenChange={vi.fn()}
onSaved={vi.fn()}
/>,
)
const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' })
await user.type(
within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }),
'Snippet Agent',
)
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
expect(mutationMock.mutate).toHaveBeenCalledWith(
{
params: {
snippet_id: 'snippet-1',
node_id: 'node-1',
},
body: expect.objectContaining({
variant: 'workflow',
save_strategy: 'save_to_roster',
new_agent_name: 'Snippet Agent',
}),
},
expect.objectContaining({
onSuccess: expect.any(Function),
}),
)
})
it('submits the visible default icon when the inline agent has no icon metadata', async () => {
const user = userEvent.setup()
renderDialog({

View File

@ -63,6 +63,7 @@ import {
useAgentConfigureBuildDraftData,
} from '@/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft'
import { consoleQuery } from '@/service/client'
import { FlowType } from '@/types/common'
import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config'
type WorkflowRosterAgentOrchestratePanelContentProps = {
@ -73,7 +74,8 @@ type WorkflowRosterAgentOrchestratePanelContentProps = {
type WorkflowInlineAgentConfigureWorkspaceProps = {
agentId?: string
appId?: string
flowId?: string
flowType?: FlowType
inlineComposerState?: WorkflowAgentComposerResponse
nodeId: string
onClose?: () => void
@ -277,8 +279,9 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
activeConfigSnapshot,
agentId,
agentSoulConfig,
appId,
buildDraft,
flowId,
flowType,
inlineComposerState,
nodeId,
onClose,
@ -301,6 +304,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
null,
)
const [workflowRunId, setWorkflowRunId] = useState<string | null>(null)
const appId = flowType === FlowType.appFlow ? flowId : undefined
const conversationIds = useAtomValue(agentConfigureConversationIdsAtom)
const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom)
const workingDirectoryPanel = useAgentWorkingDirectoryPanel({
@ -354,27 +358,54 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
}
},
)
if (!appId) return
if (!flowId) return
queryClient.setQueryData<WorkflowAgentComposerResponse | undefined>(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
input: {
params: {
app_id: appId,
node_id: nodeId,
if (flowType === FlowType.snippet) {
queryClient.setQueryData<WorkflowAgentComposerResponse | undefined>(
consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey(
{
input: {
params: {
snippet_id: flowId,
node_id: nodeId,
},
},
},
},
}),
(composerState) =>
composerState
? {
...composerState,
debug_conversation_has_messages,
debug_conversation_id,
debug_conversation_message_count,
}
: composerState,
)
),
(composerState) =>
composerState
? {
...composerState,
debug_conversation_has_messages,
debug_conversation_id,
debug_conversation_message_count,
}
: composerState,
)
return
}
if (flowType === FlowType.appFlow) {
queryClient.setQueryData<WorkflowAgentComposerResponse | undefined>(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
input: {
params: {
app_id: flowId,
node_id: nodeId,
},
},
}),
(composerState) =>
composerState
? {
...composerState,
debug_conversation_has_messages,
debug_conversation_id,
debug_conversation_message_count,
}
: composerState,
)
}
},
}),
)

View File

@ -3,6 +3,7 @@
import type {
AgentComposerAgentResponse,
AgentComposerBindingResponse,
WorkflowAgentComposerResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type {
AgentFormValues,
@ -28,9 +29,11 @@ import {
} from '@/features/agent-v2/roster/components/agent-form'
import { AgentFormFields } from '@/features/agent-v2/roster/components/agent-form-fields'
import { consoleQuery } from '@/service/client'
import { FlowType } from '@/types/common'
type SaveInlineAgentToRosterDialogProps = {
appId?: string
flowId?: string
flowType?: FlowType
formKey: number
initialAgent?: AgentComposerAgentResponse | null
nodeId: string
@ -40,7 +43,8 @@ type SaveInlineAgentToRosterDialogProps = {
}
export function SaveInlineAgentToRosterDialog({
appId,
flowId,
flowType,
formKey,
initialAgent,
nodeId,
@ -57,9 +61,14 @@ export function SaveInlineAgentToRosterDialog({
const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() =>
initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon,
)
const saveToRosterMutation = useMutation(
const appSaveToRosterMutation = useMutation(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(),
)
const snippetSaveToRosterMutation = useMutation(
consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(),
)
const isSavingToRoster =
appSaveToRosterMutation.isPending || snippetSaveToRosterMutation.isPending
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
@ -74,41 +83,60 @@ export function SaveInlineAgentToRosterDialog({
}
const handleSubmit = (formValues: AgentFormValues) => {
if (saveToRosterMutation.isPending) return
if (isSavingToRoster) return
if (!appId) return
if (!flowId) 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,
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
const body = {
variant: 'workflow' as const,
save_strategy: 'save_to_roster' as const,
new_agent_name: trimmedName,
description: formValues.description?.trim() ?? '',
role: trimmedRole,
icon_type: agentIcon.type,
icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon,
icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined,
}
const options = {
onSuccess: (composerState: WorkflowAgentComposerResponse) => {
const binding = composerState.binding
if (binding?.binding_type !== 'roster_agent' || !binding.agent_id) return
toast.success(t(($) => $['roster.saveToRosterSuccess']))
onSaved(binding)
handleOpenChange(false)
},
toast.success(t(($) => $['roster.saveToRosterSuccess']))
onSaved(binding)
handleOpenChange(false)
},
)
}
if (flowType === FlowType.snippet) {
snippetSaveToRosterMutation.mutate(
{
params: {
snippet_id: flowId,
node_id: nodeId,
},
body,
},
options,
)
return
}
if (flowType === FlowType.appFlow) {
appSaveToRosterMutation.mutate(
{
params: {
app_id: flowId,
node_id: nodeId,
},
body,
},
options,
)
}
}
return (
@ -145,7 +173,7 @@ export function SaveInlineAgentToRosterDialog({
type="button"
className="min-w-18"
onClick={() => handleOpenChange(false)}
disabled={saveToRosterMutation.isPending}
disabled={isSavingToRoster}
>
{tCommon(($) => $['operation.cancel'])}
</Button>
@ -153,7 +181,7 @@ export function SaveInlineAgentToRosterDialog({
type="submit"
variant="primary"
className="min-w-18"
loading={saveToRosterMutation.isPending}
loading={isSavingToRoster}
>
{tCommon(($) => $['operation.save'])}
</Button>

View File

@ -20,6 +20,8 @@ type CreateInlineAgentBindingOptions = {
onSuccess?: (binding: CreatedInlineAgentBinding) => void
}
const INLINE_AGENT_CREATION_REFETCH_INTERVAL = 1000
export function useAgentRosterDetail(agentId?: string) {
return useQuery(
consoleQuery.agent.byAgentId.get.queryOptions({
@ -34,10 +36,27 @@ export function useAgentRosterDetail(agentId?: string) {
)
}
export function useWorkflowInlineAgentDetail(nodeId?: string, agentId?: string | null) {
export function useWorkflowInlineAgentDetail(
nodeId?: string,
agentId?: string | null,
options?: {
pollUntilReady?: boolean
},
) {
const configsMap = useHooksStore((state) => state.configsMap)
const refetchUntilReady = options?.pollUntilReady
? {
refetchInterval: (query: {
state: {
data?: {
agent?: unknown
}
}
}) => (query.state.data?.agent ? false : INLINE_AGENT_CREATION_REFETCH_INTERVAL),
}
: {}
return useQuery(
const appComposerQuery = useQuery(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryOptions({
input:
configsMap?.flowId && configsMap.flowType === FlowType.appFlow && nodeId && agentId
@ -48,8 +67,27 @@ export function useWorkflowInlineAgentDetail(nodeId?: string, agentId?: string |
},
}
: skipToken,
...refetchUntilReady,
}),
)
const snippetComposerQuery = useQuery(
consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryOptions(
{
input:
configsMap?.flowId && configsMap.flowType === FlowType.snippet && nodeId && agentId
? {
params: {
snippet_id: configsMap.flowId,
node_id: nodeId,
},
}
: skipToken,
...refetchUntilReady,
},
),
)
return configsMap?.flowType === FlowType.snippet ? snippetComposerQuery : appComposerQuery
}
export function useCreateInlineAgentBinding() {
@ -57,36 +95,53 @@ export function useCreateInlineAgentBinding() {
const configsMap = useHooksStore((state) => state.configsMap)
const { data: defaultModel } = useDefaultModel(ModelTypeEnum.textGeneration)
const queryClient = useQueryClient()
const { isPending, mutateAsync } = useMutation(
const { isPending: isAppComposerPending, mutateAsync: mutateAppComposerAsync } = useMutation(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions(),
)
const { isPending: isSnippetComposerPending, mutateAsync: mutateSnippetComposerAsync } =
useMutation(
consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions(),
)
const createInlineAgentBinding = useCallback(
async (nodeId: string, options?: CreateInlineAgentBindingOptions) => {
if (!configsMap?.flowId || configsMap.flowType !== FlowType.appFlow) {
if (
!configsMap?.flowId ||
(configsMap.flowType !== FlowType.appFlow && configsMap.flowType !== FlowType.snippet)
) {
toast.error(t(($) => $['roster.nodeSelector.createInlineFailed']))
options?.onError?.()
return
}
try {
const composerState = await mutateAsync({
params: {
app_id: configsMap.flowId,
node_id: nodeId,
const body = {
variant: 'workflow' as const,
save_strategy: 'node_job_only' as const,
binding: {
binding_type: 'inline_agent' as const,
},
body: {
variant: 'workflow',
save_strategy: 'node_job_only',
binding: {
binding_type: 'inline_agent',
},
soul_lock: {
locked: false,
},
agent_soul: getDefaultAgentSoul(defaultModel),
soul_lock: {
locked: false,
},
})
agent_soul: getDefaultAgentSoul(defaultModel),
}
const composerState =
configsMap.flowType === FlowType.snippet
? await mutateSnippetComposerAsync({
params: {
snippet_id: configsMap.flowId,
node_id: nodeId,
},
body,
})
: await mutateAppComposerAsync({
params: {
app_id: configsMap.flowId,
node_id: nodeId,
},
body,
})
const binding = composerState.binding
if (
@ -99,17 +154,33 @@ export function useCreateInlineAgentBinding() {
return
}
queryClient.setQueryData(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
input: {
params: {
app_id: configsMap.flowId,
node_id: nodeId,
if (configsMap.flowType === FlowType.snippet) {
queryClient.setQueryData(
consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey(
{
input: {
params: {
snippet_id: configsMap.flowId,
node_id: nodeId,
},
},
},
},
}),
composerState,
)
),
composerState,
)
} else {
queryClient.setQueryData(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
input: {
params: {
app_id: configsMap.flowId,
node_id: nodeId,
},
},
}),
composerState,
)
}
options?.onSuccess?.({
binding_type: 'inline_agent',
agent_id: binding.agent_id,
@ -119,11 +190,19 @@ export function useCreateInlineAgentBinding() {
options?.onError?.()
}
},
[configsMap?.flowId, configsMap?.flowType, defaultModel, mutateAsync, queryClient, t],
[
configsMap?.flowId,
configsMap?.flowType,
defaultModel,
mutateAppComposerAsync,
mutateSnippetComposerAsync,
queryClient,
t,
],
)
return {
createInlineAgentBinding,
isCreatingInlineAgent: isPending,
isCreatingInlineAgent: isAppComposerPending || isSnippetComposerPending,
}
}

View File

@ -1,6 +1,7 @@
import type {
AgentComposerBindingResponse,
DeclaredOutputConfig,
WorkflowAgentComposerResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type { AgentRosterNodeData } from '../../block-selector/types'
import type { NodePanelProps } from '../../types'
@ -17,8 +18,10 @@ import {
replaceAgentOutputName,
} from '@/app/components/base/prompt-editor/plugins/agent-output-block/utils'
import { useNodeDataUpdate } from '@/app/components/workflow/hooks'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { useStore } from '@/app/components/workflow/store'
import { consoleQuery } from '@/service/client'
import { FlowType } from '@/types/common'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { AgentAdvancedSettings } from './components/agent-advanced-settings'
import {
@ -127,7 +130,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
const { handleNodeDataUpdate, handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const openInlineAgentPanelNodeId = useStore((state) => state.openInlineAgentPanelNodeId)
const setOpenInlineAgentPanelNodeId = useStore((state) => state.setOpenInlineAgentPanelNodeId)
const appId = useStore((state) => state.appId)
const configsMap = useHooksStore((state) => state.configsMap)
const drawerPortalContainerRef = useRef<HTMLDivElement>(null)
const [localDeclaredOutputs, setLocalDeclaredOutputs] = useState<DeclaredOutputConfig[] | null>(
null,
@ -137,26 +140,40 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
inputs.agent_binding?.binding_type === 'roster_agent'
? inputs.agent_binding.agent_id
: undefined
const inlineAgentId =
const sourceInlineAgentId =
inputs.agent_binding?.binding_type === 'inline_agent'
? inputs.agent_binding.agent_id
: undefined
const isInlineAgentReady = hasValidInlineAgentBinding(inputs)
const isInlineAgentPending =
inputs.agent_binding?.binding_type === 'inline_agent' && !isInlineAgentReady
const isInlineAgentPanelOpen =
(isInlineAgentReady || isInlineAgentPending) && openInlineAgentPanelNodeId === id
const rosterAgentQuery = useAgentRosterDetail(rosterAgentId)
const inlineAgentQuery = useWorkflowInlineAgentDetail(id, inlineAgentId)
const inlineAgentQuery = useWorkflowInlineAgentDetail(id, sourceInlineAgentId, {
pollUntilReady: isInlineAgentReady,
})
const { createInlineAgentBinding, isCreatingInlineAgent } = useCreateInlineAgentBinding()
const inlineAgent = inlineAgentQuery.data?.agent
const { isPending: isCopyingFromRoster, mutate: copyFromRoster } = useMutation(
const inlineAgentBinding = inlineAgentQuery.data?.binding
const inlineAgentId =
inlineAgentBinding?.binding_type === 'inline_agent' && inlineAgentBinding.agent_id
? inlineAgentBinding.agent_id
: sourceInlineAgentId
const isInlineAgentCreated = isInlineAgentReady && !!inlineAgent
const isInlineAgentWaitingForCreation = isInlineAgentReady && !isInlineAgentCreated
const isInlineAgentPanelOpen =
(isInlineAgentCreated || isInlineAgentPending) && openInlineAgentPanelNodeId === id
const { isPending: isAppCopyingFromRoster, mutate: copyFromRosterApp } = useMutation(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.copyFromRoster.post.mutationOptions(),
)
const { isPending: isSnippetCopyingFromRoster, mutate: copyFromRosterSnippet } = useMutation(
consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.copyFromRoster.post.mutationOptions(),
)
const isCopyingFromRoster = isAppCopyingFromRoster || isSnippetCopyingFromRoster
const isAgentPanelOpen =
isInlineAgentReady || isInlineAgentPending ? isInlineAgentPanelOpen : isRosterAgentPanelOpen
const isInlineAgentLoading = isInlineAgentPending || (isInlineAgentReady && !inlineAgent)
const isAgentBindingPending = isInlineAgentPending || isCreatingInlineAgent
isInlineAgentCreated || isInlineAgentPending ? isInlineAgentPanelOpen : isRosterAgentPanelOpen
const isInlineAgentLoading = isInlineAgentPending || isInlineAgentWaitingForCreation
const isAgentBindingPending =
isInlineAgentPending || isInlineAgentWaitingForCreation || isCreatingInlineAgent
const canStartFromScratch = inputs.agent_binding?.binding_type !== 'inline_agent'
const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent
const inlineComposerStateForPanel = inlineAgentQuery.data
@ -256,65 +273,90 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
[handleNodeDataUpdateWithSyncDraft, id, inputs, setOpenInlineAgentPanelNodeId],
)
const handleRosterCopySuccess = useCallback(
(composerState: WorkflowAgentComposerResponse) => {
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,
},
)
},
[handleNodeDataUpdateWithSyncDraft, id, setOpenInlineAgentPanelNodeId],
)
const handleMakeRosterCopy = useCallback(() => {
if (!appId || !rosterAgentId || isCopyingFromRoster) return
if (!configsMap?.flowId || !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
}
const body = {
source_agent_id: rosterAgentId,
}
const options = {
onSuccess: handleRosterCopySuccess,
}
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,
},
)
if (configsMap.flowType === FlowType.snippet) {
copyFromRosterSnippet(
{
params: {
snippet_id: configsMap.flowId,
node_id: id,
},
body,
},
},
)
options,
)
return
}
if (configsMap.flowType === FlowType.appFlow) {
copyFromRosterApp(
{
params: {
app_id: configsMap.flowId,
node_id: id,
},
body,
},
options,
)
}
}, [
appId,
copyFromRoster,
handleNodeDataUpdateWithSyncDraft,
configsMap?.flowId,
configsMap?.flowType,
copyFromRosterApp,
copyFromRosterSnippet,
handleRosterCopySuccess,
id,
isCopyingFromRoster,
rosterAgentId,
setOpenInlineAgentPanelNodeId,
])
const handleSaveInlineToRosterOpen = useCallback(() => {
@ -446,6 +488,8 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
const handleAgentPanelOpenChange = useCallback(
(open: boolean) => {
if (open && isInlineAgentWaitingForCreation) return
if (isInlineAgentReady || isInlineAgentPending) {
if (open) setIsInlineAgentPanelOpenedFromTrigger(true)
@ -470,6 +514,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
isCreatingInlineAgent,
isInlineAgentPending,
isInlineAgentReady,
isInlineAgentWaitingForCreation,
setOpenInlineAgentPanelNodeId,
],
)
@ -574,9 +619,9 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
/>
<div className="border-b border-divider-subtle">
<AgentRosterField
agent={displayedAgent}
agent={isInlineAgentWaitingForCreation ? undefined : displayedAgent}
agentId={rosterAgentId ?? inlineAgentId ?? (isInlineAgentPending ? id : undefined)}
canOpenPanel
canOpenPanel={!isInlineAgentWaitingForCreation}
isInlineSetup={isInlineAgentReady || isInlineAgentPending}
isLoading={isInlineAgentLoading}
isPanelCopyPending={isCopyingFromRoster}
@ -587,7 +632,8 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
isInlineAgentReady || isInlineAgentPending ? (
<WorkflowInlineAgentConfigureWorkspace
agentId={inlineAgentId ?? undefined}
appId={appId}
flowId={configsMap?.flowId}
flowType={configsMap?.flowType}
inlineComposerState={inlineComposerStateForPanel}
nodeId={id}
onClose={() => handleAgentPanelOpenChange(false)}
@ -621,7 +667,8 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
/>
<SaveInlineAgentToRosterDialog
key={saveToRosterSessionKey}
appId={appId}
flowId={configsMap?.flowId}
flowType={configsMap?.flowType}
formKey={saveToRosterSessionKey}
initialAgent={inlineAgent}
nodeId={id}

View File

@ -1,8 +1,10 @@
import type { TFunction } from 'i18next'
import type { CommonNodeType, Node } from './types'
import type { DSLImportWarning } from '@/models/app'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { DSLImportStatus } from '@/models/app'
import { AppModeEnum } from '@/types/app'
import { getDSLImportWarningDescription } from '@/utils/dsl-import-warning'
import { loadYaml } from '@/utils/yaml'
import { BlockEnum, SupportUploadFileTypes } from './types'
@ -75,6 +77,7 @@ export const isImportCompleted = (status: DSLImportStatus) => {
export const getImportNotificationPayload = (
status: DSLImportStatus,
t: TFunction,
warnings: DSLImportWarning[] = [],
): ImportNotificationPayload => {
return {
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
@ -84,7 +87,8 @@ export const getImportNotificationPayload = (
: t(($) => $['common.importWarning'], { ns: 'workflow' }),
children:
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
? t(($) => $['common.importWarningDetails'], { ns: 'workflow' })
? getDSLImportWarningDescription(warnings) ||
t(($) => $['common.importWarningDetails'], { ns: 'workflow' })
: undefined,
}
}

View File

@ -1,6 +1,7 @@
'use client'
import type { MouseEventHandler } from 'react'
import type { DSLImportWarning } from '@/models/app'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
@ -82,7 +83,7 @@ const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) =
const isCreatingRef = useRef(false)
const handleCompletedImport = useCallback(
async (status: DSLImportStatus, appId?: string) => {
async (status: DSLImportStatus, appId?: string, warnings: DSLImportWarning[] = []) => {
if (!appId) {
toast.error(t(($) => $['common.importFailure'], { ns: 'workflow' }))
return
@ -91,7 +92,7 @@ const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) =
await handleWorkflowUpdate(appId)
collaborationManager.emitWorkflowUpdate(appId)
onImport?.()
const payload = getImportNotificationPayload(status, t)
const payload = getImportNotificationPayload(status, t, warnings)
toast[payload.type](
payload.message,
payload.children ? { description: payload.children } : undefined,
@ -133,10 +134,10 @@ const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) =
yaml_content: fileContent,
app_id: appDetail.id,
})
const { id, status, app_id, imported_dsl_version, current_dsl_version } = response
const { id, status, app_id, imported_dsl_version, current_dsl_version, warnings } = response
if (isImportCompleted(status)) {
await handleCompletedImport(status, app_id)
await handleCompletedImport(status, app_id, warnings)
} else if (status === DSLImportStatus.PENDING) {
handlePendingImport(id, imported_dsl_version, current_dsl_version)
} else {
@ -160,10 +161,10 @@ const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) =
import_id: importId,
})
const { status, app_id } = response
const { status, app_id, warnings } = response
if (isImportCompleted(status)) {
await handleCompletedImport(status, app_id)
await handleCompletedImport(status, app_id, warnings)
} else if (status === DSLImportStatus.FAILED) {
setLoading(false)
toast.error(t(($) => $['common.importFailure'], { ns: 'workflow' }))

View File

@ -1,4 +1,5 @@
import { render, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import WorkflowPreview from '../index'
const defaultViewport = {
@ -39,4 +40,38 @@ describe('WorkflowPreview', () => {
expect(container.querySelector('.react-flow__minimap')).toHaveClass('right-4!')
expect(container.querySelector('.react-flow__minimap')).not.toHaveClass('left-4!')
})
it('should render a portable Agent v2 node without failing the preview', async () => {
render(
<div style={{ width: 800, height: 600 }}>
<WorkflowPreview
nodes={
[
{
id: 'agent-v2-1',
type: 'custom',
position: { x: 100, y: 100 },
data: {
type: BlockEnum.Agent,
version: '2',
agent_node_kind: 'dify_agent',
title: 'Research Agent',
desc: 'Handles research tasks',
agent_binding: {
binding_type: 'roster_agent',
package_ref: 'agent_1',
},
},
},
] as never
}
edges={[]}
viewport={defaultViewport}
/>
</div>,
)
expect(await screen.findByText('Research Agent')).toBeInTheDocument()
expect(screen.getByText('Handles research tasks')).toBeInTheDocument()
})
})

View File

@ -12,6 +12,40 @@ import {
} from '../store'
describe('agent composer store conversions', () => {
it('should hydrate missing file and skill references from API config', () => {
const formState = agentSoulConfigToFormState({
config_files: [
{
file_id: 'missing-file-id',
file_kind: 'upload_file',
is_missing: true,
name: 'missing.pdf',
},
],
config_skills: [
{
file_id: 'missing-skill-id',
file_kind: 'tool_file',
is_missing: true,
name: 'Missing Skill',
},
],
} as unknown as AgentSoulConfig)
expect(formState.files).toEqual([
expect.objectContaining({
isMissing: true,
name: 'missing.pdf',
}),
])
expect(formState.skills).toEqual([
expect.objectContaining({
isMissing: true,
name: 'Missing Skill',
}),
])
})
it('rebases draft baselines through the composer state action', () => {
const store = createStore()
const nextDraft = {

View File

@ -495,12 +495,16 @@ const toConfigFileConfigs = (
})
}
const isConfigReferenceMissing = (reference: object) =>
'is_missing' in reference && reference.is_missing === true
const toSkillFormState = (config?: AgentSoulConfig): AgentSkill[] => {
return (config?.config_skills ?? []).map((skill) => ({
id: skill.name,
name: skill.name,
description: skill.description ?? undefined,
fileId: skill.file_id,
isMissing: isConfigReferenceMissing(skill) || undefined,
size: skill.size ?? undefined,
hash: skill.hash ?? undefined,
mimeType: skill.mime_type ?? undefined,
@ -513,6 +517,7 @@ const toFileFormState = (config?: AgentSoulConfig): AgentFileNode[] => {
name: file.name,
icon: getFileIconType(file.name, file.mime_type ?? undefined),
fileId: file.file_id,
isMissing: isConfigReferenceMissing(file) || undefined,
configName: file.name,
size: file.size ?? undefined,
hash: file.hash ?? undefined,

View File

@ -36,6 +36,7 @@ export type AgentSkill = {
fileId?: string
hash?: string
id: string
isMissing?: boolean
mimeType?: string
name: string
size?: number
@ -47,6 +48,7 @@ export type AgentFileNode = {
hash?: string
id: string
icon: FileTreeIconType
isMissing?: boolean
fileId?: string
configName?: string
children?: AgentFileNode[]

View File

@ -0,0 +1,20 @@
import type { ReactElement, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { MissingReferenceWarning } from '../missing-reference-warning'
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <div role="tooltip">{children}</div>,
TooltipTrigger: ({ render }: { render: ReactElement }) => render,
}))
describe('MissingReferenceWarning', () => {
it('should render a warning icon with matching accessible and tooltip labels', () => {
render(<MissingReferenceWarning label="File not found" />)
const warning = screen.getByRole('button', { name: 'File not found' })
expect(warning.querySelector('.i-ri-alert-fill')).toBeInTheDocument()
expect(screen.getByRole('tooltip')).toHaveTextContent('File not found')
})
})

View File

@ -0,0 +1,32 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
export function MissingReferenceWarning({
className,
label,
}: {
className?: string
label: string
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={label}
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-md outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
className,
)}
>
<span aria-hidden className="i-ri-alert-fill size-3.5 text-text-warning-secondary" />
</button>
}
/>
<TooltipContent aria-label={label}>{label}</TooltipContent>
</Tooltip>
)
}

View File

@ -211,6 +211,53 @@ describe('AgentFiles', () => {
}))
})
it('should prevent missing files from being previewed or downloaded', async () => {
const user = userEvent.setup()
renderAgentFiles({
initialDraft: createInitialDraft({
files: [
{
id: 'missing.pdf',
name: 'missing.pdf',
icon: 'pdf',
fileId: 'missing-file-id',
configName: 'missing.pdf',
isMissing: true,
},
{
id: 'available.pdf',
name: 'available.pdf',
icon: 'pdf',
fileId: 'available-file-id',
configName: 'available.pdf',
},
],
}),
})
const warning = screen.getByRole('button', {
name: 'agentV2.agentDetail.configure.files.missing',
})
expect(warning.querySelector('.i-ri-alert-fill')).toBeInTheDocument()
expect(
screen.getAllByRole('button', {
name: 'agentV2.agentDetail.configure.files.missing',
}),
).toHaveLength(1)
expect(screen.getByRole('button', { name: 'missing.pdf' })).toBeDisabled()
expect(
screen.queryByRole('button', {
name: /agentV2\.agentDetail\.configure\.files\.download.*missing\.pdf/,
}),
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'available.pdf' }))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).queryByText('missing.pdf')).not.toBeInTheDocument()
expect(within(dialog).getAllByText('available.pdf').length).toBeGreaterThan(0)
})
it('should delete configured files by config name', async () => {
const { container } = renderAgentFiles()

View File

@ -4,6 +4,7 @@ import type { MouseEvent, ReactNode } from 'react'
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
import type { AgentConfigApiContext } from '../config-context'
import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogTrigger } from '@langgenius/dify-ui/dialog'
import {
FileTreeBadge,
@ -30,6 +31,7 @@ import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context'
import { ConfigureSectionAddButton } from '../common/add-button'
import { DocsLink } from '../common/docs-link'
import { ConfigureSectionEmpty } from '../common/empty'
import { MissingReferenceWarning } from '../common/missing-reference-warning'
import { ConfigureSection } from '../common/section'
import { AgentConfigureTipContent } from '../common/tip-content'
import { useAgentConfigApiContext } from '../config-context'
@ -216,6 +218,8 @@ function AgentFileItem({
)
const handleDownload = useCallback(
async (event: MouseEvent<HTMLButtonElement>) => {
if (file.isMissing) return
event.stopPropagation()
await downloadFile(file)
},
@ -223,10 +227,12 @@ function AgentFileItem({
)
const handlePreviewOpenChange = useCallback(
(open: boolean) => {
if (open && file.isMissing) return
if (open) setSelectedFileId(file.id)
setIsPreviewOpen(open)
},
[file.id],
[file.id, file.isMissing],
)
const canRemoveFile = !readOnly && (!file.virtualContent || isBuildNoteFile)
@ -241,7 +247,11 @@ function AgentFileItem({
<button
type="button"
aria-current={selected ? 'true' : undefined}
className="group/file-tree-row relative flex h-full min-w-0 flex-1 cursor-pointer items-center rounded-md pl-2 text-left outline-hidden select-none focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
disabled={file.isMissing}
className={cn(
'group/file-tree-row relative flex h-full min-w-0 flex-1 cursor-pointer items-center rounded-md pl-2 text-left outline-hidden select-none focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid',
file.isMissing && 'cursor-default pr-6',
)}
/>
}
>
@ -273,15 +283,28 @@ function AgentFileItem({
}}
/>
</Dialog>
<div className="pointer-events-none absolute top-1/2 right-1 z-10 flex -translate-y-1/2 items-center justify-end gap-1 opacity-0 group-focus-within/file-row:pointer-events-auto group-focus-within/file-row:opacity-100 group-hover/file-row:pointer-events-auto group-hover/file-row:opacity-100">
<button
type="button"
aria-label={t(($) => $['agentDetail.configure.files.download'], { name: file.name })}
onClick={handleDownload}
className="flex size-5 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-ri-download-line size-4" />
</button>
{file.isMissing && (
<MissingReferenceWarning
className="absolute top-1/2 right-1 -translate-y-1/2"
label={t(($) => $['agentDetail.configure.files.missing'])}
/>
)}
<div
className={cn(
'pointer-events-none absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-end gap-1 opacity-0 group-focus-within/file-row:pointer-events-auto group-focus-within/file-row:opacity-100 group-hover/file-row:pointer-events-auto group-hover/file-row:opacity-100',
file.isMissing ? 'right-7' : 'right-1',
)}
>
{!file.isMissing && (
<button
type="button"
aria-label={t(($) => $['agentDetail.configure.files.download'], { name: file.name })}
onClick={handleDownload}
className="flex size-5 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-ri-download-line size-4" />
</button>
)}
{canRemoveFile && (
<button
type="button"
@ -362,6 +385,7 @@ export function AgentFiles() {
const upsertAgentFile = useSetAtom(upsertAgentFileAtom)
const buildNoteFile = getBuildNoteFile(draft.configNote)
const visibleFiles = buildNoteFile ? [buildNoteFile, ...files] : files
const previewFiles = visibleFiles.filter((file) => !file.isMissing)
const { mutate: deleteAgentFile } = useMutation(
consoleQuery.agent.byAgentId.config.files.byName.delete.mutationOptions(),
)
@ -478,7 +502,7 @@ export function AgentFiles() {
<AgentFileItem
depth={depth}
file={file}
files={visibleFiles}
files={previewFiles}
apiContext={apiContext}
selected={selected}
onRemove={removeFile}

View File

@ -268,6 +268,49 @@ describe('AgentSkills', () => {
}))
})
it('should prevent missing skills from being previewed or downloaded', async () => {
const user = userEvent.setup()
renderAgentSkills({
initialDraft: {
...defaultAgentSoulConfigFormState,
skills: [
{
id: 'Missing Skill',
name: 'Missing Skill',
fileId: 'missing-skill-id',
isMissing: true,
},
{
id: 'Available Skill',
name: 'Available Skill',
fileId: 'available-skill-id',
},
],
},
})
const warning = screen.getByRole('button', {
name: 'agentV2.agentDetail.configure.skills.missing',
})
expect(warning.querySelector('.i-ri-alert-fill')).toBeInTheDocument()
expect(
screen.getAllByRole('button', {
name: 'agentV2.agentDetail.configure.skills.missing',
}),
).toHaveLength(1)
const missingSkill = screen.getByRole('button', { name: 'Missing Skill' })
expect(missingSkill).toBeDisabled()
expect(
screen.queryByRole('button', {
name: /common\.operation\.download Missing Skill/,
}),
).not.toBeInTheDocument()
await user.click(missingSkill)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should delete a configured skill by config name', async () => {
const { container } = renderAgentSkills()

View File

@ -9,6 +9,7 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { downloadUrl } from '@/utils/download'
import { MissingReferenceWarning } from '../common/missing-reference-warning'
import { useAgentOrchestrateReadOnly } from '../read-only-context'
import { AgentSkillDetailDialog } from './detail-dialog'
import { useAgentSkillDetail } from './use-skill-detail'
@ -31,6 +32,8 @@ export function AgentSkillItem({
onRemove(skill.id)
}, [onRemove, skill.id])
const handleDownload = useCallback(async () => {
if (skill.isMissing) return
if (apiContext.workflow) {
const result = await queryClient.fetchQuery(
consoleQuery.apps.byAppId.agent.config.skills.byName.download.get.queryOptions({
@ -66,10 +69,12 @@ export function AgentSkillItem({
}),
)
downloadUrl({ url: result.url, fileName: skill.name })
}, [apiContext, queryClient, skill.name])
}, [apiContext, queryClient, skill.isMissing, skill.name])
const handleOpenPreview = useCallback(() => {
if (skill.isMissing) return
setIsPreviewOpen(true)
}, [])
}, [skill.isMissing])
const detail = useAgentSkillDetail({
apiContext,
description: skill.description ?? t(($) => $['agentDetail.configure.skills.tip']),
@ -83,40 +88,56 @@ export function AgentSkillItem({
<button
type="button"
aria-label={skill.name}
className="flex h-full w-full min-w-0 cursor-pointer items-center gap-1 rounded-lg py-1 pr-2.5 pl-2 text-left outline-hidden select-none focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
disabled={skill.isMissing}
className="flex h-full w-full min-w-0 cursor-pointer items-center gap-1 rounded-lg py-1 pr-2.5 pl-2 text-left outline-hidden select-none focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid disabled:cursor-default"
onClick={handleOpenPreview}
>
<span aria-hidden className="i-custom-public-agent-building-blocks size-4 shrink-0" />
<span className="w-0 min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
{skill.name}
</span>
<span
{skill.isMissing ? (
<span aria-hidden className="size-4 shrink-0" />
) : (
<span
className={cn(
'shrink-0 system-xs-regular text-text-tertiary',
'group-focus-within:opacity-0 group-hover:opacity-0',
)}
>
{t(($) => $['agentDetail.configure.skills.itemType'])}
</span>
)}
</button>
{skill.isMissing && (
<MissingReferenceWarning
className="absolute top-1/2 right-1 -translate-y-1/2"
label={t(($) => $['agentDetail.configure.skills.missing'])}
/>
)}
{!skill.isMissing && (
<button
type="button"
aria-label={`${tCommon(($) => $['operation.download'])} ${skill.name}`}
onClick={handleDownload}
className={cn(
'shrink-0 system-xs-regular text-text-tertiary',
'group-focus-within:opacity-0 group-hover:opacity-0',
'pointer-events-none absolute top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
readOnly ? 'right-1' : 'right-7',
)}
>
{t(($) => $['agentDetail.configure.skills.itemType'])}
</span>
</button>
<button
type="button"
aria-label={`${tCommon(($) => $['operation.download'])} ${skill.name}`}
onClick={handleDownload}
className={cn(
'pointer-events-none absolute top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
readOnly ? 'right-1' : 'right-7',
)}
>
<span aria-hidden className="i-ri-download-line size-4" />
</button>
<span aria-hidden className="i-ri-download-line size-4" />
</button>
)}
{!readOnly && (
<button
type="button"
data-agent-skill-remove-button
aria-label={t(($) => $['agentDetail.configure.skills.remove'], { name: skill.name })}
onClick={handleRemove}
className="pointer-events-none absolute top-1/2 right-1 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:bg-state-destructive-hover focus-visible:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
className={cn(
'pointer-events-none absolute top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:bg-state-destructive-hover focus-visible:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
skill.isMissing ? 'right-7' : 'right-1',
)}
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
</button>

View File

@ -9,6 +9,16 @@ import { AgentRosterList } from '../agent-roster-list'
const { duplicateAgentMutationFn } = vi.hoisted(() => ({
duplicateAgentMutationFn: vi.fn(),
}))
const exportAppConfigMock = vi.hoisted(() => vi.fn())
const downloadBlobMock = vi.hoisted(() => vi.fn())
vi.mock('@/service/apps', () => ({
exportAppConfig: exportAppConfigMock,
}))
vi.mock('@/utils/download', () => ({
downloadBlob: downloadBlobMock,
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
@ -50,6 +60,7 @@ vi.mock('@/service/client', () => ({
const createAgent = (overrides: Partial<AgentAppPartial> = {}): AgentAppPartial => ({
active_config_is_published: false,
app_id: 'app-1',
description: 'Find and summarize market materials.',
id: 'agent-1',
icon: '🧸',
@ -104,6 +115,7 @@ describe('AgentRosterList', () => {
name: 'Research Agent copy',
}),
)
exportAppConfigMock.mockResolvedValue({ data: 'kind: app\napp:\n mode: agent\n' })
})
afterEach(() => {
@ -254,6 +266,27 @@ describe('AgentRosterList', () => {
expect(duplicateAgentMutationFn).not.toHaveBeenCalled()
})
it('exports the Agent App DSL with the backing App id', async () => {
const user = userEvent.setup()
renderList([createAgent()])
await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ }))
await user.click(screen.getByRole('menuitem', { name: 'app.export' }))
await waitFor(() => {
expect(exportAppConfigMock).toHaveBeenCalledWith({
appID: 'app-1',
include: false,
})
})
expect(downloadBlobMock).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: 'Research Agent.yml',
})
const [{ data }] = downloadBlobMock.mock.calls[0] as [{ data: Blob }]
expect(await data.text()).toBe('kind: app\napp:\n mode: agent\n')
})
it('uses the latest cached agent detail when opening the duplicate dialog', async () => {
const user = userEvent.setup()
const { queryClient } = renderList([

View File

@ -4,6 +4,15 @@ import userEvent from '@testing-library/user-event'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { RosterToolbar } from '../roster-toolbar'
vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
default: ({ show, onSuccess }: { show: boolean; onSuccess?: () => void }) =>
show ? (
<div role="dialog" aria-label="agentV2.roster.importDSL">
<button onClick={onSuccess}>Complete agent import</button>
</div>
) : null,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
@ -17,15 +26,38 @@ const renderToolbar = ({
} = {}) => {
const queryClient = new QueryClient()
return renderWithNuqs(
const result = renderWithNuqs(
<QueryClientProvider client={queryClient}>
<RosterToolbar draftAgents={2} publishedAgents={1} />
</QueryClientProvider>,
{ searchParams },
)
return { ...result, queryClient }
}
describe('RosterToolbar', () => {
it('opens the shared create menu for blank Agent creation and DSL import', async () => {
const user = userEvent.setup()
const { queryClient } = renderToolbar()
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
await user.click(screen.getByRole('button', { name: 'common.operation.create' }))
expect(screen.getByRole('menuitem', { name: 'app.newApp.startFromBlank' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /app\.importDSL/ })).toBeInTheDocument()
await user.click(screen.getByRole('menuitem', { name: /app\.importDSL/ }))
expect(
await screen.findByRole('dialog', { name: 'agentV2.roster.importDSL' }),
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Complete agent import' }))
expect(invalidateQueries).toHaveBeenCalledTimes(1)
})
it('enables roster filters and emits the selected filter', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderToolbar()

View File

@ -9,12 +9,15 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import useTimestamp from '@/hooks/use-timestamp'
import Link from '@/next/link'
import { exportAppConfig } from '@/service/apps'
import { downloadBlob } from '@/utils/download'
import { AgentWorkflowReferencesDropdown } from './agent-workflow-references-dropdown'
import { DeleteAgentDialog } from './delete-agent-dialog'
import { DuplicateAgentDialog } from './duplicate-agent-dialog'
@ -103,6 +106,7 @@ function AgentRosterPlaceholderState({ title }: { title: string }) {
function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const { t: tApp } = useTranslation('app')
const { formatTime } = useTimestamp()
const nameId = useId()
const descriptionId = useId()
@ -136,6 +140,26 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
setIsDuplicateOpen(true)
}
const handleExport = async () => {
if (!agent.app_id) {
toast.error(tApp(($) => $.exportFailed))
return
}
try {
const { data } = await exportAppConfig({
appID: agent.app_id,
include: false,
})
downloadBlob({
data: new Blob([data], { type: 'application/yaml' }),
fileName: `${agent.name}.yml`,
})
} catch {
toast.error(tApp(($) => $.exportFailed))
}
}
return (
<article className="group relative col-span-1 h-36.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:content-[''] hover:shadow-lg has-[>div>a:focus-visible]:after:inset-ring-2 has-[>div>a:focus-visible]:after:inset-ring-state-accent-solid">
<div className="flex h-full min-w-0 flex-col">
@ -228,6 +252,10 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
/>
<span>{tCommon(($) => $['operation.duplicate'])}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2" onClick={handleExport}>
<span aria-hidden className="i-ri-download-line size-4 shrink-0 text-text-tertiary" />
<span>{tApp(($) => $.export)}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"

View File

@ -23,11 +23,16 @@ import { getAgentDetailPath } from '../../agent-detail/routes'
import { defaultAgentIcon } from './agent-form'
import { AgentFormFields } from './agent-form-fields'
export function CreateAgentDialog() {
type CreateAgentDialogProps = {
open?: boolean
onOpenChange?: (open: boolean) => void
}
export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps = {}) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const router = useRouter()
const [open, setOpen] = useState(false)
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
const [formKey, setFormKey] = useState(0)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
@ -46,7 +51,8 @@ export function CreateAgentDialog() {
}
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (open === undefined) setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
if (!nextOpen) resetForm()
}
@ -80,11 +86,17 @@ export function CreateAgentDialog() {
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
<DialogTrigger render={<Button variant="primary" className="h-8 gap-0.5 px-3" />}>
<span aria-hidden className="i-ri-add-line size-4" />
<span className="px-0.5 system-sm-medium">{t(($) => $['roster.createAgent'])}</span>
</DialogTrigger>
<Dialog
open={open ?? uncontrolledOpen}
onOpenChange={handleOpenChange}
disablePointerDismissal
>
{open === undefined && (
<DialogTrigger render={<Button variant="primary" className="h-8 gap-0.5 px-3" />}>
<span aria-hidden className="i-ri-add-line size-4" />
<span className="px-0.5 system-sm-medium">{t(($) => $['roster.createAgent'])}</span>
</DialogTrigger>
)}
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[520px] flex-col overflow-hidden! p-0!">
<DialogCloseButton />
<div className="shrink-0 pt-6 pr-14 pb-3 pl-6">

View File

@ -0,0 +1,33 @@
'use client'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { CreateAppDropdown } from '@/app/components/app/create-app-dropdown'
import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal'
import { consoleQuery } from '@/service/client'
import { CreateAgentDialog } from './create-agent-dialog'
export function RosterCreateMenu() {
const queryClient = useQueryClient()
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [importDialogOpen, setImportDialogOpen] = useState(false)
return (
<>
<CreateAppDropdown
onCreateBlank={() => setCreateDialogOpen(true)}
onImportDSL={() => setImportDialogOpen(true)}
/>
<CreateAgentDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen} />
{importDialogOpen && (
<CreateFromDSLModal
show
onClose={() => setImportDialogOpen(false)}
onSuccess={() => {
void queryClient.invalidateQueries({ queryKey: consoleQuery.agent.get.key() })
}}
/>
)}
</>
)
}

View File

@ -12,7 +12,7 @@ import {
rosterKeywordQueryParser,
rosterQueryParamNames,
} from '../query-params'
import { CreateAgentDialog } from './create-agent-dialog'
import { RosterCreateMenu } from './roster-create-menu'
import { RosterSortSelect } from './roster-sort-select'
type RosterToolbarProps = {
@ -122,7 +122,7 @@ export function RosterToolbar({ draftAgents, publishedAgents }: RosterToolbarPro
<RosterCreatedByMeFilter />
<div className="ml-auto flex shrink-0 items-center gap-2">
<RosterSortSelect />
<CreateAgentDialog />
<RosterCreateMenu />
</div>
</div>
)

View File

@ -15,6 +15,7 @@ import { useRouter } from '@/next/navigation'
import { importDSL, importDSLConfirm } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'
import { getRedirection } from '@/utils/app-redirection'
import { resolveImportedAppRedirectionTarget } from '@/utils/imported-app-redirection'
type DSLPayload = {
mode: DSLImportMode
@ -86,7 +87,12 @@ export const useImportDSL = () => {
setNeedRefresh('1')
invalidateAppList()
await handleCheckPluginDependencies(app_id)
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, {
const redirectionTarget = await resolveImportedAppRedirectionTarget({
id: app_id,
mode: app_mode,
permission_keys,
})
getRedirection(redirectionTarget, push, {
currentUserId,
resourceMaintainer: currentUserId,
workspacePermissionKeys,
@ -143,7 +149,12 @@ export const useImportDSL = () => {
await handleCheckPluginDependencies(app_id)
setNeedRefresh('1')
invalidateAppList()
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, {
const redirectionTarget = await resolveImportedAppRedirectionTarget({
id: app_id,
mode: app_mode,
permission_keys,
})
getRedirection(redirectionTarget, push, {
currentUserId,
resourceMaintainer: currentUserId,
workspacePermissionKeys,

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "قم بتحميل المستندات التي يمكن للوكيل قراءتها، مثل المواصفات أو القوالب أو الإرشادات",
"agentDetail.configure.files.empty.title": "لا توجد ملفات بعد",
"agentDetail.configure.files.label": "الملفات",
"agentDetail.configure.files.missing": "الملف غير موجود",
"agentDetail.configure.files.preview.empty": "لا يوجد محتوى معاينة.",
"agentDetail.configure.files.preview.failed": "فشل تحميل المعاينة.",
"agentDetail.configure.files.preview.unsupported": "هذا الملف لا يدعم المعاينة.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "لا توجد مهارات بعد",
"agentDetail.configure.skills.itemType": "مهارة",
"agentDetail.configure.skills.label": "المهارات",
"agentDetail.configure.skills.missing": "المهارة غير موجودة",
"agentDetail.configure.skills.remove": "إزالة {{name}}",
"agentDetail.configure.skills.richTip": "اجمع التعليمات والملفات والبرامج النصية لمهمة متكررة في Skill. أشر إليها باستخدام / في Prompt. <docLink>معرفة المزيد</docLink>\n\nفي وضع Build، يمكن للوكيل إعدادها لك.",
"agentDetail.configure.skills.tip": "اجمع التعليمات والملفات والبرامج النصية لمهمة متكررة في Skill. أشر إليها باستخدام / في Prompt. معرفة المزيد\n\nفي وضع Build، يمكن للوكيل إعدادها لك.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Laden Sie Dokumente hoch, die der Agent lesen kann, z. B. Spezifikationen, Vorlagen oder Richtlinien",
"agentDetail.configure.files.empty.title": "Noch keine Dateien",
"agentDetail.configure.files.label": "Dateien",
"agentDetail.configure.files.missing": "Datei nicht gefunden",
"agentDetail.configure.files.preview.empty": "Kein Vorschauinhalt.",
"agentDetail.configure.files.preview.failed": "Vorschau konnte nicht geladen werden.",
"agentDetail.configure.files.preview.unsupported": "Für diese Datei wird keine Vorschau unterstützt.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Noch keine Skills",
"agentDetail.configure.skills.itemType": "Skill",
"agentDetail.configure.skills.label": "Skills",
"agentDetail.configure.skills.missing": "Skill nicht gefunden",
"agentDetail.configure.skills.remove": "{{name}} entfernen",
"agentDetail.configure.skills.richTip": "Bündeln Sie Anweisungen, Dateien und Skripte für eine wiederkehrende Aufgabe in einer Skill. Verweisen Sie im Prompt mit / darauf. <docLink>Mehr erfahren</docLink>\n\nIm Build-Modus kann der Agent diese Einrichtung für Sie übernehmen.",
"agentDetail.configure.skills.tip": "Bündeln Sie Anweisungen, Dateien und Skripte für eine wiederkehrende Aufgabe in einer Skill. Verweisen Sie im Prompt mit / darauf. Mehr erfahren\n\nIm Build-Modus kann der Agent diese Einrichtung für Sie übernehmen.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Upload docs the agent can read, like specs, templates, or guidelines",
"agentDetail.configure.files.empty.title": "No files yet",
"agentDetail.configure.files.label": "Files",
"agentDetail.configure.files.missing": "File not found",
"agentDetail.configure.files.preview.empty": "No preview content.",
"agentDetail.configure.files.preview.failed": "Failed to load preview.",
"agentDetail.configure.files.preview.unsupported": "Preview is not supported for this file.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "No skills yet",
"agentDetail.configure.skills.itemType": "Skill",
"agentDetail.configure.skills.label": "Skills",
"agentDetail.configure.skills.missing": "Skill not found",
"agentDetail.configure.skills.remove": "Remove {{name}}",
"agentDetail.configure.skills.richTip": "Package the instructions, files, and scripts for a recurring task into a skill. Reference with / in the Prompt. <docLink>Learn more</docLink>\n\nIn Build mode, the agent can set these up for you.",
"agentDetail.configure.skills.tip": "Package the instructions, files, and scripts for a recurring task into a skill. Reference with / in the Prompt. Learn more\n\nIn Build mode, the agent can set these up for you.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Sube documentos que el agente pueda leer, como especificaciones, plantillas o guías",
"agentDetail.configure.files.empty.title": "Aún no hay archivos",
"agentDetail.configure.files.label": "Archivos",
"agentDetail.configure.files.missing": "Archivo no encontrado",
"agentDetail.configure.files.preview.empty": "Sin contenido de vista previa.",
"agentDetail.configure.files.preview.failed": "Error al cargar la vista previa.",
"agentDetail.configure.files.preview.unsupported": "Este archivo no admite vista previa.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Aún no hay habilidades",
"agentDetail.configure.skills.itemType": "Habilidad",
"agentDetail.configure.skills.label": "Habilidades",
"agentDetail.configure.skills.missing": "Habilidad no encontrada",
"agentDetail.configure.skills.remove": "Eliminar {{name}}",
"agentDetail.configure.skills.richTip": "Empaqueta las instrucciones, archivos y scripts de una tarea recurrente en una skill. Haz referencia con / en el Prompt. <docLink>Más información</docLink>\n\nEn modo Build, el agente puede configurarlas por ti.",
"agentDetail.configure.skills.tip": "Empaqueta las instrucciones, archivos y scripts de una tarea recurrente en una skill. Haz referencia con / en el Prompt. Más información\n\nEn modo Build, el agente puede configurarlas por ti.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "اسنادی را که عامل می‌تواند بخواند بارگذاری کنید، مانند مشخصات، قالب‌ها یا دستورالعمل‌ها",
"agentDetail.configure.files.empty.title": "هنوز فایلی وجود ندارد",
"agentDetail.configure.files.label": "فایل‌ها",
"agentDetail.configure.files.missing": "فایل یافت نشد",
"agentDetail.configure.files.preview.empty": "محتوای پیش‌نمایش وجود ندارد.",
"agentDetail.configure.files.preview.failed": "بارگذاری پیش‌نمایش ناموفق بود.",
"agentDetail.configure.files.preview.unsupported": "این فایل از پیش‌نمایش پشتیبانی نمی‌کند.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "هنوز مهارتی وجود ندارد",
"agentDetail.configure.skills.itemType": "مهارت",
"agentDetail.configure.skills.label": "مهارت‌ها",
"agentDetail.configure.skills.missing": "مهارت یافت نشد",
"agentDetail.configure.skills.remove": "حذف {{name}}",
"agentDetail.configure.skills.richTip": "دستورالعمل‌ها، فایل‌ها و اسکریپت‌های یک کار تکرارشونده را در یک Skill بسته‌بندی کنید. در Prompt با / به آن ارجاع دهید. <docLink>بیشتر بدانید</docLink>\n\nدر حالت Build، عامل می‌تواند این موارد را برای شما تنظیم کند.",
"agentDetail.configure.skills.tip": "دستورالعمل‌ها، فایل‌ها و اسکریپت‌های یک کار تکرارشونده را در یک Skill بسته‌بندی کنید. در Prompt با / به آن ارجاع دهید. بیشتر بدانید\n\nدر حالت Build، عامل می‌تواند این موارد را برای شما تنظیم کند.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Téléchargez des documents que lagent peut lire, comme des spécifications, des modèles ou des directives",
"agentDetail.configure.files.empty.title": "Pas encore de fichiers",
"agentDetail.configure.files.label": "Fichiers",
"agentDetail.configure.files.missing": "Fichier introuvable",
"agentDetail.configure.files.preview.empty": "Aucun contenu daperçu.",
"agentDetail.configure.files.preview.failed": "Échec du chargement de laperçu.",
"agentDetail.configure.files.preview.unsupported": "Ce fichier ne prend pas en charge laperçu.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Pas encore de compétences",
"agentDetail.configure.skills.itemType": "Compétence",
"agentDetail.configure.skills.label": "Compétences",
"agentDetail.configure.skills.missing": "Compétence introuvable",
"agentDetail.configure.skills.remove": "Supprimer {{name}}",
"agentDetail.configure.skills.richTip": "Regroupez les instructions, fichiers et scripts d'une tâche récurrente dans une skill. Référencez-la avec / dans le Prompt. <docLink>En savoir plus</docLink>\n\nEn mode Build, l'agent peut les configurer pour vous.",
"agentDetail.configure.skills.tip": "Regroupez les instructions, fichiers et scripts d'une tâche récurrente dans une skill. Référencez-la avec / dans le Prompt. En savoir plus\n\nEn mode Build, l'agent peut les configurer pour vous.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "ऐसे दस्तावेज़ अपलोड करें जिन्हें एजेंट पढ़ सके, जैसे विनिर्देश, टेम्पलेट या दिशानिर्देश",
"agentDetail.configure.files.empty.title": "अभी तक कोई फ़ाइल नहीं",
"agentDetail.configure.files.label": "फ़ाइलें",
"agentDetail.configure.files.missing": "फ़ाइल नहीं मिली",
"agentDetail.configure.files.preview.empty": "कोई पूर्वावलोकन सामग्री नहीं।",
"agentDetail.configure.files.preview.failed": "पूर्वावलोकन लोड करने में विफल।",
"agentDetail.configure.files.preview.unsupported": "यह फ़ाइल पूर्वावलोकन का समर्थन नहीं करती।",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "अभी तक कोई कौशल नहीं",
"agentDetail.configure.skills.itemType": "कौशल",
"agentDetail.configure.skills.label": "कौशल",
"agentDetail.configure.skills.missing": "कौशल नहीं मिला",
"agentDetail.configure.skills.remove": "{{name}} हटाएँ",
"agentDetail.configure.skills.richTip": "किसी दोहराए जाने वाले कार्य के निर्देशों, फ़ाइलों और स्क्रिप्ट को एक Skill में पैकेज करें। Prompt में / से संदर्भ दें। <docLink>और जानें</docLink>\n\nBuild mode में, एजेंट इन्हें आपके लिए सेट कर सकता है।",
"agentDetail.configure.skills.tip": "किसी दोहराए जाने वाले कार्य के निर्देशों, फ़ाइलों और स्क्रिप्ट को एक Skill में पैकेज करें। Prompt में / से संदर्भ दें। और जानें\n\nBuild mode में, एजेंट इन्हें आपके लिए सेट कर सकता है।",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Unggah dokumen yang dapat dibaca agen, seperti spesifikasi, templat, atau pedoman",
"agentDetail.configure.files.empty.title": "Belum ada file",
"agentDetail.configure.files.label": "File",
"agentDetail.configure.files.missing": "File tidak ditemukan",
"agentDetail.configure.files.preview.empty": "Tidak ada konten pratinjau.",
"agentDetail.configure.files.preview.failed": "Gagal memuat pratinjau.",
"agentDetail.configure.files.preview.unsupported": "File ini tidak mendukung pratinjau.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Belum ada keterampilan",
"agentDetail.configure.skills.itemType": "Keterampilan",
"agentDetail.configure.skills.label": "Keterampilan",
"agentDetail.configure.skills.missing": "Keterampilan tidak ditemukan",
"agentDetail.configure.skills.remove": "Hapus {{name}}",
"agentDetail.configure.skills.richTip": "Kemas instruksi, file, dan skrip untuk tugas berulang menjadi skill. Referensikan dengan / di Prompt. <docLink>Pelajari selengkapnya</docLink>\n\nDalam mode Build, agen dapat menyiapkannya untuk Anda.",
"agentDetail.configure.skills.tip": "Kemas instruksi, file, dan skrip untuk tugas berulang menjadi skill. Referensikan dengan / di Prompt. Pelajari selengkapnya\n\nDalam mode Build, agen dapat menyiapkannya untuk Anda.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Carica documenti che lagente possa leggere, come specifiche, modelli o linee guida",
"agentDetail.configure.files.empty.title": "Nessun file al momento",
"agentDetail.configure.files.label": "File",
"agentDetail.configure.files.missing": "File non trovato",
"agentDetail.configure.files.preview.empty": "Nessun contenuto in anteprima.",
"agentDetail.configure.files.preview.failed": "Impossibile caricare lanteprima.",
"agentDetail.configure.files.preview.unsupported": "Questo file non supporta lanteprima.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Nessuna abilità al momento",
"agentDetail.configure.skills.itemType": "Abilità",
"agentDetail.configure.skills.label": "Abilità",
"agentDetail.configure.skills.missing": "Abilità non trovata",
"agentDetail.configure.skills.remove": "Rimuovi {{name}}",
"agentDetail.configure.skills.richTip": "Raggruppa istruzioni, file e script per un'attività ricorrente in una skill. Fai riferimento con / nel Prompt. <docLink>Scopri di più</docLink>\n\nIn modalità Build, l'agente può configurarli per te.",
"agentDetail.configure.skills.tip": "Raggruppa istruzioni, file e script per un'attività ricorrente in una skill. Fai riferimento con / nel Prompt. Scopri di più\n\nIn modalità Build, l'agente può configurarli per te.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "仕様、テンプレート、ガイドラインなど、エージェントが読めるドキュメントをアップロード",
"agentDetail.configure.files.empty.title": "ファイルはまだありません",
"agentDetail.configure.files.label": "ファイル",
"agentDetail.configure.files.missing": "ファイルが見つかりません",
"agentDetail.configure.files.preview.empty": "プレビュー内容はありません。",
"agentDetail.configure.files.preview.failed": "プレビューの読み込みに失敗しました。",
"agentDetail.configure.files.preview.unsupported": "ファイルはプレビューに対応していません。",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "スキルはまだありません",
"agentDetail.configure.skills.itemType": "スキル",
"agentDetail.configure.skills.label": "スキル",
"agentDetail.configure.skills.missing": "スキルが見つかりません",
"agentDetail.configure.skills.remove": "{{name}} を削除",
"agentDetail.configure.skills.richTip": "繰り返し行うタスクに必要な指示、ファイル、スクリプトを Skill にまとめます。Prompt で / を使って参照します。<docLink>詳しく見る</docLink>\n\nBuild モードでは、エージェントがこれらを設定できます。",
"agentDetail.configure.skills.tip": "繰り返し行うタスクに必要な指示、ファイル、スクリプトを Skill にまとめます。Prompt で / を使って参照します。詳しく見る\n\nBuild モードでは、エージェントがこれらを設定できます。",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "사양, 템플릿, 가이드라인 등 에이전트가 읽을 수 있는 문서를 업로드하세요",
"agentDetail.configure.files.empty.title": "아직 파일이 없습니다",
"agentDetail.configure.files.label": "파일",
"agentDetail.configure.files.missing": "파일을 찾을 수 없음",
"agentDetail.configure.files.preview.empty": "미리보기 내용이 없습니다.",
"agentDetail.configure.files.preview.failed": "미리보기를 불러오지 못했습니다.",
"agentDetail.configure.files.preview.unsupported": "이 파일은 미리보기를 지원하지 않습니다.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "아직 스킬이 없습니다",
"agentDetail.configure.skills.itemType": "스킬",
"agentDetail.configure.skills.label": "스킬",
"agentDetail.configure.skills.missing": "스킬을 찾을 수 없음",
"agentDetail.configure.skills.remove": "{{name}} 제거",
"agentDetail.configure.skills.richTip": "반복 작업에 필요한 지침, 파일, 스크립트를 Skill로 패키징하세요. Prompt에서 /로 참조하세요. <docLink>자세히 알아보기</docLink>\n\nBuild mode에서는 에이전트가 이를 대신 설정할 수 있습니다.",
"agentDetail.configure.skills.tip": "반복 작업에 필요한 지침, 파일, 스크립트를 Skill로 패키징하세요. Prompt에서 /로 참조하세요. 자세히 알아보기\n\nBuild mode에서는 에이전트가 이를 대신 설정할 수 있습니다.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Upload documenten die de agent kan lezen, zoals specificaties, sjablonen of richtlijnen",
"agentDetail.configure.files.empty.title": "Nog geen bestanden",
"agentDetail.configure.files.label": "Bestanden",
"agentDetail.configure.files.missing": "Bestand niet gevonden",
"agentDetail.configure.files.preview.empty": "Geen voorbeeldinhoud.",
"agentDetail.configure.files.preview.failed": "Laden van voorbeeld mislukt.",
"agentDetail.configure.files.preview.unsupported": "Dit bestand ondersteunt geen voorbeeldweergave.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Nog geen skills",
"agentDetail.configure.skills.itemType": "Skill",
"agentDetail.configure.skills.label": "Skills",
"agentDetail.configure.skills.missing": "Skill niet gevonden",
"agentDetail.configure.skills.remove": "{{name}} verwijderen",
"agentDetail.configure.skills.richTip": "Bundel de instructies, bestanden en scripts voor een terugkerende taak in een skill. Verwijs ernaar met / in de Prompt. <docLink>Meer informatie</docLink>\n\nIn de Build-modus kan de agent dit voor u instellen.",
"agentDetail.configure.skills.tip": "Bundel de instructies, bestanden en scripts voor een terugkerende taak in een skill. Verwijs ernaar met / in de Prompt. Meer informatie\n\nIn de Build-modus kan de agent dit voor u instellen.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Prześlij dokumenty, które agent może czytać, np. specyfikacje, szablony lub wytyczne",
"agentDetail.configure.files.empty.title": "Brak plików",
"agentDetail.configure.files.label": "Pliki",
"agentDetail.configure.files.missing": "Nie znaleziono pliku",
"agentDetail.configure.files.preview.empty": "Brak treści podglądu.",
"agentDetail.configure.files.preview.failed": "Nie udało się załadować podglądu.",
"agentDetail.configure.files.preview.unsupported": "Ten plik nie obsługuje podglądu.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Brak umiejętności",
"agentDetail.configure.skills.itemType": "Umiejętność",
"agentDetail.configure.skills.label": "Umiejętności",
"agentDetail.configure.skills.missing": "Nie znaleziono umiejętności",
"agentDetail.configure.skills.remove": "Usuń {{name}}",
"agentDetail.configure.skills.richTip": "Spakuj instrukcje, pliki i skrypty dla powtarzalnego zadania w skill. Odwołuj się za pomocą / w Prompt. <docLink>Dowiedz się więcej</docLink>\n\nW trybie Build agent może skonfigurować je za Ciebie.",
"agentDetail.configure.skills.tip": "Spakuj instrukcje, pliki i skrypty dla powtarzalnego zadania w skill. Odwołuj się za pomocą / w Prompt. Dowiedz się więcej\n\nW trybie Build agent może skonfigurować je za Ciebie.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Envie documentos que o agente possa ler, como especificações, modelos ou diretrizes",
"agentDetail.configure.files.empty.title": "Ainda não há arquivos",
"agentDetail.configure.files.label": "Arquivos",
"agentDetail.configure.files.missing": "Arquivo não encontrado",
"agentDetail.configure.files.preview.empty": "Sem conteúdo de pré-visualização.",
"agentDetail.configure.files.preview.failed": "Falha ao carregar a pré-visualização.",
"agentDetail.configure.files.preview.unsupported": "Este arquivo não oferece suporte à pré-visualização.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Ainda não há habilidades",
"agentDetail.configure.skills.itemType": "Habilidade",
"agentDetail.configure.skills.label": "Habilidades",
"agentDetail.configure.skills.missing": "Habilidade não encontrada",
"agentDetail.configure.skills.remove": "Remover {{name}}",
"agentDetail.configure.skills.richTip": "Empacote as instruções, arquivos e scripts de uma tarefa recorrente em uma skill. Referencie com / no Prompt. <docLink>Saiba mais</docLink>\n\nNo modo Build, o agente pode configurar isso para você.",
"agentDetail.configure.skills.tip": "Empacote as instruções, arquivos e scripts de uma tarefa recorrente em uma skill. Referencie com / no Prompt. Saiba mais\n\nNo modo Build, o agente pode configurar isso para você.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Încarcă documente pe care agentul le poate citi, precum specificații, șabloane sau ghiduri",
"agentDetail.configure.files.empty.title": "Niciun fișier încă",
"agentDetail.configure.files.label": "Fișiere",
"agentDetail.configure.files.missing": "Fișierul nu a fost găsit",
"agentDetail.configure.files.preview.empty": "Niciun conținut de previzualizat.",
"agentDetail.configure.files.preview.failed": "Încărcarea previzualizării a eșuat.",
"agentDetail.configure.files.preview.unsupported": "Acest fișier nu acceptă previzualizarea.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Nicio abilitate încă",
"agentDetail.configure.skills.itemType": "Abilitate",
"agentDetail.configure.skills.label": "Abilități",
"agentDetail.configure.skills.missing": "Abilitatea nu a fost găsită",
"agentDetail.configure.skills.remove": "Elimină {{name}}",
"agentDetail.configure.skills.richTip": "Împachetați instrucțiunile, fișierele și scripturile pentru o sarcină recurentă într-un skill. Faceți referire cu / în Prompt. <docLink>Aflați mai multe</docLink>\n\nÎn modul Build, agentul le poate configura pentru dvs.",
"agentDetail.configure.skills.tip": "Împachetați instrucțiunile, fișierele și scripturile pentru o sarcină recurentă într-un skill. Faceți referire cu / în Prompt. Aflați mai multe\n\nÎn modul Build, agentul le poate configura pentru dvs.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Загрузите документы, которые может прочитать агент, например спецификации, шаблоны или руководства",
"agentDetail.configure.files.empty.title": "Пока нет файлов",
"agentDetail.configure.files.label": "Файлы",
"agentDetail.configure.files.missing": "Файл не найден",
"agentDetail.configure.files.preview.empty": "Нет содержимого для предпросмотра.",
"agentDetail.configure.files.preview.failed": "Не удалось загрузить предпросмотр.",
"agentDetail.configure.files.preview.unsupported": "Этот файл не поддерживает предварительный просмотр.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Пока нет навыков",
"agentDetail.configure.skills.itemType": "Навык",
"agentDetail.configure.skills.label": "Навыки",
"agentDetail.configure.skills.missing": "Навык не найден",
"agentDetail.configure.skills.remove": "Удалить {{name}}",
"agentDetail.configure.skills.richTip": "Упакуйте инструкции, файлы и скрипты для повторяющейся задачи в Skill. Ссылайтесь на него через / в Prompt. <docLink>Подробнее</docLink>\n\nВ режиме Build агент может настроить это за вас.",
"agentDetail.configure.skills.tip": "Упакуйте инструкции, файлы и скрипты для повторяющейся задачи в Skill. Ссылайтесь на него через / в Prompt. Подробнее\n\nВ режиме Build агент может настроить это за вас.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Naložite dokumente, ki jih lahko agent bere, npr. specifikacije, predloge ali smernice",
"agentDetail.configure.files.empty.title": "Še ni datotek",
"agentDetail.configure.files.label": "Datoteke",
"agentDetail.configure.files.missing": "Datoteka ni bila najdena",
"agentDetail.configure.files.preview.empty": "Ni vsebine za predogled.",
"agentDetail.configure.files.preview.failed": "Predogleda ni bilo mogoče naložiti.",
"agentDetail.configure.files.preview.unsupported": "Ta datoteka ne podpira predogleda.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Še ni veščin",
"agentDetail.configure.skills.itemType": "Veščina",
"agentDetail.configure.skills.label": "Veščine",
"agentDetail.configure.skills.missing": "Veščina ni bila najdena",
"agentDetail.configure.skills.remove": "Odstrani {{name}}",
"agentDetail.configure.skills.richTip": "Navodila, datoteke in skripte za ponavljajočo se nalogo zapakirajte v skill. Sklicujte se z / v Promptu. <docLink>Več informacij</docLink>\n\nV načinu Build jih lahko agent nastavi namesto vas.",
"agentDetail.configure.skills.tip": "Navodila, datoteke in skripte za ponavljajočo se nalogo zapakirajte v skill. Sklicujte se z / v Promptu. Več informacij\n\nV načinu Build jih lahko agent nastavi namesto vas.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "อัปโหลดเอกสารที่ตัวแทนสามารถอ่านได้ เช่น ข้อกำหนด เทมเพลต หรือแนวทาง",
"agentDetail.configure.files.empty.title": "ยังไม่มีไฟล์",
"agentDetail.configure.files.label": "ไฟล์",
"agentDetail.configure.files.missing": "ไม่พบไฟล์",
"agentDetail.configure.files.preview.empty": "ไม่มีเนื้อหาสำหรับแสดงตัวอย่าง",
"agentDetail.configure.files.preview.failed": "โหลดตัวอย่างไม่สำเร็จ",
"agentDetail.configure.files.preview.unsupported": "ไฟล์นี้ไม่รองรับการแสดงตัวอย่าง",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "ยังไม่มีทักษะ",
"agentDetail.configure.skills.itemType": "ทักษะ",
"agentDetail.configure.skills.label": "ทักษะ",
"agentDetail.configure.skills.missing": "ไม่พบทักษะ",
"agentDetail.configure.skills.remove": "ลบ {{name}}",
"agentDetail.configure.skills.richTip": "แพ็กเกจคำสั่ง ไฟล์ และสคริปต์สำหรับงานที่ทำซ้ำเป็น Skill อ้างอิงด้วย / ใน Prompt <docLink>เรียนรู้เพิ่มเติม</docLink>\n\nในโหมด Build agent สามารถตั้งค่าสิ่งเหล่านี้ให้คุณได้",
"agentDetail.configure.skills.tip": "แพ็กเกจคำสั่ง ไฟล์ และสคริปต์สำหรับงานที่ทำซ้ำเป็น Skill อ้างอิงด้วย / ใน Prompt เรียนรู้เพิ่มเติม\n\nในโหมด Build agent สามารถตั้งค่าสิ่งเหล่านี้ให้คุณได้",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Ajanın okuyabileceği belgeleri yükleyin, örneğin spesifikasyonlar, şablonlar veya yönergeler",
"agentDetail.configure.files.empty.title": "Henüz dosya yok",
"agentDetail.configure.files.label": "Dosyalar",
"agentDetail.configure.files.missing": "Dosya bulunamadı",
"agentDetail.configure.files.preview.empty": "Önizleme içeriği yok.",
"agentDetail.configure.files.preview.failed": "Önizleme yüklenemedi.",
"agentDetail.configure.files.preview.unsupported": "Bu dosya önizlemeyi desteklemiyor.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Henüz beceri yok",
"agentDetail.configure.skills.itemType": "Beceri",
"agentDetail.configure.skills.label": "Beceriler",
"agentDetail.configure.skills.missing": "Beceri bulunamadı",
"agentDetail.configure.skills.remove": "{{name}} kaldır",
"agentDetail.configure.skills.richTip": "Tekrarlanan bir görev için gereken talimatları, dosyaları ve betikleri bir Skill olarak paketleyin. Prompt içinde / ile referans verin. <docLink>Daha fazla bilgi</docLink>\n\nBuild mode'da agent bunları sizin için ayarlayabilir.",
"agentDetail.configure.skills.tip": "Tekrarlanan bir görev için gereken talimatları, dosyaları ve betikleri bir Skill olarak paketleyin. Prompt içinde / ile referans verin. Daha fazla bilgi\n\nBuild mode'da agent bunları sizin için ayarlayabilir.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Завантажте документи, які може читати агент, наприклад специфікації, шаблони чи інструкції",
"agentDetail.configure.files.empty.title": "Файлів ще немає",
"agentDetail.configure.files.label": "Файли",
"agentDetail.configure.files.missing": "Файл не знайдено",
"agentDetail.configure.files.preview.empty": "Немає вмісту для перегляду.",
"agentDetail.configure.files.preview.failed": "Не вдалося завантажити перегляд.",
"agentDetail.configure.files.preview.unsupported": "Попередній перегляд цього файлу не підтримується.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Поки що немає навичок",
"agentDetail.configure.skills.itemType": "Навичка",
"agentDetail.configure.skills.label": "Навички",
"agentDetail.configure.skills.missing": "Навичку не знайдено",
"agentDetail.configure.skills.remove": "Видалити {{name}}",
"agentDetail.configure.skills.richTip": "Запакуйте інструкції, файли та скрипти для повторюваного завдання в Skill. Посилайтеся на нього через / у Prompt. <docLink>Докладніше</docLink>\n\nУ режимі Build агент може налаштувати це за вас.",
"agentDetail.configure.skills.tip": "Запакуйте інструкції, файли та скрипти для повторюваного завдання в Skill. Посилайтеся на нього через / у Prompt. Докладніше\n\nУ режимі Build агент може налаштувати це за вас.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "Tải lên tài liệu mà tác nhân có thể đọc, như đặc tả, mẫu hoặc hướng dẫn",
"agentDetail.configure.files.empty.title": "Chưa có tệp nào",
"agentDetail.configure.files.label": "Tệp",
"agentDetail.configure.files.missing": "Không tìm thấy tệp",
"agentDetail.configure.files.preview.empty": "Không có nội dung xem trước.",
"agentDetail.configure.files.preview.failed": "Tải xem trước thất bại.",
"agentDetail.configure.files.preview.unsupported": "Tệp này không hỗ trợ xem trước.",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "Chưa có kỹ năng nào",
"agentDetail.configure.skills.itemType": "Kỹ năng",
"agentDetail.configure.skills.label": "Kỹ năng",
"agentDetail.configure.skills.missing": "Không tìm thấy kỹ năng",
"agentDetail.configure.skills.remove": "Xóa {{name}}",
"agentDetail.configure.skills.richTip": "Đóng gói hướng dẫn, tệp và script cho một tác vụ lặp lại thành skill. Tham chiếu bằng / trong Prompt. <docLink>Tìm hiểu thêm</docLink>\n\nỞ chế độ Build, tác nhân có thể thiết lập những mục này cho bạn.",
"agentDetail.configure.skills.tip": "Đóng gói hướng dẫn, tệp và script cho một tác vụ lặp lại thành skill. Tham chiếu bằng / trong Prompt. Tìm hiểu thêm\n\nỞ chế độ Build, tác nhân có thể thiết lập những mục này cho bạn.",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "上传 Agent 可读取的文档,例如规格、模板或指南",
"agentDetail.configure.files.empty.title": "暂无文件",
"agentDetail.configure.files.label": "文件",
"agentDetail.configure.files.missing": "未找到文件",
"agentDetail.configure.files.preview.empty": "暂无预览内容。",
"agentDetail.configure.files.preview.failed": "预览加载失败。",
"agentDetail.configure.files.preview.unsupported": "该文件不支持预览。",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "暂无 Skill",
"agentDetail.configure.skills.itemType": "Skill",
"agentDetail.configure.skills.label": "Skill",
"agentDetail.configure.skills.missing": "未找到 Skill",
"agentDetail.configure.skills.remove": "移除 {{name}}",
"agentDetail.configure.skills.richTip": "将重复任务所需的指令、文件和脚本打包成 Skill。在提示词中用 / 引用。<docLink>了解更多</docLink>\n\n在构建模式中Agent 可帮你完成这些设置。",
"agentDetail.configure.skills.tip": "将重复任务所需的指令、文件和脚本打包成 Skill。在提示词中用 / 引用。了解更多\n\n在构建模式中Agent 可帮你完成这些设置。",

View File

@ -98,6 +98,7 @@
"agentDetail.configure.files.empty.description": "上傳 Agent 可讀取的文件,例如規格、範本或指南",
"agentDetail.configure.files.empty.title": "暫無檔案",
"agentDetail.configure.files.label": "檔案",
"agentDetail.configure.files.missing": "找不到檔案",
"agentDetail.configure.files.preview.empty": "暫無預覽內容。",
"agentDetail.configure.files.preview.failed": "預覽載入失敗。",
"agentDetail.configure.files.preview.unsupported": "此檔案不支援預覽。",
@ -208,6 +209,7 @@
"agentDetail.configure.skills.empty.title": "暫無 Skill",
"agentDetail.configure.skills.itemType": "Skill",
"agentDetail.configure.skills.label": "Skill",
"agentDetail.configure.skills.missing": "找不到 Skill",
"agentDetail.configure.skills.remove": "移除 {{name}}",
"agentDetail.configure.skills.richTip": "將重複任務所需的指令、檔案和指令碼打包成 Skill。在提示詞中用 / 引用。<docLink>了解更多</docLink>\n\n在建置模式中Agent 可幫你完成這些設定。",
"agentDetail.configure.skills.tip": "將重複任務所需的指令、檔案和指令碼打包成 Skill。在提示詞中用 / 引用。了解更多\n\n在建置模式中Agent 可幫你完成這些設定。",

View File

@ -26,6 +26,13 @@ export enum DSLImportStatus {
FAILED = 'failed',
}
export type DSLImportWarning = {
code: string
path: string
message: string
details: Record<string, unknown>
}
export type AppListResponse = {
data: App[]
has_more: boolean
@ -46,6 +53,7 @@ export type DSLImportResponse = {
error: string
leaked_dependencies: Dependency[]
permission_keys: string[]
warnings?: DSLImportWarning[]
}
export type UpdateAppSiteCodeResponse = { app_id: string } & SiteConfig

View File

@ -610,6 +610,54 @@ describe('consoleQuery agent mutation defaults', () => {
).toEqual(composerState)
})
it('should cache snippet composer state after saving the inline agent', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const composerState = createWorkflowComposerState({
binding: {
agent_id: 'snippet-inline-agent-1',
binding_type: 'inline_agent',
current_snapshot_id: 'snippet-inline-snapshot-1',
id: 'binding-1',
node_id: 'node-1',
workflow_id: 'workflow-1',
},
})
const mutationOptions =
consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions()
await mutationOptions.onSuccess?.(
composerState,
{
params: {
snippet_id: 'snippet-1',
node_id: 'node-1',
},
body: {
variant: 'workflow',
save_strategy: 'node_job_only',
},
},
undefined,
createMutationContext(queryClient),
)
expect(
queryClient.getQueryData(
consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey(
{
input: {
params: {
snippet_id: 'snippet-1',
node_id: 'node-1',
},
},
},
),
),
).toEqual(composerState)
})
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()
@ -666,6 +714,64 @@ describe('consoleQuery agent mutation defaults', () => {
})
})
it('should cache snippet 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.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions()
await mutationOptions.onSuccess?.(
composerState,
{
params: {
snippet_id: 'snippet-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.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey(
{
input: {
params: {
snippet_id: 'snippet-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

@ -461,6 +461,94 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
},
},
},
snippets: {
bySnippetId: {
workflows: {
draft: {
nodes: {
byNodeId: {
agentComposer: {
put: {
mutationOptions: {
onSuccess: (composerState, variables, _onMutateResult, context) => {
context.client.setQueryData(
consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey(
{
input: {
params: variables.params,
},
},
),
composerState,
)
},
},
},
copyFromRoster: {
post: {
mutationOptions: {
onSuccess: (composerState, variables, _onMutateResult, context) => {
context.client.setQueryData(
consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey(
{
input: {
params: variables.params,
},
},
),
composerState,
)
},
},
},
},
saveToRoster: {
post: {
mutationOptions: {
onSuccess: (composerState, variables, _onMutateResult, context) => {
context.client.setQueryData(
consoleQuery.snippets.bySnippetId.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: {

View File

@ -51,13 +51,20 @@ export const RETRIEVE_METHOD = {
/**
* App modes
*/
export type AppModeEnum = 'completion' | 'workflow' | 'chat' | 'advanced-chat' | 'agent-chat'
export type AppModeEnum =
| 'completion'
| 'workflow'
| 'chat'
| 'advanced-chat'
| 'agent-chat'
| 'agent'
export const AppModeEnum = {
COMPLETION: 'completion' as AppModeEnum,
WORKFLOW: 'workflow' as AppModeEnum,
CHAT: 'chat' as AppModeEnum,
ADVANCED_CHAT: 'advanced-chat' as AppModeEnum,
AGENT_CHAT: 'agent-chat' as AppModeEnum,
AGENT: 'agent' as AppModeEnum,
} as const
export const AppModes = [
AppModeEnum.COMPLETION,

View File

@ -70,6 +70,27 @@ describe('app-redirection', () => {
expect(result).toBe('/app/app-456/configuration')
})
it('returns the Agent configure path when the backing Agent ID is available', () => {
const app = {
id: 'app-1',
mode: AppModeEnum.AGENT,
permission_keys: [AppACLPermission.ViewLayout],
bound_agent_id: 'agent-1',
}
expect(getRedirectionPath(app)).toBe('/agents/agent-1/configure')
})
it('falls back to the Agent roster when the backing Agent ID is unavailable', () => {
const app = {
id: 'app-1',
mode: AppModeEnum.AGENT,
permission_keys: [AppACLPermission.ViewLayout],
}
expect(getRedirectionPath(app)).toBe('/agents')
})
it('handles different app IDs', () => {
const app1 = { id: 'abc-123', mode: AppModeEnum.CHAT, permission_keys: [] }
const app2 = {

View File

@ -2,16 +2,20 @@ import type { ResourceMaintainerPermissionOptions } from '@/utils/permission'
import { AppModeEnum } from '@/types/app'
import { getAppACLCapabilities } from '@/utils/permission'
type AppRedirectionTarget = {
export type AppRedirectionTarget = {
id: string
mode: AppModeEnum
permission_keys?: string[]
bound_agent_id?: string | null
}
export const getRedirectionPath = (
app: AppRedirectionTarget,
maintainerPermissionOptions?: ResourceMaintainerPermissionOptions,
) => {
if (app.mode === AppModeEnum.AGENT)
return app.bound_agent_id ? `/agents/${app.bound_agent_id}/configure` : '/agents'
const appACLCapabilities = getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions)
if (appACLCapabilities.canAccessLayout) {

View File

@ -0,0 +1,12 @@
import type { DSLImportWarning } from '@/models/app'
const MAX_VISIBLE_IMPORT_WARNINGS = 3
export const getDSLImportWarningDescription = (warnings: DSLImportWarning[] = []) => {
const messages = [...new Set(warnings.map((warning) => warning.message.trim()).filter(Boolean))]
if (!messages.length) return
const visibleMessages = messages.slice(0, MAX_VISIBLE_IMPORT_WARNINGS)
if (messages.length > MAX_VISIBLE_IMPORT_WARNINGS) visibleMessages.push('…')
return visibleMessages.join(' · ')
}

View File

@ -0,0 +1,50 @@
import { AppModeEnum } from '@/types/app'
import { resolveImportedAppRedirectionTarget } from './imported-app-redirection'
const mockFetchAppDetail = vi.hoisted(() => vi.fn())
vi.mock('@/service/client', () => ({
consoleClient: {
apps: {
byAppId: {
get: (...args: unknown[]) => mockFetchAppDetail(...args),
},
},
},
}))
describe('resolveImportedAppRedirectionTarget', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('resolves the backing Agent ID for an imported Agent App', async () => {
mockFetchAppDetail.mockResolvedValue({ bound_agent_id: 'agent-1' })
await expect(
resolveImportedAppRedirectionTarget({
id: 'app-1',
mode: AppModeEnum.AGENT,
permission_keys: ['app.acl.view_layout'],
}),
).resolves.toEqual({
id: 'app-1',
mode: AppModeEnum.AGENT,
permission_keys: ['app.acl.view_layout'],
bound_agent_id: 'agent-1',
})
expect(mockFetchAppDetail).toHaveBeenCalledWith({
params: { app_id: 'app-1' },
})
})
it('keeps the roster fallback when resolving the imported Agent App fails', async () => {
mockFetchAppDetail.mockRejectedValue(new Error('Failed to fetch App detail'))
const target = {
id: 'app-1',
mode: AppModeEnum.AGENT,
}
await expect(resolveImportedAppRedirectionTarget(target)).resolves.toEqual(target)
})
})

View File

@ -0,0 +1,22 @@
import type { AppRedirectionTarget } from '@/utils/app-redirection'
import { consoleClient } from '@/service/client'
import { AppModeEnum } from '@/types/app'
export const resolveImportedAppRedirectionTarget = async (
app: AppRedirectionTarget,
): Promise<AppRedirectionTarget> => {
if (app.mode !== AppModeEnum.AGENT) return app
try {
const importedApp = await consoleClient.apps.byAppId.get({
params: { app_id: app.id },
})
return {
...app,
bound_agent_id: importedApp.bound_agent_id,
}
} catch {
return app
}
}