+ showAccessIcon?: boolean
showConsoleLink?: boolean
showDetailActions?: boolean
onClose: () => void
@@ -144,7 +146,7 @@ function AgentRosterDrawer({
{title}
- {!isSetup && }
+ {!isSetup && showAccessIcon && }
{description && (
@@ -341,6 +343,7 @@ export function AgentRosterField({
mode={panelMode}
open={panelOpen}
portalContainerRef={portalContainerRef}
+ showAccessIcon={!isInlineSetup}
showDetailActions={showPanelDetailActions}
onClose={() => setPanelOpen(false)}
>
diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx
index 371b2d847ee..c01c46fe513 100644
--- a/web/app/components/workflow/nodes/agent-v2/panel.tsx
+++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx
@@ -33,6 +33,7 @@ export function AgentV2Panel({
const { handleNodeDataUpdate, handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const openInlineAgentPanelNodeId = useStore(state => state.openInlineAgentPanelNodeId)
const setOpenInlineAgentPanelNodeId = useStore(state => state.setOpenInlineAgentPanelNodeId)
+ const appId = useStore(state => state.appId)
const drawerPortalContainerRef = useRef(null)
const declaredOutputs = getAgentV2DeclaredOutputs(inputs)
const rosterAgentId = inputs.agent_binding?.binding_type === 'roster_agent' ? inputs.agent_binding.agent_id : undefined
@@ -175,6 +176,7 @@ export function AgentV2Panel({
? (
{
+ it('should use Agent V2 catalog type for graph agent nodes with the Agent V2 discriminator', () => {
+ expect(getNodeCatalogType({
+ title: 'Agent',
+ desc: '',
+ type: BlockEnum.Agent,
+ agent_node_kind: 'dify_agent',
+ version: '2',
+ } as CommonNodeType)).toBe(BlockEnum.AgentV2)
+ })
+
+ it('should keep the graph node type for regular nodes and legacy Agent nodes', () => {
+ expect(getNodeCatalogType({
+ title: 'Code',
+ desc: '',
+ type: BlockEnum.Code,
+ })).toBe(BlockEnum.Code)
+
+ expect(getNodeCatalogType({
+ title: 'Agent',
+ desc: '',
+ type: BlockEnum.Agent,
+ })).toBe(BlockEnum.Agent)
+ })
+})
+
describe('generateNewNode', () => {
it('should create a basic node with default CUSTOM_NODE type', () => {
const { newNode } = generateNewNode({
diff --git a/web/app/components/workflow/utils/node.ts b/web/app/components/workflow/utils/node.ts
index 7432591f899..d319b020058 100644
--- a/web/app/components/workflow/utils/node.ts
+++ b/web/app/components/workflow/utils/node.ts
@@ -1,6 +1,7 @@
import type { IterationNodeType } from '../nodes/iteration/types'
import type { LoopNodeType } from '../nodes/loop/types'
import type {
+ CommonNodeType,
Node,
} from '../types'
import {
@@ -14,12 +15,17 @@ import {
LOOP_CHILDREN_Z_INDEX,
LOOP_NODE_Z_INDEX,
} from '../constants'
+import { isAgentV2NodeData } from '../nodes/agent-v2/types'
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
import {
BlockEnum,
} from '../types'
+export function getNodeCatalogType(data: CommonNodeType): BlockEnum {
+ return isAgentV2NodeData(data) ? BlockEnum.AgentV2 : data.type
+}
+
export function generateNewNode({ data, position, id, zIndex, type, ...rest }: Omit & { id?: string }): {
newNode: Node
newIterationStartNode?: Node
diff --git a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts
index 623a00f474d..302d04c2888 100644
--- a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts
+++ b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts
@@ -22,6 +22,7 @@ describe('agent composer store conversions', () => {
id: 'secret-1',
key: 'OPENAI_API_KEY',
ref: 'credential-1',
+ value: 'credential-1',
},
],
},
@@ -155,6 +156,7 @@ describe('agent composer store conversions', () => {
key: 'OPENAI_API_KEY',
masked: true,
scope: 'secret',
+ value: 'credential-1',
}),
],
})
@@ -229,12 +231,36 @@ describe('agent composer store conversions', () => {
secret_refs: [
expect.objectContaining({
key: 'OPENAI_API_KEY',
- ref: 'secret-1',
+ value: 'credential-1',
}),
],
})
})
+ it('should hydrate legacy secret refs from ref when value is absent', () => {
+ const formState = agentSoulConfigToFormState({
+ env: {
+ secret_refs: [
+ {
+ id: 'secret-1',
+ key: 'OPENAI_API_KEY',
+ ref: 'credential-legacy',
+ },
+ ],
+ },
+ })
+
+ expect(formState.envVariables).toEqual([
+ {
+ id: 'secret-1',
+ key: 'OPENAI_API_KEY',
+ masked: true,
+ scope: 'secret',
+ value: 'credential-legacy',
+ },
+ ])
+ })
+
it('should keep unauthorized credential type when no-auth tool settings change', () => {
const publishConfig = formStateToAgentSoulConfig({
formState: {
@@ -419,7 +445,7 @@ describe('agent composer store conversions', () => {
id: 'valid-secret',
key: 'OPENAI_API_KEY',
name: 'OPENAI_API_KEY',
- ref: 'valid-secret',
+ value: '********',
variable: 'OPENAI_API_KEY',
},
],
diff --git a/web/features/agent-v2/agent-composer/conversions.ts b/web/features/agent-v2/agent-composer/conversions.ts
index d194b63af3f..3411bd6556c 100644
--- a/web/features/agent-v2/agent-composer/conversions.ts
+++ b/web/features/agent-v2/agent-composer/conversions.ts
@@ -78,10 +78,12 @@ const toFileFormState = (config?: AgentSoulConfig): AgentFileNode[] => (
id,
name: file.name ?? id,
icon: toFileIcon(file),
+ driveKey: file.drive_key ?? undefined,
}]
})
const toFileRefs = (files: AgentFileNode[]) => flattenFileNodes(files).map(file => ({
+ ...(file.driveKey ? { drive_key: file.driveKey } : {}),
id: file.id,
name: file.name,
type: file.icon,
@@ -289,10 +291,11 @@ const toCliEnvVariables = (tool: AgentSoulCliToolConfig): EnvVariable[] => [
}),
...(tool.env?.secret_refs ?? []).map((secret): EnvVariable => {
const key = secret.key ?? secret.name ?? secret.variable ?? secret.env_name ?? ''
+ const value = secret.value ?? secret.ref ?? secret.credential_id ?? ''
return {
id: secret.id ?? secret.ref ?? secret.credential_id ?? key,
key,
- value: '••••••••••••',
+ value,
scope: 'secret',
masked: true,
}
@@ -352,7 +355,7 @@ const toCliToolConfigs = (tools: AgentTool[]) => tools.flatMap((tool) => {
id: variable.id,
key: variable.key.trim(),
name: variable.key.trim(),
- ref: variable.id,
+ value: variable.value,
variable: variable.key.trim(),
})),
},
@@ -376,10 +379,11 @@ const toEnvVariableFormState = (config?: AgentSoulConfig): EnvVariable[] => [
}),
...(config?.env?.secret_refs ?? []).map((secret): EnvVariable => {
const key = secret.key ?? secret.name ?? secret.variable ?? secret.env_name ?? ''
+ const value = secret.value ?? secret.ref ?? secret.credential_id ?? ''
return {
id: secret.id ?? secret.ref ?? secret.credential_id ?? key,
key,
- value: '••••••••••••',
+ value,
scope: 'secret',
masked: true,
}
@@ -402,7 +406,7 @@ const toEnvConfig = (variables: EnvVariable[]): AgentSoulConfig['env'] => ({
id: variable.id,
key: variable.key.trim(),
name: variable.key.trim(),
- ref: variable.id,
+ value: variable.value,
variable: variable.key.trim(),
})),
})
diff --git a/web/features/agent-v2/agent-composer/form-state.ts b/web/features/agent-v2/agent-composer/form-state.ts
index 26ad4b39501..e75c3491f91 100644
--- a/web/features/agent-v2/agent-composer/form-state.ts
+++ b/web/features/agent-v2/agent-composer/form-state.ts
@@ -39,6 +39,7 @@ export type AgentFileNode = {
id: string
name: string
icon: FileTreeIconType
+ driveKey?: string
children?: AgentFileNode[]
}
diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx
index 787120dd3cf..cde113dda3d 100644
--- a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx
+++ b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx
@@ -38,6 +38,12 @@ vi.mock('@/service/client', () => ({
consoleQuery: {
agent: {
byAgentId: {
+ get: {
+ queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [
+ 'agent-detail',
+ input.params.agent_id,
+ ],
+ },
composer: {
get: {
queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [
@@ -165,6 +171,10 @@ describe('useAgentConfigureSync', () => {
it('should publish only when publishDraft is called explicitly', async () => {
const { queryClient, result } = renderUseAgentConfigureSync()
+ queryClient.setQueryData(['agent-detail', 'agent-1'], {
+ active_config_is_published: false,
+ name: 'Agent',
+ })
await act(async () => {
await result.current.publishDraft({
@@ -198,5 +208,9 @@ describe('useAgentConfigureSync', () => {
},
},
})
+ expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
+ active_config_is_published: true,
+ name: 'Agent',
+ })
})
})
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/empty-sections.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/empty-sections.spec.tsx
index 2660d6d3aec..ef583288a07 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/empty-sections.spec.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/empty-sections.spec.tsx
@@ -23,7 +23,7 @@ function renderEmptySections() {
}}
>
-
+
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx
index d5f3a4dec13..5e4fe175882 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx
@@ -1,7 +1,8 @@
-import type { AgentConfigSnapshotSummaryResponse, AgentPublishedReferenceResponse } from '@dify/contracts/api/console/agent/types.gen'
+import type { AgentConfigSnapshotSummaryResponse, AgentReferencingWorkflowResponse } from '@dify/contracts/api/console/agent/types.gen'
import type { ComponentProps } from 'react'
import type { Mock } from 'vitest'
-import { fireEvent, render, screen, within } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { agentComposerDraftAtom, agentComposerOriginalDraftAtom, agentComposerPublishedDraftAtom } from '@/features/agent-v2/agent-composer/store'
@@ -18,6 +19,9 @@ const hotkeyRegistrations = vi.hoisted(() => new Map vi.fn((hotkey: string) => `display:${hotkey}`))
const mockFormatTimeFromNow = vi.hoisted(() => vi.fn(() => 'just now'))
+const workflowReferences = vi.hoisted(() => ({
+ data: [] as AgentReferencingWorkflowResponse[],
+}))
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
const actual = await importOriginal()
@@ -36,8 +40,28 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({
}),
}))
+vi.mock('@/service/client', () => ({
+ consoleQuery: {
+ agent: {
+ byAgentId: {
+ referencingWorkflows: {
+ get: {
+ queryOptions: ({ input }: { input: { params: { agent_id: string } } }) => ({
+ queryKey: ['agent-referencing-workflows', input],
+ queryFn: async () => ({
+ data: workflowReferences.data,
+ }),
+ }),
+ },
+ },
+ },
+ },
+ },
+}))
+
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
+ const ReactDOM = await import('react-dom')
const PopoverContext = React.createContext<{
open: boolean
onOpenChange?: (open: boolean) => void
@@ -68,7 +92,7 @@ vi.mock('@langgenius/dify-ui/popover', async () => {
if (!context.open)
return null
- return {children}
+ return ReactDOM.createPortal({children}
, document.body)
},
PopoverTrigger: ({
render: trigger,
@@ -96,60 +120,73 @@ const originalDraftWithFile = {
],
} satisfies typeof defaultAgentSoulConfigFormState
-const publishedReferences: AgentPublishedReferenceResponse[] = [
+const publishedReferences: AgentReferencingWorkflowResponse[] = [
{
app_id: 'app-python',
app_mode: 'workflow',
app_name: 'Python bug fixer',
workflow_id: 'workflow-python',
workflow_version: '1',
+ node_ids: ['node-python'],
},
{
app_id: 'app-translation',
+ app_icon: 'T',
+ app_icon_background: '#E0F2FE',
+ app_icon_type: 'emoji',
app_mode: 'workflow',
app_name: 'Translation Workflow',
workflow_id: 'workflow-translation',
workflow_version: '1',
+ node_ids: ['node-translation'],
},
]
function renderPublishBar({
+ activeConfigIsPublished,
activeConfigSnapshot,
draftSavedAt,
isPublishing,
onPublish = vi.fn(),
prompt = '',
- publishedReferenceCount,
- publishedReferences,
setupStore,
+ usedByAppReferences = [],
}: {
+ activeConfigIsPublished?: boolean
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
draftSavedAt?: number
isPublishing?: boolean
onPublish?: PublishMock
prompt?: string
- publishedReferenceCount?: number
- publishedReferences?: AgentPublishedReferenceResponse[]
setupStore?: (store: ReturnType) => void
+ usedByAppReferences?: AgentReferencingWorkflowResponse[]
} = {}) {
+ workflowReferences.data = usedByAppReferences
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
const store = createStore()
store.set(agentComposerPromptAtom, prompt)
setupStore?.(store)
render(
-
-
- ,
+
+
+
+
+ ,
)
return {
@@ -161,6 +198,7 @@ describe('AgentConfigurePublishBar', () => {
beforeEach(() => {
vi.clearAllMocks()
hotkeyRegistrations.clear()
+ workflowReferences.data = []
vi.spyOn(console, 'log').mockImplementation(() => {})
})
@@ -191,7 +229,10 @@ describe('AgentConfigurePublishBar', () => {
})
it('should render published state from the active snapshot and disable publish logic', () => {
- const { onPublish } = renderPublishBar({ activeConfigSnapshot })
+ const { onPublish } = renderPublishBar({
+ activeConfigIsPublished: true,
+ activeConfigSnapshot,
+ })
expect(screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate')).toBeInTheDocument()
expect(screen.getByText(/agentV2\.agentDetail\.configure\.publishBar\.publishedAt/)).toBeInTheDocument()
@@ -207,7 +248,28 @@ describe('AgentConfigurePublishBar', () => {
expect(onPublish).not.toHaveBeenCalled()
})
- it('should publish the current draft payload from the unpublished changes state', () => {
+ it('should initialize unpublished state when active config is not published', async () => {
+ const { onPublish } = renderPublishBar({
+ activeConfigIsPublished: false,
+ activeConfigSnapshot,
+ })
+
+ expect(screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeInTheDocument()
+ expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual(
+ expect.objectContaining({ enabled: true, ignoreInputs: false }),
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ }))
+
+ await waitFor(() => {
+ expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({
+ agent_id: 'agent-1',
+ }))
+ })
+ })
+
+ it('should publish the current draft payload from the unpublished changes state', async () => {
const { onPublish } = renderPublishBar({
activeConfigSnapshot,
prompt: 'Updated system prompt',
@@ -217,14 +279,16 @@ describe('AgentConfigurePublishBar', () => {
fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ }))
- expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({
- agent_id: 'agent-1',
- config_snapshot: expect.objectContaining({
- prompt: expect.objectContaining({
- system_prompt: 'Updated system prompt',
+ await waitFor(() => {
+ expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({
+ agent_id: 'agent-1',
+ config_snapshot: expect.objectContaining({
+ prompt: expect.objectContaining({
+ system_prompt: 'Updated system prompt',
+ }),
}),
- }),
- }))
+ }))
+ })
})
it('should mark non-prompt draft changes as unpublished', () => {
@@ -277,20 +341,27 @@ describe('AgentConfigurePublishBar', () => {
)
})
- it('should show affected workflow references when clicking a publishable agent in use', () => {
+ it('should show affected workflow references when clicking a publishable agent in use', async () => {
const { onPublish } = renderPublishBar({
activeConfigSnapshot,
prompt: 'Updated system prompt',
- publishedReferenceCount: 2,
- publishedReferences,
+ usedByAppReferences: publishedReferences,
})
expect(screen.queryByTestId('publish-impact-popover')).not.toBeInTheDocument()
+ const publishBar = screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges').closest('[aria-hidden]')
+ expect(publishBar).toHaveAttribute('aria-hidden', 'false')
fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ }))
expect(onPublish).not.toHaveBeenCalled()
- expect(screen.getByTestId('publish-impact-popover')).toBeInTheDocument()
+ const impactPopover = await screen.findByTestId('publish-impact-popover')
+ expect(impactPopover).toBeInTheDocument()
+ expect(publishBar).toHaveAttribute('aria-hidden', 'false')
+ await waitFor(() => {
+ expect(publishBar).toHaveAttribute('aria-hidden', 'true')
+ expect(publishBar).toHaveClass('opacity-0')
+ })
expect(screen.getByText(/agentV2\.agentDetail\.configure\.publishImpact\.title/)).toBeInTheDocument()
expect(screen.getByText(/agentV2\.agentDetail\.configure\.publishImpact\.descriptionPrefix/)).toBeInTheDocument()
expect(screen.getByText(/agentV2\.agentDetail\.configure\.publishImpact\.workflowCount/)).toBeInTheDocument()
@@ -298,18 +369,20 @@ describe('AgentConfigurePublishBar', () => {
expect(screen.getByText('Translation Workflow')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Python bug fixer' })).toHaveAttribute('target', '_blank')
expect(screen.getByRole('link', { name: 'Python bug fixer' })).toHaveAttribute('rel', 'noopener noreferrer')
+ expect(within(impactPopover).getByText('display:Mod')).toBeInTheDocument()
+ expect(within(impactPopover).getByText('display:Shift')).toBeInTheDocument()
+ expect(within(impactPopover).getByText('display:P')).toBeInTheDocument()
})
- it('should publish from the affected workflow popover action', () => {
+ it('should publish from the affected workflow popover action', async () => {
const { onPublish } = renderPublishBar({
activeConfigSnapshot,
prompt: 'Updated system prompt',
- publishedReferenceCount: 2,
- publishedReferences,
+ usedByAppReferences: publishedReferences,
})
fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ }))
- fireEvent.click(within(screen.getByTestId('publish-impact-popover')).getByRole('button', {
+ fireEvent.click(within(await screen.findByTestId('publish-impact-popover')).getByRole('button', {
name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/,
}))
@@ -317,4 +390,28 @@ describe('AgentConfigurePublishBar', () => {
agent_id: 'agent-1',
}))
})
+
+ it('should open the affected workflow popover from the publish shortcut before publishing', async () => {
+ const { onPublish } = renderPublishBar({
+ activeConfigSnapshot,
+ prompt: 'Updated system prompt',
+ usedByAppReferences: publishedReferences,
+ })
+ const publishShortcut = hotkeyRegistrations.get('Mod+Shift+P')
+
+ await act(async () => {
+ await publishShortcut?.callback({ preventDefault: vi.fn() })
+ })
+
+ expect(onPublish).not.toHaveBeenCalled()
+ expect(await screen.findByTestId('publish-impact-popover')).toBeInTheDocument()
+
+ await act(async () => {
+ await hotkeyRegistrations.get('Mod+Shift+P')?.callback({ preventDefault: vi.fn() })
+ })
+
+ expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({
+ agent_id: 'agent-1',
+ }))
+ })
})
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/configurable-item.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/configurable-item.tsx
index ee56d237cdd..337d7be8e32 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/configurable-item.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/configurable-item.tsx
@@ -32,12 +32,12 @@ export function ConfigureSectionConfigurableItem({
{!readOnly && (
-
+
@@ -45,14 +45,14 @@ export function ConfigureSectionConfigurableItem({
type="button"
aria-label={removeAriaLabel}
onClick={onRemove}
- className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
+ className="flex size-6 items-center justify-center rounded-md text-text-tertiary 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"
>
)}
{hasBadge && (
-
+
{badge}
)}
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/file-icon.spec.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/file-icon.spec.ts
new file mode 100644
index 00000000000..28827f3c560
--- /dev/null
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/file-icon.spec.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { getDriveFileIconType, getFileIconType } from '../file-icon'
+
+describe('agent file icon helpers', () => {
+ it('should infer supported icons for uploaded drive file pointer kinds', () => {
+ expect(getDriveFileIconType({
+ fileKind: 'upload_file',
+ fileName: 'report.md',
+ mimeType: 'text/markdown',
+ })).toBe('markdown')
+ expect(getDriveFileIconType({
+ fileKind: 'tool_file',
+ fileName: 'image.png',
+ mimeType: 'image/png',
+ })).toBe('image')
+ })
+
+ it('should keep supported drive file kinds and normalize directories', () => {
+ expect(getDriveFileIconType({
+ fileKind: 'directory',
+ fileName: 'files',
+ })).toBe('folder')
+ expect(getDriveFileIconType({
+ fileKind: 'pdf',
+ fileName: 'guide',
+ })).toBe('pdf')
+ })
+
+ it('should infer icons from file extension when mime type is not enough', () => {
+ expect(getFileIconType('data.csv')).toBe('table')
+ expect(getFileIconType('archive.zip')).toBe('archive')
+ expect(getFileIconType('script.ts')).toBe('code')
+ })
+})
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx
index 3476c890b3b..9961ba7d242 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx
@@ -1,5 +1,6 @@
+import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { fireEvent, render, screen, within } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
@@ -7,20 +8,87 @@ import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
import { AgentFiles } from '../index'
const mocks = vi.hoisted(() => ({
- filePreviewQueryOptions: vi.fn(),
+ agentDriveFilesQueryOptions: vi.fn(),
+ agentFileCommitMutationFn: vi.fn(),
+ agentFileDeleteMutationFn: vi.fn(),
+ agentFileDeleteMutationOptions: vi.fn(),
+ agentFileDownloadQueryOptions: vi.fn(),
+ agentFilePreviewQueryOptions: vi.fn(),
+ agentFileCommitMutationOptions: vi.fn(),
+ workflowAgentDriveFilesQueryOptions: vi.fn(),
+ workflowAgentFileCommitMutationFn: vi.fn(),
+ workflowAgentFileDeleteMutationFn: vi.fn(),
+ workflowAgentFileDeleteMutationOptions: vi.fn(),
+ workflowAgentFileDownloadQueryOptions: vi.fn(),
+ workflowAgentFilePreviewQueryOptions: vi.fn(),
+ workflowAgentFileCommitMutationOptions: vi.fn(),
+ uploadFileMutationFn: vi.fn(),
uploadFileMutationOptions: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
- files: {
- byFileId: {
- preview: {
- get: {
- queryOptions: mocks.filePreviewQueryOptions,
+ agent: {
+ byAgentId: {
+ drive: {
+ files: {
+ get: {
+ queryOptions: mocks.agentDriveFilesQueryOptions,
+ },
+ download: {
+ get: {
+ queryOptions: mocks.agentFileDownloadQueryOptions,
+ },
+ },
+ preview: {
+ get: {
+ queryOptions: mocks.agentFilePreviewQueryOptions,
+ },
+ },
+ },
+ },
+ files: {
+ delete: {
+ mutationOptions: mocks.agentFileDeleteMutationOptions,
+ },
+ post: {
+ mutationOptions: mocks.agentFileCommitMutationOptions,
},
},
},
+ },
+ apps: {
+ byAppId: {
+ agent: {
+ drive: {
+ files: {
+ get: {
+ queryOptions: mocks.workflowAgentDriveFilesQueryOptions,
+ },
+ download: {
+ get: {
+ queryOptions: mocks.workflowAgentFileDownloadQueryOptions,
+ },
+ },
+ preview: {
+ get: {
+ queryOptions: mocks.workflowAgentFilePreviewQueryOptions,
+ },
+ },
+ },
+ },
+ files: {
+ delete: {
+ mutationOptions: mocks.workflowAgentFileDeleteMutationOptions,
+ },
+ post: {
+ mutationOptions: mocks.workflowAgentFileCommitMutationOptions,
+ },
+ },
+ },
+ },
+ },
+ files: {
upload: {
post: {
mutationOptions: mocks.uploadFileMutationOptions,
@@ -37,16 +105,36 @@ const agentFilesDraft = {
id: 'preview-image',
name: 'agent-roster-skill-detail-dialog-preview-image.png',
icon: 'image',
+ driveKey: 'files/agent-roster-skill-detail-dialog-preview-image.png',
},
{
id: 'brief',
name: 'brief.md',
icon: 'markdown',
+ driveKey: 'files/brief.md',
},
],
-} satisfies typeof defaultAgentSoulConfigFormState
+} satisfies AgentSoulConfigFormState
-function renderAgentFiles() {
+const agentSkillFilesDraft = {
+ ...defaultAgentSoulConfigFormState,
+ files: [
+ {
+ id: 'script',
+ name: 'run.py',
+ icon: 'file',
+ driveKey: 'files/run.py',
+ },
+ {
+ id: 'skill-md',
+ name: 'SKILL.md',
+ icon: 'markdown',
+ driveKey: 'files/SKILL.md',
+ },
+ ],
+} satisfies AgentSoulConfigFormState
+
+function renderAgentFiles(initialDraft: AgentSoulConfigFormState = agentFilesDraft) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -57,8 +145,8 @@ function renderAgentFiles() {
return render(
-
-
+
+
,
)
@@ -77,7 +165,7 @@ function renderReadonlyAgentFiles() {
-
+
,
@@ -87,38 +175,272 @@ function renderReadonlyAgentFiles() {
describe('AgentFiles', () => {
beforeEach(() => {
vi.clearAllMocks()
- mocks.filePreviewQueryOptions.mockImplementation(({ input }) => ({
- queryKey: ['file-preview', input],
+ mocks.agentDriveFilesQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['agent-drive-files', input],
+ queryFn: () => new Promise(() => {}),
+ }))
+ mocks.workflowAgentDriveFilesQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['workflow-agent-drive-files', input],
+ queryFn: () => new Promise(() => {}),
+ }))
+ mocks.agentFilePreviewQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['agent-file-preview', input],
queryFn: async () => ({
- content: `Preview content for ${input.params.file_id}`,
+ binary: false,
+ text: `Preview content for ${input.query.key}`,
+ }),
+ }))
+ mocks.workflowAgentFilePreviewQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['workflow-agent-file-preview', input],
+ queryFn: async () => ({
+ binary: false,
+ text: `Preview content for ${input.query.key}`,
+ }),
+ }))
+ mocks.agentFileDownloadQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['agent-file-download', input],
+ queryFn: async () => ({
+ url: `https://signed.example/${input.query.key}`,
+ }),
+ }))
+ mocks.workflowAgentFileDownloadQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['workflow-agent-file-download', input],
+ queryFn: async () => ({
+ url: `https://signed.example/${input.query.key}`,
}),
}))
mocks.uploadFileMutationOptions.mockReturnValue({
- mutationFn: vi.fn(),
+ mutationFn: mocks.uploadFileMutationFn.mockResolvedValue({
+ id: 'upload-file-1',
+ name: 'uploaded.md',
+ mime_type: 'text/markdown',
+ }),
mutationKey: ['upload-file'],
})
+ mocks.agentFileCommitMutationOptions.mockReturnValue({
+ mutationFn: mocks.agentFileCommitMutationFn.mockResolvedValue({
+ file: {
+ drive_key: 'files/uploaded.md',
+ file_id: 'drive-file-1',
+ mime_type: 'text/markdown',
+ name: 'uploaded.md',
+ },
+ }),
+ mutationKey: ['commit-agent-file'],
+ })
+ mocks.workflowAgentFileCommitMutationOptions.mockReturnValue({
+ mutationFn: mocks.workflowAgentFileCommitMutationFn.mockResolvedValue({
+ file: {
+ drive_key: 'files/uploaded.md',
+ file_id: 'drive-file-1',
+ mime_type: 'text/markdown',
+ name: 'uploaded.md',
+ },
+ }),
+ mutationKey: ['commit-workflow-agent-file'],
+ })
+ mocks.agentFileDeleteMutationOptions.mockReturnValue({
+ mutationFn: mocks.agentFileDeleteMutationFn.mockResolvedValue({ result: 'success' }),
+ mutationKey: ['delete-agent-file'],
+ })
+ mocks.workflowAgentFileDeleteMutationOptions.mockReturnValue({
+ mutationFn: mocks.workflowAgentFileDeleteMutationFn.mockResolvedValue({ result: 'success' }),
+ mutationKey: ['delete-workflow-agent-file'],
+ })
+ })
+
+ it('should list Agent App drive files under the files prefix', () => {
+ renderAgentFiles()
+
+ expect(mocks.agentDriveFilesQueryOptions).toHaveBeenCalledWith({
+ input: {
+ params: {
+ agent_id: 'agent-1',
+ },
+ query: {
+ prefix: 'files/',
+ },
+ },
+ })
+ })
+
+ it('should keep the file preview trigger focus ring inside the row bounds', () => {
+ renderAgentFiles()
+
+ expect(screen.getByRole('button', { name: 'brief.md' })).toHaveClass(
+ 'focus-visible:ring-2',
+ 'focus-visible:ring-state-accent-solid',
+ 'focus-visible:ring-inset',
+ )
})
it('should open the shared detail dialog with the full file tree when the file row is clicked', async () => {
renderAgentFiles()
fireEvent.click(screen.getByRole('button', {
- name: 'agent-roster-skill-detail-dialog-preview-image.png',
+ name: 'brief.md',
}))
const dialog = screen.getByRole('dialog')
expect(dialog).toBeInTheDocument()
- expect(within(dialog).getAllByText('agent-roster-skill-detail-dialog-preview-image.png')).toHaveLength(2)
- expect(within(dialog).getByText('brief.md')).toBeInTheDocument()
- expect(mocks.filePreviewQueryOptions).toHaveBeenCalledWith({
+ expect(within(dialog).getByText('agent-roster-skill-detail-dialog-preview-image.png')).toBeInTheDocument()
+ expect(within(dialog).getAllByText('brief.md')).toHaveLength(2)
+ expect(mocks.agentFilePreviewQueryOptions).toHaveBeenCalledWith({
input: {
params: {
- file_id: 'preview-image',
+ agent_id: 'agent-1',
+ },
+ query: {
+ key: 'files/brief.md',
},
},
})
- expect(await within(dialog).findByText('Preview content for preview-image')).toBeInTheDocument()
+ expect(await within(dialog).findByText('Preview content for files/brief.md')).toBeInTheDocument()
+ })
+
+ it('should preview the clicked file when SKILL.md also exists', async () => {
+ renderAgentFiles(agentSkillFilesDraft)
+
+ fireEvent.click(screen.getByRole('button', {
+ name: 'run.py',
+ }))
+
+ const dialog = screen.getByRole('dialog')
+
+ expect(await within(dialog).findByText('Preview content for files/run.py')).toBeInTheDocument()
+ expect(mocks.agentFilePreviewQueryOptions).toHaveBeenCalledWith({
+ input: {
+ params: {
+ agent_id: 'agent-1',
+ },
+ query: {
+ key: 'files/run.py',
+ },
+ },
+ })
+ })
+
+ it('should preview the selected file from the detail file tree', async () => {
+ renderAgentFiles(agentSkillFilesDraft)
+
+ fireEvent.click(screen.getByRole('button', {
+ name: 'run.py',
+ }))
+
+ const dialog = screen.getByRole('dialog')
+ const skillFile = within(dialog).getByRole('button', { name: 'SKILL.md' })
+ fireEvent.click(skillFile)
+
+ expect(await within(dialog).findByText('Preview content for files/SKILL.md')).toBeInTheDocument()
+
+ const scriptFile = within(dialog).getAllByRole('button', { name: 'run.py' }).at(-1)
+ expect(scriptFile).toBeDefined()
+ fireEvent.click(scriptFile!)
+
+ expect(await within(dialog).findByText('Preview content for files/run.py')).toBeInTheDocument()
+ expect(mocks.agentFilePreviewQueryOptions).toHaveBeenCalledWith({
+ input: {
+ params: {
+ agent_id: 'agent-1',
+ },
+ query: {
+ key: 'files/run.py',
+ },
+ },
+ })
+ })
+
+ it('should render image files directly from the drive download URL without a download link', async () => {
+ mocks.agentFilePreviewQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['agent-file-preview', input],
+ queryFn: async () => ({
+ binary: false,
+ key: input.query.key,
+ size: 12345,
+ text: 'image preview should not render as text',
+ truncated: false,
+ }),
+ }))
+ renderAgentFiles()
+
+ fireEvent.click(screen.getByRole('button', {
+ name: 'agent-roster-skill-detail-dialog-preview-image.png',
+ }))
+
+ const image = await screen.findByRole('img', {
+ name: 'agent-roster-skill-detail-dialog-preview-image.png',
+ })
+
+ expect(image).toHaveAttribute('src', 'https://signed.example/files/agent-roster-skill-detail-dialog-preview-image.png')
+ expect(screen.queryByRole('link', { name: /common\.operation\.download/ })).not.toBeInTheDocument()
+ expect(screen.queryByText('image preview should not render as text')).not.toBeInTheDocument()
+ expect(mocks.agentFileDownloadQueryOptions).toHaveBeenCalledWith({
+ input: {
+ params: {
+ agent_id: 'agent-1',
+ },
+ query: {
+ key: 'files/agent-roster-skill-detail-dialog-preview-image.png',
+ },
+ },
+ })
+ })
+
+ it('should render a download link for binary non-image files', async () => {
+ mocks.agentFilePreviewQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['agent-file-preview', input],
+ queryFn: async () => ({
+ binary: true,
+ key: input.query.key,
+ size: 12345,
+ text: null,
+ truncated: false,
+ }),
+ }))
+ renderAgentFiles()
+
+ fireEvent.click(screen.getByRole('button', {
+ name: 'brief.md',
+ }))
+
+ const link = await screen.findByRole('link', { name: 'common.operation.download' })
+
+ expect(screen.getByText('agentV2.agentDetail.configure.files.preview.unsupported')).toBeInTheDocument()
+ expect(link).toHaveAttribute('href', 'https://signed.example/files/brief.md')
+ expect(screen.queryByText('Preview content for files/brief.md')).not.toBeInTheDocument()
+ })
+
+ it('should commit an uploaded file to the Agent App drive before adding it to the composer draft', async () => {
+ renderAgentFiles({
+ ...defaultAgentSoulConfigFormState,
+ files: [],
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.files.add' }))
+ const input = document.querySelector('input[type="file"]')
+ expect(input).toBeInstanceOf(HTMLInputElement)
+
+ fireEvent.change(input!, {
+ target: {
+ files: [new File(['# Uploaded'], 'uploaded.md', { type: 'text/markdown' })],
+ },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.files.upload.action' }))
+
+ await waitFor(() => {
+ expect(mocks.agentFileCommitMutationFn).toHaveBeenCalledWith(
+ {
+ params: {
+ agent_id: 'agent-1',
+ },
+ body: {
+ upload_file_id: 'upload-file-1',
+ },
+ },
+ expect.anything(),
+ )
+ })
})
// File rows expose a hover/focus remove action that updates the composer draft.
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/api-context.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/api-context.ts
new file mode 100644
index 00000000000..886dd16841e
--- /dev/null
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/api-context.ts
@@ -0,0 +1,7 @@
+export type AgentFileApiContext = {
+ agentId: string
+ workflow?: {
+ appId: string
+ nodeId: string
+ }
+}
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/file-icon.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/file-icon.ts
new file mode 100644
index 00000000000..cb462055058
--- /dev/null
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/file-icon.ts
@@ -0,0 +1,81 @@
+import type { FileTreeIconType } from '@langgenius/dify-ui/file-tree'
+
+const codeFileExtensions = new Set([
+ 'css',
+ 'go',
+ 'html',
+ 'js',
+ 'jsx',
+ 'py',
+ 'rb',
+ 'rs',
+ 'scss',
+ 'sh',
+ 'ts',
+ 'tsx',
+ 'vue',
+ 'yaml',
+ 'yml',
+])
+const tableFileExtensions = new Set(['csv', 'xls', 'xlsx'])
+const archiveFileExtensions = new Set(['7z', 'gz', 'rar', 'tar', 'zip'])
+const driveFileIconTypes = new Set([
+ 'archive',
+ 'code',
+ 'database',
+ 'file',
+ 'folder',
+ 'image',
+ 'json',
+ 'markdown',
+ 'pdf',
+ 'table',
+ 'text',
+])
+
+function getFileExtension(fileName: string) {
+ return fileName.split('.').pop()?.toLowerCase() ?? ''
+}
+
+export function getFileIconType(fileName: string, mimeType?: string | null): FileTreeIconType {
+ const extension = getFileExtension(fileName)
+
+ if (mimeType?.startsWith('image/'))
+ return 'image'
+ if (mimeType === 'application/pdf' || extension === 'pdf')
+ return 'pdf'
+ if (extension === 'md' || extension === 'markdown' || extension === 'mdx')
+ return 'markdown'
+ if (extension === 'json')
+ return 'json'
+ if (tableFileExtensions.has(extension))
+ return 'table'
+ if (archiveFileExtensions.has(extension))
+ return 'archive'
+ if (codeFileExtensions.has(extension))
+ return 'code'
+ if (mimeType?.startsWith('text/'))
+ return 'text'
+
+ return 'file'
+}
+
+export function getDriveFileIconType({
+ fileKind,
+ fileName,
+ mimeType,
+}: {
+ fileKind?: string | null
+ fileName: string
+ mimeType?: string | null
+}): FileTreeIconType {
+ const normalizedFileKind = fileKind?.toLowerCase()
+
+ if (normalizedFileKind === 'directory')
+ return 'folder'
+
+ if (normalizedFileKind && driveFileIconTypes.has(normalizedFileKind as FileTreeIconType))
+ return normalizedFileKind as FileTreeIconType
+
+ return getFileIconType(fileName, mimeType)
+}
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx
index 7a0930bca87..4d61b51f9ec 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx
@@ -2,16 +2,18 @@
import type { ReactNode } from 'react'
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
+import type { AgentFileApiContext } from './api-context'
import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state'
import {
Dialog,
+ DialogTrigger,
} from '@langgenius/dify-ui/dialog'
import {
FileTreeGuide,
} from '@langgenius/dify-ui/file-tree'
-import { useQuery } from '@tanstack/react-query'
+import { useMutation, useQuery } from '@tanstack/react-query'
import { useAtom } from 'jotai'
-import { useCallback, useRef, useState } from 'react'
+import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files'
import { consoleQuery } from '@/service/client'
@@ -21,6 +23,7 @@ import { ConfigureSectionEmpty } from '../common/empty'
import { ConfigureSection } from '../common/section'
import { useAgentOrchestrateReadOnly } from '../read-only-context'
import { AgentSkillDetailDialog } from '../skills/detail-dialog'
+import { getDriveFileIconType } from './file-icon'
import { AgentFileTree } from './tree'
import { AgentFileUploadDialog } from './upload-dialog'
@@ -31,11 +34,47 @@ const removeFileNode = (files: AgentFileNode[], fileId: string): AgentFileNode[]
children: file.children ? removeFileNode(file.children, fileId) : undefined,
}))
+const FILES_DRIVE_PREFIX = 'files/'
+
+const getAgentFilePreviewKey = (file: AgentFileNode) => file.driveKey ?? file.id
+
+const findAgentFileNode = (files: AgentFileNode[], fileId: string): AgentFileNode | undefined => {
+ for (const file of files) {
+ if (file.id === fileId)
+ return file
+
+ const child = file.children ? findAgentFileNode(file.children, fileId) : undefined
+ if (child)
+ return child
+ }
+}
+
+const getAgentDriveFileName = (key: string) => {
+ const normalizedKey = key.endsWith('/') ? key.slice(0, -1) : key
+ return normalizedKey.split('/').pop() || normalizedKey
+}
+
+const toAgentFileNodeFromDriveItem = (item: {
+ file_kind: string
+ key: string
+ mime_type?: string | null
+}): AgentFileNode => ({
+ id: item.key,
+ name: getAgentDriveFileName(item.key),
+ icon: getDriveFileIconType({
+ fileKind: item.file_kind,
+ fileName: getAgentDriveFileName(item.key),
+ mimeType: item.mime_type,
+ }),
+ driveKey: item.key,
+})
+
function AgentFileItem({
children,
depth,
file,
files,
+ apiContext,
onRemove,
selected,
}: {
@@ -43,35 +82,96 @@ function AgentFileItem({
depth: number
file: AgentFileNode
files: AgentFileNode[]
+ apiContext: AgentFileApiContext
onRemove: (fileId: string) => void
selected: boolean
}) {
const { t } = useTranslation('agentV2')
const readOnly = useAgentOrchestrateReadOnly()
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
- const previewQuery = useQuery({
- ...consoleQuery.files.byFileId.preview.get.queryOptions({
+ const [selectedFileId, setSelectedFileId] = useState()
+ const selectedFile = selectedFileId ? findAgentFileNode(files, selectedFileId) : undefined
+ const previewFileId = getAgentFilePreviewKey(selectedFile ?? file)
+ const agentPreviewQuery = useQuery({
+ ...consoleQuery.agent.byAgentId.drive.files.preview.get.queryOptions({
input: {
params: {
- file_id: file.id,
+ agent_id: apiContext.agentId,
+ },
+ query: {
+ key: previewFileId ?? '',
},
},
}),
- enabled: isPreviewOpen,
+ enabled: isPreviewOpen && !!previewFileId && !apiContext.workflow,
})
+ const workflowPreviewQuery = useQuery({
+ ...consoleQuery.apps.byAppId.agent.drive.files.preview.get.queryOptions({
+ input: {
+ params: {
+ app_id: apiContext.workflow?.appId ?? '',
+ },
+ query: {
+ key: previewFileId ?? '',
+ node_id: apiContext.workflow?.nodeId,
+ },
+ },
+ }),
+ enabled: isPreviewOpen && !!previewFileId && !!apiContext.workflow,
+ })
+ const previewQuery = apiContext.workflow ? workflowPreviewQuery : agentPreviewQuery
+ const selectedPreviewFile = selectedFile ?? file
+ const isImagePreviewFile = selectedPreviewFile.icon === 'image'
+ const shouldDownloadPreviewFile = isPreviewOpen && !!previewFileId && (isImagePreviewFile || !!previewQuery.data?.binary)
+ const agentDownloadQuery = useQuery({
+ ...consoleQuery.agent.byAgentId.drive.files.download.get.queryOptions({
+ input: {
+ params: {
+ agent_id: apiContext.agentId,
+ },
+ query: {
+ key: previewFileId ?? '',
+ },
+ },
+ }),
+ enabled: shouldDownloadPreviewFile && !apiContext.workflow,
+ })
+ const workflowDownloadQuery = useQuery({
+ ...consoleQuery.apps.byAppId.agent.drive.files.download.get.queryOptions({
+ input: {
+ params: {
+ app_id: apiContext.workflow?.appId ?? '',
+ },
+ query: {
+ key: previewFileId ?? '',
+ node_id: apiContext.workflow?.nodeId,
+ },
+ },
+ }),
+ enabled: shouldDownloadPreviewFile && !!apiContext.workflow,
+ })
+ const downloadQuery = apiContext.workflow ? workflowDownloadQuery : agentDownloadQuery
const handleRemove = useCallback(() => {
onRemove(file.id)
}, [file.id, onRemove])
+ const handlePreviewOpenChange = useCallback((open: boolean) => {
+ if (open)
+ setSelectedFileId(file.id)
+ setIsPreviewOpen(open)
+ }, [file.id])
return (
-
{!readOnly && (