mirror of
https://github.com/langgenius/dify.git
synced 2026-07-20 09:38:32 +08:00
feat: new agent support import and export in frontend (#39012)
This commit is contained in:
parent
dfabe4cb28
commit
9e6a2cd19c
95
web/app/components/app/create-app-dropdown.tsx
Normal file
95
web/app/components/app/create-app-dropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[]
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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() })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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، يمكن للوكيل إعدادها لك.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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، عامل میتواند این موارد را برای شما تنظیم کند.",
|
||||
|
||||
@ -98,6 +98,7 @@
|
||||
"agentDetail.configure.files.empty.description": "Téléchargez des documents que l’agent 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 d’aperçu.",
|
||||
"agentDetail.configure.files.preview.failed": "Échec du chargement de l’aperçu.",
|
||||
"agentDetail.configure.files.preview.unsupported": "Ce fichier ne prend pas en charge l’aperç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.",
|
||||
|
||||
@ -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 में, एजेंट इन्हें आपके लिए सेट कर सकता है।",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -98,6 +98,7 @@
|
||||
"agentDetail.configure.files.empty.description": "Carica documenti che l’agente 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 l’anteprima.",
|
||||
"agentDetail.configure.files.preview.unsupported": "Questo file non supporta l’anteprima.",
|
||||
@ -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.",
|
||||
|
||||
@ -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 モードでは、エージェントがこれらを設定できます。",
|
||||
|
||||
@ -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에서는 에이전트가 이를 대신 설정할 수 있습니다.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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ê.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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 агент может настроить это за вас.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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 สามารถตั้งค่าสิ่งเหล่านี้ให้คุณได้",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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 агент може налаштувати це за вас.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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 可帮你完成这些设置。",
|
||||
|
||||
@ -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 可幫你完成這些設定。",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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) {
|
||||
|
||||
12
web/utils/dsl-import-warning.ts
Normal file
12
web/utils/dsl-import-warning.ts
Normal 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(' · ')
|
||||
}
|
||||
50
web/utils/imported-app-redirection.spec.ts
Normal file
50
web/utils/imported-app-redirection.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
22
web/utils/imported-app-redirection.ts
Normal file
22
web/utils/imported-app-redirection.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user