diff --git a/api/controllers/console/snippets/payloads.py b/api/controllers/console/snippets/payloads.py
index de4115b6576..86b7735de6f 100644
--- a/api/controllers/console/snippets/payloads.py
+++ b/api/controllers/console/snippets/payloads.py
@@ -167,3 +167,9 @@ class IncludeSecretQuery(BaseModel):
"""Query parameter for including secret variables in export."""
include_secret: str = Field(default="false", description="Whether to include secret variables")
+
+
+class SnippetExportQuery(IncludeSecretQuery):
+ """Query parameters for exporting a snippet workflow as DSL."""
+
+ workflow_id: str | None = Field(default=None, description="Specific published workflow version to export")
diff --git a/api/controllers/console/workspace/snippets.py b/api/controllers/console/workspace/snippets.py
index 8c254dfa58b..f5e2f85a0b7 100644
--- a/api/controllers/console/workspace/snippets.py
+++ b/api/controllers/console/workspace/snippets.py
@@ -17,7 +17,7 @@ from controllers.console import console_ns
from controllers.console.app.wraps import with_session
from controllers.console.snippets.payloads import (
CreateSnippetPayload,
- IncludeSecretQuery,
+ SnippetExportQuery,
SnippetImportPayload,
SnippetListQuery,
UpdateSnippetPayload,
@@ -89,7 +89,7 @@ register_schema_models(
CreateSnippetPayload,
UpdateSnippetPayload,
SnippetImportPayload,
- IncludeSecretQuery,
+ SnippetExportQuery,
)
register_response_schema_models(
console_ns,
@@ -289,7 +289,7 @@ class CustomizedSnippetExportApi(Resource):
@console_ns.doc("export_customized_snippet")
@console_ns.doc(description="Export snippet configuration as DSL")
@console_ns.doc(params={"snippet_id": "Snippet ID to export"})
- @console_ns.doc(params=query_params_from_model(IncludeSecretQuery))
+ @console_ns.doc(params=query_params_from_model(SnippetExportQuery))
@console_ns.response(200, "Snippet exported successfully", console_ns.models[TextFileResponse.__name__])
@console_ns.response(404, "Snippet not found")
@setup_required
@@ -312,11 +312,18 @@ class CustomizedSnippetExportApi(Resource):
raise NotFound("Snippet not found")
# Get include_secret parameter
- query = IncludeSecretQuery.model_validate(request.args.to_dict())
+ query = SnippetExportQuery.model_validate(request.args.to_dict())
with Session(db.engine) as session:
export_service = SnippetDslService(session)
- result = export_service.export_snippet_dsl(snippet=snippet, include_secret=query.include_secret == "true")
+ try:
+ result = export_service.export_snippet_dsl(
+ snippet=snippet,
+ include_secret=query.include_secret == "true",
+ workflow_id=query.workflow_id,
+ )
+ except ValueError as exc:
+ raise NotFound(str(exc)) from exc
# Set filename with .snippet extension
filename = f"{snippet.name}.snippet"
diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md
index ecdd1f40916..6e97fe124e3 100644
--- a/api/openapi/markdown/console-openapi.md
+++ b/api/openapi/markdown/console-openapi.md
@@ -9929,6 +9929,7 @@ Export snippet configuration as DSL
| ---- | ---------- | ----------- | -------- | ------ |
| snippet_id | path | Snippet ID to export | Yes | string (uuid) |
| include_secret | query | Whether to include secret variables | No | string,
**Default:** false |
+| workflow_id | query | Specific published workflow version to export | No | string |
#### Responses
@@ -18218,11 +18219,9 @@ How Dify forwards the end-user's identity to an MCP server.
#### IncludeSecretQuery
-Query parameter for including secret variables in export.
-
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
-| include_secret | string,
**Default:** false | Whether to include secret variables | No |
+| include_secret | string,
**Default:** false | Whether to include secret values in the exported DSL | No |
#### IndexingEstimate
@@ -21421,6 +21420,15 @@ Payload for syncing snippet draft workflow.
| hash | string | | No |
| input_fields | [ object ] | | No |
+#### SnippetExportQuery
+
+Query parameters for exporting a snippet workflow as DSL.
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| include_secret | string,
**Default:** false | Whether to include secret variables | No |
+| workflow_id | string | Specific published workflow version to export | No |
+
#### SnippetImportPayload
Payload for importing snippet from DSL.
diff --git a/api/services/snippet_dsl_service.py b/api/services/snippet_dsl_service.py
index b7d45d5caba..78361700ad4 100644
--- a/api/services/snippet_dsl_service.py
+++ b/api/services/snippet_dsl_service.py
@@ -497,17 +497,27 @@ class SnippetDslService:
)
return snippet
- def export_snippet_dsl(self, snippet: CustomizedSnippet, include_secret: bool = False) -> str:
+ def export_snippet_dsl(
+ self, snippet: CustomizedSnippet, include_secret: bool = False, workflow_id: str | None = None
+ ) -> str:
"""
Export snippet as DSL
:param snippet: CustomizedSnippet instance
:param include_secret: Whether include secret variable
+ :param workflow_id: Optional published workflow version to export; defaults to the draft workflow
:return: YAML string
"""
snippet_service = self._snippet_service()
- workflow = snippet_service.get_draft_workflow(snippet=snippet)
+ workflow = (
+ snippet_service.get_published_workflow_by_id(snippet=snippet, workflow_id=workflow_id)
+ if workflow_id
+ else snippet_service.get_draft_workflow(snippet=snippet)
+ )
if not workflow:
- raise ValueError("Missing draft workflow configuration, please check.")
+ workflow_description = (
+ f"published workflow {workflow_id}" if workflow_id else "draft workflow configuration"
+ )
+ raise ValueError(f"Missing {workflow_description}, please check.")
icon_info = snippet.icon_info or {}
export_data = {
diff --git a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py
index 211d9229a2a..e89aa6a6b57 100644
--- a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py
+++ b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py
@@ -354,14 +354,40 @@ def test_export_snippet_returns_yaml_attachment(app: Flask, monkeypatch: pytest.
api = snippets_module.CustomizedSnippetExportApi()
handler = unwrap(api.get)
- with app.test_request_context("/workspaces/current/customized-snippets/snippet-1/export?include_secret=true"):
+ with app.test_request_context(
+ "/workspaces/current/customized-snippets/snippet-1/export?include_secret=true&workflow_id=workflow-1"
+ ):
response = handler(api, "tenant-1", snippet_id="snippet-1")
assert response.status_code == 200
assert response.get_data(as_text=True) == "version: 0.1.0\nkind: snippet\n"
assert response.headers["Content-Type"] == "application/x-yaml"
assert "Snippet%20One.snippet" in response.headers["Content-Disposition"]
- export_snippet_dsl.assert_called_once_with(snippet=snippet, include_secret=True)
+ export_snippet_dsl.assert_called_once_with(snippet=snippet, include_secret=True, workflow_id="workflow-1")
+
+
+def test_export_snippet_raises_not_found_for_missing_workflow(app: Flask, monkeypatch: pytest.MonkeyPatch):
+ snippet = _snippet(name="Snippet One")
+
+ monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet))
+ monkeypatch.setattr(
+ snippets_module,
+ "SnippetDslService",
+ Mock(
+ return_value=SimpleNamespace(
+ export_snippet_dsl=Mock(side_effect=ValueError("Missing published workflow workflow-1"))
+ )
+ ),
+ )
+ monkeypatch.setattr(snippets_module, "Session", _SessionContext)
+ monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object()))
+
+ api = snippets_module.CustomizedSnippetExportApi()
+ handler = unwrap(api.get)
+
+ with app.test_request_context("/workspaces/current/customized-snippets/snippet-1/export?workflow_id=workflow-1"):
+ with pytest.raises(NotFound, match="Missing published workflow workflow-1"):
+ handler(api, "tenant-1", snippet_id="snippet-1")
def test_import_snippet_returns_202_for_pending_confirmation(app: Flask, monkeypatch: pytest.MonkeyPatch):
diff --git a/api/tests/unit_tests/services/test_snippet_dsl_service.py b/api/tests/unit_tests/services/test_snippet_dsl_service.py
index b706da797da..f213dd95267 100644
--- a/api/tests/unit_tests/services/test_snippet_dsl_service.py
+++ b/api/tests/unit_tests/services/test_snippet_dsl_service.py
@@ -650,6 +650,40 @@ def test_export_snippet_dsl_returns_yaml(monkeypatch: pytest.MonkeyPatch):
assert "input_fields:" in result
+def test_export_snippet_dsl_uses_requested_published_workflow(monkeypatch: pytest.MonkeyPatch):
+ service = SnippetDslService(session=SimpleNamespace(get_bind=Mock()))
+ workflow = SimpleNamespace(
+ to_dict=Mock(return_value={"graph": {"nodes": []}}),
+ graph_dict={"nodes": []},
+ )
+ snippet = SimpleNamespace(
+ tenant_id="tenant-1",
+ name="Exported",
+ description=None,
+ type="node",
+ icon_info=None,
+ input_fields_list=[],
+ )
+ get_published_workflow_by_id = Mock(return_value=workflow)
+ get_draft_workflow = Mock()
+ monkeypatch.setattr(
+ "services.snippet_dsl_service.SnippetService",
+ lambda *_args, **_kwargs: SimpleNamespace(
+ get_draft_workflow=get_draft_workflow,
+ get_published_workflow_by_id=get_published_workflow_by_id,
+ ),
+ )
+ monkeypatch.setattr(
+ "services.snippet_dsl_service.DependenciesAnalysisService.generate_dependencies",
+ Mock(return_value=[]),
+ )
+
+ service.export_snippet_dsl(snippet, workflow_id="workflow-1")
+
+ get_published_workflow_by_id.assert_called_once_with(snippet=snippet, workflow_id="workflow-1")
+ get_draft_workflow.assert_not_called()
+
+
def test_append_workflow_export_data_filters_credentials_and_extracts_dependencies(monkeypatch: pytest.MonkeyPatch):
service = SnippetDslService(session=SimpleNamespace())
workflow_dict = {
diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts
index a7c53ab7a4b..99dab0f6291 100644
--- a/packages/contracts/generated/api/console/workspaces/types.gen.ts
+++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts
@@ -2699,6 +2699,7 @@ export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportData = {
}
query?: {
include_secret?: string
+ workflow_id?: string
}
url: '/workspaces/current/customized-snippets/{snippet_id}/export'
}
diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts
index 8bcd255b3c9..c55c0e70099 100644
--- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts
+++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts
@@ -3609,6 +3609,7 @@ export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportPath = z.ob
export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportQuery = z.object({
include_secret: z.string().optional().default('false'),
+ workflow_id: z.string().optional(),
})
/**
diff --git a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx
index b9cb9c000cf..d1fe99577cd 100644
--- a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx
+++ b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx
@@ -24,6 +24,7 @@ const mockHandleStartWorkflowRun = vi.fn()
const mockHandleStopRun = vi.fn()
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleCheckBeforePublish = vi.fn()
+const mockHandleExportDSL = vi.fn()
const mockUseAvailableNodesMetaData = vi.hoisted(() => vi.fn())
const mockConsoleState = vi.hoisted(() => ({
workspacePermissionKeys: ['snippets.create_and_modify'] as string[],
@@ -141,6 +142,12 @@ vi.mock('../../hooks/use-snippet-start-run', () => ({
}),
}))
+vi.mock('../hooks/use-snippet-dsl', () => ({
+ useSnippetDSL: () => ({
+ handleExportDSL: mockHandleExportDSL,
+ }),
+}))
+
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({
children,
@@ -642,4 +649,12 @@ describe('SnippetMain', () => {
})
})
})
+
+ describe('DSL Export', () => {
+ it('should pass the snippet DSL export handler to WorkflowWithInnerContext', () => {
+ renderSnippetMain()
+
+ expect(capturedHooksStore?.handleExportDSL).toBe(mockHandleExportDSL)
+ })
+ })
})
diff --git a/web/app/components/snippets/components/hooks/__tests__/use-snippet-dsl.spec.ts b/web/app/components/snippets/components/hooks/__tests__/use-snippet-dsl.spec.ts
new file mode 100644
index 00000000000..0a02982a096
--- /dev/null
+++ b/web/app/components/snippets/components/hooks/__tests__/use-snippet-dsl.spec.ts
@@ -0,0 +1,62 @@
+import { toast } from '@langgenius/dify-ui/toast'
+import { renderHook } from '@testing-library/react'
+import { act } from 'react'
+import { useExportSnippetMutation } from '@/service/use-snippets'
+import { downloadBlob } from '@/utils/download'
+import { useSnippetDSL } from '../use-snippet-dsl'
+
+const mockMutateAsync = vi.fn()
+
+vi.mock('@/service/use-snippets', () => ({
+ useExportSnippetMutation: vi.fn(() => ({
+ mutateAsync: mockMutateAsync,
+ })),
+}))
+
+vi.mock('@/utils/download', () => ({
+ downloadBlob: vi.fn(),
+}))
+
+vi.mock('@langgenius/dify-ui/toast', () => ({
+ toast: {
+ error: vi.fn(),
+ },
+}))
+
+describe('useSnippetDSL', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockMutateAsync.mockResolvedValue('kind: snippet')
+ })
+
+ it('exports the requested historical workflow version', async () => {
+ const { result } = renderHook(() =>
+ useSnippetDSL({ snippetId: 'snippet-1', snippetName: 'My Snippet' }),
+ )
+
+ await act(() => result.current.handleExportDSL(false, 'workflow-1'))
+
+ expect(useExportSnippetMutation).toHaveBeenCalled()
+ expect(mockMutateAsync).toHaveBeenCalledWith({
+ snippetId: 'snippet-1',
+ include: false,
+ workflowId: 'workflow-1',
+ })
+ expect(downloadBlob).toHaveBeenCalledWith({
+ data: expect.any(Blob),
+ fileName: 'My Snippet.yml',
+ })
+ })
+
+ it('shows an error when exporting fails', async () => {
+ mockMutateAsync.mockRejectedValueOnce(new Error('failed'))
+ const { result } = renderHook(() =>
+ useSnippetDSL({ snippetId: 'snippet-1', snippetName: 'My Snippet' }),
+ )
+
+ await act(() => result.current.handleExportDSL(false, 'workflow-1'))
+
+ expect(toast.error).toHaveBeenCalledWith('snippet.exportFailed')
+ expect(downloadBlob).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/snippets/components/hooks/use-snippet-dsl.ts b/web/app/components/snippets/components/hooks/use-snippet-dsl.ts
new file mode 100644
index 00000000000..eba5a0879e9
--- /dev/null
+++ b/web/app/components/snippets/components/hooks/use-snippet-dsl.ts
@@ -0,0 +1,34 @@
+import { toast } from '@langgenius/dify-ui/toast'
+import { useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useExportSnippetMutation } from '@/service/use-snippets'
+import { downloadBlob } from '@/utils/download'
+
+type UseSnippetDSLOptions = {
+ snippetId: string
+ snippetName: string
+}
+
+export const useSnippetDSL = ({ snippetId, snippetName }: UseSnippetDSLOptions) => {
+ const { t } = useTranslation('snippet')
+ const exportSnippetMutation = useExportSnippetMutation()
+
+ const handleExportDSL = useCallback(
+ async (include = false, workflowId?: string) => {
+ try {
+ const data = await exportSnippetMutation.mutateAsync({
+ snippetId,
+ include,
+ workflowId,
+ })
+ const file = new Blob([data], { type: 'application/yaml' })
+ downloadBlob({ data: file, fileName: `${snippetName}.yml` })
+ } catch {
+ toast.error(t(($) => $.exportFailed))
+ }
+ },
+ [exportSnippetMutation, snippetId, snippetName, t],
+ )
+
+ return { handleExportDSL }
+}
diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx
index 84d3a7ee542..ed53c71ba0c 100644
--- a/web/app/components/snippets/components/snippet-main.tsx
+++ b/web/app/components/snippets/components/snippet-main.tsx
@@ -26,6 +26,7 @@ import { useSnippetRun } from '../hooks/use-snippet-run'
import { useSnippetStartRun } from '../hooks/use-snippet-start-run'
import { useSnippetDetailStore } from '../store'
import { canCreateAndModifySnippets } from '../utils/permission'
+import { useSnippetDSL } from './hooks/use-snippet-dsl'
import { useSnippetInputFieldActions } from './hooks/use-snippet-input-field-actions'
import { useSnippetPublish } from './hooks/use-snippet-publish'
import SnippetChildren from './snippet-children'
@@ -203,6 +204,7 @@ const SnippetMain = ({
canEdit: canEditSnippet,
snippetId,
})
+ const { handleExportDSL } = useSnippetDSL({ snippetId, snippetName: snippet.name })
const { handleStartWorkflowRun, handleWorkflowStartRunInWorkflow } = useSnippetStartRun({
handleRun,
})
@@ -320,6 +322,7 @@ const SnippetMain = ({
handleStopRun,
handleStartWorkflowRun,
handleWorkflowStartRunInWorkflow,
+ handleExportDSL,
getWorkflowRunAndTraceUrl,
availableNodesMetaData,
fetchInspectVars,
@@ -366,6 +369,7 @@ const SnippetMain = ({
handleStartWorkflowRun,
handleStopRun,
handleWorkflowStartRunInWorkflow,
+ handleExportDSL,
getWorkflowRunAndTraceUrl,
hasNodeInspectVars,
hasSetInspectVar,
diff --git a/web/service/__tests__/use-snippets.spec.tsx b/web/service/__tests__/use-snippets.spec.tsx
new file mode 100644
index 00000000000..e254874af9b
--- /dev/null
+++ b/web/service/__tests__/use-snippets.spec.tsx
@@ -0,0 +1,81 @@
+import type { ReactNode } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, renderHook } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import { useExportSnippetMutation } from '../use-snippets'
+
+const mockExportSnippet = vi.hoisted(() => vi.fn())
+
+vi.mock('@/service/client', () => ({
+ consoleClient: {
+ workspaces: {
+ current: {
+ customizedSnippets: {
+ bySnippetId: {
+ export: {
+ get: mockExportSnippet,
+ },
+ },
+ },
+ },
+ },
+ },
+ consoleQuery: {
+ snippets: {
+ key: vi.fn(() => ['snippets']),
+ },
+ workspaces: {
+ current: {
+ customizedSnippets: {
+ key: vi.fn(() => ['customized-snippets']),
+ bySnippetId: {
+ export: {
+ get: {
+ mutationKey: vi.fn(() => ['customized-snippets', 'export']),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+}))
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ mutations: { retry: false },
+ queries: { retry: false },
+ },
+ })
+
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return {children}
+ }
+}
+
+describe('useExportSnippetMutation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockExportSnippet.mockResolvedValue('kind: snippet')
+ })
+
+ it('exports the requested historical workflow version', async () => {
+ const { result } = renderHook(() => useExportSnippetMutation(), { wrapper: createWrapper() })
+
+ await act(async () => {
+ await result.current.mutateAsync({
+ snippetId: 'snippet-1',
+ workflowId: 'workflow-1',
+ })
+ })
+
+ expect(mockExportSnippet).toHaveBeenCalledWith({
+ params: { snippet_id: 'snippet-1' },
+ query: {
+ include_secret: 'false',
+ workflow_id: 'workflow-1',
+ },
+ })
+ })
+})
diff --git a/web/service/use-snippets.ts b/web/service/use-snippets.ts
index d0ec5d2f18b..58a10fa22e0 100644
--- a/web/service/use-snippets.ts
+++ b/web/service/use-snippets.ts
@@ -272,11 +272,14 @@ export const useIncrementSnippetUseCountMutation = () => {
}
export const useExportSnippetMutation = () => {
- return useMutation({
- mutationFn: ({ snippetId, include = false }) => {
+ return useMutation({
+ mutationFn: ({ snippetId, include = false, workflowId }) => {
return customizedSnippetsClient.bySnippetId.export.get({
params: { snippet_id: snippetId },
- query: { include_secret: include ? 'true' : 'false' },
+ query: {
+ include_secret: include ? 'true' : 'false',
+ workflow_id: workflowId,
+ },
})
},
})