mirror of
https://github.com/langgenius/dify.git
synced 2026-05-20 00:37:15 +08:00
Compare commits
1 Commits
1.14.2
...
work/web-m
| Author | SHA1 | Date | |
|---|---|---|---|
| 7071614775 |
@ -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')
|
||||
})
|
||||
})
|
||||
126
web/app/components/agent-context/__tests__/dom.spec.ts
Normal file
126
web/app/components/agent-context/__tests__/dom.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
128
web/app/components/agent-context/__tests__/runtime.spec.ts
Normal file
128
web/app/components/agent-context/__tests__/runtime.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
835
web/app/components/agent-context/__tests__/tools.spec.ts
Normal file
835
web/app/components/agent-context/__tests__/tools.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
196
web/app/components/agent-context/__tests__/workflow.spec.ts
Normal file
196
web/app/components/agent-context/__tests__/workflow.spec.ts
Normal 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),
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
315
web/app/components/agent-context/capabilities.ts
Normal file
315
web/app/components/agent-context/capabilities.ts
Normal 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)
|
||||
}
|
||||
521
web/app/components/agent-context/dom.ts
Normal file
521
web/app/components/agent-context/dom.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
25
web/app/components/agent-context/provider.tsx
Normal file
25
web/app/components/agent-context/provider.tsx
Normal 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
|
||||
}
|
||||
161
web/app/components/agent-context/runtime.ts
Normal file
161
web/app/components/agent-context/runtime.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
2840
web/app/components/agent-context/tools.ts
Normal file
2840
web/app/components/agent-context/tools.ts
Normal file
File diff suppressed because it is too large
Load Diff
63
web/app/components/agent-context/types.ts
Normal file
63
web/app/components/agent-context/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
199
web/app/components/agent-context/workflow-recipes.ts
Normal file
199
web/app/components/agent-context/workflow-recipes.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
487
web/app/components/agent-context/workflow.ts
Normal file
487
web/app/components/agent-context/workflow.ts
Normal 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.',
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
819
web/public/dify-agent-context.js
Normal file
819
web/public/dify-agent-context.js
Normal 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.
|
||||
})()
|
||||
Reference in New Issue
Block a user