feat(web): locate workflow nodes by node_id in the workflow editor (#38187)

Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
This commit is contained in:
Ponder 2026-07-24 10:16:41 +08:00 committed by GitHub
parent ba5b26be03
commit 14c50a9f09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 376 additions and 2 deletions

View File

@ -165,6 +165,7 @@ vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/context/event-emitter', () => ({
@ -445,6 +446,10 @@ vi.mock('../hooks/use-workflow-search', () => ({
useWorkflowSearch: workflowHookMocks.useWorkflowSearch,
}))
vi.mock('../hooks/use-locate-node', () => ({
useLocateNode: vi.fn(),
}))
vi.mock('../nodes/_base/components/variable/use-match-schema-type', () => ({
default: () => ({
schemaTypeDefinitions: undefined,

View File

@ -0,0 +1,176 @@
import type { CommonNodeType, Node } from '../../types'
import { renderHook } from '@testing-library/react'
import { useLocateNode } from '../use-locate-node'
const mockHandleNodeSelect = vi.hoisted(() => vi.fn())
const mockScrollToWorkflowNode = vi.hoisted(() => vi.fn())
const mockSearchParams = vi.hoisted(() => new URLSearchParams())
const mockToastSuccess = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.mock('@/next/navigation', () => ({
useSearchParams: () => mockSearchParams,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: Object.assign(vi.fn(), {
success: mockToastSuccess,
error: mockToastError,
warning: vi.fn(),
info: vi.fn(),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (
selector: (source: Record<string, string>) => string,
options?: Record<string, unknown>,
) => {
const key = selector(
new Proxy({}, { get: (_, property: string) => property }) as Record<string, string>,
)
if (options?.nodeId) return `${key}:${options.nodeId}`
if (options?.title) return `${key}:${options.title}`
return key
},
}),
}))
vi.mock('../use-nodes-interactions', () => ({
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}))
vi.mock('../../utils/node-navigation', () => ({
scrollToWorkflowNode: (nodeId: string) => mockScrollToWorkflowNode(nodeId),
}))
const createNode = (overrides: Partial<Node> = {}): Node => ({
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: 'llm',
title: 'Writer',
desc: 'Draft content',
} as CommonNodeType,
...overrides,
})
describe('useLocateNode', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockSearchParams.delete('node_id')
})
afterEach(() => {
vi.useRealTimers()
})
it('does nothing when node_id param is absent', () => {
const nodes = [createNode({ id: 'n1' })]
renderHook(() => useLocateNode(nodes))
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
expect(mockToastSuccess).not.toHaveBeenCalled()
expect(mockToastError).not.toHaveBeenCalled()
})
it('does nothing when nodes are empty', () => {
mockSearchParams.set('node_id', 'n1')
renderHook(() => useLocateNode([]))
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
expect(mockToastError).not.toHaveBeenCalled()
})
it('selects, scrolls to, and shows success toast when node is found', () => {
mockSearchParams.set('node_id', 'target-node')
const nodes = [createNode({ id: 'target-node', data: { title: 'My Node' } as CommonNodeType })]
renderHook(() => useLocateNode(nodes))
expect(mockHandleNodeSelect).toHaveBeenCalledWith('target-node')
expect(mockToastSuccess).toHaveBeenCalledWith('panel.locateNodeSuccess:My Node')
// Scroll happens after a 200ms delay
expect(mockScrollToWorkflowNode).not.toHaveBeenCalled()
vi.advanceTimersByTime(200)
expect(mockScrollToWorkflowNode).toHaveBeenCalledWith('target-node')
})
it('shows error toast after debounce when node is not found', () => {
mockSearchParams.set('node_id', 'missing-node')
const nodes = [createNode({ id: 'other-node' })]
renderHook(() => useLocateNode(nodes))
// Not immediately reported
expect(mockToastError).not.toHaveBeenCalled()
// After 500ms debounce, reports not found
vi.advanceTimersByTime(500)
expect(mockToastError).toHaveBeenCalledWith('panel.locateNodeNotFound:missing-node')
})
it('does not report not-found prematurely when nodes are still loading', () => {
mockSearchParams.set('node_id', 'target-node')
const initialNodes = [createNode({ id: 'other-node' })]
const { rerender } = renderHook(({ nodes }) => useLocateNode(nodes), {
initialProps: { nodes: initialNodes },
})
// Before debounce fires, more nodes arrive including the target
vi.advanceTimersByTime(300)
rerender({
nodes: [
createNode({ id: 'other-node' }),
createNode({ id: 'target-node', data: { title: 'Found!' } as CommonNodeType }),
],
})
// Should have located the node, not reported error
expect(mockToastError).not.toHaveBeenCalled()
expect(mockHandleNodeSelect).toHaveBeenCalledWith('target-node')
expect(mockToastSuccess).toHaveBeenCalledWith('panel.locateNodeSuccess:Found!')
})
it('locates only once even if nodes update afterwards', () => {
mockSearchParams.set('node_id', 'target-node')
const nodes1 = [createNode({ id: 'target-node' })]
const { rerender } = renderHook(({ nodes }) => useLocateNode(nodes), {
initialProps: { nodes: nodes1 },
})
expect(mockHandleNodeSelect).toHaveBeenCalledTimes(1)
// Simulate a nodes update after locate has already happened
rerender({
nodes: [createNode({ id: 'target-node' }), createNode({ id: 'extra' })],
})
expect(mockHandleNodeSelect).toHaveBeenCalledTimes(1)
expect(mockToastSuccess).toHaveBeenCalledTimes(1)
})
it('clears scroll timeout on unmount', () => {
mockSearchParams.set('node_id', 'target-node')
const nodes = [createNode({ id: 'target-node' })]
const { unmount } = renderHook(() => useLocateNode(nodes))
// Unmount before the 200ms scroll timer fires
unmount()
vi.advanceTimersByTime(500)
expect(mockScrollToWorkflowNode).not.toHaveBeenCalled()
})
})

View File

@ -94,13 +94,68 @@ describe('useWorkflowSearch', () => {
const toolResults = findWorkflowNodes('search')
expect(toolResults.map((item) => item.id)).toEqual(['tool-1'])
expect(toolResults[0]?.description).toBe('Search the web')
// description now includes nodeId suffix: "desc · nodeId"
expect(toolResults[0]?.description).toBe('Search the web · tool-1')
unmount()
expect(findWorkflowNodes('gpt')).toEqual([])
})
it('matches by node_id with highest priority scoring', async () => {
runtimeNodes.push(
createNode({
id: '1721234567890',
data: {
type: BlockEnum.LLM,
title: 'Writer',
desc: 'Draft content',
} as CommonNodeType,
}),
createNode({
id: 'other-node',
data: {
type: BlockEnum.LLM,
title: '1721234567890', // title also matches the searchTerm
desc: '',
} as CommonNodeType,
}),
)
const { unmount } = renderHook(() => useWorkflowSearch())
// Exact nodeId match (120pts) should rank higher than title exact match (100pts)
const results = findWorkflowNodes('1721234567890')
expect(results.map((item) => item.id)).toEqual(['1721234567890', 'other-node'])
unmount()
})
it('matches by partial node_id', async () => {
runtimeNodes.push(
createNode({
id: '1721234567890',
data: {
type: BlockEnum.LLM,
title: 'Writer',
desc: 'Draft content',
} as CommonNodeType,
}),
)
const { unmount } = renderHook(() => useWorkflowSearch())
// Prefix match
const prefixResults = findWorkflowNodes('172123')
expect(prefixResults.map((item) => item.id)).toEqual(['1721234567890'])
// Partial match
const partialResults = findWorkflowNodes('456')
expect(partialResults.map((item) => item.id)).toEqual(['1721234567890'])
unmount()
})
it('binds the node selection listener to handleNodeSelect', () => {
const { unmount } = renderHook(() => useWorkflowSearch())

View File

@ -0,0 +1,80 @@
'use client'
import type { Node } from '../types'
import { toast } from '@langgenius/dify-ui/toast'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useSearchParams } from '@/next/navigation'
import { scrollToWorkflowNode } from '../utils/node-navigation'
import { useNodesInteractions } from './use-nodes-interactions'
/**
* Hook to locate a node by ID from URL query parameter `node_id`.
*
* Usage scenario: operators find a failing node ID from server logs,
* construct a URL like `/workflow?node_id=xxx`, and the editor will
* automatically select and scroll to that node on load.
*
* The hook reads `node_id` from the URL search params, waits for nodes
* to be available, then selects and scrolls to the target node.
* A toast message is shown to indicate success or failure.
*/
export const useLocateNode = (nodes: Node[]) => {
const { t } = useTranslation('workflow')
const searchParams = useSearchParams()
const nodeIdFromUrl = searchParams.get('node_id')
const { handleNodeSelect } = useNodesInteractions()
const hasLocateRef = useRef(false)
useEffect(() => {
if (!nodeIdFromUrl || hasLocateRef.current) return
// Wait for nodes to be loaded
if (!nodes.length) return
const targetNode = nodes.find((n) => n.id === nodeIdFromUrl)
if (!targetNode) {
// Don't mark as located yet — nodes may still be loading asynchronously.
// Retry on the next nodes update before reporting "not found".
return
}
// Select the node (opens its panel) and scroll to it
handleNodeSelect(nodeIdFromUrl)
// Delay scroll to ensure node selection state has been applied
const scrollTimer = setTimeout(() => {
scrollToWorkflowNode(nodeIdFromUrl)
}, 200)
toast.success(
t(($) => $['panel.locateNodeSuccess'], {
title: targetNode.data?.title || nodeIdFromUrl,
}),
)
hasLocateRef.current = true
return () => clearTimeout(scrollTimer)
}, [nodeIdFromUrl, nodes, handleNodeSelect, t])
// Report "not found" after nodes have settled (no longer changing)
useEffect(() => {
if (!nodeIdFromUrl || hasLocateRef.current || !nodes.length) return
const targetNode = nodes.find((n) => n.id === nodeIdFromUrl)
if (targetNode) return
// Debounce: wait to see if nodes continue loading before reporting not found
const notFoundTimer = setTimeout(() => {
if (hasLocateRef.current) return
const stillMissing = !nodes.find((n) => n.id === nodeIdFromUrl)
if (stillMissing) {
toast.error(t(($) => $['panel.locateNodeNotFound'], { nodeId: nodeIdFromUrl }))
hasLocateRef.current = true
}
}, 500)
return () => clearTimeout(notFoundTimer)
}, [nodeIdFromUrl, nodes, t])
}

View File

@ -80,6 +80,7 @@ export const useWorkflowSearch = () => {
return {
id: node.id,
nodeId: node.id,
title: nodeData?.title || nodeData?.type || 'Untitled',
type: nodeData?.type || '',
desc: nodeData?.desc || '',
@ -95,6 +96,7 @@ export const useWorkflowSearch = () => {
const calculateScore = useCallback(
(
node: {
nodeId: string
title: string
type: string
desc: string
@ -107,12 +109,18 @@ export const useWorkflowSearch = () => {
const titleMatch = node.title.toLowerCase()
const typeMatch = node.type.toLowerCase()
const descMatch = node.desc?.toLowerCase() || ''
const nodeIdMatch = node.nodeId?.toLowerCase() || ''
const modelProviderMatch = node.modelInfo?.provider?.toLowerCase() || ''
const modelNameMatch = node.modelInfo?.name?.toLowerCase() || ''
const modelModeMatch = node.modelInfo?.mode?.toLowerCase() || ''
let score = 0
// Node ID matching (exact > partial — useful for locating nodes from server logs)
if (nodeIdMatch === searchTerm) score += 120
else if (nodeIdMatch.startsWith(searchTerm)) score += 90
else if (nodeIdMatch.includes(searchTerm)) score += 40
// Title matching (exact prefix > partial match)
if (titleMatch.startsWith(searchTerm)) score += 100
else if (titleMatch.includes(searchTerm)) score += 50
@ -149,7 +157,7 @@ export const useWorkflowSearch = () => {
? {
id: node.id,
title: node.title,
description: node.desc || node.type,
description: [node.desc || node.type, node.nodeId].filter(Boolean).join(' · '),
type: 'workflow-node' as const,
path: `#${node.id}`,
icon: (

View File

@ -70,6 +70,7 @@ import {
useWorkflowRefreshDraft,
} from './hooks'
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
import { useLocateNode } from './hooks/use-locate-node'
import { useWorkflowComment } from './hooks/use-workflow-comment'
import { useWorkflowSearch } from './hooks/use-workflow-search'
import { shouldPreventWorkflowBrowserDefault } from './hotkeys'
@ -565,6 +566,9 @@ export const Workflow: FC<WorkflowProps> = memo(
// Initialize workflow node search functionality
useWorkflowSearch()
// Locate a node by ID from URL query parameter `node_id`
useLocateNode(nodes)
// Set up scroll to node event listener using the utility function
useEffect(() => {
return setupScrollToNodeListener(nodes, reactflow)

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "الذهاب إلى",
"panel.goToFix": "الذهاب إلى الإصلاح",
"panel.helpLink": "عرض المستندات",
"panel.locateNodeNotFound": "العقدة غير موجودة: {{nodeId}}",
"panel.locateNodeSuccess": "تم تحديد موقع العقدة: {{title}}",
"panel.nextStep": "الخطوة التالية",
"panel.openWorkflow": "فتح سير العمل",
"panel.optional": "(اختياري)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Gehe zu",
"panel.goToFix": "Zur Behebung",
"panel.helpLink": "Hilfe",
"panel.locateNodeNotFound": "Knoten nicht gefunden: {{nodeId}}",
"panel.locateNodeSuccess": "Knoten gefunden: {{title}}",
"panel.nextStep": "Nächster Schritt",
"panel.openWorkflow": "Workflow öffnen",
"panel.optional": "(optional)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Go to",
"panel.goToFix": "Go to fix",
"panel.helpLink": "View Docs",
"panel.locateNodeNotFound": "Node not found: {{nodeId}}",
"panel.locateNodeSuccess": "Located node: {{title}}",
"panel.nextStep": "Next Step",
"panel.openWorkflow": "Open Workflow",
"panel.optional": "(optional)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Ir a",
"panel.goToFix": "Ir a corregir",
"panel.helpLink": "Ayuda",
"panel.locateNodeNotFound": "Nodo no encontrado: {{nodeId}}",
"panel.locateNodeSuccess": "Nodo localizado: {{title}}",
"panel.nextStep": "Siguiente paso",
"panel.openWorkflow": "Abrir flujo de trabajo",
"panel.optional": "(opcional)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "برو به",
"panel.goToFix": "برو به رفع",
"panel.helpLink": "راهنما",
"panel.locateNodeNotFound": "گره پیدا نشد: {{nodeId}}",
"panel.locateNodeSuccess": "گره پیدا شد: {{title}}",
"panel.nextStep": "مرحله بعدی",
"panel.openWorkflow": "باز کردن گردش کار",
"panel.optional": "(اختیاری)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Aller à",
"panel.goToFix": "Aller corriger",
"panel.helpLink": "Aide",
"panel.locateNodeNotFound": "Nœud introuvable : {{nodeId}}",
"panel.locateNodeSuccess": "Nœud localisé : {{title}}",
"panel.nextStep": "Étape suivante",
"panel.openWorkflow": "Ouvrir le flux de travail",
"panel.optional": "(facultatif)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "जाओ",
"panel.goToFix": "ठीक करने जाएँ",
"panel.helpLink": "सहायता",
"panel.locateNodeNotFound": "नोड नहीं मिला: {{nodeId}}",
"panel.locateNodeSuccess": "नोड मिला: {{title}}",
"panel.nextStep": "अगला कदम",
"panel.openWorkflow": "वर्कफ़्लो खोलें",
"panel.optional": "(वैकल्पिक)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Pergi ke",
"panel.goToFix": "Pergi ke perbaikan",
"panel.helpLink": "Docs",
"panel.locateNodeNotFound": "Node tidak ditemukan: {{nodeId}}",
"panel.locateNodeSuccess": "Node ditemukan: {{title}}",
"panel.nextStep": "Langkah Berikutnya",
"panel.openWorkflow": "Buka Alur Kerja",
"panel.optional": "(opsional)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Vai a",
"panel.goToFix": "Vai a correggere",
"panel.helpLink": "Aiuto",
"panel.locateNodeNotFound": "Nodo non trovato: {{nodeId}}",
"panel.locateNodeSuccess": "Nodo localizzato: {{title}}",
"panel.nextStep": "Prossimo Passo",
"panel.openWorkflow": "Apri flusso di lavoro",
"panel.optional": "(opzionale)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "移動",
"panel.goToFix": "修正する",
"panel.helpLink": "ドキュメントを見る",
"panel.locateNodeNotFound": "ノードが見つかりません:{{nodeId}}",
"panel.locateNodeSuccess": "ノードを特定しました:{{title}}",
"panel.nextStep": "次のステップ",
"panel.openWorkflow": "ワークフローを開く",
"panel.optional": "(任意)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "로 이동",
"panel.goToFix": "수정하러 가기",
"panel.helpLink": "도움말 센터",
"panel.locateNodeNotFound": "노드를 찾을 수 없습니다: {{nodeId}}",
"panel.locateNodeSuccess": "노드를 찾았습니다: {{title}}",
"panel.nextStep": "다음 단계",
"panel.openWorkflow": "워크플로 열기",
"panel.optional": "(선택사항)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Go to",
"panel.goToFix": "Ga naar repareren",
"panel.helpLink": "View Docs",
"panel.locateNodeNotFound": "Node niet gevonden: {{nodeId}}",
"panel.locateNodeSuccess": "Node gevonden: {{title}}",
"panel.nextStep": "Next Step",
"panel.openWorkflow": "Open Workflow",
"panel.optional": "(optional)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Idź do",
"panel.goToFix": "Przejdź do naprawy",
"panel.helpLink": "Pomoc",
"panel.locateNodeNotFound": "Nie znaleziono węzła: {{nodeId}}",
"panel.locateNodeSuccess": "Zlokalizowano węzeł: {{title}}",
"panel.nextStep": "Następny krok",
"panel.openWorkflow": "Otwórz przepływ pracy",
"panel.optional": "(opcjonalne)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Ir para",
"panel.goToFix": "Ir para correção",
"panel.helpLink": "Ajuda",
"panel.locateNodeNotFound": "Nó não encontrado: {{nodeId}}",
"panel.locateNodeSuccess": "Nó localizado: {{title}}",
"panel.nextStep": "Próximo passo",
"panel.openWorkflow": "Abrir fluxo de trabalho",
"panel.optional": "(opcional)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Du-te la",
"panel.goToFix": "Mergi la remediere",
"panel.helpLink": "Ajutor",
"panel.locateNodeNotFound": "Nod negăsit: {{nodeId}}",
"panel.locateNodeSuccess": "Nod localizat: {{title}}",
"panel.nextStep": "Pasul următor",
"panel.openWorkflow": "Deschide fluxul de lucru",
"panel.optional": "(opțional)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Перейти к",
"panel.goToFix": "Перейти к исправлению",
"panel.helpLink": "Помощь",
"panel.locateNodeNotFound": "Узел не найден: {{nodeId}}",
"panel.locateNodeSuccess": "Узел найден: {{title}}",
"panel.nextStep": "Следующий шаг",
"panel.openWorkflow": "Открыть рабочий процесс",
"panel.optional": "(необязательно)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Pojdi na",
"panel.goToFix": "Pojdi na popravi",
"panel.helpLink": "Pomoč",
"panel.locateNodeNotFound": "Vozlišče ni najdeno: {{nodeId}}",
"panel.locateNodeSuccess": "Vozlišče najdeno: {{title}}",
"panel.nextStep": "Naslednji korak",
"panel.openWorkflow": "Odpri delovni tok",
"panel.optional": "(neobvezno)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "ไปที่",
"panel.goToFix": "ไปแก้ไข",
"panel.helpLink": "วิธีใช้",
"panel.locateNodeNotFound": "ไม่พบโหนด: {{nodeId}}",
"panel.locateNodeSuccess": "พบโหนดแล้ว: {{title}}",
"panel.nextStep": "ขั้นตอนถัดไป",
"panel.openWorkflow": "เปิดเวิร์กโฟลว์",
"panel.optional": "(ไม่บังคับ)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Git",
"panel.goToFix": "Düzeltmeye git",
"panel.helpLink": "Yardım",
"panel.locateNodeNotFound": "Düğüm bulunamadı: {{nodeId}}",
"panel.locateNodeSuccess": "Düğüm bulundu: {{title}}",
"panel.nextStep": "Sonraki Adım",
"panel.openWorkflow": "İş Akışını Aç",
"panel.optional": "(isteğe bağlı)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Перейти до",
"panel.goToFix": "Перейти до виправлення",
"panel.helpLink": "Довідковий центр",
"panel.locateNodeNotFound": "Вузол не знайдено: {{nodeId}}",
"panel.locateNodeSuccess": "Вузол знайдено: {{title}}",
"panel.nextStep": "Наступний крок",
"panel.openWorkflow": "Відкрити робочий процес",
"panel.optional": "(необов'язково)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "Đi tới",
"panel.goToFix": "Đi đến sửa lỗi",
"panel.helpLink": "Trung tâm trợ giúp",
"panel.locateNodeNotFound": "Không tìm thấy nút: {{nodeId}}",
"panel.locateNodeSuccess": "Đã định vị nút: {{title}}",
"panel.nextStep": "Bước tiếp theo",
"panel.openWorkflow": "Mở quy trình làm việc",
"panel.optional": "(tùy chọn)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "转到",
"panel.goToFix": "前往修复",
"panel.helpLink": "查看帮助文档",
"panel.locateNodeNotFound": "未找到节点:{{nodeId}}",
"panel.locateNodeSuccess": "已定位到节点:{{title}}",
"panel.nextStep": "下一步",
"panel.openWorkflow": "打开工作流",
"panel.optional": "(选填)",

View File

@ -1199,6 +1199,8 @@
"panel.goTo": "前往",
"panel.goToFix": "前往修復",
"panel.helpLink": "查看幫助文件",
"panel.locateNodeNotFound": "未找到節點:{{nodeId}}",
"panel.locateNodeSuccess": "已定位到節點:{{title}}",
"panel.nextStep": "下一步",
"panel.openWorkflow": "打開工作流程",
"panel.optional": "(選擇性)",