Compare commits

...

1 Commits

Author SHA1 Message Date
7071614775 Add Dify WebMCP agent context 2026-05-19 18:27:12 +08:00
16 changed files with 6803 additions and 0 deletions

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { getFrontendCapabilities, getRouteContext } from '../capabilities'
describe('agent context capabilities', () => {
it('should expose Dify frontend capability areas', () => {
const capabilities = getFrontendCapabilities()
expect(capabilities.some(capability => capability.id === 'apps')).toBe(true)
expect(capabilities.some(capability => capability.id === 'workflow')).toBe(true)
expect(capabilities.some(capability => capability.id === 'published-app-runtime')).toBe(true)
expect(capabilities.some(capability => capability.id === 'tools')).toBe(true)
})
it('should infer workflow route context', () => {
const context = getRouteContext('/app/app-123/workflow')
expect(context.page_type).toBe('workflow-builder')
expect(context.app_id).toBe('app-123')
expect(context.capability_ids).toContain('workflow')
})
it('should infer dataset route context', () => {
const context = getRouteContext('/datasets/dataset-123')
expect(context.page_type).toBe('datasets')
expect(context.dataset_id).toBe('dataset-123')
expect(context.capability_ids).toContain('datasets')
})
it('should infer RAG pipeline route context', () => {
const context = getRouteContext('/datasets/dataset-123/pipeline')
expect(context.page_type).toBe('rag-pipeline-builder')
expect(context.dataset_id).toBe('dataset-123')
expect(context.capability_ids).toContain('rag-pipeline')
})
it('should infer published app runtime context', () => {
const context = getRouteContext('/workflow/public-token')
expect(context.page_type).toBe('published-app-runtime')
expect(context.token).toBe('public-token')
expect(context.capability_ids).toContain('published-app-runtime')
})
})

View File

@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { collectInteractiveElements, getDomSnapshot, performBrowserAction } from '../dom'
describe('agent context DOM tools', () => {
let rectSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
bottom: 20,
height: 20,
left: 0,
right: 100,
toJSON: () => ({}),
top: 0,
width: 100,
x: 0,
y: 0,
} as DOMRect)
})
afterEach(() => {
document.body.innerHTML = ''
rectSpy.mockRestore()
})
it('should collect visible interactive elements with action ids', () => {
document.body.innerHTML = `
<button aria-label="Create app">Create</button>
<input aria-label="App name" value="Demo" />
`
const actions = collectInteractiveElements()
expect(actions).toHaveLength(2)
expect(actions[0]).toMatchObject({
actions: ['click', 'focus'],
name: 'Create app',
role: 'button',
})
expect(actions[0]!.action_id).toMatch(/^dify-action-/)
expect(actions[0]!.stable_id).toBe(actions[0]!.action_id)
expect(actions[1]).toMatchObject({
name: 'App name',
role: 'textbox',
value: 'Demo',
})
})
it('should perform click actions from collected action ids', () => {
const handleClick = vi.fn()
const button = document.createElement('button')
button.textContent = 'Run'
button.addEventListener('click', handleClick)
document.body.append(button)
const [action] = collectInteractiveElements()
const result = performBrowserAction({ action: 'click', action_id: action!.action_id })
expect(result).toMatchObject({ ok: true, action: 'click' })
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should fill text inputs and dispatch form events', () => {
const handleInput = vi.fn()
const input = document.createElement('input')
input.setAttribute('aria-label', 'Node title')
input.addEventListener('input', handleInput)
document.body.append(input)
const [action] = collectInteractiveElements()
performBrowserAction({ action: 'fill', action_id: action!.action_id, value: 'LLM node' })
expect(input.value).toBe('LLM node')
expect(handleInput).toHaveBeenCalledTimes(1)
})
it('should collect Dify cursor-pointer elements as executable actions', () => {
document.body.innerHTML = `
<div class="flex cursor-pointer items-center">
<span>LLM</span>
<span>Invoke language models</span>
</div>
`
const actions = collectInteractiveElements()
expect(actions).toHaveLength(1)
expect(actions[0]).toMatchObject({
actions: ['click', 'focus'],
name: 'LLM Invoke language models',
role: 'button',
tag: 'div',
})
})
it('should keep action ids stable across equivalent DOM refreshes', () => {
document.body.innerHTML = '<button>Run</button>'
const [firstAction] = collectInteractiveElements()
document.body.innerHTML = '<button>Run</button>'
const refreshedButton = document.querySelector('button')!
const handleClick = vi.fn()
refreshedButton.addEventListener('click', handleClick)
const result = performBrowserAction({ action: 'click', action_id: firstAction!.action_id })
expect(result).toMatchObject({ ok: true, action: 'click' })
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should include visible dialog text in snapshots', () => {
document.body.innerHTML = `
<div role="dialog" aria-label="Create workflow node">
<h2>Choose block</h2>
</div>
`
const snapshot = getDomSnapshot()
expect(snapshot.dialogs).toHaveLength(1)
expect(snapshot.dialogs[0]).toMatchObject({
name: 'Create workflow node',
text: 'Choose block',
})
})
})

View File

@ -0,0 +1,128 @@
import type { AgentTool } from '../types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getRegisteredPageContexts, registerDifyAgentPageContext, registerDifyAgentTools } from '../runtime'
describe('agent context runtime', () => {
const registerTool = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
window.__DIFY_AGENT_CONTEXT__ = undefined
Object.defineProperty(navigator, 'modelContext', {
configurable: true,
value: {
registerTool,
},
})
Object.defineProperty(navigator, 'modelContextTesting', {
configurable: true,
value: undefined,
})
})
afterEach(() => {
window.__DIFY_AGENT_CONTEXT__ = undefined
Object.defineProperty(navigator, 'modelContext', {
configurable: true,
value: undefined,
})
Object.defineProperty(navigator, 'modelContextTesting', {
configurable: true,
value: undefined,
})
})
it('should expose tools through window fallback and WebMCP registration', async () => {
const tool: AgentTool = {
name: 'dify_test_tool',
description: 'Test tool',
inputSchema: {
type: 'object',
properties: {},
},
execute: () => ({ ok: true }),
}
const cleanup = registerDifyAgentTools([tool])
expect(window.__DIFY_AGENT_CONTEXT__).toBeDefined()
expect(window.__DIFY_AGENT_CONTEXT__!.listTools()).toEqual([
{
name: 'dify_test_tool',
description: 'Test tool',
inputSchema: {
type: 'object',
properties: {},
},
},
])
expect(registerTool).toHaveBeenCalledWith(tool, expect.objectContaining({ signal: expect.any(AbortSignal) }))
await expect(window.__DIFY_AGENT_CONTEXT__!.callTool('dify_test_tool')).resolves.toEqual({ ok: true })
expect(await navigator.modelContextTesting!.listTools()).toEqual([
{
name: 'dify_test_tool',
description: 'Test tool',
inputSchema: {
type: 'object',
properties: {},
},
},
])
await expect(navigator.modelContextTesting!.executeTool('dify_test_tool', '{}')).resolves.toEqual({ ok: true })
cleanup()
})
it('should upgrade the early public fallback after React hydration', async () => {
const staleTestingApi = {
executeTool: vi.fn(async () => ({ stale: true })),
listTools: vi.fn(() => []),
}
window.__DIFY_AGENT_CONTEXT__ = {
version: 'public-fallback',
callTool: vi.fn(async () => ({ stale: true })),
getPageContext: vi.fn(async () => ({ stale: true })),
listTools: vi.fn(() => []),
registerPageContext: vi.fn(() => vi.fn()),
}
Object.defineProperty(navigator, 'modelContextTesting', {
configurable: true,
value: staleTestingApi,
})
const tool: AgentTool = {
name: 'dify_hydrated_tool',
description: 'Hydrated tool',
execute: () => ({ hydrated: true }),
}
const cleanup = registerDifyAgentTools([tool])
expect(window.__DIFY_AGENT_CONTEXT__!.version).toBe('2026-05-19')
expect(window.__DIFY_AGENT_CONTEXT__!.listTools()).toEqual([
{
name: 'dify_hydrated_tool',
description: 'Hydrated tool',
},
])
await expect(navigator.modelContextTesting!.executeTool('dify_hydrated_tool', '{}')).resolves.toEqual({ hydrated: true })
expect(staleTestingApi.executeTool).not.toHaveBeenCalled()
cleanup()
})
it('should register and remove page context providers', () => {
const cleanup = registerDifyAgentPageContext('test-page', () => ({ page: 'test' }))
expect(getRegisteredPageContexts()).toEqual([
{
id: 'test-page',
value: { page: 'test' },
},
])
cleanup()
expect(getRegisteredPageContexts()).toEqual([])
})
})

View File

@ -0,0 +1,835 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { DSLImportStatus } from '@/models/app'
import { createApp, exportAppConfig, importDSL, importDSLConfirm } from '@/service/apps'
import { post } from '@/service/base'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { buildDifyAgentTools } from '../tools'
vi.mock('@/service/apps', () => ({
createApp: vi.fn(),
exportAppConfig: vi.fn(),
importDSL: vi.fn(),
importDSLConfirm: vi.fn(),
}))
vi.mock('@/service/base', () => ({
post: vi.fn(),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn(),
}))
const getTool = (name: string) => {
const tool = buildDifyAgentTools().find(tool => tool.name === name)
if (!tool)
throw new Error(`Missing tool ${name}`)
return tool
}
describe('agent context tools', () => {
beforeEach(() => {
vi.clearAllMocks()
window.history.replaceState({}, '', '/app/app-123/workflow')
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should expose high-level workflow orchestration tools', () => {
const names = buildDifyAgentTools().map(tool => tool.name)
expect(names).toEqual(expect.arrayContaining([
'dify_explain_workflow_schema',
'dify_validate_workflow_graph',
'dify_get_workflow_node_default_config',
'dify_get_workflow_draft',
'dify_sync_workflow_draft',
'dify_run_workflow_draft',
'dify_run_workflow_node',
'dify_get_workflow_runs',
'dify_get_workflow_run_detail',
'dify_get_workflow_run_node_executions',
'dify_stop_workflow_run',
'dify_create_workflow_app',
'dify_search_marketplace_plugins',
'dify_list_installed_plugin_capabilities',
'dify_list_mcp_adapted_plugin_tools',
'dify_get_plugin_tool_credential_info',
'dify_build_plugin_tool_workflow_node',
'dify_get_plugin_task_detail',
'dify_uninstall_plugin',
'dify_get_trigger_provider_detail',
'dify_create_trigger_subscription_builder',
'dify_get_trigger_subscription_builder_logs',
'dify_get_app_triggers',
'dify_import_app_dsl',
'dify_publish_workflow',
'dify_export_app_dsl',
]))
})
it('should explain workflow schema for non-visual agents', async () => {
const result = await getTool('dify_explain_workflow_schema').execute()
expect(result).toMatchObject({
graph_contract: expect.any(Object),
node_types: expect.objectContaining({
control_flow: expect.any(Array),
data_and_tools: expect.any(Array),
terminal: expect.any(Array),
}),
})
})
it('should validate the current draft workflow graph', async () => {
vi.mocked(fetchWorkflowDraft).mockResolvedValue({
conversation_variables: [],
created_at: 1710000000,
created_by: { email: 'owner@example.com', id: 'account-1', name: 'Owner' },
environment_variables: [],
graph: {
edges: [],
nodes: [
{
data: {
desc: '',
title: 'Start',
type: BlockEnum.Start,
},
id: 'start',
position: { x: 0, y: 0 },
type: 'custom',
},
],
},
hash: 'hash-1',
id: 'workflow-1',
marked_comment: '',
marked_name: '',
tool_published: false,
updated_at: 1710000001,
updated_by: { email: 'owner@example.com', id: 'account-1', name: 'Owner' },
version: 'draft',
})
const result = await getTool('dify_validate_workflow_graph').execute()
expect(fetchWorkflowDraft).toHaveBeenCalledWith('/apps/app-123/workflows/draft')
expect(result).toMatchObject({
app_id: 'app-123',
analysis: {
graph: {
issues: [
expect.objectContaining({
code: 'missing_terminal_node',
}),
],
node_count: 1,
},
},
ok: true,
source: 'draft',
})
})
it('should fetch workflow node default config', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
config: {
title: 'LLM',
type: BlockEnum.LLM,
},
type: BlockEnum.LLM,
}), { status: 200 }))
vi.stubGlobal('fetch', fetch)
const result = await getTool('dify_get_workflow_node_default_config').execute({
block_type: BlockEnum.LLM,
})
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/console/api/apps/app-123/workflows/default-workflow-block-configs/llm?q=%7B%7D'), expect.objectContaining({
credentials: 'include',
}))
expect(result).toMatchObject({
app_id: 'app-123',
block_type: BlockEnum.LLM,
config: {
type: BlockEnum.LLM,
},
ok: true,
})
})
it('should import app DSL and confirm pending version mismatch', async () => {
vi.mocked(importDSL).mockResolvedValue({
app_mode: AppModeEnum.WORKFLOW,
current_dsl_version: '0.3.0',
error: '',
id: 'import-1',
imported_dsl_version: '0.2.0',
leaked_dependencies: [],
status: DSLImportStatus.PENDING,
})
vi.mocked(importDSLConfirm).mockResolvedValue({
app_id: 'new-app',
app_mode: AppModeEnum.WORKFLOW,
current_dsl_version: '0.3.0',
error: '',
id: 'import-1',
imported_dsl_version: '0.2.0',
leaked_dependencies: [],
status: DSLImportStatus.COMPLETED,
})
const result = await getTool('dify_import_app_dsl').execute({
navigate_to_workflow: false,
yaml_content: 'app:\n mode: workflow\n',
})
expect(importDSL).toHaveBeenCalledWith(expect.objectContaining({
mode: 'yaml-content',
yaml_content: 'app:\n mode: workflow\n',
}))
expect(importDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-1' })
expect(result).toMatchObject({
app_id: 'new-app',
ok: true,
status: DSLImportStatus.COMPLETED,
})
})
it('should create a workflow app without visual navigation', async () => {
vi.mocked(createApp).mockResolvedValue({
app_model_config: null,
created_at: 1710000000,
description: 'Plugin workflow',
icon: '🤖',
icon_background: '#D5F5F6',
icon_type: 'emoji',
id: 'new-workflow-app',
max_active_requests: null,
mode: AppModeEnum.WORKFLOW,
name: 'Mercury to QuickBooks',
site: null,
tags: [],
updated_at: 1710000000,
use_icon_as_answer_icon: false,
} as unknown as Awaited<ReturnType<typeof createApp>>)
const result = await getTool('dify_create_workflow_app').execute({
description: 'Plugin workflow',
name: 'Mercury to QuickBooks',
navigate_to_workflow: false,
})
expect(createApp).toHaveBeenCalledWith(expect.objectContaining({
mode: AppModeEnum.WORKFLOW,
name: 'Mercury to QuickBooks',
}))
expect(result).toMatchObject({
app_id: 'new-workflow-app',
app_url: '/app/new-workflow-app/workflow',
ok: true,
})
})
it('should search Marketplace plugins for plugin-aware workflow construction', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: {
page: 1,
page_size: 2,
plugins: [
{
brief: { en_US: 'Mercury transaction trigger' },
category: 'trigger',
label: { en_US: 'Mercury Banking' },
name: 'mercury_trigger',
org: 'petrus',
plugin_unique_identifier: 'petrus/mercury_trigger:0.4.9@hash',
verified: true,
},
],
total: 1,
},
}), { status: 200 }))
vi.stubGlobal('fetch', fetch)
const result = await getTool('dify_search_marketplace_plugins').execute({
category: 'trigger',
page_size: 2,
query: 'Mercury',
})
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/plugins/search/advanced'), expect.objectContaining({
method: 'POST',
}))
expect(result).toMatchObject({
ok: true,
plugins: [
expect.objectContaining({
plugin_id: 'petrus/mercury_trigger',
plugin_unique_identifier: 'petrus/mercury_trigger:0.4.9@hash',
}),
],
total: 1,
})
})
it('should list installed plugin capabilities across tools and triggers', async () => {
const fetch = vi.fn(async (url: string) => {
if (url.includes('/workspaces/current/triggers')) {
return new Response(JSON.stringify([
{
events: [
{
identity: { label: { en_US: 'Transaction Activity' } },
name: 'transaction',
output_schema: { properties: { transaction_id: { type: 'string' } } },
parameters: [{ name: 'operation_filter', required: false, type: 'select' }],
},
],
label: { en_US: 'Mercury Transaction Trigger' },
name: 'petrus/mercury_trigger/mercury_trigger',
plugin_id: 'petrus/mercury_trigger',
},
]), { status: 200 })
}
if (url.includes('/workspaces/current/tools/builtin')) {
return new Response(JSON.stringify([
{
id: 'petrus/quickbooks/quickbooks',
label: { en_US: 'QuickBooks Online' },
name: 'petrus/quickbooks/quickbooks',
plugin_id: 'petrus/quickbooks',
tools: [
{
label: { en_US: 'Record Expense' },
name: 'create_purchase',
parameters: [{ name: 'amount', required: true, type: 'number' }],
},
],
type: 'builtin',
},
]), { status: 200 })
}
if (url.includes('/workspaces/current/plugin/list')) {
return new Response(JSON.stringify({
plugins: [
{
declaration: { category: 'tool', label: { en_US: 'QuickBooks Online' } },
id: 'install-quickbooks',
name: 'quickbooks',
plugin_id: 'petrus/quickbooks',
plugin_unique_identifier: 'petrus/quickbooks:0.2.10@hash',
version: '0.2.10',
},
],
total: 1,
}), { status: 200 })
}
return new Response(JSON.stringify([]), { status: 200 })
})
vi.stubGlobal('fetch', fetch)
const result = await getTool('dify_list_installed_plugin_capabilities').execute()
expect(result).toMatchObject({
catalog: {
installed_plugins: {
plugins: [
expect.objectContaining({
installation_id: 'install-quickbooks',
}),
],
total: 1,
},
tools: {
builtin: [
expect.objectContaining({
plugin_id: 'petrus/quickbooks',
tools: [
expect.objectContaining({
name: 'create_purchase',
required_parameters: ['amount'],
}),
],
}),
],
},
triggers: [
expect.objectContaining({
plugin_id: 'petrus/mercury_trigger',
}),
],
},
ok: true,
})
})
it('should list installed Marketplace plugin tools as MCP-compatible definitions', async () => {
const fetch = vi.fn(async (url: string) => {
if (url.includes('/credential/info')) {
return new Response(JSON.stringify({
credentials: [{ id: 'cred-1', name: 'Default' }],
is_oauth_custom_client_enabled: false,
supported_credential_types: ['OAUTH'],
}), { status: 200 })
}
if (url.includes('/workspaces/current/tools/builtin')) {
return new Response(JSON.stringify([
{
id: 'petrus/quickbooks/quickbooks',
label: { en_US: 'QuickBooks Online' },
name: 'petrus/quickbooks/quickbooks',
plugin_id: 'petrus/quickbooks',
plugin_unique_identifier: 'petrus/quickbooks:0.2.10@hash',
tools: [
{
description: { en_US: 'Creates a purchase record.' },
label: { en_US: 'Create Purchase' },
name: 'create_purchase',
output_schema: {
properties: {
purchase_id: { type: 'string' },
},
type: 'object',
},
parameters: [
{
llm_description: 'Purchase amount.',
name: 'amount',
required: true,
type: 'number',
},
{
name: 'account_id',
options: [
{ label: { en_US: 'Checking' }, value: 'checking' },
],
required: false,
type: 'select',
},
],
},
],
type: 'builtin',
},
{
id: 'langgenius/google/google',
label: { en_US: 'Google' },
name: 'langgenius/google/google',
tools: [
{
label: { en_US: 'Search' },
name: 'search',
parameters: [],
},
],
type: 'builtin',
},
]), { status: 200 })
}
return new Response(JSON.stringify([]), { status: 200 })
})
vi.stubGlobal('fetch', fetch)
const result = await getTool('dify_list_mcp_adapted_plugin_tools').execute({
include_credential_info: true,
plugin_id: 'petrus/quickbooks',
})
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/console/api/workspaces/current/tools/builtin'), expect.any(Object))
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/console/api/workspaces/current/tool-provider/builtin/petrus%2Fquickbooks%2Fquickbooks/credential/info'), expect.any(Object))
expect(result).toMatchObject({
ok: true,
provider_count: 1,
protocol: 'mcp',
providers: [
expect.objectContaining({
credential_info: expect.objectContaining({
authorized: true,
}),
plugin_id: 'petrus/quickbooks',
tool_count: 1,
}),
],
tool_count: 1,
tools: [
expect.objectContaining({
dify: expect.objectContaining({
provider_id: 'petrus/quickbooks/quickbooks',
provider_type: 'builtin',
tool_name: 'create_purchase',
}),
inputSchema: {
additionalProperties: false,
properties: {
account_id: {
enum: ['checking'],
type: 'string',
},
amount: {
description: 'Purchase amount.',
type: 'number',
},
},
required: ['amount'],
type: 'object',
},
name: 'dify_plugin__petrus_quickbooks_quickbooks__create_purchase',
outputSchema: {
properties: {
purchase_id: { type: 'string' },
},
type: 'object',
},
}),
],
})
})
it('should fetch plugin tool credential info', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
credentials: [{ id: 'cred-1' }],
supported_credential_types: ['API_KEY'],
}), { status: 200 }))
vi.stubGlobal('fetch', fetch)
const result = await getTool('dify_get_plugin_tool_credential_info').execute({
provider: 'petrus/quickbooks/quickbooks',
})
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/console/api/workspaces/current/tool-provider/builtin/petrus%2Fquickbooks%2Fquickbooks/credential/info'), expect.objectContaining({
credentials: 'include',
}))
expect(result).toMatchObject({
authorized: true,
ok: true,
provider: 'petrus/quickbooks/quickbooks',
})
})
it('should build a Marketplace plugin workflow tool node', async () => {
const result = await getTool('dify_build_plugin_tool_workflow_node').execute({
constant_parameters: {
amount: 42,
},
node_id: 'quickbooks-create-purchase',
plugin_id: 'petrus/quickbooks',
plugin_unique_identifier: 'petrus/quickbooks:0.2.10@hash',
provider_id: 'petrus/quickbooks/quickbooks',
tool_label: 'Create Purchase',
tool_name: 'create_purchase',
})
expect(result).toMatchObject({
mcp_tool_name: 'dify_plugin__petrus_quickbooks_quickbooks__create_purchase',
node: {
data: {
plugin_id: 'petrus/quickbooks',
plugin_unique_identifier: 'petrus/quickbooks:0.2.10@hash',
provider_id: 'petrus/quickbooks/quickbooks',
provider_type: 'builtin',
tool_label: 'Create Purchase',
tool_name: 'create_purchase',
tool_node_version: '2',
tool_parameters: {
amount: {
type: 'constant',
value: 42,
},
},
type: 'tool',
},
id: 'quickbooks-create-purchase',
type: 'custom',
},
ok: true,
})
})
it('should return next steps when installing Marketplace plugins', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
task_id: 'task-1',
}), { status: 200 }))
vi.stubGlobal('fetch', fetch)
const result = await getTool('dify_install_marketplace_plugins').execute({
plugin_unique_identifier: 'petrus/quickbooks:0.2.10@hash',
})
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/console/api/workspaces/current/plugin/install/marketplace'), expect.objectContaining({
body: JSON.stringify({
plugin_unique_identifiers: ['petrus/quickbooks:0.2.10@hash'],
}),
method: 'POST',
}))
expect(result).toMatchObject({
next_steps: expect.arrayContaining([
expect.stringContaining('dify_get_plugin_task_detail'),
expect.stringContaining('dify_list_mcp_adapted_plugin_tools'),
]),
ok: true,
task_id: 'task-1',
})
})
it('should fetch plugin task detail by task id', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
status: 'success',
}), { status: 200 }))
vi.stubGlobal('fetch', fetch)
const result = await getTool('dify_get_plugin_task_detail').execute({
task_id: 'task-1',
})
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/console/api/workspaces/current/plugin/tasks/task-1'), expect.objectContaining({
credentials: 'include',
}))
expect(result).toMatchObject({
ok: true,
status: 'success',
task_id: 'task-1',
})
})
it('should uninstall plugin by installation id', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
success: true,
}), { status: 200 }))
vi.stubGlobal('fetch', fetch)
const result = await getTool('dify_uninstall_plugin').execute({
plugin_installation_id: 'install-quickbooks',
})
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/console/api/workspaces/current/plugin/uninstall'), expect.objectContaining({
body: JSON.stringify({
plugin_installation_id: 'install-quickbooks',
}),
method: 'POST',
}))
expect(result).toMatchObject({
ok: true,
plugin_installation_id: 'install-quickbooks',
post_uninstall_next_steps: expect.arrayContaining([
expect.stringContaining('dify_list_installed_plugin_capabilities'),
]),
})
})
it('should resolve installed plugin before uninstalling by unique identifier', async () => {
const fetch = vi.fn(async (url: string) => {
if (url.includes('/workspaces/current/plugin/list')) {
return new Response(JSON.stringify({
plugins: [
{
id: 'install-quickbooks',
plugin_id: 'petrus/quickbooks',
plugin_unique_identifier: 'petrus/quickbooks:0.2.10@hash',
},
],
}), { status: 200 })
}
return new Response(JSON.stringify({
success: true,
}), { status: 200 })
})
vi.stubGlobal('fetch', fetch)
const result = await getTool('dify_uninstall_plugin').execute({
plugin_unique_identifier: 'petrus/quickbooks:0.2.10@hash',
})
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/console/api/workspaces/current/plugin/list?page=1&page_size=100'), expect.any(Object))
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/console/api/workspaces/current/plugin/uninstall'), expect.objectContaining({
body: JSON.stringify({
plugin_installation_id: 'install-quickbooks',
}),
}))
expect(result).toMatchObject({
ok: true,
plugin_installation_id: 'install-quickbooks',
})
})
it('should sync workflow draft and return preflight validation', async () => {
vi.mocked(post).mockResolvedValue({
hash: 'hash-2',
result: 'success',
updated_at: 1710000002,
})
const graph = {
edges: [
{
data: {
sourceType: BlockEnum.Start,
targetType: BlockEnum.End,
},
id: 'edge-start-end',
source: 'start',
target: 'end',
},
],
nodes: [
{
data: {
desc: '',
title: 'Start',
type: BlockEnum.Start,
},
id: 'start',
position: { x: 0, y: 0 },
type: 'custom',
},
{
data: {
desc: '',
title: 'End',
type: BlockEnum.End,
},
id: 'end',
position: { x: 300, y: 0 },
type: 'custom',
},
],
}
const result = await getTool('dify_sync_workflow_draft').execute({
graph,
})
expect(post).toHaveBeenCalledWith(
'apps/app-123/workflows/draft',
{
body: {
conversation_variables: [],
environment_variables: [],
features: {},
graph,
},
},
{ silent: true },
)
expect(result).toMatchObject({
app_id: 'app-123',
hash: 'hash-2',
ok: true,
preflight_validation: {
error_count: 0,
valid: true,
warning_count: 0,
},
})
})
it('should publish workflow for the current app route', async () => {
vi.mocked(post).mockResolvedValue({
created_at: 1710000000,
result: 'success',
})
const result = await getTool('dify_publish_workflow').execute({
marked_comment: 'release notes',
marked_name: 'finance release',
})
expect(post).toHaveBeenCalledWith('apps/app-123/workflows/publish', {
body: {
marked_comment: 'release notes',
marked_name: 'finance release',
},
})
expect(result).toMatchObject({
app_id: 'app-123',
ok: true,
})
})
it('should export app DSL for the current route', async () => {
vi.mocked(exportAppConfig).mockResolvedValue({ data: 'app:\n mode: workflow\n' })
const result = await getTool('dify_export_app_dsl').execute()
expect(exportAppConfig).toHaveBeenCalledWith({
appID: 'app-123',
include: false,
workflowID: undefined,
})
expect(result).toMatchObject({
app_id: 'app-123',
ok: true,
yaml_content: 'app:\n mode: workflow\n',
})
})
it('should run workflow draft and parse streaming events', async () => {
const fetch = vi.fn().mockResolvedValue(new Response([
'data: {"event":"workflow_started","task_id":"task-1","workflow_run_id":"run-1","data":{"id":"run-1"}}\n\n',
'data: {"event":"node_finished","task_id":"task-1","workflow_run_id":"run-1","data":{"node_id":"start","status":"succeeded"}}\n\n',
'data: {"event":"workflow_finished","task_id":"task-1","workflow_run_id":"run-1","data":{"status":"succeeded","outputs":{"result":"ok"}}}\n\n',
].join(''), {
headers: {
'Content-Type': 'text/event-stream',
},
status: 200,
}))
vi.stubGlobal('fetch', fetch)
const result = await getTool('dify_run_workflow_draft').execute({
inputs: { query: 'test' },
})
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/console/api/apps/app-123/workflows/draft/run'), expect.objectContaining({
method: 'POST',
}))
expect(result).toMatchObject({
app_id: 'app-123',
ok: true,
summary: {
event_count: 3,
status: 'succeeded',
task_id: 'task-1',
workflow_run_id: 'run-1',
},
})
})
it('should fetch workflow run node executions', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
data: [
{
node_id: 'start',
status: 'succeeded',
},
],
}), { status: 200 }))
vi.stubGlobal('fetch', fetch)
const result = await getTool('dify_get_workflow_run_node_executions').execute({
run_id: 'run-1',
})
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/console/api/apps/app-123/workflow-runs/run-1/node-executions'), expect.objectContaining({
credentials: 'include',
}))
expect(result).toMatchObject({
app_id: 'app-123',
data: [
{
node_id: 'start',
},
],
ok: true,
run_id: 'run-1',
})
})
})

View File

@ -0,0 +1,196 @@
import type { Edge, Node } from '@/app/components/workflow/types'
import { describe, expect, it } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { buildWorkflowAgentContext, getWorkflowConstructionGuide, summarizeWorkflowGraph } from '../workflow'
describe('workflow agent context', () => {
it('should summarize workflow nodes and edges for agents', () => {
const nodes: Node[] = [
{
data: {
desc: 'Workflow input',
selected: false,
title: 'Start',
type: BlockEnum.Start,
variables: [
{
label: 'Question',
required: true,
type: 'text-input',
variable: 'query',
},
],
},
id: 'start',
position: { x: 0, y: 0 },
type: 'custom',
},
{
data: {
desc: 'Generate answer',
selected: true,
title: 'LLM',
type: BlockEnum.LLM,
},
id: 'llm',
position: { x: 300, y: 0 },
type: 'custom',
},
] as Node[]
const edges: Edge[] = [
{
data: {
sourceType: BlockEnum.Start,
targetType: BlockEnum.LLM,
},
id: 'edge-1',
source: 'start',
target: 'llm',
},
] as Edge[]
const context = buildWorkflowAgentContext({
controlMode: 'pointer',
edges,
isListening: false,
nodes,
pathname: '/app/app-123/workflow',
pluginCatalog: {
buildInTools: [
{
id: 'petrus/quickbooks/quickbooks',
label: { en_US: 'QuickBooks Online' },
name: 'petrus/quickbooks/quickbooks',
plugin_id: 'petrus/quickbooks',
tools: [
{
label: { en_US: 'Record Expense' },
name: 'create_purchase',
parameters: [{ name: 'amount', required: true, type: 'number' }],
},
],
type: 'builtin',
},
],
triggerPlugins: [
{
events: [
{
identity: { label: { en_US: 'Transaction Activity' } },
name: 'transaction',
output_schema: { properties: { transaction_id: { type: 'string' } } },
parameters: [],
},
],
label: { en_US: 'Mercury Transaction Trigger' },
name: 'petrus/mercury_trigger/mercury_trigger',
plugin_id: 'petrus/mercury_trigger',
type: 'trigger',
},
],
},
})
expect(context).toMatchObject({
graph: {
edge_count: 1,
node_count: 2,
nodes: [
{
id: 'start',
title: 'Start',
type: BlockEnum.Start,
},
{
id: 'llm',
selected: true,
title: 'LLM',
},
],
},
plugin_catalog: {
tools: {
builtin: [
expect.objectContaining({
plugin_id: 'petrus/quickbooks',
tools: [
expect.objectContaining({
name: 'create_purchase',
required_parameters: ['amount'],
}),
],
}),
],
},
triggers: [
expect.objectContaining({
plugin_id: 'petrus/mercury_trigger',
}),
],
},
state: {
control_mode: 'pointer',
selected_node: {
id: 'llm',
},
},
})
})
it('should report graph issues that block reliable workflow construction', () => {
const graph = summarizeWorkflowGraph({
edges: [
{
id: 'edge-1',
source: 'missing-source',
target: 'lonely',
},
] as Edge[],
nodes: [
{
data: {
desc: 'No entry',
title: 'LLM',
type: BlockEnum.LLM,
},
id: 'lonely',
position: { x: 0, y: 0 },
type: 'custom',
},
] as Node[],
})
expect(graph).toMatchObject({
error_count: 2,
issues: expect.arrayContaining([
expect.objectContaining({ code: 'missing_entry_node' }),
expect.objectContaining({ code: 'missing_terminal_node' }),
expect.objectContaining({ code: 'dangling_edges' }),
]),
node_type_counts: {
[BlockEnum.LLM]: 1,
},
valid: false,
warning_count: 1,
})
})
it('should explain workflow construction primitives for agents', () => {
const guide = getWorkflowConstructionGuide()
expect(guide).toMatchObject({
build_strategy: expect.any(Array),
debug_cycle: expect.arrayContaining([
'dify_search_marketplace_plugins',
'dify_list_installed_plugin_capabilities',
'dify_run_workflow_draft',
'dify_get_workflow_run_node_executions',
'dify_publish_workflow',
]),
node_types: expect.objectContaining({
control_flow: expect.any(Array),
data_and_tools: expect.any(Array),
}),
})
})
})

View File

@ -0,0 +1,315 @@
'use client'
type FrontendCapability = {
id: string
name: string
routes: string[]
summary: string
agent_guidance: string[]
}
type RouteContext = {
app_id?: string
capability_ids: string[]
dataset_id?: string
document_id?: string
page_type: string
pathname: string
route_params: Record<string, string>
token?: string
}
export const FRONTEND_CAPABILITIES: FrontendCapability[] = [
{
id: 'apps',
name: 'Apps and App Overview',
routes: ['/apps', '/app/:appId/overview'],
summary: 'Create, browse, import, duplicate, configure, publish, inspect API access, and monitor Dify applications.',
agent_guidance: [
'Use the Apps list to create apps or open an existing app.',
'Use App Overview to inspect app metadata, publishing status, API access, site URL, and MCP server publishing.',
],
},
{
id: 'app-operations',
name: 'App Operations',
routes: ['/app/:appId/logs', '/app/:appId/annotations'],
summary: 'Inspect app logs, conversations, traces, user feedback, and curated annotation replies.',
agent_guidance: [
'Use Logs to inspect runtime behavior before changing prompts or workflow logic.',
'Use Annotations to review and curate reusable replies for supported app modes.',
],
},
{
id: 'app-configuration',
name: 'App Configuration',
routes: ['/app/:appId/configuration'],
summary: 'Configure app prompts, variables, model providers, tools, knowledge, and app-level behavior.',
agent_guidance: [
'Inspect visible form fields before editing configuration.',
'Prefer browser actions against named fields and buttons from dify_get_page_context.',
'After changing configuration, look for Save or Publish actions in the visible action list.',
],
},
{
id: 'workflow',
name: 'Workflow Builder',
routes: ['/app/:appId/workflow'],
summary: 'Visually orchestrate Dify workflow graphs with start, LLM, tool, logic, transform, retrieval, HTTP, human-input, loop, iteration, and end nodes.',
agent_guidance: [
'Call dify_get_workflow_context before editing to understand existing nodes and edges.',
'Call dify_get_page_context to retrieve visible action IDs, then call dify_perform_browser_action to click add-node controls, block selector items, node panels, and form fields.',
'Use browser actions for orchestration so Dify validation, collaboration, undo, and draft-sync behavior stay intact.',
'After edits, call dify_get_workflow_context again and compare node and edge counts.',
],
},
{
id: 'app-develop',
name: 'App Develop',
routes: ['/app/:appId/develop'],
summary: 'Debug and preview application behavior with test inputs and generated outputs.',
agent_guidance: [
'Use visible form fields for test inputs.',
'Run preview only after checking required inputs in page context.',
],
},
{
id: 'datasets',
name: 'Knowledge Datasets',
routes: [
'/datasets',
'/datasets/connect',
'/datasets/create',
'/datasets/create-from-pipeline',
'/datasets/:datasetId',
'/datasets/:datasetId/api',
'/datasets/:datasetId/documents',
'/datasets/:datasetId/documents/create',
'/datasets/:datasetId/documents/create-from-pipeline',
'/datasets/:datasetId/documents/:documentId',
'/datasets/:datasetId/documents/:documentId/settings',
'/datasets/:datasetId/hitTesting',
'/datasets/:datasetId/settings',
],
summary: 'Create, import, segment, index, search, test, and manage knowledge datasets and documents.',
agent_guidance: [
'Use dataset pages to inspect document lists, indexing state, segment settings, retrieval tests, API access, and data source connections.',
],
},
{
id: 'rag-pipeline',
name: 'RAG Pipeline Builder',
routes: ['/datasets/:datasetId/pipeline'],
summary: 'Design dataset ingestion and transformation pipelines using the workflow-style RAG pipeline builder.',
agent_guidance: [
'Use workflow context and browser actions when a dataset pipeline mounts the workflow canvas.',
'Confirm dataset and document scope before editing ingestion pipeline behavior.',
],
},
{
id: 'tools',
name: 'Tools and MCP Providers',
routes: ['/tools'],
summary: 'Manage built-in tools, custom tools, workflow tools, and remote MCP tool providers.',
agent_guidance: [
'Use tools pages to add MCP providers and refresh available tool lists for use in apps and workflows.',
],
},
{
id: 'plugins',
name: 'Plugins',
routes: ['/plugins'],
summary: 'Install, inspect, authorize, update, and manage plugins that provide models, tools, datasources, and triggers.',
agent_guidance: [
'Plugin installation and authorization can affect workflow node availability.',
],
},
{
id: 'explore',
name: 'Explore',
routes: ['/explore/apps', '/explore/installed/:appId'],
summary: 'Browse and run published apps available to the workspace.',
agent_guidance: [
'Use Explore to find runnable app experiences rather than editing builders.',
],
},
{
id: 'published-app-runtime',
name: 'Published App Runtime',
routes: ['/chat/:token', '/chatbot/:token', '/completion/:token', '/workflow/:token', '/explore/installed/:appId'],
summary: 'Run published chat, chatbot, completion, workflow, and installed Explore app experiences.',
agent_guidance: [
'Use visible input fields and submit actions to run published apps.',
'Treat these routes as runtime experiences, not builders.',
],
},
{
id: 'human-input',
name: 'Human Input Forms',
routes: ['/form/:token'],
summary: 'Complete human-input forms generated by workflow human-in-the-loop steps.',
agent_guidance: [
'Inspect required fields before submitting a human-input form.',
'Ask for user confirmation before submitting irreversible external workflow input.',
],
},
{
id: 'workspace-settings',
name: 'Workspace Settings',
routes: ['/account', '/account/*'],
summary: 'Manage members, model providers, data sources, billing, and workspace preferences.',
agent_guidance: [
'Model provider settings affect whether LLM and agent workflow nodes can run.',
'Ask for user confirmation before changing workspace-wide settings.',
],
},
{
id: 'authentication-onboarding',
name: 'Authentication and Onboarding',
routes: [
'/install',
'/init',
'/activate',
'/signin',
'/signin/check-code',
'/signin/invite-settings',
'/signup',
'/signup/check-code',
'/signup/set-password',
'/forgot-password',
'/reset-password',
'/reset-password/check-code',
'/reset-password/set-password',
'/webapp-signin',
'/webapp-signin/check-code',
'/webapp-reset-password',
'/webapp-reset-password/check-code',
'/webapp-reset-password/set-password',
'/oauth-callback',
'/account/oauth/authorize',
],
summary: 'Install Dify, initialize accounts, authenticate users, handle OAuth, and reset passwords.',
agent_guidance: [
'Do not enter credentials or one-time codes unless the user provides them for the current session.',
'Use these routes to understand setup state before trying authenticated workspace operations.',
],
},
]
const matchRoute = (pathname: string, route: string) => {
const routeParts = route.split('/').filter(Boolean)
const pathParts = pathname.split('/').filter(Boolean)
if (route.endsWith('/*')) {
const prefix = route.slice(0, -2)
return pathname === prefix || pathname.startsWith(`${prefix}/`)
}
if (routeParts.length !== pathParts.length)
return false
return routeParts.every((part, index) => part.startsWith(':') || part === pathParts[index])
}
const getRouteParams = (pathname: string) => {
const appMatch = pathname.match(/\/app\/([^/]+)/)
const datasetMatch = pathname.match(/\/datasets\/([^/]+)/)
const documentMatch = pathname.match(/\/documents\/([^/]+)/)
const tokenMatch = pathname.match(/^\/(?:chat|chatbot|completion|workflow|form)\/([^/]+)/)
return {
...(appMatch?.[1] ? { appId: appMatch[1] } : {}),
...(datasetMatch?.[1] ? { datasetId: datasetMatch[1] } : {}),
...(documentMatch?.[1] ? { documentId: documentMatch[1] } : {}),
...(tokenMatch?.[1] ? { token: tokenMatch[1] } : {}),
}
}
const inferPageType = (pathname: string) => {
if (pathname === '/apps')
return 'apps-list'
if (/\/app\/[^/]+\/workflow/.test(pathname))
return 'workflow-builder'
if (/\/app\/[^/]+\/configuration/.test(pathname))
return 'app-configuration'
if (/\/app\/[^/]+\/develop/.test(pathname))
return 'app-debug-preview'
if (/\/app\/[^/]+\/overview/.test(pathname))
return 'app-overview'
if (/\/app\/[^/]+\/logs/.test(pathname))
return 'app-logs'
if (/\/app\/[^/]+\/annotations/.test(pathname))
return 'app-annotations'
if (/\/datasets\/[^/]+\/pipeline/.test(pathname))
return 'rag-pipeline-builder'
if (/\/datasets\/[^/]+\/documents\/[^/]+\/settings/.test(pathname))
return 'dataset-document-settings'
if (/\/datasets\/[^/]+\/documents\/[^/]+/.test(pathname))
return 'dataset-document-detail'
if (/\/datasets\/[^/]+\/documents/.test(pathname))
return 'dataset-documents'
if (/\/datasets\/[^/]+\/hitTesting/.test(pathname))
return 'dataset-hit-testing'
if (/\/datasets\/[^/]+\/api/.test(pathname))
return 'dataset-api'
if (/\/datasets\/[^/]+\/settings/.test(pathname))
return 'dataset-settings'
if (pathname.startsWith('/datasets'))
return 'datasets'
if (pathname.startsWith('/tools'))
return 'tools'
if (pathname.startsWith('/plugins'))
return 'plugins'
if (pathname.startsWith('/explore'))
return 'explore'
if (/^\/(?:chat|chatbot|completion|workflow)\//.test(pathname))
return 'published-app-runtime'
if (/^\/form\//.test(pathname))
return 'human-input-form'
if ([
'/install',
'/init',
'/activate',
'/signin',
'/signup',
'/forgot-password',
'/oauth-callback',
'/account/oauth/authorize',
'/webapp-signin',
].some(prefix => pathname.startsWith(prefix)) || pathname.includes('reset-password')) {
return 'authentication-onboarding'
}
if (pathname.startsWith('/account'))
return 'workspace-settings'
return 'unknown'
}
export const getFrontendCapabilities = () => FRONTEND_CAPABILITIES
export const getRouteContext = (pathname: string): RouteContext => {
const capabilityIds = FRONTEND_CAPABILITIES
.filter(capability => capability.routes.some(route => matchRoute(pathname, route)))
.map(capability => capability.id)
const routeParams = getRouteParams(pathname)
return {
app_id: routeParams.appId,
capability_ids: capabilityIds,
dataset_id: routeParams.datasetId,
document_id: routeParams.documentId,
page_type: inferPageType(pathname),
pathname,
route_params: routeParams,
token: routeParams.token,
}
}
export const getCurrentRouteContext = () => {
if (typeof window === 'undefined') {
return getRouteContext('/')
}
return getRouteContext(window.location.pathname)
}

View File

@ -0,0 +1,521 @@
'use client'
import type { AgentToolInput, AgentToolResult } from './types'
type AgentActionKind = 'click' | 'fill' | 'focus' | 'press' | 'select' | 'toggle'
type AgentBounds = {
height: number
width: number
x: number
y: number
}
type AgentElementState = {
checked?: boolean
expanded?: boolean
pressed?: boolean
required?: boolean
selected?: boolean
}
export type AgentActionDescriptor = {
action_id: string
actions: AgentActionKind[]
bounds: AgentBounds
context?: string
disabled: boolean
href?: string
name: string
placeholder?: string
role: string
selector_hint?: string
stable_id: string
state?: AgentElementState
tag: string
type?: string
value?: string
}
type DomSnapshotOptions = {
actionLimit?: number
textLimit?: number
}
const actionRegistry = new Map<string, Element>()
const INTERACTIVE_SELECTOR = [
'a[href]',
'button',
'input',
'select',
'textarea',
'[contenteditable="true"]',
'[role="button"]',
'[role="checkbox"]',
'[role="combobox"]',
'[role="link"]',
'[role="menuitem"]',
'[role="option"]',
'[role="radio"]',
'[role="switch"]',
'[role="tab"]',
'[aria-expanded]',
'[aria-haspopup]',
'[class*="cursor-pointer"]',
'[tabindex]:not([tabindex="-1"])',
'summary',
].join(',')
const DIALOG_SELECTOR = [
'[role="dialog"]',
'[aria-modal="true"]',
'dialog',
].join(',')
const textFrom = (value: unknown) => {
if (typeof value !== 'string')
return ''
return value.replace(/\s+/g, ' ').trim()
}
const compactText = (value: string, limit = 160) => {
const text = textFrom(value)
return text.length > limit ? `${text.slice(0, limit - 1)}...` : text
}
const toNumber = (value: number) => Math.round(value * 100) / 100
const isElementVisible = (element: Element) => {
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement))
return false
const style = window.getComputedStyle(element)
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')
return false
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
const getBounds = (element: Element): AgentBounds => {
const rect = element.getBoundingClientRect()
return {
height: toNumber(rect.height),
width: toNumber(rect.width),
x: toNumber(rect.x),
y: toNumber(rect.y),
}
}
const getInputValue = (element: Element) => {
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement)
return element.value
if (element instanceof HTMLElement && element.isContentEditable)
return textFrom(element.textContent)
return undefined
}
const getLabelText = (element: Element) => {
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) {
const label = element.labels?.[0]
if (label)
return textFrom(label.textContent)
}
const id = element.getAttribute('id')
if (!id)
return ''
const label = document.querySelector(`label[for="${id.replace(/"/g, '\\"')}"]`)
return label ? textFrom(label.textContent) : ''
}
const getAccessibleName = (element: Element) => {
const ariaLabel = textFrom(element.getAttribute('aria-label'))
if (ariaLabel)
return ariaLabel
const labelText = getLabelText(element)
if (labelText)
return labelText
const labelledBy = element.getAttribute('aria-labelledby')
if (labelledBy) {
const text = labelledBy
.split(/\s+/)
.map(id => textFrom(document.getElementById(id)?.textContent))
.filter(Boolean)
.join(' ')
if (text)
return text
}
const placeholder = textFrom(element.getAttribute('placeholder'))
if (placeholder)
return placeholder
const title = textFrom(element.getAttribute('title'))
if (title)
return title
return textFrom(element.textContent)
}
const getRole = (element: Element) => {
const explicitRole = textFrom(element.getAttribute('role'))
if (explicitRole)
return explicitRole
const tag = element.tagName.toLowerCase()
if (tag === 'a')
return 'link'
if (tag === 'button' || tag === 'summary')
return 'button'
if (element instanceof HTMLElement && element.className.includes('cursor-pointer'))
return 'button'
if (tag === 'select')
return 'combobox'
if (tag === 'textarea')
return 'textbox'
if (tag === 'input') {
const type = (element.getAttribute('type') || 'text').toLowerCase()
if (type === 'checkbox')
return 'checkbox'
if (type === 'radio')
return 'radio'
return 'textbox'
}
return tag
}
const getActionKinds = (element: Element): AgentActionKind[] => {
const tag = element.tagName.toLowerCase()
const role = getRole(element)
const type = (element.getAttribute('type') || '').toLowerCase()
if (tag === 'input' && ['checkbox', 'radio'].includes(type))
return ['toggle', 'click', 'focus']
if (role === 'checkbox' || role === 'switch' || role === 'radio')
return ['toggle', 'click', 'focus']
if (tag === 'input' || tag === 'textarea' || (element instanceof HTMLElement && element.isContentEditable))
return ['fill', 'focus', 'press']
if (tag === 'select' || role === 'combobox')
return ['select', 'click', 'focus']
return ['click', 'focus']
}
const cssIdentifier = (value: string) => {
if (typeof CSS !== 'undefined' && CSS.escape)
return CSS.escape(value)
return value.replace(/[^\w-]/g, '\\$&')
}
const getSelectorHint = (element: Element) => {
const testId = element.getAttribute('data-testid')
if (testId)
return `[data-testid="${testId.replace(/"/g, '\\"')}"]`
const ariaLabel = element.getAttribute('aria-label')
if (ariaLabel)
return `[aria-label="${ariaLabel.replace(/"/g, '\\"')}"]`
const id = element.getAttribute('id')
if (id)
return `#${cssIdentifier(id)}`
const name = element.getAttribute('name')
if (name)
return `${element.tagName.toLowerCase()}[name="${name.replace(/"/g, '\\"')}"]`
return undefined
}
const isDisabled = (element: Element) => {
if (element instanceof HTMLButtonElement || element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement)
return element.disabled
return element.getAttribute('aria-disabled') === 'true'
}
const hasActionableAncestor = (element: Element, candidateSet: Set<Element>) => {
let parent = element.parentElement
while (parent) {
if (candidateSet.has(parent))
return true
parent = parent.parentElement
}
return false
}
const isNativeInteractiveElement = (element: Element) => {
const tag = element.tagName.toLowerCase()
return ['a', 'button', 'input', 'select', 'summary', 'textarea'].includes(tag)
}
const hashString = (value: string) => {
let hash = 5381
for (let index = 0; index < value.length; index += 1)
hash = ((hash << 5) + hash) ^ value.charCodeAt(index)
return (hash >>> 0).toString(36)
}
const getElementState = (element: Element): AgentElementState | undefined => {
const state: AgentElementState = {}
if (element instanceof HTMLInputElement) {
if (element.type === 'checkbox' || element.type === 'radio')
state.checked = element.checked
state.required = element.required
}
if (element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement)
state.required = element.required
const expanded = element.getAttribute('aria-expanded')
if (expanded)
state.expanded = expanded === 'true'
const pressed = element.getAttribute('aria-pressed')
if (pressed)
state.pressed = pressed === 'true'
const selected = element.getAttribute('aria-selected')
if (selected)
state.selected = selected === 'true'
return Object.keys(state).length ? state : undefined
}
const getHref = (element: Element) => {
if (element instanceof HTMLAnchorElement)
return element.href
return undefined
}
const getContextText = (element: Element) => {
const dialog = element.closest(DIALOG_SELECTOR)
if (dialog)
return compactText(getAccessibleName(dialog) || dialog.textContent || '', 120)
const labelledRegion = element.closest('[aria-label], [data-testid], form, main, aside, section')
if (!labelledRegion)
return undefined
const label = labelledRegion.getAttribute('aria-label') || labelledRegion.getAttribute('data-testid') || labelledRegion.tagName.toLowerCase()
return compactText(label, 120)
}
const getStableActionIdBase = (element: Element) => {
const parts = [
element.tagName.toLowerCase(),
getRole(element),
getAccessibleName(element),
getSelectorHint(element) ?? '',
getHref(element) ?? '',
element.getAttribute('placeholder') ?? '',
element.getAttribute('type') ?? '',
getContextText(element) ?? '',
]
return `dify-action-${hashString(parts.map(part => textFrom(part).toLowerCase()).join('|'))}`
}
export const collectInteractiveElements = (limit = 80): AgentActionDescriptor[] => {
actionRegistry.clear()
const candidates = [...document.querySelectorAll(INTERACTIVE_SELECTOR)]
.filter(isElementVisible)
const candidateSet = new Set(candidates)
const occurrenceCounts = new Map<string, number>()
const elements = candidates
.filter(element => isNativeInteractiveElement(element) || !hasActionableAncestor(element, candidateSet))
.slice(0, limit)
return elements.map((element) => {
const stableIdBase = getStableActionIdBase(element)
const nextOccurrence = (occurrenceCounts.get(stableIdBase) ?? 0) + 1
occurrenceCounts.set(stableIdBase, nextOccurrence)
const stableId = nextOccurrence === 1 ? stableIdBase : `${stableIdBase}-${nextOccurrence}`
const actionId = stableId
actionRegistry.set(actionId, element)
const placeholder = textFrom(element.getAttribute('placeholder')) || undefined
return {
action_id: actionId,
actions: getActionKinds(element),
bounds: getBounds(element),
context: getContextText(element),
disabled: isDisabled(element),
href: getHref(element),
name: compactText(getAccessibleName(element), 180),
placeholder,
role: getRole(element),
selector_hint: getSelectorHint(element),
stable_id: stableId,
state: getElementState(element),
tag: element.tagName.toLowerCase(),
type: textFrom(element.getAttribute('type')) || undefined,
value: getInputValue(element),
}
})
}
const setNativeValue = (element: HTMLInputElement | HTMLTextAreaElement, value: string) => {
const valueSetter = Object.getOwnPropertyDescriptor(element, 'value')?.set
const prototype = Object.getPrototypeOf(element) as HTMLInputElement | HTMLTextAreaElement
const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (prototypeValueSetter && valueSetter !== prototypeValueSetter)
prototypeValueSetter.call(element, value)
else if (valueSetter)
valueSetter.call(element, value)
else
element.value = value
}
const dispatchInputEvents = (element: Element) => {
element.dispatchEvent(new Event('input', { bubbles: true }))
element.dispatchEvent(new Event('change', { bubbles: true }))
}
const getStringInput = (input: AgentToolInput | undefined, key: string) => {
const value = input?.[key]
return typeof value === 'string' ? value : undefined
}
const getActionElement = (actionId: string) => {
const element = actionRegistry.get(actionId)
if (element && document.contains(element) && isElementVisible(element))
return element
collectInteractiveElements()
const refreshedElement = actionRegistry.get(actionId)
if (refreshedElement && document.contains(refreshedElement) && isElementVisible(refreshedElement))
return refreshedElement
return undefined
}
export const performBrowserAction = (input?: AgentToolInput): AgentToolResult => {
const actionId = getStringInput(input, 'action_id')
const action = getStringInput(input, 'action') as AgentActionKind | undefined
if (!actionId || !action)
throw new Error('Both action_id and action are required.')
const element = getActionElement(actionId)
if (!element)
throw new Error(`No visible element found for action_id "${actionId}". Refresh page context and try again.`)
if (isDisabled(element))
throw new Error(`Element "${actionId}" is disabled.`)
if (action === 'focus') {
if (element instanceof HTMLElement)
element.focus()
return { ok: true, action, action_id: actionId }
}
if (action === 'click' || action === 'toggle') {
if (!(element instanceof HTMLElement))
throw new Error(`Element "${actionId}" cannot be clicked.`)
element.click()
return { ok: true, action, action_id: actionId }
}
if (action === 'fill') {
const value = getStringInput(input, 'value') ?? ''
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
setNativeValue(element, value)
dispatchInputEvents(element)
return { ok: true, action, action_id: actionId, value }
}
if (element instanceof HTMLElement && element.isContentEditable) {
element.textContent = value
dispatchInputEvents(element)
return { ok: true, action, action_id: actionId, value }
}
throw new Error(`Element "${actionId}" does not support fill.`)
}
if (action === 'select') {
const value = getStringInput(input, 'value')
if (!value)
throw new Error('value is required for select actions.')
if (!(element instanceof HTMLSelectElement))
throw new Error(`Element "${actionId}" does not support select.`)
element.value = value
dispatchInputEvents(element)
return { ok: true, action, action_id: actionId, value }
}
if (action === 'press') {
const key = getStringInput(input, 'key')
if (!key)
throw new Error('key is required for press actions.')
element.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key }))
element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key }))
return { ok: true, action, action_id: actionId, key }
}
throw new Error(`Unsupported action "${action}".`)
}
const getVisibleTextBlocks = (limit: number) => {
const selector = [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'label',
'[role="heading"]',
'[role="status"]',
'[role="alert"]',
'[aria-live]',
].join(',')
return [...document.querySelectorAll(selector)]
.filter(isElementVisible)
.map(element => textFrom(element.textContent))
.filter(Boolean)
.slice(0, limit)
}
const getDialogs = () => {
return [...document.querySelectorAll(DIALOG_SELECTOR)]
.filter(isElementVisible)
.map((element, index) => ({
id: `dialog-${index + 1}`,
name: getAccessibleName(element),
text: textFrom(element.textContent).slice(0, 1000),
}))
}
export const getDomSnapshot = (options: DomSnapshotOptions = {}) => {
const {
actionLimit = 80,
textLimit = 40,
} = options
return {
actions: collectInteractiveElements(actionLimit),
dialogs: getDialogs(),
visible_text: getVisibleTextBlocks(textLimit),
}
}

View File

@ -0,0 +1,25 @@
'use client'
import { useEffect } from 'react'
import { registerDifyAgentTools } from './runtime'
import { buildDifyAgentTools } from './tools'
let registered = false
const ensureAgentToolsRegistered = () => {
if (registered || typeof window === 'undefined')
return
registerDifyAgentTools(buildDifyAgentTools())
registered = true
}
ensureAgentToolsRegistered()
export function AgentContextProvider() {
useEffect(() => {
ensureAgentToolsRegistered()
}, [])
return null
}

View File

@ -0,0 +1,161 @@
'use client'
import type {
AgentContextApi,
AgentPageContextProvider,
AgentTool,
AgentToolDefinition,
AgentToolInput,
AgentToolResult,
} from './types'
const RUNTIME_VERSION = '2026-05-19'
const tools = new Map<string, AgentTool>()
const pageContextProviders = new Map<string, AgentPageContextProvider>()
const webMcpControllers = new Map<string, AbortController>()
let runtimeApi: AgentContextApi | undefined
const getToolDefinition = (tool: AgentTool): AgentToolDefinition => {
const {
execute: _execute,
...definition
} = tool
return definition
}
const getToolDefinitions = () => {
return [...tools.values()].map(getToolDefinition)
}
const normalizeTestingInput = (input?: string | AgentToolInput) => {
if (typeof input !== 'string')
return input
if (!input)
return {}
const value = JSON.parse(input) as unknown
if (!value || typeof value !== 'object' || Array.isArray(value))
return {}
return value as AgentToolInput
}
const callTool = async (name: string, input?: AgentToolInput): Promise<AgentToolResult> => {
const tool = tools.get(name)
if (!tool)
throw new Error(`Dify agent tool "${name}" is not registered.`)
return tool.execute(input)
}
const collectPageContexts = () => {
return [...pageContextProviders.entries()].map(([id, provider]) => {
try {
return {
id,
value: provider(),
}
}
catch (error) {
return {
id,
error: error instanceof Error ? error.message : String(error),
}
}
})
}
export const getRegisteredPageContexts = () => collectPageContexts()
export const registerDifyAgentPageContext = (id: string, provider: AgentPageContextProvider) => {
pageContextProviders.set(id, provider)
return () => {
if (pageContextProviders.get(id) === provider)
pageContextProviders.delete(id)
}
}
const ensureWebMcpTestingApi = (api: AgentContextApi) => {
try {
Object.defineProperty(navigator, 'modelContextTesting', {
configurable: true,
value: {
executeTool: (name: string, input?: string | AgentToolInput) => api.callTool(name, normalizeTestingInput(input)),
listTools: () => api.listTools(),
},
})
}
catch (error) {
console.warn('[Dify Agent Context] WebMCP testing API registration failed:', error)
}
}
const ensureWindowApi = () => {
if (typeof window === 'undefined')
return undefined
runtimeApi ??= {
version: RUNTIME_VERSION,
callTool,
getPageContext: () => callTool('dify_get_page_context', {}),
listTools: getToolDefinitions,
registerPageContext: registerDifyAgentPageContext,
}
window.__DIFY_AGENT_CONTEXT__ = runtimeApi
ensureWebMcpTestingApi(runtimeApi)
return runtimeApi
}
const registerWithWebMCP = (tool: AgentTool) => {
if (typeof window === 'undefined')
return false
const modelContext = navigator.modelContext
if (!modelContext?.registerTool)
return false
if (webMcpControllers.has(tool.name))
return true
const controller = new AbortController()
try {
modelContext.registerTool(tool, { signal: controller.signal })
webMcpControllers.set(tool.name, controller)
return true
}
catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes('already registered'))
console.warn('[Dify Agent Context] WebMCP tool registration failed:', tool.name, error)
return false
}
}
export const registerDifyAgentTools = (nextTools: AgentTool[]) => {
const api = ensureWindowApi()
if (!api)
return () => undefined
nextTools.forEach((tool) => {
tools.set(tool.name, tool)
registerWithWebMCP(tool)
})
return () => {
nextTools.forEach((tool) => {
if (tools.get(tool.name) === tool)
tools.delete(tool.name)
const controller = webMcpControllers.get(tool.name)
if (controller) {
controller.abort()
webMcpControllers.delete(tool.name)
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
'use client'
export type AgentJsonSchema = {
type: string
description?: string
properties?: Record<string, AgentJsonSchema>
items?: AgentJsonSchema
required?: string[]
enum?: string[]
additionalProperties?: boolean | AgentJsonSchema
}
export type AgentToolInput = Record<string, unknown>
export type AgentToolResult = Record<string, unknown> | string | number | boolean | null | AgentToolResult[] | {
[key: string]: AgentToolResult
}
export type AgentTool = {
name: string
title?: string
description: string
inputSchema?: AgentJsonSchema
annotations?: {
readOnlyHint?: boolean
untrustedContentHint?: boolean
}
execute: (input?: AgentToolInput) => AgentToolResult | Promise<AgentToolResult>
}
export type AgentToolDefinition = Omit<AgentTool, 'execute'>
export type AgentPageContextProvider = () => AgentToolResult
export type AgentContextApi = {
version: string
callTool: (name: string, input?: AgentToolInput) => Promise<AgentToolResult>
getPageContext: () => Promise<AgentToolResult>
listTools: () => AgentToolDefinition[]
registerPageContext: (id: string, provider: AgentPageContextProvider) => () => void
}
export type WebMCPModelContext = {
registerTool: (tool: AgentTool, options?: { signal?: AbortSignal }) => void
}
export type WebMCPTestingContext = {
executeTool: (name: string, input?: string | AgentToolInput) => Promise<AgentToolResult>
listTools: () => AgentToolDefinition[] | Promise<AgentToolDefinition[]>
}
declare global {
// eslint-disable-next-line ts/consistent-type-definitions -- interface required for declaration merging
interface Navigator {
modelContext?: WebMCPModelContext
modelContextTesting?: WebMCPTestingContext
}
// eslint-disable-next-line ts/consistent-type-definitions -- interface required for declaration merging
interface Window {
__DIFY_AGENT_CONTEXT__?: AgentContextApi
}
}

View File

@ -0,0 +1,199 @@
'use client'
import type { AgentToolResult } from './types'
import { BlockEnum } from '@/app/components/workflow/types'
type WorkflowRecipe = {
app_mode: string
build_notes: string[]
edge_blueprint: Array<Record<string, string>>
hard_controls: string[]
id: string
input_contract: Array<Record<string, string | boolean>>
node_blueprint: Array<Record<string, string>>
output_contract: Array<Record<string, string>>
prerequisites: string[]
source: {
published_at: string
title: string
url: string
}
summary: string
test_cases: Array<Record<string, string | Record<string, unknown>>>
title: string
}
const FINANCE_CREDIT_FIRST_RECIPE: WorkflowRecipe = {
app_mode: 'workflow',
build_notes: [
'Keep the workflow deterministic around financial controls; use LLM only for bounded classification and explanation.',
'Use Marketplace plugin discovery first: install Mercury Trigger, Mercury Banking Tools, and QuickBooks Online Accounting if they are absent.',
'Use the Mercury trigger-plugin node as the entry point; do not model Mercury events as a generic HTTP request when the plugin is available.',
'Use QuickBooks tool nodes for accounting writes; do not hand-roll QuickBooks REST calls unless the plugin is unavailable.',
'Fetch default configs for every block type before writing node data.',
'Use if-else rules for review thresholds instead of letting the LLM decide whether to bypass controls.',
'Persist the classification basis, final route, external posting result, and human review result as audit evidence.',
],
edge_blueprint: [
{ from: 'mercury_transaction_trigger', to: 'fetch_transaction', route: 'new_or_updated_transaction' },
{ from: 'fetch_transaction', to: 'normalize_transaction', route: 'success' },
{ from: 'normalize_transaction', to: 'lookup_context', route: 'success' },
{ from: 'lookup_context', to: 'classify_transaction', route: 'success' },
{ from: 'classify_transaction', to: 'risk_gate', route: 'success' },
{ from: 'risk_gate', to: 'post_to_quickbooks', route: 'direct_pass' },
{ from: 'risk_gate', to: 'create_pending_entry', route: 'review_required' },
{ from: 'create_pending_entry', to: 'finance_review', route: 'success' },
{ from: 'finance_review', to: 'post_to_quickbooks', route: 'approved' },
{ from: 'post_to_quickbooks', to: 'audit_summary', route: 'success' },
{ from: 'audit_summary', to: 'end', route: 'success' },
],
hard_controls: [
'Route new suppliers to review unless an allow-list rule explicitly permits direct posting.',
'Route large transactions to review using a configured amount threshold.',
'Route sensitive accounts, low confidence classifications, missing notes, and malformed transactions to review.',
'Never post to QuickBooks without a deterministic route decision and stored audit basis.',
'Stop or fail closed when Mercury, context lookup, or QuickBooks API calls return ambiguous data.',
],
id: 'finance-credit-first-management',
input_contract: [
{ name: 'event_id', required: true, source: 'Mercury trigger output', type: 'string' },
{ name: 'transaction_id', required: true, source: 'Mercury trigger output', type: 'string' },
{ name: 'account_id', required: true, source: 'Mercury trigger output', type: 'string' },
{ name: 'amount', required: true, source: 'Mercury trigger output', type: 'number' },
{ name: 'status', required: true, source: 'Mercury trigger output', type: 'string' },
{ name: 'operation_type', required: true, source: 'Mercury trigger output', type: 'string' },
],
node_blueprint: [
{ id: 'mercury_transaction_trigger', plugin: 'petrus/mercury_trigger', provider: 'petrus/mercury_trigger/mercury_trigger', purpose: 'Subscribe to Mercury transaction.created and transaction.updated webhook events.', title: 'Mercury Transaction Trigger', type: BlockEnum.TriggerPlugin },
{ id: 'fetch_transaction', plugin: 'petrus/mercury_tools', provider: 'petrus/mercury_tools/mercury_tools', purpose: 'Fetch Mercury transaction details: amount, merchant, notes, time, and cardholder.', title: 'Fetch Mercury Transaction', tool: 'get_transaction', type: BlockEnum.Tool },
{ id: 'normalize_transaction', purpose: 'Normalize merchant names, amounts, memo text, and missing optional fields.', title: 'Normalize Transaction', type: BlockEnum.Code },
{ id: 'lookup_context', purpose: 'Resolve cost center and retrieve supplier historical bookkeeping habits.', title: 'Lookup Context', type: BlockEnum.Tool },
{ id: 'classify_transaction', purpose: 'Suggest account classification, confidence, risk flags, and reasoning basis.', title: 'Finance Classification', type: BlockEnum.LLM },
{ id: 'risk_gate', purpose: 'Apply deterministic controls for direct pass versus finance review.', title: 'Risk Gate', type: BlockEnum.IfElse },
{ id: 'create_pending_entry', purpose: 'Create a pending bookkeeping entry and notify finance for exception review.', title: 'Create Pending Entry', type: BlockEnum.Tool },
{ id: 'finance_review', purpose: 'Collect finance approval, corrected account, and reviewer notes.', title: 'Finance Review', type: BlockEnum.HumanInput },
{ id: 'post_to_quickbooks', plugin: 'petrus/quickbooks', provider: 'petrus/quickbooks/quickbooks', purpose: 'Write the approved expense or journal entry to QuickBooks.', title: 'Post to QuickBooks', tool: 'create_purchase', type: BlockEnum.Tool },
{ id: 'audit_summary', purpose: 'Render a compact audit record with inputs, route, basis, reviewer, and external ids.', title: 'Audit Summary', type: BlockEnum.TemplateTransform },
{ id: 'end', purpose: 'Return posting status, route, audit summary, and follow-up action.', title: 'End', type: BlockEnum.End },
],
output_contract: [
{ name: 'route', type: 'string' },
{ name: 'status', type: 'string' },
{ name: 'account_code', type: 'string' },
{ name: 'confidence', type: 'number' },
{ name: 'basis', type: 'string' },
{ name: 'quickbooks_entry_id', type: 'string' },
{ name: 'audit_summary', type: 'string' },
],
prerequisites: [
'Installed Marketplace plugin: petrus/mercury_trigger with a verified subscription for transaction events.',
'Installed Marketplace plugin: petrus/mercury_tools with Mercury Banking credentials for transaction enrichment.',
'Installed Marketplace plugin: petrus/quickbooks with QuickBooks Online credentials for accounting writes.',
'Cardholder-to-department or cost-center mapping.',
'Supplier history lookup source, such as dataset, internal API, or tool.',
'QuickBooks account ids for bank account, expense account, and optional vendor mapping, plus idempotency key strategy.',
'Finance reviewer notification channel and human-input form ownership.',
],
source: {
published_at: '2026-02-17',
title: 'Finance Automation in Action: How to Solve the "Credit First" Management Challenge with Dify Workflow',
url: 'https://dify.ai/blog/finance-automation-in-action-how-to-solve-the-credit-first-management-challenge-with-dify-workflow',
},
summary: 'Routes Mercury card transactions into QuickBooks with deterministic financial controls, LLM-assisted classification, review handling for exceptions, and auditable execution records.',
test_cases: [
{
expected_route: 'direct_pass',
id: 'known_subscription_low_risk',
inputs: { amount: 49, merchant_name: 'Known SaaS Vendor', memo: 'monthly subscription' },
purpose: 'Known fixed-pattern transaction posts automatically.',
},
{
expected_route: 'review_required',
id: 'new_supplier',
inputs: { amount: 320, merchant_name: 'New Vendor LLC', memo: 'team tooling' },
purpose: 'New supplier is held for review even when the LLM classification is plausible.',
},
{
expected_route: 'review_required',
id: 'large_transaction',
inputs: { amount: 12000, merchant_name: 'Cloud Provider', memo: 'annual commit' },
purpose: 'High amount crosses the deterministic manual review threshold.',
},
{
expected_route: 'review_required',
id: 'sensitive_or_low_confidence',
inputs: { amount: 900, merchant_name: 'Consulting Partner', memo: '' },
purpose: 'Missing context or sensitive account flags must route to review.',
},
],
title: 'Credit-first finance automation from Mercury to QuickBooks',
}
export const WORKFLOW_RECIPES = [
FINANCE_CREDIT_FIRST_RECIPE,
]
export const listWorkflowRecipes = (): AgentToolResult => ({
recipes: WORKFLOW_RECIPES.map(recipe => ({
app_mode: recipe.app_mode,
id: recipe.id,
required_block_types: Array.from(new Set(recipe.node_blueprint.map(node => node.type))),
source: recipe.source,
summary: recipe.summary,
title: recipe.title,
})),
})
export const getWorkflowRecipeById = (recipeId?: string) => {
const id = recipeId || FINANCE_CREDIT_FIRST_RECIPE.id
const recipe = WORKFLOW_RECIPES.find(recipe => recipe.id === id)
if (!recipe)
throw new Error(`Unknown workflow recipe "${id}".`)
return recipe
}
export const getWorkflowRecipeForAgent = (recipeId?: string): AgentToolResult => {
return {
ok: true,
recipe: getWorkflowRecipeById(recipeId),
}
}
export const buildWorkflowRecipePlan = (recipeId?: string): AgentToolResult => {
const recipe = getWorkflowRecipeById(recipeId)
const requiredBlockTypes = Array.from(new Set(recipe.node_blueprint.map(node => node.type)))
return {
ok: true,
plan: {
authoring_loop: [
'Open or create the target workflow app.',
'Call dify_get_workflow_node_default_config for each required block type.',
'Assemble the graph from the recipe blueprint using backend default configs as the base for node data.',
'Call dify_sync_workflow_draft or dify_import_app_dsl with the complete graph.',
'Call dify_validate_workflow_graph and fix structural issues before running.',
'Run every recipe test case with dify_run_workflow_draft.',
'Inspect dify_get_workflow_run_detail and dify_get_workflow_run_node_executions for each run.',
'Publish only after direct-pass, review-required, and failure/edge-case paths all behave as expected.',
],
publish_gate: [
'No dangling or unreachable nodes.',
'Every deterministic hard control is represented by a branch condition or explicit failure path.',
'Every external write has an idempotency key or duplicate protection note.',
'Audit output contains route, inputs, classification basis, reviewer result when present, and external ids.',
],
required_block_config_calls: requiredBlockTypes.map(blockType => ({
block_type: blockType,
tool: 'dify_get_workflow_node_default_config',
})),
run_matrix: recipe.test_cases.map(testCase => ({
expected_route: testCase.expected_route,
inputs: testCase.inputs,
test_case_id: testCase.id,
tool: 'dify_run_workflow_draft',
})),
},
recipe_id: recipe.id,
}
}

View File

@ -0,0 +1,487 @@
'use client'
import type { AgentToolResult } from './types'
import type { Edge, Node } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
type WorkflowAgentContextParams = {
candidateNode?: Node
controlMode: string
edges: Edge[]
isListening: boolean
nodes: Node[]
pathname: string
pendingSingleRun?: {
action: string
nodeId: string
}
pluginCatalog?: WorkflowPluginCatalog
selectedNodeId?: string
}
export type WorkflowDraftLike = {
conversation_variables?: unknown[]
environment_variables?: unknown[]
features?: unknown
graph: {
edges: Edge[]
nodes: Node[]
viewport?: unknown
}
hash?: string
id?: string
updated_at?: number | string
version?: string
}
type WorkflowGraphIssue = {
code: string
message: string
node_ids?: string[]
severity: 'error' | 'warning' | 'info'
}
type WorkflowPluginCatalog = {
buildInTools?: unknown[]
customTools?: unknown[]
dataSourceList?: unknown[]
mcpTools?: unknown[]
triggerPlugins?: unknown[]
workflowTools?: unknown[]
}
const ENTRY_NODE_TYPES = new Set<string>([
BlockEnum.Start,
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
BlockEnum.DataSource,
])
const TERMINAL_NODE_TYPES = new Set<string>([
BlockEnum.End,
BlockEnum.Answer,
])
const CONTROL_NODE_TYPES = new Set<string>([
BlockEnum.IfElse,
BlockEnum.QuestionClassifier,
BlockEnum.Iteration,
BlockEnum.Loop,
])
export const compactNodeData = (node: Node) => {
const {
data,
} = node
const dynamicData = data as Record<string, unknown>
const toolLabel = dynamicData.tool_label ?? dynamicData.tool_name
const providerName = dynamicData.provider_name
return {
id: node.id,
title: data.title,
type: data.type,
description: data.desc,
selected: Boolean(data.selected || node.selected),
position: node.position,
connected_source_handles: data._connectedSourceHandleIds,
connected_target_handles: data._connectedTargetHandleIds,
provider: typeof providerName === 'string' ? providerName : undefined,
tool: typeof toolLabel === 'string' ? toolLabel : undefined,
variable: 'variable' in data ? data.variable : undefined,
variables: 'variables' in data ? data.variables : undefined,
}
}
export const compactEdgeData = (edge: Edge) => ({
id: edge.id,
source: edge.source,
source_handle: edge.sourceHandle,
source_type: edge.data?.sourceType,
target: edge.target,
target_handle: edge.targetHandle,
target_type: edge.data?.targetType,
})
const summarizeNode = (node: Node) => ({
id: node.id,
title: node.data.title,
type: node.data.type,
})
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
const getLocalizedText = (value: unknown, fallback = '') => {
if (typeof value === 'string')
return value
if (!isRecord(value))
return fallback
const preferred = [
value.en_US,
value.zh_Hans,
value.ja_JP,
value.pt_BR,
].find(item => typeof item === 'string' && item.length > 0)
return typeof preferred === 'string' ? preferred : fallback
}
const toRecordArray = (value: unknown): Record<string, unknown>[] => {
return Array.isArray(value)
? value.filter(isRecord)
: []
}
const compactCatalogParameter = (parameter: Record<string, unknown>) => {
const name = typeof parameter.name === 'string' ? parameter.name : ''
return {
default: parameter.default,
label: getLocalizedText(parameter.label, name),
name,
required: Boolean(parameter.required),
type: typeof parameter.type === 'string' ? parameter.type : undefined,
}
}
const compactCatalogTool = (tool: Record<string, unknown>) => {
const parameters = toRecordArray(tool.parameters).map(compactCatalogParameter)
return {
description: getLocalizedText(tool.description, ''),
label: getLocalizedText(tool.label, typeof tool.name === 'string' ? tool.name : ''),
name: tool.name,
parameters,
required_parameters: parameters.filter(parameter => parameter.required).map(parameter => parameter.name),
}
}
const compactCatalogToolProvider = (provider: Record<string, unknown>) => ({
id: provider.id,
label: getLocalizedText(provider.label, typeof provider.name === 'string' ? provider.name : ''),
name: provider.name,
plugin_id: provider.plugin_id,
plugin_unique_identifier: provider.plugin_unique_identifier,
tool_count: toRecordArray(provider.tools).length,
tools: toRecordArray(provider.tools).map(compactCatalogTool),
type: provider.type,
})
const compactCatalogTriggerProvider = (provider: Record<string, unknown>) => ({
events: toRecordArray(provider.events).map((event) => {
const identity = isRecord(event.identity) ? event.identity : {}
const outputSchema = isRecord(event.output_schema) ? event.output_schema : {}
const properties = isRecord(outputSchema.properties) ? outputSchema.properties : {}
return {
label: getLocalizedText(identity.label, typeof event.name === 'string' ? event.name : ''),
name: event.name,
output_schema_keys: Object.keys(properties),
parameters: toRecordArray(event.parameters).map(compactCatalogParameter),
}
}),
id: provider.id,
label: getLocalizedText(provider.label, typeof provider.name === 'string' ? provider.name : ''),
name: provider.name,
plugin_id: provider.plugin_id,
plugin_unique_identifier: provider.plugin_unique_identifier,
type: provider.type,
})
const summarizePluginCatalog = (catalog?: WorkflowPluginCatalog) => {
const buildInTools = toRecordArray(catalog?.buildInTools)
const customTools = toRecordArray(catalog?.customTools)
const workflowTools = toRecordArray(catalog?.workflowTools)
const mcpTools = toRecordArray(catalog?.mcpTools)
const triggerPlugins = toRecordArray(catalog?.triggerPlugins)
return {
data_sources: {
count: Array.isArray(catalog?.dataSourceList) ? catalog.dataSourceList.length : 0,
},
tools: {
builtin: buildInTools.map(compactCatalogToolProvider),
custom: customTools.map(compactCatalogToolProvider),
mcp: mcpTools.map(compactCatalogToolProvider),
workflow: workflowTools.map(compactCatalogToolProvider),
},
triggers: triggerPlugins.map(compactCatalogTriggerProvider),
}
}
const buildAdjacency = (nodes: Node[], edges: Edge[]) => {
const nodeIds = new Set(nodes.map(node => node.id))
const adjacency = new Map<string, string[]>()
nodes.forEach((node) => {
adjacency.set(node.id, [])
})
edges.forEach((edge) => {
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target))
return
adjacency.get(edge.source)?.push(edge.target)
})
return adjacency
}
const getReachableNodeIds = (entryNodes: Node[], adjacency: Map<string, string[]>) => {
const reachable = new Set<string>()
const queue = entryNodes.map(node => node.id)
while (queue.length > 0) {
const current = queue.shift()
if (!current || reachable.has(current))
continue
reachable.add(current)
adjacency.get(current)?.forEach((target) => {
if (!reachable.has(target))
queue.push(target)
})
}
return reachable
}
const getStartVariables = (nodes: Node[]) => {
return nodes
.filter(node => ENTRY_NODE_TYPES.has(node.data.type))
.flatMap((node) => {
const variables = (node.data as Record<string, unknown>).variables
return Array.isArray(variables)
? variables.map(variable => ({
node_id: node.id,
node_title: node.data.title,
variable,
}))
: []
})
}
const collectWorkflowGraphIssues = (nodes: Node[], edges: Edge[]): WorkflowGraphIssue[] => {
const issues: WorkflowGraphIssue[] = []
const nodeIds = new Set(nodes.map(node => node.id))
const entryNodes = nodes.filter(node => ENTRY_NODE_TYPES.has(node.data.type))
const terminalNodes = nodes.filter(node => TERMINAL_NODE_TYPES.has(node.data.type))
const danglingEdges = edges.filter(edge => !nodeIds.has(edge.source) || !nodeIds.has(edge.target))
if (nodes.length === 0) {
issues.push({
code: 'empty_graph',
message: 'The workflow graph has no nodes.',
severity: 'error',
})
return issues
}
if (entryNodes.length === 0) {
issues.push({
code: 'missing_entry_node',
message: 'The workflow graph has no Start, Trigger, or Data Source entry node.',
severity: 'error',
})
}
if (terminalNodes.length === 0) {
issues.push({
code: 'missing_terminal_node',
message: 'The workflow graph has no End or Answer terminal node.',
severity: 'warning',
})
}
if (danglingEdges.length > 0) {
issues.push({
code: 'dangling_edges',
message: 'Some edges reference missing source or target nodes.',
node_ids: Array.from(new Set(danglingEdges.flatMap(edge => [edge.source, edge.target]))),
severity: 'error',
})
}
const adjacency = buildAdjacency(nodes, edges)
const reachable = getReachableNodeIds(entryNodes, adjacency)
const unreachableNodes = entryNodes.length > 0
? nodes.filter(node => !reachable.has(node.id))
: []
if (unreachableNodes.length > 0) {
issues.push({
code: 'unreachable_nodes',
message: 'Some nodes cannot be reached from any entry node.',
node_ids: unreachableNodes.map(node => node.id),
severity: 'warning',
})
}
const orphanControlNodes = nodes.filter((node) => {
if (!CONTROL_NODE_TYPES.has(node.data.type))
return false
return !edges.some(edge => edge.source === node.id)
})
if (orphanControlNodes.length > 0) {
issues.push({
code: 'control_nodes_without_outputs',
message: 'Some branching, loop, or iteration nodes do not have outgoing edges.',
node_ids: orphanControlNodes.map(node => node.id),
severity: 'warning',
})
}
return issues
}
export const getWorkflowConstructionGuide = (): AgentToolResult => ({
graph_contract: {
nodes: 'React Flow nodes. Each node must have id, type, position, and data.type/title/desc plus node-specific config.',
edges: 'React Flow edges. Each edge must connect source -> target and should include sourceType and targetType in data.',
draft_hash: 'Use the draft hash from dify_get_workflow_draft when syncing to avoid overwriting newer user edits.',
},
build_strategy: [
'Start from the intended business process and map each decision, external call, LLM step, tool call, human approval, and final output to a node.',
'Fetch default node configs before constructing node data so generated graphs match Dify backend validation.',
'For complex workflow creation, prefer importing DSL or syncing a full draft graph, then use browser actions for targeted UI checks and small edits.',
'After every structural edit, validate the graph, run the draft with representative inputs, inspect node executions, then publish.',
],
node_types: {
entry: [
{ type: BlockEnum.Start, use: 'Manual workflow input variables.' },
{ type: BlockEnum.TriggerSchedule, use: 'Time-based workflow entry.' },
{ type: BlockEnum.TriggerWebhook, use: 'HTTP webhook entry.' },
{ type: BlockEnum.TriggerPlugin, use: 'Plugin-provided trigger entry.' },
],
reasoning_and_generation: [
{ type: BlockEnum.LLM, use: 'Prompt an LLM with variables, memory, model settings, and structured output.' },
{ type: BlockEnum.Agent, use: 'Run an agent strategy with tools and multi-step reasoning.' },
{ type: BlockEnum.QuestionClassifier, use: 'Classify input into branches.' },
],
data_and_tools: [
{ type: BlockEnum.KnowledgeRetrieval, use: 'Retrieve records from knowledge datasets.' },
{ type: BlockEnum.Tool, use: 'Invoke built-in, custom, workflow, or MCP tools.' },
{ type: BlockEnum.HttpRequest, use: 'Call external HTTP APIs.' },
{ type: BlockEnum.DocExtractor, use: 'Extract content from files.' },
],
control_flow: [
{ type: BlockEnum.IfElse, use: 'Branch by conditions.' },
{ type: BlockEnum.Iteration, use: 'Process list items.' },
{ type: BlockEnum.Loop, use: 'Repeat until an exit condition.' },
{ type: BlockEnum.LoopEnd, use: 'Exit a loop branch.' },
{ type: BlockEnum.HumanInput, use: 'Pause for human approval or form input.' },
],
transform: [
{ type: BlockEnum.Code, use: 'Run Python/JavaScript transformations.' },
{ type: BlockEnum.TemplateTransform, use: 'Render text from variables.' },
{ type: BlockEnum.VariableAssigner, use: 'Assign or update variables.' },
{ type: BlockEnum.VariableAggregator, use: 'Merge variables from branches.' },
{ type: BlockEnum.ParameterExtractor, use: 'Extract structured fields from text.' },
{ type: BlockEnum.ListFilter, use: 'Filter or slice lists.' },
],
terminal: [
{ type: BlockEnum.End, use: 'Return final workflow outputs.' },
{ type: BlockEnum.Answer, use: 'Return chatflow answer content.' },
],
},
debug_cycle: [
'dify_get_workflow_node_default_config',
'dify_search_marketplace_plugins',
'dify_list_installed_plugin_capabilities',
'dify_get_trigger_provider_detail',
'dify_create_trigger_subscription_builder',
'dify_get_trigger_subscription_builder_logs',
'dify_validate_workflow_graph',
'dify_run_workflow_draft',
'dify_get_workflow_runs',
'dify_get_workflow_run_detail',
'dify_get_workflow_run_node_executions',
'dify_sync_workflow_draft or dify_import_app_dsl',
'dify_publish_workflow',
],
})
export const summarizeWorkflowGraph = (graph: WorkflowDraftLike['graph']): AgentToolResult => {
const nodes = graph.nodes
const edges = graph.edges
const issues = collectWorkflowGraphIssues(nodes, edges)
const nodeTypeCounts = nodes.reduce<Record<string, number>>((counts, node) => {
counts[node.data.type] = (counts[node.data.type] ?? 0) + 1
return counts
}, {})
const entryNodes = nodes.filter(node => ENTRY_NODE_TYPES.has(node.data.type))
const terminalNodes = nodes.filter(node => TERMINAL_NODE_TYPES.has(node.data.type))
return {
edge_count: edges.length,
edges: edges.map(compactEdgeData),
entry_nodes: entryNodes.map(summarizeNode),
error_count: issues.filter(issue => issue.severity === 'error').length,
issues,
node_count: nodes.length,
node_type_counts: nodeTypeCounts,
nodes: nodes.map(compactNodeData),
start_variables: getStartVariables(nodes),
terminal_nodes: terminalNodes.map(summarizeNode),
valid: issues.every(issue => issue.severity !== 'error'),
warning_count: issues.filter(issue => issue.severity === 'warning').length,
}
}
export const summarizeWorkflowDraftForAgent = (draft: WorkflowDraftLike): AgentToolResult => ({
draft: {
hash: draft.hash,
id: draft.id,
updated_at: draft.updated_at,
version: draft.version,
},
graph: summarizeWorkflowGraph(draft.graph),
variables: {
conversation_variable_count: draft.conversation_variables?.length ?? 0,
environment_variable_count: draft.environment_variables?.length ?? 0,
},
})
export const buildWorkflowAgentContext = ({
candidateNode,
controlMode,
edges,
isListening,
nodes,
pathname,
pendingSingleRun,
pluginCatalog,
selectedNodeId,
}: WorkflowAgentContextParams): AgentToolResult => {
const selectedNode = nodes.find(node => node.id === selectedNodeId || node.selected || node.data.selected)
return {
page_type: 'workflow-builder',
pathname,
graph: summarizeWorkflowGraph({ edges, nodes }),
plugin_catalog: summarizePluginCatalog(pluginCatalog),
state: {
candidate_node: candidateNode ? compactNodeData(candidateNode) : null,
control_mode: controlMode,
is_listening: isListening,
pending_single_run: pendingSingleRun ?? null,
selected_node: selectedNode ? compactNodeData(selectedNode) : null,
},
orchestration: {
browser_action_workflow: [
'Use dify_get_page_context to discover current visible add-node, node-panel, menu, form, and run/debug action IDs.',
'Use dify_perform_browser_action to click or fill those controls.',
'Use this workflow context after each operation to confirm graph state and selected node state.',
],
safe_editing_notes: [
'For small visual edits, use Dify UI actions so validation, collaboration, undo history, and draft sync remain correct.',
'For full workflow construction, import DSL or sync a full draft graph, then validate and test the result.',
'If a node or panel control is missing from dom.actions, click the node or open the relevant panel first, then refresh page context.',
],
},
}
}

View File

@ -51,6 +51,8 @@ import ReactFlow, {
useReactFlow,
useStoreApi,
} from 'reactflow'
import { registerDifyAgentPageContext } from '@/app/components/agent-context/runtime'
import { buildWorkflowAgentContext } from '@/app/components/agent-context/workflow'
import { IS_DEV } from '@/config'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import {
@ -59,6 +61,7 @@ import {
useAllMCPTools,
useAllWorkflowTools,
} from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { fetchAllInspectVars } from '@/service/workflow'
import CandidateNode from './candidate-node'
import UserCursors from './collaboration/components/user-cursors'
@ -206,6 +209,9 @@ export const Workflow: FC<WorkflowProps> = memo(({
const [isMouseOverCanvas, setIsMouseOverCanvas] = useState(false)
const [nodes, setNodes] = useNodesState(originalNodes)
const [edges, setEdges] = useEdgesState(originalEdges)
const nodesRef = useRef<Node[]>(nodes as Node[])
const edgesRef = useRef<Edge[]>(edges as Edge[])
const agentPluginCatalogRef = useRef<Record<string, unknown[]>>({})
const controlMode = useStore(s => s.controlMode)
const nodeAnimation = useStore(s => s.nodeAnimation)
const showConfirm = useStore(s => s.showConfirm)
@ -256,6 +262,31 @@ export const Workflow: FC<WorkflowProps> = memo(({
}
}, [setWorkflowCanvasHeight, setWorkflowCanvasWidth])
useEffect(() => {
nodesRef.current = nodes as Node[]
}, [nodes])
useEffect(() => {
edgesRef.current = edges as Edge[]
}, [edges])
useEffect(() => {
return registerDifyAgentPageContext('workflow', () => {
const state = workflowStore.getState()
return buildWorkflowAgentContext({
candidateNode: state.candidateNode,
controlMode: state.controlMode,
edges: edgesRef.current,
isListening: state.isListening,
nodes: nodesRef.current,
pathname: window.location.pathname,
pendingSingleRun: state.pendingSingleRun,
pluginCatalog: agentPluginCatalogRef.current,
})
})
}, [workflowStore])
const {
setShowConfirm,
setControlPromptEditorRerenderKey,
@ -574,7 +605,16 @@ export const Workflow: FC<WorkflowProps> = memo(({
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const { data: triggerPlugins } = useAllTriggerPlugins()
const dataSourceList = useStore(s => s.dataSourceList)
agentPluginCatalogRef.current = {
buildInTools: buildInTools || [],
customTools: customTools || [],
dataSourceList: dataSourceList ?? [],
mcpTools: mcpTools || [],
triggerPlugins: triggerPlugins || [],
workflowTools: workflowTools || [],
}
// buildInTools, customTools, workflowTools, mcpTools, dataSourceList
const configsMap = useHooksStore(s => s.configsMap)
const [isLoadedVars, setIsLoadedVars] = useState(false)

View File

@ -4,6 +4,7 @@ import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
import { Provider as JotaiProvider } from 'jotai/react'
import { ThemeProvider } from 'next-themes'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { AgentContextProvider } from '@/app/components/agent-context/provider'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { IS_PROD } from '@/config'
import { TanstackQueryInitializer } from '@/context/query-client'
@ -59,6 +60,7 @@ const LocaleLayout = async ({
className="h-full select-auto"
{...datasetMap}
>
<script src="/dify-agent-context.js" nonce={nonce} />
<div className="isolate h-full">
<AmplitudeProvider />
<JotaiProvider>
@ -73,6 +75,7 @@ const LocaleLayout = async ({
<TanstackQueryInitializer>
<I18nServerProvider>
<ToastHost timeout={5000} limit={3} />
<AgentContextProvider />
<PartnerStackCookieRecorder />
<TooltipProvider delay={300} closeDelay={200}>
{children}

View File

@ -0,0 +1,819 @@
(function () {
const VERSION = '2026-05-14'
const tools = new Map()
const pageContexts = new Map()
const hash = (value) => {
let next = 5381
for (let i = 0; i < value.length; i += 1)
next = ((next << 5) + next) ^ value.charCodeAt(i)
return (next >>> 0).toString(36)
}
const textOf = (element) => (
element.getAttribute('aria-label')
|| element.getAttribute('title')
|| element.getAttribute('placeholder')
|| element.innerText
|| element.textContent
|| element.value
|| ''
).replace(/\s+/g, ' ').trim()
const isVisible = (element) => {
const rect = element.getBoundingClientRect()
const style = window.getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
}
const selectorOf = (element) => {
if (element.id)
return `#${CSS.escape(element.id)}`
const parts = []
let node = element
while (node && node.nodeType === Node.ELEMENT_NODE && parts.length < 4) {
let part = node.tagName.toLowerCase()
const testId = node.getAttribute('data-testid')
if (testId)
part += `[data-testid="${CSS.escape(testId)}"]`
else {
const parent = node.parentElement
if (parent) {
const siblings = Array.from(parent.children).filter(item => item.tagName === node.tagName)
if (siblings.length > 1)
part += `:nth-of-type(${siblings.indexOf(node) + 1})`
}
}
parts.unshift(part)
node = node.parentElement
}
return parts.join(' > ')
}
const getRouteContext = () => {
const pathname = window.location.pathname
const appMatch = pathname.match(/^\/app\/([^/]+)/)
const datasetMatch = pathname.match(/^\/datasets\/([^/]+)/)
let pageType = 'unknown'
const capabilityIds = []
if (pathname === '/apps' || pathname.startsWith('/app/')) {
pageType = pathname.includes('/workflow') ? 'workflow-builder' : 'studio-apps'
capabilityIds.push('apps', 'workflow')
}
else if (pathname.startsWith('/datasets')) {
pageType = 'datasets'
capabilityIds.push('datasets', 'rag-pipeline')
}
else if (pathname.startsWith('/tools') || pathname.startsWith('/plugins')) {
pageType = 'tools-plugins'
capabilityIds.push('tools', 'plugins', 'mcp')
}
else if (pathname.startsWith('/signin') || pathname.startsWith('/install') || pathname.startsWith('/init')) {
pageType = 'authentication-onboarding'
capabilityIds.push('authentication-onboarding')
}
return {
capability_ids: capabilityIds,
page_type: pageType,
pathname,
route_params: {
app_id: appMatch ? appMatch[1] : undefined,
dataset_id: datasetMatch ? datasetMatch[1] : undefined,
},
}
}
const getCapabilities = () => ([
{ id: 'apps', title: 'Apps', description: 'Create, import, configure, and operate Dify applications.' },
{ id: 'workflow', title: 'Workflow Builder', description: 'Create workflow graphs, configure nodes, run drafts, and publish versions.' },
{ id: 'datasets', title: 'Knowledge', description: 'Create datasets, upload documents, configure retrieval, and test RAG.' },
{ id: 'rag-pipeline', title: 'RAG Pipeline', description: 'Configure dataset ingestion and indexing workflows.' },
{ id: 'tools', title: 'Tools', description: 'Configure built-in, custom, workflow, and MCP tools.' },
{ id: 'plugins', title: 'Plugins', description: 'Install and manage providers, tools, and extension packages.' },
{ id: 'mcp', title: 'MCP', description: 'Expose published apps and tools as MCP services where supported.' },
{ id: 'model-providers', title: 'Model Providers', description: 'Configure LLM, embedding, rerank, TTS, and speech providers.' },
{ id: 'published-runtime', title: 'Published Runtime', description: 'Run published chat, completion, and workflow apps.' },
{ id: 'human-input', title: 'Human Input', description: 'Pause workflows for approval and resume after form submission.' },
{ id: 'workspace-settings', title: 'Workspace Settings', description: 'Manage members, billing, integrations, and security settings.' },
{ id: 'authentication-onboarding', title: 'Authentication', description: 'Sign in, install, initialize, and recover accounts.' },
{ id: 'explore', title: 'Explore', description: 'Browse and launch installed or shared applications.' },
{ id: 'non-visual-browser-control', title: 'Non-visual Browser Control', description: 'Operate visible UI through stable DOM action descriptors.' },
])
const ENTRY_NODE_TYPES = new Set(['start', 'trigger-schedule', 'trigger-webhook', 'trigger-plugin', 'datasource'])
const TERMINAL_NODE_TYPES = new Set(['end', 'answer'])
const CONTROL_NODE_TYPES = new Set(['if-else', 'question-classifier', 'iteration', 'loop'])
const compactNode = node => ({
connected_source_handles: node.data?._connectedSourceHandleIds,
connected_target_handles: node.data?._connectedTargetHandleIds,
description: node.data?.desc,
id: node.id,
position: node.position,
provider: node.data?.provider_name,
selected: Boolean(node.selected || node.data?.selected),
title: node.data?.title,
tool: node.data?.tool_label || node.data?.tool_name,
type: node.data?.type,
variable: node.data?.variable,
variables: node.data?.variables,
})
const compactEdge = edge => ({
id: edge.id,
source: edge.source,
source_handle: edge.sourceHandle,
source_type: edge.data?.sourceType,
target: edge.target,
target_handle: edge.targetHandle,
target_type: edge.data?.targetType,
})
const summarizeNode = node => ({
id: node.id,
title: node.data?.title,
type: node.data?.type,
})
const collectGraphIssues = (nodes, edges) => {
const issues = []
const nodeIds = new Set(nodes.map(node => node.id))
const entryNodes = nodes.filter(node => ENTRY_NODE_TYPES.has(node.data?.type))
const terminalNodes = nodes.filter(node => TERMINAL_NODE_TYPES.has(node.data?.type))
const danglingEdges = edges.filter(edge => !nodeIds.has(edge.source) || !nodeIds.has(edge.target))
if (!nodes.length)
return [{ code: 'empty_graph', message: 'The workflow graph has no nodes.', severity: 'error' }]
if (!entryNodes.length)
issues.push({ code: 'missing_entry_node', message: 'The workflow graph has no Start, Trigger, or Data Source entry node.', severity: 'error' })
if (!terminalNodes.length)
issues.push({ code: 'missing_terminal_node', message: 'The workflow graph has no End or Answer terminal node.', severity: 'warning' })
if (danglingEdges.length)
issues.push({ code: 'dangling_edges', message: 'Some edges reference missing source or target nodes.', node_ids: Array.from(new Set(danglingEdges.flatMap(edge => [edge.source, edge.target]))), severity: 'error' })
const adjacency = new Map(nodes.map(node => [node.id, []]))
edges.forEach((edge) => {
if (nodeIds.has(edge.source) && nodeIds.has(edge.target))
adjacency.get(edge.source)?.push(edge.target)
})
const reachable = new Set()
const queue = entryNodes.map(node => node.id)
while (queue.length) {
const current = queue.shift()
if (!current || reachable.has(current))
continue
reachable.add(current)
adjacency.get(current)?.forEach((target) => {
if (!reachable.has(target))
queue.push(target)
})
}
const unreachableNodes = entryNodes.length ? nodes.filter(node => !reachable.has(node.id)) : []
if (unreachableNodes.length)
issues.push({ code: 'unreachable_nodes', message: 'Some nodes cannot be reached from any entry node.', node_ids: unreachableNodes.map(node => node.id), severity: 'warning' })
const orphanControlNodes = nodes.filter(node => CONTROL_NODE_TYPES.has(node.data?.type) && !edges.some(edge => edge.source === node.id))
if (orphanControlNodes.length)
issues.push({ code: 'control_nodes_without_outputs', message: 'Some branching, loop, or iteration nodes do not have outgoing edges.', node_ids: orphanControlNodes.map(node => node.id), severity: 'warning' })
return issues
}
const summarizeGraph = (graph) => {
const nodes = graph?.nodes || []
const edges = graph?.edges || []
const nodeTypeCounts = nodes.reduce((counts, node) => {
const type = node.data?.type || 'unknown'
counts[type] = (counts[type] || 0) + 1
return counts
}, {})
return {
edge_count: edges.length,
edges: edges.map(compactEdge),
entry_nodes: nodes.filter(node => ENTRY_NODE_TYPES.has(node.data?.type)).map(summarizeNode),
issues: collectGraphIssues(nodes, edges),
node_count: nodes.length,
node_type_counts: nodeTypeCounts,
nodes: nodes.map(compactNode),
start_variables: nodes
.filter(node => ENTRY_NODE_TYPES.has(node.data?.type) && Array.isArray(node.data?.variables))
.flatMap(node => node.data.variables.map(variable => ({ node_id: node.id, node_title: node.data?.title, variable }))),
terminal_nodes: nodes.filter(node => TERMINAL_NODE_TYPES.has(node.data?.type)).map(summarizeNode),
}
}
const summarizeDraft = draft => ({
draft: {
hash: draft.hash,
id: draft.id,
updated_at: draft.updated_at,
version: draft.version,
},
graph: summarizeGraph(draft.graph),
variables: {
conversation_variable_count: draft.conversation_variables?.length || 0,
environment_variable_count: draft.environment_variables?.length || 0,
},
})
const workflowGuide = () => ({
graph_contract: {
nodes: 'React Flow nodes with id, type, position, data.type/title/desc, and node-specific config.',
edges: 'React Flow edges connecting source -> target with sourceType and targetType in data.',
draft_hash: 'Use the draft hash when syncing to avoid overwriting newer edits.',
},
build_strategy: [
'Map the business process to entry, transform, tool, control-flow, human-input, and terminal nodes.',
'Fetch default node configs before constructing node data so generated graphs match Dify backend validation.',
'For large construction, import DSL or sync the full draft graph, then verify with browser context.',
'Validate, run representative inputs, inspect node executions, iterate, then publish.',
],
node_types: {
entry: ['start', 'trigger-schedule', 'trigger-webhook', 'trigger-plugin'],
reasoning_and_generation: ['llm', 'agent', 'question-classifier'],
data_and_tools: ['knowledge-retrieval', 'tool', 'http-request', 'document-extractor'],
control_flow: ['if-else', 'iteration', 'loop', 'loop-end', 'human-input'],
transform: ['code', 'template-transform', 'variable-assigner', 'variable-aggregator', 'parameter-extractor', 'list-operator'],
terminal: ['end', 'answer'],
},
debug_cycle: ['dify_search_marketplace_plugins', 'dify_list_installed_plugin_capabilities', 'dify_get_trigger_provider_detail', 'dify_get_workflow_node_default_config', 'dify_validate_workflow_graph', 'dify_run_workflow_draft', 'dify_get_workflow_runs', 'dify_get_workflow_run_detail', 'dify_get_workflow_run_node_executions', 'dify_sync_workflow_draft or dify_import_app_dsl', 'dify_publish_workflow'],
})
const getDomSnapshot = (input) => {
const actionLimit = Number.isFinite(input?.action_limit) ? input.action_limit : 80
const textLimit = Number.isFinite(input?.text_limit) ? input.text_limit : 40
const selector = [
'button',
'a[href]',
'input',
'textarea',
'select',
'[contenteditable="true"]',
'[role="button"]',
'[role="menuitem"]',
'[role="option"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="tab"]',
'[aria-haspopup]',
'[aria-expanded]',
'[data-testid]',
'.cursor-pointer',
].join(',')
const actions = Array.from(document.querySelectorAll(selector))
.filter(isVisible)
.slice(0, actionLimit)
.map((element) => {
const rect = element.getBoundingClientRect()
const name = textOf(element)
const descriptor = {
action_id: `dify_action_${hash([element.tagName, element.getAttribute('role') || '', name, selectorOf(element), element.getAttribute('href') || '', element.getAttribute('placeholder') || '', element.getAttribute('type') || ''].join('|'))}`,
tag: element.tagName.toLowerCase(),
role: element.getAttribute('role') || undefined,
name,
href: element.getAttribute('href') || undefined,
placeholder: element.getAttribute('placeholder') || undefined,
selector: selectorOf(element),
state: {
checked: element.checked === undefined ? undefined : !!element.checked,
disabled: element.disabled === undefined ? undefined : !!element.disabled,
expanded: element.getAttribute('aria-expanded') || undefined,
selected: element.getAttribute('aria-selected') || undefined,
},
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
}
element.setAttribute('data-dify-agent-action-id', descriptor.action_id)
return descriptor
})
const text = Array.from(document.body.querySelectorAll('h1,h2,h3,p,span,label,div'))
.filter(isVisible)
.map(textOf)
.filter(Boolean)
.slice(0, textLimit)
return {
actions,
dialogs: Array.from(document.querySelectorAll('[role="dialog"],[aria-modal="true"]')).filter(isVisible).map(textOf).filter(Boolean),
title: document.title,
url: window.location.href,
visible_text: Array.from(new Set(text)),
}
}
const csrfHeaders = () => {
const match = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)
return match ? { 'X-CSRF-Token': decodeURIComponent(match[1]) } : {}
}
const readJson = async (response) => {
const text = await response.text()
let data = null
try { data = text ? JSON.parse(text) : null }
catch (_) { data = { text } }
if (!response.ok)
throw new Error(`${response.status} ${data?.message || data?.text || response.statusText}`)
return data
}
const consoleFetch = async (path, options) => {
const headers = {
...(options?.body ? { 'Content-Type': 'application/json' } : {}),
...csrfHeaders(),
...(options?.headers || {}),
}
return readJson(await fetch(`/console/api/${path.replace(/^\/+/, '')}`, {
credentials: 'include',
...options,
headers,
}))
}
const marketplaceFetch = async (path, options) => {
const headers = {
...(options?.body ? { 'Content-Type': 'application/json' } : {}),
'X-Dify-Version': '999.0.0',
...(options?.headers || {}),
}
const prefix = window.__DIFY_MARKETPLACE_API_PREFIX__ || 'https://marketplace.dify.ai/api/v1'
return readJson(await fetch(`${prefix.replace(/\/$/, '')}/${path.replace(/^\/+/, '')}`, {
cache: 'no-store',
...options,
headers,
}))
}
const parseRunSummary = (events) => {
const finalEvent = [...events].reverse().find(event => ['workflow_finished', 'workflow_paused', 'error'].includes(event.event)) || null
const text = events
.filter(event => event.event === 'text_chunk' || event.event === 'text_replace')
.map(event => event.data?.text)
.filter(value => typeof value === 'string')
.join('')
return {
event_count: events.length,
final_event: finalEvent,
node_executions: events.filter(event => event.event === 'node_finished'),
status: finalEvent?.data?.status || finalEvent?.event || null,
task_id: finalEvent?.task_id || events.find(event => event.task_id)?.task_id || null,
text,
workflow_run_id: finalEvent?.workflow_run_id || events.find(event => event.workflow_run_id)?.workflow_run_id || null,
}
}
const runWorkflowSse = async (path, body) => {
const response = await fetch(`/console/api/${path.replace(/^\/+/, '')}`, {
body: JSON.stringify(body),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...csrfHeaders(),
},
method: 'POST',
})
if (!response.ok)
throw new Error(`${response.status} ${response.statusText}`)
const reader = response.body?.getReader()
if (!reader)
return { events: [], summary: parseRunSummary([]) }
const decoder = new TextDecoder('utf-8')
const events = []
let buffer = ''
const parseLine = (line) => {
if (!line.startsWith('data: '))
return
const payload = line.slice(6).trim()
if (payload)
events.push(JSON.parse(payload))
}
while (true) {
const result = await reader.read()
if (result.done)
break
buffer += decoder.decode(result.value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
lines.forEach(parseLine)
}
buffer.split('\n').forEach(parseLine)
return { events, summary: parseRunSummary(events) }
}
const currentAppId = (input) => input?.app_id || input?.appId || getRouteContext().route_params.app_id
const requireAppId = (input) => {
const appId = currentAppId(input)
if (!appId)
throw new Error('app_id is required when the current route is not an app page.')
return appId
}
const performAction = async (input) => {
const snapshot = getDomSnapshot({ action_limit: 500, text_limit: 0 })
const action = snapshot.actions.find(item => item.action_id === input?.action_id)
if (!action)
throw new Error(`Action not found: ${input?.action_id}`)
const element = document.querySelector(`[data-dify-agent-action-id="${action.action_id}"]`)
if (!element)
throw new Error(`Action element not found: ${action.action_id}`)
if (input.action === 'fill') {
element.focus()
element.value = String(input.value ?? '')
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: String(input.value ?? '') }))
element.dispatchEvent(new Event('change', { bubbles: true }))
}
else if (input.action === 'select') {
element.value = String(input.value ?? '')
element.dispatchEvent(new Event('change', { bubbles: true }))
}
else if (input.action === 'focus') {
element.focus()
}
else if (input.action === 'press') {
element.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: String(input.key || 'Enter') }))
element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: String(input.key || 'Enter') }))
}
else {
element.click()
}
return { ok: true, action_id: action.action_id, action: input.action }
}
const definitions = [
['dify_get_page_context', 'Get Dify page context', 'Returns structured route, visible text, dialogs, and browser-operable action IDs.', { readOnlyHint: true }],
['dify_list_frontend_capabilities', 'List Dify frontend capabilities', 'Lists Dify frontend areas and what a browser agent can operate.', { readOnlyHint: true }],
['dify_perform_browser_action', 'Perform Dify browser action', 'Performs click, fill, select, focus, press, and toggle using action IDs.'],
['dify_navigate', 'Navigate within Dify', 'Navigates to a same-origin Dify path.'],
['dify_get_workflow_context', 'Get Dify workflow context', 'Returns workflow route context and registered page contexts when available.', { readOnlyHint: true }],
['dify_explain_workflow_schema', 'Explain Dify workflow schema', 'Returns workflow graph contract, node type purposes, build strategy, and debug cycle.', { readOnlyHint: true }],
['dify_validate_workflow_graph', 'Validate workflow graph', 'Summarizes and validates a workflow graph or current draft.', { readOnlyHint: true }],
['dify_get_workflow_node_default_config', 'Get workflow node default config', 'Fetches backend default config for a workflow block type.', { readOnlyHint: true }],
['dify_get_workflow_draft', 'Get workflow draft', 'Fetches the current app workflow draft through the authenticated console API.', { readOnlyHint: true }],
['dify_sync_workflow_draft', 'Sync workflow draft', 'Writes a workflow graph draft through the authenticated console API.'],
['dify_run_workflow_draft', 'Run workflow draft', 'Runs the workflow draft and returns parsed streaming events.'],
['dify_run_workflow_node', 'Run workflow node', 'Runs one draft workflow node with supplied inputs.'],
['dify_get_workflow_runs', 'Get workflow runs', 'Lists recent workflow runs.', { readOnlyHint: true }],
['dify_get_workflow_run_detail', 'Get workflow run detail', 'Fetches workflow run detail by run ID.', { readOnlyHint: true }],
['dify_get_workflow_run_node_executions', 'Get workflow run node executions', 'Fetches per-node execution traces for a workflow run.', { readOnlyHint: true }],
['dify_stop_workflow_run', 'Stop workflow run', 'Stops a running workflow task by task ID.'],
['dify_import_app_dsl', 'Import Dify app DSL', 'Imports a Dify YAML DSL through the authenticated console API.'],
['dify_publish_workflow', 'Publish workflow', 'Publishes the current or specified app workflow.'],
['dify_export_app_dsl', 'Export Dify app DSL', 'Exports the current or specified app YAML DSL.', { readOnlyHint: true }],
['dify_explain_workflow_orchestration', 'Explain Dify workflow orchestration', 'Explains how a browser agent should orchestrate Dify workflows.', { readOnlyHint: true }],
]
const addTool = (name, title, description, annotations, execute) => {
tools.set(name, { name, title, description, annotations, inputSchema: { type: 'object', properties: {} }, execute })
}
addTool(definitions[0][0], definitions[0][1], definitions[0][2], definitions[0][3], async input => ({
application: { name: 'Dify', purpose: 'LLM application development platform.' },
route: getRouteContext(),
dom: getDomSnapshot(input || {}),
page_contexts: Array.from(pageContexts.entries()).map(([id, provider]) => ({ id, value: provider() })),
usage: {
next_step: 'Use dom.actions action_id values with dify_perform_browser_action. Refresh context after navigation or UI changes.',
no_screenshot_required: true,
},
}))
addTool(definitions[1][0], definitions[1][1], definitions[1][2], definitions[1][3], async () => ({ capabilities: getCapabilities(), current_route: getRouteContext() }))
addTool(definitions[2][0], definitions[2][1], definitions[2][2], undefined, performAction)
addTool(definitions[3][0], definitions[3][1], definitions[3][2], undefined, async (input) => {
const target = new URL(String(input?.path || ''), window.location.origin)
if (target.origin !== window.location.origin)
throw new Error('Only same-origin Dify navigation is allowed.')
window.location.assign(`${target.pathname}${target.search}${target.hash}`)
return { ok: true, target: `${target.pathname}${target.search}${target.hash}` }
})
addTool(definitions[4][0], definitions[4][1], definitions[4][2], definitions[4][3], async () => ({ route: getRouteContext(), page_contexts: Array.from(pageContexts.keys()) }))
addTool(definitions[5][0], definitions[5][1], definitions[5][2], definitions[5][3], async () => workflowGuide())
addTool(definitions[6][0], definitions[6][1], definitions[6][2], definitions[6][3], async input => {
if (input?.graph)
return { ok: true, source: 'input', graph: summarizeGraph(input.graph) }
const appId = requireAppId(input)
const draft = await consoleFetch(`/apps/${appId}/workflows/draft`)
return { ok: true, app_id: appId, analysis: summarizeDraft(draft), source: 'draft' }
})
addTool(definitions[7][0], definitions[7][1], definitions[7][2], definitions[7][3], async input => {
const appId = requireAppId(input)
const blockType = input?.block_type || input?.type
if (!blockType)
throw new Error('block_type is required.')
const params = new URLSearchParams({ q: JSON.stringify(input?.query || {}) })
const result = await consoleFetch(`/apps/${appId}/workflows/default-workflow-block-configs/${blockType}?${params.toString()}`)
return { ok: true, app_id: appId, block_type: blockType, config: result }
})
addTool(definitions[8][0], definitions[8][1], definitions[8][2], definitions[8][3], async input => {
const appId = requireAppId(input)
const draft = await consoleFetch(`/apps/${appId}/workflows/draft`)
return { ok: true, app_id: appId, analysis: summarizeDraft(draft), draft, summary: { node_count: draft.graph?.nodes?.length || 0, edge_count: draft.graph?.edges?.length || 0, hash: draft.hash, version: draft.version } }
})
addTool(definitions[9][0], definitions[9][1], definitions[9][2], undefined, async input => {
const appId = requireAppId(input)
const result = await consoleFetch(`/apps/${appId}/workflows/draft`, { method: 'POST', body: JSON.stringify({ graph: input.graph, features: input.features || {}, hash: input.hash, environment_variables: input.environment_variables || [], conversation_variables: input.conversation_variables || [] }) })
return { ok: result.result === 'success', app_id: appId, ...result }
})
addTool(definitions[10][0], definitions[10][1], definitions[10][2], undefined, async input => {
const appId = requireAppId(input)
const body = { inputs: input?.inputs || {} }
if (input?.files)
body.files = input.files
if (input?.query !== undefined)
body.query = input.query
if (input?.conversation_id)
body.conversation_id = input.conversation_id
if (input?.parent_message_id)
body.parent_message_id = input.parent_message_id
const path = input?.app_mode === 'advanced-chat'
? `/apps/${appId}/advanced-chat/workflows/draft/run`
: `/apps/${appId}/workflows/draft/run`
const result = await runWorkflowSse(path, body)
return { ok: result.summary.status !== 'error', app_id: appId, events: result.events, summary: result.summary }
})
addTool(definitions[11][0], definitions[11][1], definitions[11][2], undefined, async input => {
const appId = requireAppId(input)
if (!input?.node_id)
throw new Error('node_id is required.')
const result = await consoleFetch(`/apps/${appId}/workflows/draft/nodes/${input.node_id}/run`, { method: 'POST', body: JSON.stringify({ inputs: input.inputs || {}, query: input.query || '', files: input.files }) })
return { ok: true, app_id: appId, node_id: input.node_id, result }
})
addTool(definitions[12][0], definitions[12][1], definitions[12][2], definitions[12][3], async input => {
const appId = requireAppId(input)
const params = new URLSearchParams({ limit: String(input?.limit || 20) })
if (input?.last_id)
params.set('last_id', input.last_id)
if (input?.status)
params.set('status', input.status)
if (input?.triggered_from)
params.set('triggered_from', input.triggered_from)
const result = await consoleFetch(`/apps/${appId}/workflow-runs?${params.toString()}`)
return { ok: true, app_id: appId, ...result }
})
addTool(definitions[13][0], definitions[13][1], definitions[13][2], definitions[13][3], async input => {
const appId = requireAppId(input)
const runId = input?.run_id || input?.workflow_run_id
if (!runId)
throw new Error('run_id is required.')
const result = await consoleFetch(`/apps/${appId}/workflow-runs/${runId}`)
return { ok: true, app_id: appId, run_id: runId, detail: result }
})
addTool(definitions[14][0], definitions[14][1], definitions[14][2], definitions[14][3], async input => {
const appId = requireAppId(input)
const runId = input?.run_id || input?.workflow_run_id
if (!runId)
throw new Error('run_id is required.')
const result = await consoleFetch(`/apps/${appId}/workflow-runs/${runId}/node-executions`)
return { ok: true, app_id: appId, run_id: runId, ...result }
})
addTool(definitions[15][0], definitions[15][1], definitions[15][2], undefined, async input => {
const appId = requireAppId(input)
if (!input?.task_id)
throw new Error('task_id is required.')
const result = await consoleFetch(`/apps/${appId}/workflow-runs/tasks/${input.task_id}/stop`, { method: 'POST', body: '{}' })
return { ok: result.result === 'success', app_id: appId, task_id: input.task_id, ...result }
})
addTool(definitions[16][0], definitions[16][1], definitions[16][2], undefined, async input => {
let result = await consoleFetch('/apps/imports', { method: 'POST', body: JSON.stringify({ mode: 'yaml-content', yaml_content: input?.yaml_content, name: input?.name, description: input?.description, app_id: input?.app_id }) })
if (result.status === 'pending' && input?.auto_confirm_version_mismatch !== false)
result = await consoleFetch(`/apps/imports/${result.id}/confirm`, { method: 'POST', body: '{}' })
if (result.app_id && input?.navigate_to_workflow !== false)
window.location.assign(`/app/${result.app_id}/workflow`)
return { ok: result.status === 'completed' || result.status === 'completed-with-warnings', app_id: result.app_id || null, ...result }
})
addTool(definitions[17][0], definitions[17][1], definitions[17][2], undefined, async input => {
const appId = requireAppId(input)
const result = await consoleFetch(`/apps/${appId}/workflows/publish`, { method: 'POST', body: JSON.stringify({ marked_name: String(input?.marked_name || '').slice(0, 20), marked_comment: String(input?.marked_comment || '').slice(0, 100) }) })
return { ok: result.result === 'success', app_id: appId, ...result }
})
addTool(definitions[18][0], definitions[18][1], definitions[18][2], definitions[18][3], async input => {
const appId = requireAppId(input)
const params = new URLSearchParams({ include_secret: input?.include_secret ? 'true' : 'false' })
if (input?.workflow_id)
params.set('workflow_id', input.workflow_id)
const result = await consoleFetch(`/apps/${appId}/export?${params.toString()}`)
return { ok: true, app_id: appId, yaml_content: result.data }
})
addTool(definitions[19][0], definitions[19][1], definitions[19][2], definitions[19][3], async () => ({
purpose: 'Operate Dify workflow builder through structured context instead of screenshots.',
recommended_loop: ['Call dify_explain_workflow_schema.', 'Search/install plugins with dify_search_marketplace_plugins and dify_install_marketplace_plugins.', 'List installed plugin capabilities.', 'Fetch default node configs with dify_get_workflow_node_default_config.', 'Call dify_get_workflow_context.', 'Validate with dify_validate_workflow_graph.', 'Run with dify_run_workflow_draft.', 'Inspect with dify_get_workflow_run_node_executions.', 'Iterate, then publish.'],
}))
addTool('dify_create_workflow_app', 'Create workflow app', 'Creates a workflow app through the authenticated console API.', undefined, async input => {
const result = await consoleFetch('/apps', { method: 'POST', body: JSON.stringify({ name: input?.name || 'Untitled workflow', mode: 'workflow', description: input?.description, icon_type: 'emoji', icon: input?.icon || '🤖', icon_background: input?.icon_background || '#D5F5F6' }) })
if (result.id && input?.navigate_to_workflow !== false)
window.location.assign(`/app/${result.id}/workflow`)
return { ok: true, app_id: result.id, app_url: result.id ? `/app/${result.id}/workflow` : null, app: result }
})
const financeRecipe = {
id: 'finance-credit-first-management',
title: 'Credit-first finance automation from Mercury to QuickBooks',
summary: 'Plugin-aware Mercury transaction trigger, Mercury enrichment, deterministic finance controls, human review for exceptions, and QuickBooks posting.',
required_plugins: ['petrus/mercury_trigger', 'petrus/mercury_tools', 'petrus/quickbooks'],
required_block_types: ['trigger-plugin', 'tool', 'code', 'llm', 'if-else', 'human-input', 'template-transform', 'end'],
}
addTool('dify_list_workflow_recipes', 'List workflow recipes', 'Lists known workflow construction recipes.', { readOnlyHint: true }, async () => ({ recipes: [financeRecipe] }))
addTool('dify_get_workflow_recipe', 'Get workflow recipe', 'Returns the plugin-aware finance workflow recipe.', { readOnlyHint: true }, async () => ({ ok: true, recipe: financeRecipe }))
addTool('dify_build_workflow_recipe_plan', 'Build workflow recipe plan', 'Returns a build/debug/publish plan for the finance workflow recipe.', { readOnlyHint: true }, async () => ({
ok: true,
plan: {
authoring_loop: ['Discover Marketplace plugins.', 'Install missing plugins.', 'List installed trigger and tool capabilities.', 'Create workflow app.', 'Construct graph with Mercury trigger-plugin and QuickBooks tool nodes.', 'Sync draft.', 'Validate graph.', 'Debug plugin credentials/subscriptions.', 'Publish and enable trigger.'],
required_block_config_calls: financeRecipe.required_block_types.map(block_type => ({ block_type, tool: 'dify_get_workflow_node_default_config' })),
},
recipe_id: financeRecipe.id,
}))
addTool('dify_search_marketplace_plugins', 'Search Marketplace plugins', 'Searches Dify Marketplace for trigger/tool plugins.', { readOnlyHint: true, untrustedContentHint: true }, async input => {
const body = { page: input?.page || 1, page_size: input?.page_size || 10, query: input?.query || '', type: input?.type || 'plugin' }
if (input?.category)
body.category = input.category
const result = await marketplaceFetch('/plugins/search/advanced', { method: 'POST', body: JSON.stringify(body) })
const data = result.data || result
return { ok: true, page: data.page || body.page, page_size: data.page_size || body.page_size, plugins: data.plugins || [], query: body, total: data.total || 0 }
})
addTool('dify_list_installed_plugin_capabilities', 'List installed plugin capabilities', 'Lists installed plugin packages plus available tool and trigger providers.', { readOnlyHint: true }, async input => {
const appId = currentAppId(input)
const safe = async (path) => {
try { return { ok: true, data: await consoleFetch(path) } }
catch (error) { return { ok: false, error: String(error?.message || error) } }
}
const [plugins, triggers, builtin, custom, workflow, mcp, appTriggers] = await Promise.all([
safe('/workspaces/current/plugin/list?page=1&page_size=100'),
safe('/workspaces/current/triggers'),
safe('/workspaces/current/tools/builtin'),
safe('/workspaces/current/tools/api'),
safe('/workspaces/current/tools/workflow'),
safe('/workspaces/current/tools/mcp'),
appId ? safe(`/apps/${appId}/triggers`) : Promise.resolve({ ok: true, data: null }),
])
return { ok: true, app_id: appId || null, catalog: { app_triggers: appTriggers.data, installed_plugins: plugins.data, tools: { builtin: builtin.data, custom: custom.data, mcp: mcp.data, workflow: workflow.data }, triggers: triggers.data }, request_status: { appTriggers, builtin, custom, mcp, plugins, triggers, workflow } }
})
addTool('dify_get_plugin_readme', 'Get plugin README', 'Fetches installed plugin README content.', { readOnlyHint: true, untrustedContentHint: true }, async input => {
const identifier = input?.plugin_unique_identifier || input?.unique_identifier
if (!identifier)
throw new Error('plugin_unique_identifier is required.')
const params = new URLSearchParams({ plugin_unique_identifier: identifier })
if (input?.language)
params.set('language', input.language)
const result = await consoleFetch(`/workspaces/current/plugin/readme?${params.toString()}`)
return { ok: true, plugin_unique_identifier: identifier, readme: result.readme || '' }
})
addTool('dify_install_marketplace_plugins', 'Install Marketplace plugins', 'Installs one or more Marketplace plugin packages.', undefined, async input => {
const identifiers = input?.plugin_unique_identifiers || input?.unique_identifiers || (input?.plugin_unique_identifier ? [input.plugin_unique_identifier] : [])
if (!identifiers.length)
throw new Error('plugin_unique_identifiers is required.')
const result = await consoleFetch('/workspaces/current/plugin/install/marketplace', { method: 'POST', body: JSON.stringify({ plugin_unique_identifiers: identifiers }) })
return { ok: result.all_installed === true || !!result.task_id, plugin_unique_identifiers: identifiers, ...result }
})
addTool('dify_get_plugin_install_tasks', 'Get plugin install tasks', 'Lists plugin install/upgrade tasks.', { readOnlyHint: true }, async () => ({ ok: true, ...await consoleFetch('/workspaces/current/plugin/tasks?page=1&page_size=100') }))
addTool('dify_get_trigger_provider_detail', 'Get trigger provider detail', 'Fetches trigger provider info and subscriptions.', { readOnlyHint: true }, async input => {
const provider = input?.provider || input?.provider_id || input?.provider_name
if (!provider)
throw new Error('provider is required.')
const safe = async (path) => {
try { return { ok: true, data: await consoleFetch(path) } }
catch (error) { return { ok: false, error: String(error?.message || error) } }
}
const encoded = encodeURIComponent(provider)
const [info, subscriptions] = await Promise.all([safe(`/workspaces/current/trigger-provider/${encoded}/info`), safe(`/workspaces/current/trigger-provider/${encoded}/subscriptions/list`)])
return { ok: info.ok || subscriptions.ok, provider, info: info.data || null, subscriptions: subscriptions.data || [], raw: { info, subscriptions } }
})
addTool('dify_create_trigger_subscription_builder', 'Create trigger subscription builder', 'Creates a trigger subscription builder.', undefined, async input => {
const provider = input?.provider || input?.provider_id || input?.provider_name
if (!provider)
throw new Error('provider is required.')
return { ok: true, provider, ...await consoleFetch(`/workspaces/current/trigger-provider/${encodeURIComponent(provider)}/subscriptions/builder/create`, { method: 'POST', body: JSON.stringify({ credential_type: input?.credential_type }) }) }
})
addTool('dify_update_trigger_subscription_builder', 'Update trigger subscription builder', 'Updates trigger subscription builder credentials, properties, or parameters.', undefined, async input => {
const provider = input?.provider || input?.provider_id || input?.provider_name
const id = input?.subscription_builder_id || input?.subscriptionBuilderId
if (!provider || !id)
throw new Error('provider and subscription_builder_id are required.')
const body = { credentials: input?.credentials, name: input?.name, parameters: input?.parameters, properties: input?.properties }
return { ok: true, provider, subscription_builder_id: id, ...await consoleFetch(`/workspaces/current/trigger-provider/${encodeURIComponent(provider)}/subscriptions/builder/update/${encodeURIComponent(id)}`, { method: 'POST', body: JSON.stringify(body) }) }
})
addTool('dify_verify_trigger_subscription_builder', 'Verify trigger subscription builder', 'Verifies trigger subscription builder credentials.', undefined, async input => {
const provider = input?.provider || input?.provider_id || input?.provider_name
const id = input?.subscription_builder_id || input?.subscriptionBuilderId
if (!provider || !id)
throw new Error('provider and subscription_builder_id are required.')
const result = await consoleFetch(`/workspaces/current/trigger-provider/${encodeURIComponent(provider)}/subscriptions/builder/verify-and-update/${encodeURIComponent(id)}`, { method: 'POST', body: JSON.stringify({ credentials: input?.credentials || {} }) })
return { ok: result.verified === true, provider, subscription_builder_id: id, ...result }
})
addTool('dify_build_trigger_subscription', 'Build trigger subscription', 'Builds a trigger subscription from a verified builder.', undefined, async input => {
const provider = input?.provider || input?.provider_id || input?.provider_name
const id = input?.subscription_builder_id || input?.subscriptionBuilderId
if (!provider || !id)
throw new Error('provider and subscription_builder_id are required.')
return { ok: true, provider, subscription_builder_id: id, ...await consoleFetch(`/workspaces/current/trigger-provider/${encodeURIComponent(provider)}/subscriptions/builder/build/${encodeURIComponent(id)}`, { method: 'POST', body: JSON.stringify({ name: input?.name, parameters: input?.parameters }) }) }
})
addTool('dify_get_trigger_subscription_builder_logs', 'Get trigger subscription builder logs', 'Fetches trigger subscription builder logs.', { readOnlyHint: true }, async input => {
const provider = input?.provider || input?.provider_id || input?.provider_name
const id = input?.subscription_builder_id || input?.subscriptionBuilderId
if (!provider || !id)
throw new Error('provider and subscription_builder_id are required.')
return { ok: true, provider, subscription_builder_id: id, ...await consoleFetch(`/workspaces/current/trigger-provider/${encodeURIComponent(provider)}/subscriptions/builder/logs/${encodeURIComponent(id)}`) }
})
addTool('dify_get_plugin_dynamic_options', 'Get plugin dynamic options', 'Fetches dynamic options for plugin parameters.', { readOnlyHint: true }, async input => {
const params = new URLSearchParams()
;['plugin_id', 'provider', 'action', 'parameter', 'provider_type'].forEach((key) => {
if (input?.[key])
params.set(key, input[key])
})
if (!params.get('plugin_id') || !params.get('provider') || !params.get('action') || !params.get('parameter'))
throw new Error('plugin_id, provider, action, and parameter are required.')
if (input?.extra) {
Object.entries(input.extra).forEach(([key, value]) => {
if (['string', 'number', 'boolean'].includes(typeof value))
params.set(key, String(value))
})
}
return { ok: true, ...await consoleFetch(`/workspaces/current/plugin/parameters/dynamic-options?${params.toString()}`) }
})
addTool('dify_get_app_triggers', 'Get app triggers', 'Lists trigger records for a published workflow app.', { readOnlyHint: true }, async input => {
const appId = requireAppId(input)
return { ok: true, app_id: appId, ...await consoleFetch(`/apps/${appId}/triggers`) }
})
addTool('dify_set_app_trigger_enabled', 'Enable or disable app trigger', 'Enables or disables a published app trigger record.', undefined, async input => {
const appId = requireAppId(input)
if (!input?.trigger_id)
throw new Error('trigger_id is required.')
return { ok: true, app_id: appId, ...await consoleFetch(`/apps/${appId}/trigger-enable`, { method: 'POST', body: JSON.stringify({ enable_trigger: input.enable_trigger !== false, trigger_id: input.trigger_id }) }) }
})
const normalizeTestingInput = (input) => {
if (typeof input !== 'string')
return input || {}
if (!input)
return {}
const value = JSON.parse(input)
if (!value || typeof value !== 'object' || Array.isArray(value))
return {}
return value
}
const api = {
version: VERSION,
callTool: async (name, input) => {
const tool = tools.get(name)
if (!tool)
throw new Error(`Dify agent tool "${name}" is not registered.`)
return tool.execute(input || {})
},
getPageContext: () => api.callTool('dify_get_page_context', {}),
listTools: () => Array.from(tools.values()).map(({ execute, ...tool }) => tool),
registerPageContext: (id, provider) => {
pageContexts.set(id, provider)
return () => {
if (pageContexts.get(id) === provider)
pageContexts.delete(id)
}
},
}
window.__DIFY_AGENT_CONTEXT__ = api
if (!navigator.modelContextTesting) {
try {
Object.defineProperty(navigator, 'modelContextTesting', {
configurable: true,
value: {
executeTool: (name, input) => api.callTool(name, normalizeTestingInput(input)),
listTools: () => api.listTools(),
},
})
}
catch (error) {
console.warn('[Dify Agent Context] WebMCP testing API registration failed:', error)
}
}
// The React runtime registers the full hydrated toolset with WebMCP. This
// early fallback only exposes a same-origin window API before hydration.
})()