From 277926f5a32feafb8796772cbf7f042d7ca450a2 Mon Sep 17 00:00:00 2001 From: crazywoola <427733928@qq.com> Date: Wed, 24 Dec 2025 13:49:07 +0800 Subject: [PATCH 01/29] feat: v1 --- api/controllers/console/app/generator.py | 45 ++ api/core/llm_generator/llm_generator.py | 56 ++ api/core/llm_generator/prompts.py | 34 + .../goto-anything/actions/commands/slash.tsx | 3 + .../goto-anything/actions/commands/vibe.tsx | 59 ++ web/app/components/workflow/constants.ts | 1 + web/app/components/workflow/hooks/index.ts | 1 + .../workflow/hooks/use-workflow-vibe.tsx | 672 ++++++++++++++++++ web/app/components/workflow/index.tsx | 2 + web/i18n/en-US/app.ts | 3 + web/i18n/en-US/workflow.ts | 10 + web/service/debug.ts | 11 + 12 files changed, 897 insertions(+) create mode 100644 web/app/components/goto-anything/actions/commands/vibe.tsx create mode 100644 web/app/components/workflow/hooks/use-workflow-vibe.tsx diff --git a/api/controllers/console/app/generator.py b/api/controllers/console/app/generator.py index b4fc44767a..1a1cde7329 100644 --- a/api/controllers/console/app/generator.py +++ b/api/controllers/console/app/generator.py @@ -55,6 +55,14 @@ class InstructionTemplatePayload(BaseModel): type: str = Field(..., description="Instruction template type") +class FlowchartGeneratePayload(BaseModel): + instruction: str = Field(..., description="Workflow flowchart generation instruction") + model_config_data: dict[str, Any] = Field(..., alias="model_config", description="Model configuration") + available_nodes: list[dict[str, Any]] = Field(default_factory=list, description="Available node types") + existing_nodes: list[dict[str, Any]] = Field(default_factory=list, description="Existing workflow nodes") + available_tools: list[dict[str, Any]] = Field(default_factory=list, description="Available tools") + + def reg(cls: type[BaseModel]): console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) @@ -64,6 +72,7 @@ reg(RuleCodeGeneratePayload) reg(RuleStructuredOutputPayload) reg(InstructionGeneratePayload) reg(InstructionTemplatePayload) +reg(FlowchartGeneratePayload) @console_ns.route("/rule-generate") @@ -255,6 +264,42 @@ class InstructionGenerateApi(Resource): raise CompletionRequestError(e.description) +@console_ns.route("/flowchart-generate") +class FlowchartGenerateApi(Resource): + @console_ns.doc("generate_workflow_flowchart") + @console_ns.doc(description="Generate workflow flowchart using LLM") + @console_ns.expect(console_ns.models[FlowchartGeneratePayload.__name__]) + @console_ns.response(200, "Flowchart generated successfully") + @console_ns.response(400, "Invalid request parameters") + @console_ns.response(402, "Provider quota exceeded") + @setup_required + @login_required + @account_initialization_required + def post(self): + args = FlowchartGeneratePayload.model_validate(console_ns.payload) + _, current_tenant_id = current_account_with_tenant() + + try: + result = LLMGenerator.generate_workflow_flowchart( + tenant_id=current_tenant_id, + instruction=args.instruction, + model_config=args.model_config_data, + available_nodes=args.available_nodes, + existing_nodes=args.existing_nodes, + available_tools=args.available_tools, + ) + except ProviderTokenNotInitError as ex: + raise ProviderNotInitializeError(ex.description) + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except InvokeError as e: + raise CompletionRequestError(e.description) + + return result + + @console_ns.route("/instruction-generate/template") class InstructionGenerationTemplateApi(Resource): @console_ns.doc("get_instruction_template") diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index b4c3ec1caf..4cc60a4878 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -18,6 +18,7 @@ from core.llm_generator.prompts import ( SUGGESTED_QUESTIONS_MAX_TOKENS, SUGGESTED_QUESTIONS_TEMPERATURE, SYSTEM_STRUCTURED_OUTPUT_GENERATE, + WORKFLOW_FLOWCHART_PROMPT_TEMPLATE, WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE, ) from core.model_manager import ModelManager @@ -285,6 +286,61 @@ class LLMGenerator: return rule_config + @classmethod + def generate_workflow_flowchart( + cls, + tenant_id: str, + instruction: str, + model_config: dict, + available_nodes: Sequence[dict[str, object]] | None = None, + existing_nodes: Sequence[dict[str, object]] | None = None, + available_tools: Sequence[dict[str, object]] | None = None, + ): + model_parameters = model_config.get("completion_params", {}) + prompt_template = PromptTemplateParser(WORKFLOW_FLOWCHART_PROMPT_TEMPLATE) + prompt_generate = prompt_template.format( + inputs={ + "TASK_DESCRIPTION": instruction, + "AVAILABLE_NODES": json.dumps(available_nodes or [], ensure_ascii=False), + "EXISTING_NODES": json.dumps(existing_nodes or [], ensure_ascii=False), + "AVAILABLE_TOOLS": json.dumps(available_tools or [], ensure_ascii=False), + }, + remove_template_variables=False, + ) + + prompt_messages = [UserPromptMessage(content=prompt_generate)] + + model_manager = ModelManager() + model_instance = model_manager.get_model_instance( + tenant_id=tenant_id, + model_type=ModelType.LLM, + provider=model_config.get("provider", ""), + model=model_config.get("name", ""), + ) + + flowchart = "" + error = "" + + try: + response: LLMResult = model_instance.invoke_llm( + prompt_messages=list(prompt_messages), + model_parameters=model_parameters, + stream=False, + ) + content = response.message.get_text_content() + if not isinstance(content, str): + raise ValueError("Flowchart response is not a string") + + match = re.search(r"```(?:mermaid)?\s*([\s\S]+?)```", content, flags=re.IGNORECASE) + flowchart = (match.group(1) if match else content).strip() + except InvokeError as e: + error = str(e) + except Exception as e: + logger.exception("Failed to generate workflow flowchart, model: %s", model_config.get("name")) + error = str(e) + + return {"flowchart": flowchart, "error": error} + @classmethod def generate_code(cls, tenant_id: str, instruction: str, model_config: dict, code_language: str = "javascript"): if code_language == "python": diff --git a/api/core/llm_generator/prompts.py b/api/core/llm_generator/prompts.py index ec2b7f2d44..e617f401eb 100644 --- a/api/core/llm_generator/prompts.py +++ b/api/core/llm_generator/prompts.py @@ -143,6 +143,40 @@ Based on task description, please create a well-structured prompt template that Please generate the full prompt template with at least 300 words and output only the prompt template. """ # noqa: E501 +WORKFLOW_FLOWCHART_PROMPT_TEMPLATE = """ +You are an expert workflow designer. Generate a Mermaid flowchart based on the user's request. + +Constraints: +- Use only node types listed in . +- Use only tools listed in . When using a tool node, set type=tool and tool=/. +- Prefer reusing node titles from when possible. +- Output must be valid Mermaid flowchart syntax, no markdown, no extra text. +- First line must be: flowchart LR +- Every node must be declared on its own line using: + ["type=|title=|tool=<provider_id>/<tool_name>"] + - type is required and must match a type in <available_nodes>. + - title is required for non-tool nodes. + - tool is required only when type=tool, otherwise omit tool. +- Edges must use: + <id> --> <id> + <id> -->|true| <id> + <id> -->|false| <id> +- Keep node ids unique and simple (N1, N2, ...). + +<user_request> +{{TASK_DESCRIPTION}} +</user_request> +<available_nodes> +{{AVAILABLE_NODES}} +</available_nodes> +<existing_nodes> +{{EXISTING_NODES}} +</existing_nodes> +<available_tools> +{{AVAILABLE_TOOLS}} +</available_tools> +""" # noqa: E501 + RULE_CONFIG_PROMPT_GENERATE_TEMPLATE = """ Here is a task description for which I would like you to create a high-quality prompt template for: <task_description> diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index 4c43b5b61e..d6b808e1e3 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -12,6 +12,7 @@ import { forumCommand } from './forum' import { languageCommand } from './language' import { slashCommandRegistry } from './registry' import { themeCommand } from './theme' +import { vibeCommand } from './vibe' import { zenCommand } from './zen' export const slashAction: ActionItem = { @@ -41,6 +42,7 @@ export const registerSlashCommands = (deps: Record<string, any>) => { slashCommandRegistry.register(communityCommand, {}) slashCommandRegistry.register(accountCommand, {}) slashCommandRegistry.register(zenCommand, {}) + slashCommandRegistry.register(vibeCommand, {}) } export const unregisterSlashCommands = () => { @@ -52,6 +54,7 @@ export const unregisterSlashCommands = () => { slashCommandRegistry.unregister('community') slashCommandRegistry.unregister('account') slashCommandRegistry.unregister('zen') + slashCommandRegistry.unregister('vibe') } export const SlashCommandProvider = () => { diff --git a/web/app/components/goto-anything/actions/commands/vibe.tsx b/web/app/components/goto-anything/actions/commands/vibe.tsx new file mode 100644 index 0000000000..80e350d488 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/vibe.tsx @@ -0,0 +1,59 @@ +import type { SlashCommandHandler } from './types' +import { RiSparklingFill } from '@remixicon/react' +import * as React from 'react' +import { isInWorkflowPage, VIBE_COMMAND_EVENT } from '@/app/components/workflow/constants' +import i18n from '@/i18n-config/i18next-config' +import { registerCommands, unregisterCommands } from './command-bus' + +type VibeDeps = Record<string, never> + +const VIBE_PROMPT_EXAMPLE = 'Summarize a document, classify sentiment, then notify Slack' + +const dispatchVibeCommand = (input?: string) => { + if (typeof document === 'undefined') + return + + document.dispatchEvent(new CustomEvent(VIBE_COMMAND_EVENT, { detail: { dsl: input } })) +} + +export const vibeCommand: SlashCommandHandler<VibeDeps> = { + name: 'vibe', + description: i18n.t('app.gotoAnything.actions.vibeDesc'), + mode: 'submenu', + isAvailable: () => isInWorkflowPage(), + + async search(args: string, locale: string = 'en') { + const trimmed = args.trim() + const hasInput = !!trimmed + + return [{ + id: 'vibe', + title: i18n.t('app.gotoAnything.actions.vibeTitle', { lng: locale }) || 'Vibe', + description: hasInput + ? i18n.t('app.gotoAnything.actions.vibeDesc', { lng: locale }) + : i18n.t('app.gotoAnything.actions.vibeHint', { lng: locale, prompt: VIBE_PROMPT_EXAMPLE }), + type: 'command' as const, + icon: ( + <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> + <RiSparklingFill className="h-4 w-4 text-text-tertiary" /> + </div> + ), + data: { + command: 'workflow.vibe', + args: { dsl: trimmed }, + }, + }] + }, + + register(_deps: VibeDeps) { + registerCommands({ + 'workflow.vibe': async (args) => { + dispatchVibeCommand(args?.dsl) + }, + }) + }, + + unregister() { + unregisterCommands(['workflow.vibe']) + }, +} diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 4d95db7fcf..8fc179a4f2 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -9,6 +9,7 @@ export const NODE_WIDTH = 240 export const X_OFFSET = 60 export const NODE_WIDTH_X_OFFSET = NODE_WIDTH + X_OFFSET export const Y_OFFSET = 39 +export const VIBE_COMMAND_EVENT = 'workflow-vibe-command' export const START_INITIAL_POSITION = { x: 80, y: 282 } export const AUTO_LAYOUT_OFFSET = { x: -42, diff --git a/web/app/components/workflow/hooks/index.ts b/web/app/components/workflow/hooks/index.ts index 4b879738e7..df54065dea 100644 --- a/web/app/components/workflow/hooks/index.ts +++ b/web/app/components/workflow/hooks/index.ts @@ -24,3 +24,4 @@ export * from './use-workflow-run' export * from './use-workflow-search' export * from './use-workflow-start-run' export * from './use-workflow-variables' +export * from './use-workflow-vibe' diff --git a/web/app/components/workflow/hooks/use-workflow-vibe.tsx b/web/app/components/workflow/hooks/use-workflow-vibe.tsx new file mode 100644 index 0000000000..959e60c681 --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-vibe.tsx @@ -0,0 +1,672 @@ +'use client' + +import type { ToolDefaultValue } from '../block-selector/types' +import type { Edge, Node, ToolWithProvider } from '../types' +import type { Tool } from '@/app/components/tools/types' +import type { Model } from '@/types/app' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useStoreApi } from 'reactflow' +import { v4 as uuid4 } from 'uuid' +import Toast from '@/app/components/base/toast' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { useGetLanguage } from '@/context/i18n' +import { generateFlowchart } from '@/service/debug' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' +import { ModelModeType } from '@/types/app' +import { basePath } from '@/utils/var' +import { + CUSTOM_EDGE, + NODE_WIDTH, + NODE_WIDTH_X_OFFSET, + VIBE_COMMAND_EVENT, +} from '../constants' +import { BlockEnum } from '../types' +import { + generateNewNode, + getLayoutByDagre, + getNodeCustomTypeByNodeDataType, + getNodesConnectedSourceOrTargetHandleIdsMap, +} from '../utils' +import { useNodesMetaData } from './use-nodes-meta-data' +import { useNodesSyncDraft } from './use-nodes-sync-draft' +import { useNodesReadOnly } from './use-workflow' +import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history' + +type VibeCommandDetail = { + dsl?: string +} + +type ParsedNodeDraft = { + id: string + type?: BlockEnum + title?: string + toolKey?: string +} + +type ParsedNode = { + id: string + type: BlockEnum + title?: string + toolKey?: string +} + +type ParsedEdge = { + sourceId: string + targetId: string + label?: string +} + +type ParseError = { + error: 'invalidMermaid' | 'missingNodeType' | 'unknownNodeType' | 'unknownTool' | 'missingNodeDefinition' + detail?: string +} + +type ParseResult = { + nodes: ParsedNode[] + edges: ParsedEdge[] +} + +const NODE_DECLARATION = /^([A-Z][\w-]*)\s*\[(?:"([^"]+)"|([^\]]+))\]\s*$/i +const EDGE_DECLARATION = /^(.+?)\s*-->\s*(?:\|([^|]+)\|\s*)?(.+)$/ + +const extractMermaidCode = (raw: string) => { + const fencedMatch = raw.match(/```(?:mermaid)?\s*([\s\S]*?)```/i) + return (fencedMatch ? fencedMatch[1] : raw).trim() +} + +const isMermaidFlowchart = (value: string) => { + const trimmed = value.trim().toLowerCase() + return trimmed.startsWith('flowchart') || trimmed.startsWith('graph') +} + +const normalizeKey = (value: string) => value.trim().toLowerCase().replace(/[^\p{L}\p{N}]/gu, '') + +const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => { + if (!icon) + return icon + if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`)) + return `${basePath}${icon}` + return icon +} + +const parseNodeLabel = (label: string) => { + const tokens = label.split('|').map(token => token.trim()).filter(Boolean) + const info: Record<string, string> = {} + + tokens.forEach((token) => { + const [rawKey, ...rest] = token.split('=') + if (!rawKey || rest.length === 0) + return + info[rawKey.trim().toLowerCase()] = rest.join('=').trim() + }) + + if (!info.type && tokens.length === 1 && !tokens[0].includes('=')) { + info.type = tokens[0] + } + + return info +} + +const parseNodeToken = (token: string) => { + const trimmed = token.trim() + const match = trimmed.match(NODE_DECLARATION) + if (match) + return { id: match[1], label: match[2] || match[3] } + const idMatch = trimmed.match(/^([A-Z][\w-]*)$/i) + if (idMatch) + return { id: idMatch[1] } + return null +} + +const parseMermaidFlowchart = ( + raw: string, + nodeTypeLookup: Map<string, BlockEnum>, + toolLookup: Map<string, ToolDefaultValue>, +): ParseResult | ParseError => { + const code = extractMermaidCode(raw) + const lines = code.split(/\r?\n/).map((line) => { + const commentIndex = line.indexOf('%%') + return (commentIndex >= 0 ? line.slice(0, commentIndex) : line).trim() + }).filter(Boolean) + + const nodesMap = new Map<string, ParsedNodeDraft>() + const edges: ParsedEdge[] = [] + + const registerNode = (id: string, label?: string): ParseError | null => { + const existing = nodesMap.get(id) + if (!label) { + if (!existing) + nodesMap.set(id, { id }) + return null + } + + const info = parseNodeLabel(label) + if (!info.type) + return { error: 'missingNodeType', detail: label } + + const typeKey = normalizeKey(info.type) + const nodeType = nodeTypeLookup.get(typeKey) + if (!nodeType) + return { error: 'unknownNodeType', detail: info.type } + + const nodeData: ParsedNodeDraft = { + id, + type: nodeType, + title: info.title, + } + + if (nodeType === BlockEnum.Tool) { + if (!info.tool) + return { error: 'unknownTool', detail: 'tool' } + const toolKey = normalizeKey(info.tool) + if (!toolLookup.has(toolKey)) + return { error: 'unknownTool', detail: info.tool } + nodeData.toolKey = toolKey + } + + nodesMap.set(id, { ...(existing || {}), ...nodeData }) + return null + } + + for (const line of lines) { + if (line.toLowerCase().startsWith('flowchart') || line.toLowerCase().startsWith('graph')) + continue + + if (line.includes('-->')) { + const edgeMatch = line.match(EDGE_DECLARATION) + if (!edgeMatch) + return { error: 'invalidMermaid', detail: line } + + const sourceToken = parseNodeToken(edgeMatch[1]) + const targetToken = parseNodeToken(edgeMatch[3]) + if (!sourceToken || !targetToken) + return { error: 'invalidMermaid', detail: line } + + const sourceError = registerNode(sourceToken.id, sourceToken.label) + if (sourceError) + return sourceError + const targetError = registerNode(targetToken.id, targetToken.label) + if (targetError) + return targetError + + edges.push({ + sourceId: sourceToken.id, + targetId: targetToken.id, + label: edgeMatch[2]?.trim() || undefined, + }) + continue + } + + const nodeMatch = line.match(NODE_DECLARATION) + if (nodeMatch) { + const error = registerNode(nodeMatch[1], nodeMatch[2] || nodeMatch[3]) + if (error) + return error + } + } + + const parsedNodes: ParsedNode[] = [] + for (const node of nodesMap.values()) { + if (!node.type) + return { error: 'missingNodeDefinition', detail: node.id } + parsedNodes.push(node as ParsedNode) + } + + if (!parsedNodes.length) + return { error: 'invalidMermaid', detail: '' } + + return { nodes: parsedNodes, edges } +} + +const dedupeHandles = (handles?: string[]) => { + if (!handles) + return handles + return Array.from(new Set(handles)) +} + +const normalizeBranchLabel = (label?: string) => { + if (!label) + return '' + const normalized = label.trim().toLowerCase() + if (['true', 'yes', 'y', '1'].includes(normalized)) + return 'true' + if (['false', 'no', 'n', '0'].includes(normalized)) + return 'false' + return '' +} + +const buildToolParams = (parameters?: Tool['parameters']) => { + const params: Record<string, string> = {} + if (!parameters) + return params + parameters.forEach((item) => { + params[item.name] = '' + }) + return params +} + +export const useWorkflowVibe = () => { + const { t } = useTranslation() + const store = useStoreApi() + const language = useGetLanguage() + const { nodesMap: nodesMetaDataMap } = useNodesMetaData() + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const { getNodesReadOnly } = useNodesReadOnly() + const { saveStateToHistory } = useWorkflowHistory() + const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) + + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() + + const [modelConfig, setModelConfig] = useState<Model | null>(null) + const isGeneratingRef = useRef(false) + + useEffect(() => { + const storedModel = (() => { + if (typeof window === 'undefined') + return null + const stored = localStorage.getItem('auto-gen-model') + if (!stored) + return null + try { + return JSON.parse(stored) as Model + } + catch { + return null + } + })() + + if (storedModel) { + setModelConfig(storedModel) + return + } + + if (defaultModel) { + setModelConfig({ + name: defaultModel.model, + provider: defaultModel.provider.provider, + mode: ModelModeType.chat, + completion_params: {} as Model['completion_params'], + }) + } + }, [defaultModel]) + + const availableNodesList = useMemo(() => { + if (!nodesMetaDataMap) + return [] + return Object.values(nodesMetaDataMap).map(node => ({ + type: node.metaData.type, + title: node.metaData.title, + description: node.metaData.description, + })) + }, [nodesMetaDataMap]) + + const toolOptions = useMemo(() => { + const collections = [ + buildInTools, + customTools, + workflowTools, + mcpTools, + ].filter(Boolean) as ToolWithProvider[][] + + const tools: ToolDefaultValue[] = [] + const seen = new Set<string>() + + collections.forEach((collection) => { + collection.forEach((provider) => { + provider.tools.forEach((tool) => { + const key = `${provider.id}:${tool.name}` + if (seen.has(key)) + return + seen.add(key) + + const params = buildToolParams(tool.parameters) + const toolDescription = typeof tool.description === 'object' + ? tool.description?.[language] + : tool.description + tools.push({ + provider_id: provider.id, + provider_type: provider.type, + provider_name: provider.name, + plugin_id: provider.plugin_id, + plugin_unique_identifier: provider.plugin_unique_identifier, + provider_icon: normalizeProviderIcon(provider.icon), + provider_icon_dark: normalizeProviderIcon(provider.icon_dark), + tool_name: tool.name, + tool_label: tool.label[language] || tool.name, + tool_description: toolDescription || '', + is_team_authorization: provider.is_team_authorization, + paramSchemas: tool.parameters, + params, + output_schema: tool.output_schema, + meta: provider.meta, + }) + }) + }) + }) + + return tools + }, [buildInTools, customTools, workflowTools, mcpTools, language]) + + const toolLookup = useMemo(() => { + const map = new Map<string, ToolDefaultValue>() + toolOptions.forEach((tool) => { + const primaryKey = normalizeKey(`${tool.provider_id}/${tool.tool_name}`) + map.set(primaryKey, tool) + + const providerNameKey = normalizeKey(`${tool.provider_name}/${tool.tool_name}`) + map.set(providerNameKey, tool) + + const labelKey = normalizeKey(tool.tool_label) + map.set(labelKey, tool) + }) + return map + }, [toolOptions]) + + const nodeTypeLookup = useMemo(() => { + const map = new Map<string, BlockEnum>() + if (!nodesMetaDataMap) + return map + Object.values(nodesMetaDataMap).forEach((node) => { + map.set(normalizeKey(node.metaData.type), node.metaData.type) + if (node.metaData.title) + map.set(normalizeKey(node.metaData.title), node.metaData.type) + }) + map.set('ifelse', BlockEnum.IfElse) + map.set('ifelsecase', BlockEnum.IfElse) + return map + }, [nodesMetaDataMap]) + + const handleVibeCommand = useCallback(async (dsl?: string) => { + if (getNodesReadOnly()) { + Toast.notify({ type: 'error', message: t('workflow.vibe.readOnly') }) + return + } + + const trimmed = dsl?.trim() || '' + if (!trimmed) { + Toast.notify({ type: 'error', message: t('workflow.vibe.missingInstruction') }) + return + } + + if (!nodesMetaDataMap || Object.keys(nodesMetaDataMap).length === 0) { + Toast.notify({ type: 'error', message: t('workflow.vibe.nodesUnavailable') }) + return + } + + if (!modelConfig && !isMermaidFlowchart(trimmed)) { + Toast.notify({ type: 'error', message: t('workflow.vibe.modelUnavailable') }) + return + } + + if (isGeneratingRef.current) + return + isGeneratingRef.current = true + + try { + const { getNodes, setNodes, edges, setEdges } = store.getState() + const nodes = getNodes() + + const existingNodesPayload = nodes.map(node => ({ + id: node.id, + type: node.data.type, + title: node.data.title || '', + })) + + const toolsPayload = toolOptions.map(tool => ({ + provider_id: tool.provider_id, + provider_name: tool.provider_name, + tool_name: tool.tool_name, + tool_label: tool.tool_label, + tool_key: `${tool.provider_id}/${tool.tool_name}`, + })) + + const availableNodesPayload = availableNodesList.map(node => ({ + type: node.type, + title: node.title, + description: node.description, + })) + + let mermaidCode = trimmed + if (!isMermaidFlowchart(trimmed)) { + const { error, flowchart } = await generateFlowchart({ + instruction: trimmed, + model_config: modelConfig, + available_nodes: availableNodesPayload, + existing_nodes: existingNodesPayload, + available_tools: toolsPayload, + }) + + if (error) { + Toast.notify({ type: 'error', message: error }) + return + } + + if (!flowchart) { + Toast.notify({ type: 'error', message: t('workflow.vibe.missingFlowchart') }) + return + } + + mermaidCode = flowchart + } + + const parseResult = parseMermaidFlowchart(mermaidCode, nodeTypeLookup, toolLookup) + if ('error' in parseResult) { + switch (parseResult.error) { + case 'missingNodeType': + case 'missingNodeDefinition': + Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) + return + case 'unknownNodeType': + Toast.notify({ type: 'error', message: t('workflow.vibe.nodeTypeUnavailable', { type: parseResult.detail }) }) + return + case 'unknownTool': + Toast.notify({ type: 'error', message: t('workflow.vibe.toolUnavailable', { tool: parseResult.detail }) }) + return + default: + Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) + return + } + } + + const existingStartNode = nodes.find(node => node.data.type === BlockEnum.Start) + const newNodes: Node[] = [] + const nodeIdMap = new Map<string, Node>() + + parseResult.nodes.forEach((nodeSpec) => { + if (nodeSpec.type === BlockEnum.Start && existingStartNode) { + nodeIdMap.set(nodeSpec.id, existingStartNode) + return + } + + const nodeDefault = nodesMetaDataMap[nodeSpec.type] + if (!nodeDefault) + return + + const defaultValue = nodeDefault.defaultValue || {} + const title = nodeSpec.title?.trim() || nodeDefault.metaData.title || defaultValue.title || nodeSpec.type + + const toolDefaultValue = nodeSpec.toolKey ? toolLookup.get(nodeSpec.toolKey) : undefined + const desc = (toolDefaultValue?.tool_description || (defaultValue as { desc?: string }).desc || '') as string + + const data = { + ...(defaultValue as Record<string, unknown>), + title, + desc, + type: nodeSpec.type, + selected: false, + ...(toolDefaultValue || {}), + } + + const newNode = generateNewNode({ + id: uuid4(), + type: getNodeCustomTypeByNodeDataType(nodeSpec.type), + data, + position: { x: 0, y: 0 }, + }).newNode + + newNodes.push(newNode) + nodeIdMap.set(nodeSpec.id, newNode) + }) + + if (!newNodes.length) { + Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) + return + } + + const buildEdge = ( + source: Node, + target: Node, + sourceHandle = 'source', + targetHandle = 'target', + ): Edge => ({ + id: `${source.id}-${sourceHandle}-${target.id}-${targetHandle}`, + type: CUSTOM_EDGE, + source: source.id, + sourceHandle, + target: target.id, + targetHandle, + data: { + sourceType: source.data.type, + targetType: target.data.type, + isInIteration: false, + isInLoop: false, + _connectedNodeIsSelected: false, + }, + zIndex: 0, + }) + + const newEdges: Edge[] = [] + parseResult.edges.forEach((edgeSpec) => { + const sourceNode = nodeIdMap.get(edgeSpec.sourceId) + const targetNode = nodeIdMap.get(edgeSpec.targetId) + if (!sourceNode || !targetNode) + return + + let sourceHandle = 'source' + if (sourceNode.data.type === BlockEnum.IfElse) { + const branchLabel = normalizeBranchLabel(edgeSpec.label) + if (branchLabel === 'true') { + sourceHandle = (sourceNode.data as { cases?: { case_id: string }[] })?.cases?.[0]?.case_id || 'true' + } + if (branchLabel === 'false') { + sourceHandle = 'false' + } + } + + newEdges.push(buildEdge(sourceNode, targetNode, sourceHandle)) + }) + + const bounds = nodes.reduce( + (acc, node) => { + const width = node.width ?? NODE_WIDTH + acc.maxX = Math.max(acc.maxX, node.position.x + width) + acc.minY = Math.min(acc.minY, node.position.y) + return acc + }, + { maxX: 0, minY: 0 }, + ) + + const baseX = nodes.length ? bounds.maxX + NODE_WIDTH_X_OFFSET : 0 + const baseY = Number.isFinite(bounds.minY) ? bounds.minY : 0 + const branchOffset = Math.max(120, NODE_WIDTH_X_OFFSET / 2) + + const layoutNodeIds = new Set(newNodes.map(node => node.id)) + const layoutEdges = newEdges.filter(edge => + layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target), + ) + + try { + const layout = await getLayoutByDagre(newNodes, layoutEdges) + const layoutedNodes = newNodes.map((node) => { + const info = layout.nodes.get(node.id) + if (!info) + return node + return { + ...node, + position: { + x: baseX + info.x, + y: baseY + info.y, + }, + } + }) + newNodes.splice(0, newNodes.length, ...layoutedNodes) + } + catch { + newNodes.forEach((node, index) => { + const row = Math.floor(index / 4) + const col = index % 4 + node.position = { + x: baseX + col * NODE_WIDTH_X_OFFSET, + y: baseY + row * branchOffset, + } + }) + } + + const allNodes = [...nodes, ...newNodes] + const nodesConnectedMap = getNodesConnectedSourceOrTargetHandleIdsMap( + newEdges.map(edge => ({ type: 'add', edge })), + allNodes, + ) + + const updatedNodes = allNodes.map((node) => { + const connected = nodesConnectedMap[node.id] + if (!connected) + return node + + return { + ...node, + data: { + ...node.data, + ...connected, + _connectedSourceHandleIds: dedupeHandles(connected._connectedSourceHandleIds), + _connectedTargetHandleIds: dedupeHandles(connected._connectedTargetHandleIds), + }, + } + }) + + setNodes(updatedNodes) + setEdges([...edges, ...newEdges]) + saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: newNodes[0].id }) + handleSyncWorkflowDraft() + } + finally { + isGeneratingRef.current = false + } + }, [ + availableNodesList, + getNodesReadOnly, + handleSyncWorkflowDraft, + modelConfig, + nodeTypeLookup, + nodesMetaDataMap, + saveStateToHistory, + store, + t, + toolLookup, + toolOptions, + ]) + + useEffect(() => { + const handler = (event: CustomEvent<VibeCommandDetail>) => { + handleVibeCommand(event.detail?.dsl) + } + + document.addEventListener(VIBE_COMMAND_EVENT, handler as EventListener) + + return () => { + document.removeEventListener(VIBE_COMMAND_EVENT, handler as EventListener) + } + }, [handleVibeCommand]) + + return null +} diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index b31c283550..1e1c2526eb 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -67,6 +67,7 @@ import { useWorkflow, useWorkflowReadOnly, useWorkflowRefreshDraft, + useWorkflowVibe, } from './hooks' import { HooksStoreContextProvider, useHooksStore } from './hooks-store' import { useWorkflowSearch } from './hooks/use-workflow-search' @@ -318,6 +319,7 @@ export const Workflow: FC<WorkflowProps> = memo(({ useShortcuts() // Initialize workflow node search functionality useWorkflowSearch() + useWorkflowVibe() // Set up scroll to node event listener using the utility function useEffect(() => { diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts index 45ebd61aec..7ea0aadafb 100644 --- a/web/i18n/en-US/app.ts +++ b/web/i18n/en-US/app.ts @@ -333,6 +333,9 @@ const translation = { feedbackDesc: 'Open community feedback discussions', zenTitle: 'Zen Mode', zenDesc: 'Toggle canvas focus mode', + vibeTitle: 'Vibe Workflow', + vibeDesc: 'Generate a workflow from natural language', + vibeHint: 'Describe the workflow, e.g. "{{prompt}}"', }, emptyState: { noAppsFound: 'No apps found', diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 2122c20aaa..9f85d4c558 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -123,6 +123,16 @@ const translation = { noHistory: 'No History', tagBound: 'Number of apps using this tag', }, + vibe: { + readOnly: 'This workflow is read-only.', + missingInstruction: 'Describe the workflow you want to build.', + modelUnavailable: 'No model available for flowchart generation.', + nodesUnavailable: 'Workflow nodes are not available yet.', + missingFlowchart: 'No flowchart was generated.', + invalidFlowchart: 'The generated flowchart could not be parsed.', + nodeTypeUnavailable: 'Node type "{{type}}" is not available in this workflow.', + toolUnavailable: 'Tool "{{tool}}" is not available in this workspace.', + }, publishLimit: { startNodeTitlePrefix: 'Upgrade to', startNodeTitleSuffix: 'unlock unlimited triggers per workflow', diff --git a/web/service/debug.ts b/web/service/debug.ts index 850f3dfc24..40aa8c2173 100644 --- a/web/service/debug.ts +++ b/web/service/debug.ts @@ -19,6 +19,11 @@ export type GenRes = { error?: string } +export type FlowchartGenRes = { + flowchart: string + error?: string +} + export type CodeGenRes = { code: string language: string[] @@ -93,6 +98,12 @@ export const generateRule = (body: Record<string, any>) => { }) } +export const generateFlowchart = (body: Record<string, any>) => { + return post<FlowchartGenRes>('/flowchart-generate', { + body, + }) +} + export const fetchModelParams = (providerName: string, modelId: string) => { return get(`workspaces/current/model-providers/${providerName}/models/parameter-rules`, { params: { From f245611cc7b87747c30e7c1a4863c20378ad06b5 Mon Sep 17 00:00:00 2001 From: crazywoola <427733928@qq.com> Date: Wed, 24 Dec 2025 13:49:22 +0800 Subject: [PATCH 02/29] feat: v1 --- api/core/llm_generator/prompts.py | 2 +- web/app/components/workflow/hooks/use-workflow-vibe.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api/core/llm_generator/prompts.py b/api/core/llm_generator/prompts.py index e617f401eb..bbfb7dfa6d 100644 --- a/api/core/llm_generator/prompts.py +++ b/api/core/llm_generator/prompts.py @@ -175,7 +175,7 @@ Constraints: <available_tools> {{AVAILABLE_TOOLS}} </available_tools> -""" # noqa: E501 +""" RULE_CONFIG_PROMPT_GENERATE_TEMPLATE = """ Here is a task description for which I would like you to create a high-quality prompt template for: diff --git a/web/app/components/workflow/hooks/use-workflow-vibe.tsx b/web/app/components/workflow/hooks/use-workflow-vibe.tsx index 959e60c681..9581379402 100644 --- a/web/app/components/workflow/hooks/use-workflow-vibe.tsx +++ b/web/app/components/workflow/hooks/use-workflow-vibe.tsx @@ -344,6 +344,7 @@ export const useWorkflowVibe = () => { tool_name: tool.name, tool_label: tool.label[language] || tool.name, tool_description: toolDescription || '', + title: tool.label[language] || tool.name, is_team_authorization: provider.is_team_authorization, paramSchemas: tool.parameters, params, From 1935a601c5982e2e96731ce8b9a2bae31e19e313 Mon Sep 17 00:00:00 2001 From: crazywoola <427733928@qq.com> Date: Wed, 24 Dec 2025 15:04:42 +0800 Subject: [PATCH 03/29] feat: use @banana --- .../components/goto-anything/actions/commands/slash.tsx | 3 --- web/app/components/goto-anything/actions/index.ts | 5 ++++- web/app/components/goto-anything/actions/types.ts | 2 +- web/app/components/goto-anything/command-selector.tsx | 1 + web/app/components/goto-anything/index.tsx | 8 ++++++++ web/i18n/en-US/app.ts | 2 +- 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index d6b808e1e3..4c43b5b61e 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -12,7 +12,6 @@ import { forumCommand } from './forum' import { languageCommand } from './language' import { slashCommandRegistry } from './registry' import { themeCommand } from './theme' -import { vibeCommand } from './vibe' import { zenCommand } from './zen' export const slashAction: ActionItem = { @@ -42,7 +41,6 @@ export const registerSlashCommands = (deps: Record<string, any>) => { slashCommandRegistry.register(communityCommand, {}) slashCommandRegistry.register(accountCommand, {}) slashCommandRegistry.register(zenCommand, {}) - slashCommandRegistry.register(vibeCommand, {}) } export const unregisterSlashCommands = () => { @@ -54,7 +52,6 @@ export const unregisterSlashCommands = () => { slashCommandRegistry.unregister('community') slashCommandRegistry.unregister('account') slashCommandRegistry.unregister('zen') - slashCommandRegistry.unregister('vibe') } export const SlashCommandProvider = () => { diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index a038ef1a6c..abf1c077f8 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -165,6 +165,7 @@ import type { ActionItem, SearchResult } from './types' import { appAction } from './app' +import { bananaAction } from './banana' import { slashAction } from './commands' import { slashCommandRegistry } from './commands/registry' import { knowledgeAction } from './knowledge' @@ -191,6 +192,7 @@ export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolea else if (isWorkflowPage) { return { ...baseActions, + banana: bananaAction, node: workflowNodesAction, } } @@ -205,6 +207,7 @@ export const Actions = { app: appAction, knowledge: knowledgeAction, plugin: pluginAction, + banana: bananaAction, node: workflowNodesAction, } @@ -296,4 +299,4 @@ export const matchAction = (query: string, actions: Record<string, ActionItem>) export * from './commands' export * from './types' -export { appAction, knowledgeAction, pluginAction, workflowNodesAction } +export { appAction, bananaAction, knowledgeAction, pluginAction, workflowNodesAction } diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 838195ad85..7c3f77f27f 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -44,7 +44,7 @@ export type CommandSearchResult = { export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult export type ActionItem = { - key: '@app' | '@knowledge' | '@plugin' | '@node' | '/' + key: '@app' | '@banana' | '@knowledge' | '@plugin' | '@node' | '/' shortcut: string title: string | TypeWithI18N description: string diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index f62a7a3829..2241e79e42 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -127,6 +127,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co '@plugin': 'app.gotoAnything.actions.searchPluginsDesc', '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc', '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc', + '@banana': 'app.gotoAnything.actions.vibeDesc', } return t(keyMap[item.key] as any) as string })() diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index 76c2e26ebd..a87340f3d2 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' +import { VIBE_COMMAND_EVENT } from '@/app/components/workflow/constants' import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app/components/workflow/utils/common' import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' import { useGetLanguage } from '@/context/i18n' @@ -158,6 +159,13 @@ const GotoAnything: FC<Props> = ({ switch (result.type) { case 'command': { + if (result.data.command === 'workflow.vibe') { + if (typeof document !== 'undefined') { + document.dispatchEvent(new CustomEvent(VIBE_COMMAND_EVENT, { detail: { dsl: result.data.args?.dsl } })) + } + break + } + // Execute slash commands const action = Actions.slash action?.action?.(result) diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts index 7ea0aadafb..4faf8cb0ad 100644 --- a/web/i18n/en-US/app.ts +++ b/web/i18n/en-US/app.ts @@ -333,7 +333,7 @@ const translation = { feedbackDesc: 'Open community feedback discussions', zenTitle: 'Zen Mode', zenDesc: 'Toggle canvas focus mode', - vibeTitle: 'Vibe Workflow', + vibeTitle: 'Banana Workflow', vibeDesc: 'Generate a workflow from natural language', vibeHint: 'Describe the workflow, e.g. "{{prompt}}"', }, From d5842f1fe6d7a080886b9f096f01abecb9e103da Mon Sep 17 00:00:00 2001 From: crazywoola <427733928@qq.com> Date: Wed, 24 Dec 2025 15:30:36 +0800 Subject: [PATCH 04/29] feat: add MCP tools --- api/core/llm_generator/prompts.py | 12 +++- .../goto-anything/actions/banana.tsx | 39 +++++++++++++ .../workflow/hooks/use-workflow-vibe.tsx | 58 ++++++++++++++----- web/i18n/en-US/workflow.ts | 2 + 4 files changed, 94 insertions(+), 17 deletions(-) create mode 100644 web/app/components/goto-anything/actions/banana.tsx diff --git a/api/core/llm_generator/prompts.py b/api/core/llm_generator/prompts.py index bbfb7dfa6d..766ae07231 100644 --- a/api/core/llm_generator/prompts.py +++ b/api/core/llm_generator/prompts.py @@ -148,20 +148,28 @@ You are an expert workflow designer. Generate a Mermaid flowchart based on the u Constraints: - Use only node types listed in <available_nodes>. -- Use only tools listed in <available_tools>. When using a tool node, set type=tool and tool=<provider_id>/<tool_name>. +- Use only tools listed in <available_tools>. When using a tool node, set type=tool and tool=<tool_key>. +- Tools may include MCP providers (provider_type=mcp). Tool selection still uses tool_key. - Prefer reusing node titles from <existing_nodes> when possible. - Output must be valid Mermaid flowchart syntax, no markdown, no extra text. - First line must be: flowchart LR - Every node must be declared on its own line using: - <id>["type=<type>|title=<title>|tool=<provider_id>/<tool_name>"] + <id>["type=<type>|title=<title>|tool=<tool_key>"] - type is required and must match a type in <available_nodes>. - title is required for non-tool nodes. - tool is required only when type=tool, otherwise omit tool. +- Declare all node lines before any edges. - Edges must use: <id> --> <id> <id> -->|true| <id> <id> -->|false| <id> - Keep node ids unique and simple (N1, N2, ...). +- For complex orchestration: + - Break the request into stages (ingest, transform, decision, action, output). + - Use IfElse for branching and label edges true/false only. + - Fan-in branches by connecting multiple nodes into a shared downstream node. + - Avoid cycles unless explicitly requested. + - Keep each branch complete with a clear downstream target. <user_request> {{TASK_DESCRIPTION}} diff --git a/web/app/components/goto-anything/actions/banana.tsx b/web/app/components/goto-anything/actions/banana.tsx new file mode 100644 index 0000000000..a4b4c21023 --- /dev/null +++ b/web/app/components/goto-anything/actions/banana.tsx @@ -0,0 +1,39 @@ +import type { ActionItem } from './types' +import { RiSparklingFill } from '@remixicon/react' +import * as React from 'react' +import { isInWorkflowPage } from '@/app/components/workflow/constants' +import i18n from '@/i18n-config/i18next-config' + +const BANANA_PROMPT_EXAMPLE = 'Summarize a document, classify sentiment, then notify Slack' + +export const bananaAction: ActionItem = { + key: '@banana', + shortcut: '@banana', + title: i18n.t('app.gotoAnything.actions.vibeTitle'), + description: i18n.t('app.gotoAnything.actions.vibeDesc'), + search: async (_query, searchTerm = '', locale) => { + if (!isInWorkflowPage()) + return [] + + const trimmed = searchTerm.trim() + const hasInput = !!trimmed + + return [{ + id: 'banana-vibe', + title: i18n.t('app.gotoAnything.actions.vibeTitle', { lng: locale }) || 'Banana', + description: hasInput + ? i18n.t('app.gotoAnything.actions.vibeDesc', { lng: locale }) + : i18n.t('app.gotoAnything.actions.vibeHint', { lng: locale, prompt: BANANA_PROMPT_EXAMPLE }), + type: 'command' as const, + icon: ( + <div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> + <RiSparklingFill className="h-4 w-4 text-text-tertiary" /> + </div> + ), + data: { + command: 'workflow.vibe', + args: { dsl: trimmed }, + }, + }] + }, +} diff --git a/web/app/components/workflow/hooks/use-workflow-vibe.tsx b/web/app/components/workflow/hooks/use-workflow-vibe.tsx index 9581379402..d1f4637389 100644 --- a/web/app/components/workflow/hooks/use-workflow-vibe.tsx +++ b/web/app/components/workflow/hooks/use-workflow-vibe.tsx @@ -64,7 +64,7 @@ type ParsedEdge = { } type ParseError = { - error: 'invalidMermaid' | 'missingNodeType' | 'unknownNodeType' | 'unknownTool' | 'missingNodeDefinition' + error: 'invalidMermaid' | 'missingNodeType' | 'unknownNodeType' | 'unknownTool' | 'missingNodeDefinition' | 'unknownNodeId' | 'unsupportedEdgeLabel' detail?: string } @@ -111,6 +111,9 @@ const parseNodeLabel = (label: string) => { info.type = tokens[0] } + if (!info.tool && info.tool_key) + info.tool = info.tool_key + return info } @@ -125,6 +128,17 @@ const parseNodeToken = (token: string) => { return null } +const normalizeBranchLabel = (label?: string) => { + if (!label) + return '' + const normalized = label.trim().toLowerCase() + if (['true', 'yes', 'y', '1'].includes(normalized)) + return 'true' + if (['false', 'no', 'n', '0'].includes(normalized)) + return 'false' + return '' +} + const parseMermaidFlowchart = ( raw: string, nodeTypeLookup: Map<string, BlockEnum>, @@ -137,6 +151,7 @@ const parseMermaidFlowchart = ( }).filter(Boolean) const nodesMap = new Map<string, ParsedNodeDraft>() + const declaredNodeIds = new Set<string>() const edges: ParsedEdge[] = [] const registerNode = (id: string, label?: string): ParseError | null => { @@ -172,6 +187,7 @@ const parseMermaidFlowchart = ( } nodesMap.set(id, { ...(existing || {}), ...nodeData }) + declaredNodeIds.add(id) return null } @@ -189,6 +205,11 @@ const parseMermaidFlowchart = ( if (!sourceToken || !targetToken) return { error: 'invalidMermaid', detail: line } + if (!sourceToken.label && !declaredNodeIds.has(sourceToken.id)) + return { error: 'unknownNodeId', detail: sourceToken.id } + if (!targetToken.label && !declaredNodeIds.has(targetToken.id)) + return { error: 'unknownNodeId', detail: targetToken.id } + const sourceError = registerNode(sourceToken.id, sourceToken.label) if (sourceError) return sourceError @@ -213,15 +234,26 @@ const parseMermaidFlowchart = ( } const parsedNodes: ParsedNode[] = [] + const nodeTypeById = new Map<string, BlockEnum>() for (const node of nodesMap.values()) { if (!node.type) return { error: 'missingNodeDefinition', detail: node.id } parsedNodes.push(node as ParsedNode) + nodeTypeById.set(node.id, node.type) } if (!parsedNodes.length) return { error: 'invalidMermaid', detail: '' } + for (const edge of edges) { + if (!edge.label) + continue + const sourceType = nodeTypeById.get(edge.sourceId) + const branchLabel = normalizeBranchLabel(edge.label) + if (sourceType !== BlockEnum.IfElse || !branchLabel) + return { error: 'unsupportedEdgeLabel', detail: edge.label } + } + return { nodes: parsedNodes, edges } } @@ -231,17 +263,6 @@ const dedupeHandles = (handles?: string[]) => { return Array.from(new Set(handles)) } -const normalizeBranchLabel = (label?: string) => { - if (!label) - return '' - const normalized = label.trim().toLowerCase() - if (['true', 'yes', 'y', '1'].includes(normalized)) - return 'true' - if (['false', 'no', 'n', '0'].includes(normalized)) - return 'false' - return '' -} - const buildToolParams = (parameters?: Tool['parameters']) => { const params: Record<string, string> = {} if (!parameters) @@ -426,6 +447,7 @@ export const useWorkflowVibe = () => { const toolsPayload = toolOptions.map(tool => ({ provider_id: tool.provider_id, provider_name: tool.provider_name, + provider_type: tool.provider_type, tool_name: tool.tool_name, tool_label: tool.tool_label, tool_key: `${tool.provider_id}/${tool.tool_name}`, @@ -467,12 +489,18 @@ export const useWorkflowVibe = () => { case 'missingNodeDefinition': Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) return + case 'unknownNodeId': + Toast.notify({ type: 'error', message: t('workflow.vibe.unknownNodeId', { id: parseResult.detail }) }) + return case 'unknownNodeType': Toast.notify({ type: 'error', message: t('workflow.vibe.nodeTypeUnavailable', { type: parseResult.detail }) }) return case 'unknownTool': Toast.notify({ type: 'error', message: t('workflow.vibe.toolUnavailable', { tool: parseResult.detail }) }) return + case 'unsupportedEdgeLabel': + Toast.notify({ type: 'error', message: t('workflow.vibe.unsupportedEdgeLabel', { label: parseResult.detail }) }) + return default: Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) return @@ -547,11 +575,11 @@ export const useWorkflowVibe = () => { }) const newEdges: Edge[] = [] - parseResult.edges.forEach((edgeSpec) => { + for (const edgeSpec of parseResult.edges) { const sourceNode = nodeIdMap.get(edgeSpec.sourceId) const targetNode = nodeIdMap.get(edgeSpec.targetId) if (!sourceNode || !targetNode) - return + continue let sourceHandle = 'source' if (sourceNode.data.type === BlockEnum.IfElse) { @@ -565,7 +593,7 @@ export const useWorkflowVibe = () => { } newEdges.push(buildEdge(sourceNode, targetNode, sourceHandle)) - }) + } const bounds = nodes.reduce( (acc, node) => { diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 9f85d4c558..022317d9b5 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -132,6 +132,8 @@ const translation = { invalidFlowchart: 'The generated flowchart could not be parsed.', nodeTypeUnavailable: 'Node type "{{type}}" is not available in this workflow.', toolUnavailable: 'Tool "{{tool}}" is not available in this workspace.', + unknownNodeId: 'Node "{{id}}" is used before it is defined.', + unsupportedEdgeLabel: 'Unsupported edge label "{{label}}". Only true/false are allowed for if/else.', }, publishLimit: { startNodeTitlePrefix: 'Upgrade to', From 574a03c1226d4071326186c626ae13ddb61b2148 Mon Sep 17 00:00:00 2001 From: crazywoola <427733928@qq.com> Date: Wed, 24 Dec 2025 16:29:07 +0800 Subject: [PATCH 05/29] feat: add MCP tools --- api/core/helper/ssrf_proxy.py | 9 +++++--- .../unit_tests/core/helper/test_ssrf_proxy.py | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/api/core/helper/ssrf_proxy.py b/api/core/helper/ssrf_proxy.py index 0b36969cf9..e390873bf4 100644 --- a/api/core/helper/ssrf_proxy.py +++ b/api/core/helper/ssrf_proxy.py @@ -106,6 +106,9 @@ def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs): verify_option = kwargs.pop("ssl_verify", dify_config.HTTP_REQUEST_NODE_SSL_VERIFY) client = _get_ssrf_client(verify_option) + # Extract follow_redirects for client.send() - it's not a build_request parameter + follow_redirects = kwargs.pop("follow_redirects", True) + # Preserve user-provided Host header # When using a forward proxy, httpx may override the Host header based on the URL. # We extract and preserve any explicitly set Host header to support virtual hosting. @@ -120,9 +123,9 @@ def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs): # the request API to explicitly set headers before sending headers = {k: v for k, v in headers.items() if k.lower() != "host"} if user_provided_host is not None: - headers["host"] = user_provided_host - kwargs["headers"] = headers - response = client.request(method=method, url=url, **kwargs) + request.headers["Host"] = user_provided_host + + response = client.send(request, follow_redirects=follow_redirects) # Check for SSRF protection by Squid proxy if response.status_code in (401, 403): diff --git a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py index beae1d0358..7dafdce249 100644 --- a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py +++ b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py @@ -107,3 +107,24 @@ def test_host_header_preservation_with_user_header(mock_get_client): response = make_request("GET", "http://example.com", headers={"Host": custom_host}) assert response.status_code == 200 + # Verify build_request was called + mock_client.build_request.assert_called_once() + # Verify the Host header was set on the request object + assert mock_request.headers.get("Host") == custom_host + mock_client.send.assert_called_once_with(mock_request, follow_redirects=True) + + +@patch("core.helper.ssrf_proxy._get_ssrf_client") +@pytest.mark.parametrize("host_key", ["host", "HOST"]) +def test_host_header_preservation_case_insensitive(mock_get_client, host_key): + """Test that Host header is preserved regardless of case.""" + mock_client = MagicMock() + mock_request = MagicMock() + mock_request.headers = {} + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client.send.return_value = mock_response + mock_client.build_request.return_value = mock_request + mock_get_client.return_value = mock_client + response = make_request("GET", "http://example.com", headers={host_key: "api.example.com"}) + assert mock_request.headers.get("Host") == "api.example.com" From dc7e99f4446794ff6416909263756999669b6ad9 Mon Sep 17 00:00:00 2001 From: WTW0313 <twwu@dify.ai> Date: Thu, 25 Dec 2025 22:19:57 +0800 Subject: [PATCH 06/29] feat: implement Vibe panel for workflow with regeneration and acceptance features --- web/app/components/workflow/constants.ts | 2 + .../workflow/hooks/use-workflow-vibe.tsx | 469 +++++++++++------- .../components/workflow/hooks/use-workflow.ts | 2 + web/app/components/workflow/panel/index.tsx | 53 +- .../workflow/panel/vibe-panel/index.tsx | 122 +++++ .../workflow/store/workflow/panel-slice.ts | 12 + web/i18n/en-US/workflow.ts | 6 + 7 files changed, 454 insertions(+), 212 deletions(-) create mode 100644 web/app/components/workflow/panel/vibe-panel/index.tsx diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 8fc179a4f2..0a6bd74bff 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -10,6 +10,8 @@ export const X_OFFSET = 60 export const NODE_WIDTH_X_OFFSET = NODE_WIDTH + X_OFFSET export const Y_OFFSET = 39 export const VIBE_COMMAND_EVENT = 'workflow-vibe-command' +export const VIBE_REGENERATE_EVENT = 'workflow-vibe-regenerate' +export const VIBE_ACCEPT_EVENT = 'workflow-vibe-accept' export const START_INITIAL_POSITION = { x: 80, y: 282 } export const AUTO_LAYOUT_OFFSET = { x: -42, diff --git a/web/app/components/workflow/hooks/use-workflow-vibe.tsx b/web/app/components/workflow/hooks/use-workflow-vibe.tsx index d1f4637389..705147cac1 100644 --- a/web/app/components/workflow/hooks/use-workflow-vibe.tsx +++ b/web/app/components/workflow/hooks/use-workflow-vibe.tsx @@ -25,8 +25,11 @@ import { CUSTOM_EDGE, NODE_WIDTH, NODE_WIDTH_X_OFFSET, + VIBE_ACCEPT_EVENT, VIBE_COMMAND_EVENT, + VIBE_REGENERATE_EVENT, } from '../constants' +import { useWorkflowStore } from '../store' import { BlockEnum } from '../types' import { generateNewNode, @@ -276,6 +279,7 @@ const buildToolParams = (parameters?: Tool['parameters']) => { export const useWorkflowVibe = () => { const { t } = useTranslation() const store = useStoreApi() + const workflowStore = useWorkflowStore() const language = useGetLanguage() const { nodesMap: nodesMetaDataMap } = useNodesMetaData() const { handleSyncWorkflowDraft } = useNodesSyncDraft() @@ -290,6 +294,7 @@ export const useWorkflowVibe = () => { const [modelConfig, setModelConfig] = useState<Model | null>(null) const isGeneratingRef = useRef(false) + const lastInstructionRef = useRef<string>('') useEffect(() => { const storedModel = (() => { @@ -408,7 +413,227 @@ export const useWorkflowVibe = () => { return map }, [nodesMetaDataMap]) - const handleVibeCommand = useCallback(async (dsl?: string) => { + const applyFlowchartToWorkflow = useCallback(async (mermaidCode: string) => { + const { getNodes, setNodes, edges, setEdges } = store.getState() + const nodes = getNodes() + const { + setShowVibePanel, + } = workflowStore.getState() + + const parseResultToUse = parseMermaidFlowchart(mermaidCode, nodeTypeLookup, toolLookup) + if ('error' in parseResultToUse) { + switch (parseResultToUse.error) { + case 'missingNodeType': + case 'missingNodeDefinition': + Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) + setShowVibePanel(false) + return + case 'unknownNodeId': + Toast.notify({ type: 'error', message: t('workflow.vibe.unknownNodeId', { id: parseResultToUse.detail }) }) + setShowVibePanel(false) + return + case 'unknownNodeType': + Toast.notify({ type: 'error', message: t('workflow.vibe.nodeTypeUnavailable', { type: parseResultToUse.detail }) }) + setShowVibePanel(false) + return + case 'unknownTool': + Toast.notify({ type: 'error', message: t('workflow.vibe.toolUnavailable', { tool: parseResultToUse.detail }) }) + setShowVibePanel(false) + return + case 'unsupportedEdgeLabel': + Toast.notify({ type: 'error', message: t('workflow.vibe.unsupportedEdgeLabel', { label: parseResultToUse.detail }) }) + setShowVibePanel(false) + return + default: + Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) + setShowVibePanel(false) + return + } + } + + if (!nodesMetaDataMap) { + Toast.notify({ type: 'error', message: t('workflow.vibe.nodesUnavailable') }) + setShowVibePanel(false) + return + } + + const existingStartNode = nodes.find(node => node.data.type === BlockEnum.Start) + const newNodes: Node[] = [] + const nodeIdMap = new Map<string, Node>() + + parseResultToUse.nodes.forEach((nodeSpec) => { + if (nodeSpec.type === BlockEnum.Start && existingStartNode) { + nodeIdMap.set(nodeSpec.id, existingStartNode) + return + } + + const nodeDefault = nodesMetaDataMap![nodeSpec.type] + if (!nodeDefault) + return + + const defaultValue = nodeDefault.defaultValue || {} + const title = nodeSpec.title?.trim() || nodeDefault.metaData.title || defaultValue.title || nodeSpec.type + + const toolDefaultValue = nodeSpec.toolKey ? toolLookup.get(nodeSpec.toolKey) : undefined + const desc = (toolDefaultValue?.tool_description || (defaultValue as { desc?: string }).desc || '') as string + + const data = { + ...(defaultValue as Record<string, unknown>), + title, + desc, + type: nodeSpec.type, + selected: false, + ...(toolDefaultValue || {}), + } + + const newNode = generateNewNode({ + id: uuid4(), + type: getNodeCustomTypeByNodeDataType(nodeSpec.type), + data, + position: { x: 0, y: 0 }, + }).newNode + + newNodes.push(newNode) + nodeIdMap.set(nodeSpec.id, newNode) + }) + + if (!newNodes.length) { + Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) + return + } + + const buildEdge = ( + source: Node, + target: Node, + sourceHandle = 'source', + targetHandle = 'target', + ): Edge => ({ + id: `${source.id}-${sourceHandle}-${target.id}-${targetHandle}`, + type: CUSTOM_EDGE, + source: source.id, + sourceHandle, + target: target.id, + targetHandle, + data: { + sourceType: source.data.type, + targetType: target.data.type, + isInIteration: false, + isInLoop: false, + _connectedNodeIsSelected: false, + }, + zIndex: 0, + }) + + const newEdges: Edge[] = [] + for (const edgeSpec of parseResultToUse.edges) { + const sourceNode = nodeIdMap.get(edgeSpec.sourceId) + const targetNode = nodeIdMap.get(edgeSpec.targetId) + if (!sourceNode || !targetNode) + continue + + let sourceHandle = 'source' + if (sourceNode.data.type === BlockEnum.IfElse) { + const branchLabel = normalizeBranchLabel(edgeSpec.label) + if (branchLabel === 'true') { + sourceHandle = (sourceNode.data as { cases?: { case_id: string }[] })?.cases?.[0]?.case_id || 'true' + } + if (branchLabel === 'false') { + sourceHandle = 'false' + } + } + + newEdges.push(buildEdge(sourceNode, targetNode, sourceHandle)) + } + + const bounds = nodes.reduce( + (acc, node) => { + const width = node.width ?? NODE_WIDTH + acc.maxX = Math.max(acc.maxX, node.position.x + width) + acc.minY = Math.min(acc.minY, node.position.y) + return acc + }, + { maxX: 0, minY: 0 }, + ) + + const baseX = nodes.length ? bounds.maxX + NODE_WIDTH_X_OFFSET : 0 + const baseY = Number.isFinite(bounds.minY) ? bounds.minY : 0 + const branchOffset = Math.max(120, NODE_WIDTH_X_OFFSET / 2) + + const layoutNodeIds = new Set(newNodes.map(node => node.id)) + const layoutEdges = newEdges.filter(edge => + layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target), + ) + + try { + const layout = await getLayoutByDagre(newNodes, layoutEdges) + const layoutedNodes = newNodes.map((node) => { + const info = layout.nodes.get(node.id) + if (!info) + return node + return { + ...node, + position: { + x: baseX + info.x, + y: baseY + info.y, + }, + } + }) + newNodes.splice(0, newNodes.length, ...layoutedNodes) + } + catch { + newNodes.forEach((node, index) => { + const row = Math.floor(index / 4) + const col = index % 4 + node.position = { + x: baseX + col * NODE_WIDTH_X_OFFSET, + y: baseY + row * branchOffset, + } + }) + } + + const allNodes = [...nodes, ...newNodes] + const nodesConnectedMap = getNodesConnectedSourceOrTargetHandleIdsMap( + newEdges.map(edge => ({ type: 'add', edge })), + allNodes, + ) + + const updatedNodes = allNodes.map((node) => { + const connected = nodesConnectedMap[node.id] + if (!connected) + return node + + return { + ...node, + data: { + ...node.data, + ...connected, + _connectedSourceHandleIds: dedupeHandles(connected._connectedSourceHandleIds), + _connectedTargetHandleIds: dedupeHandles(connected._connectedTargetHandleIds), + }, + } + }) + + setNodes(updatedNodes) + setEdges([...edges, ...newEdges]) + saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: newNodes[0].id }) + handleSyncWorkflowDraft() + + workflowStore.setState(state => ({ + ...state, + showVibePanel: false, + vibePanelMermaidCode: '', + })) + }, [ + handleSyncWorkflowDraft, + nodeTypeLookup, + nodesMetaDataMap, + saveStateToHistory, + store, + t, + toolLookup, + ]) + + const handleVibeCommand = useCallback(async (dsl?: string, skipPanelPreview = false) => { if (getNodesReadOnly()) { Toast.notify({ type: 'error', message: t('workflow.vibe.readOnly') }) return @@ -434,9 +659,22 @@ export const useWorkflowVibe = () => { return isGeneratingRef.current = true + if (!isMermaidFlowchart(trimmed)) + lastInstructionRef.current = trimmed + + workflowStore.setState(state => ({ + ...state, + showVibePanel: true, + isVibeGenerating: true, + vibePanelMermaidCode: '', + })) + try { - const { getNodes, setNodes, edges, setEdges } = store.getState() + const { getNodes } = store.getState() const nodes = getNodes() + const { + setIsVibeGenerating, + } = workflowStore.getState() const existingNodesPayload = nodes.map(node => ({ id: node.id, @@ -471,202 +709,27 @@ export const useWorkflowVibe = () => { if (error) { Toast.notify({ type: 'error', message: error }) + setIsVibeGenerating(false) return } if (!flowchart) { Toast.notify({ type: 'error', message: t('workflow.vibe.missingFlowchart') }) + setIsVibeGenerating(false) return } mermaidCode = flowchart } - const parseResult = parseMermaidFlowchart(mermaidCode, nodeTypeLookup, toolLookup) - if ('error' in parseResult) { - switch (parseResult.error) { - case 'missingNodeType': - case 'missingNodeDefinition': - Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) - return - case 'unknownNodeId': - Toast.notify({ type: 'error', message: t('workflow.vibe.unknownNodeId', { id: parseResult.detail }) }) - return - case 'unknownNodeType': - Toast.notify({ type: 'error', message: t('workflow.vibe.nodeTypeUnavailable', { type: parseResult.detail }) }) - return - case 'unknownTool': - Toast.notify({ type: 'error', message: t('workflow.vibe.toolUnavailable', { tool: parseResult.detail }) }) - return - case 'unsupportedEdgeLabel': - Toast.notify({ type: 'error', message: t('workflow.vibe.unsupportedEdgeLabel', { label: parseResult.detail }) }) - return - default: - Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) - return - } - } + workflowStore.setState(state => ({ + ...state, + vibePanelMermaidCode: mermaidCode, + isVibeGenerating: false, + })) - const existingStartNode = nodes.find(node => node.data.type === BlockEnum.Start) - const newNodes: Node[] = [] - const nodeIdMap = new Map<string, Node>() - - parseResult.nodes.forEach((nodeSpec) => { - if (nodeSpec.type === BlockEnum.Start && existingStartNode) { - nodeIdMap.set(nodeSpec.id, existingStartNode) - return - } - - const nodeDefault = nodesMetaDataMap[nodeSpec.type] - if (!nodeDefault) - return - - const defaultValue = nodeDefault.defaultValue || {} - const title = nodeSpec.title?.trim() || nodeDefault.metaData.title || defaultValue.title || nodeSpec.type - - const toolDefaultValue = nodeSpec.toolKey ? toolLookup.get(nodeSpec.toolKey) : undefined - const desc = (toolDefaultValue?.tool_description || (defaultValue as { desc?: string }).desc || '') as string - - const data = { - ...(defaultValue as Record<string, unknown>), - title, - desc, - type: nodeSpec.type, - selected: false, - ...(toolDefaultValue || {}), - } - - const newNode = generateNewNode({ - id: uuid4(), - type: getNodeCustomTypeByNodeDataType(nodeSpec.type), - data, - position: { x: 0, y: 0 }, - }).newNode - - newNodes.push(newNode) - nodeIdMap.set(nodeSpec.id, newNode) - }) - - if (!newNodes.length) { - Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) - return - } - - const buildEdge = ( - source: Node, - target: Node, - sourceHandle = 'source', - targetHandle = 'target', - ): Edge => ({ - id: `${source.id}-${sourceHandle}-${target.id}-${targetHandle}`, - type: CUSTOM_EDGE, - source: source.id, - sourceHandle, - target: target.id, - targetHandle, - data: { - sourceType: source.data.type, - targetType: target.data.type, - isInIteration: false, - isInLoop: false, - _connectedNodeIsSelected: false, - }, - zIndex: 0, - }) - - const newEdges: Edge[] = [] - for (const edgeSpec of parseResult.edges) { - const sourceNode = nodeIdMap.get(edgeSpec.sourceId) - const targetNode = nodeIdMap.get(edgeSpec.targetId) - if (!sourceNode || !targetNode) - continue - - let sourceHandle = 'source' - if (sourceNode.data.type === BlockEnum.IfElse) { - const branchLabel = normalizeBranchLabel(edgeSpec.label) - if (branchLabel === 'true') { - sourceHandle = (sourceNode.data as { cases?: { case_id: string }[] })?.cases?.[0]?.case_id || 'true' - } - if (branchLabel === 'false') { - sourceHandle = 'false' - } - } - - newEdges.push(buildEdge(sourceNode, targetNode, sourceHandle)) - } - - const bounds = nodes.reduce( - (acc, node) => { - const width = node.width ?? NODE_WIDTH - acc.maxX = Math.max(acc.maxX, node.position.x + width) - acc.minY = Math.min(acc.minY, node.position.y) - return acc - }, - { maxX: 0, minY: 0 }, - ) - - const baseX = nodes.length ? bounds.maxX + NODE_WIDTH_X_OFFSET : 0 - const baseY = Number.isFinite(bounds.minY) ? bounds.minY : 0 - const branchOffset = Math.max(120, NODE_WIDTH_X_OFFSET / 2) - - const layoutNodeIds = new Set(newNodes.map(node => node.id)) - const layoutEdges = newEdges.filter(edge => - layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target), - ) - - try { - const layout = await getLayoutByDagre(newNodes, layoutEdges) - const layoutedNodes = newNodes.map((node) => { - const info = layout.nodes.get(node.id) - if (!info) - return node - return { - ...node, - position: { - x: baseX + info.x, - y: baseY + info.y, - }, - } - }) - newNodes.splice(0, newNodes.length, ...layoutedNodes) - } - catch { - newNodes.forEach((node, index) => { - const row = Math.floor(index / 4) - const col = index % 4 - node.position = { - x: baseX + col * NODE_WIDTH_X_OFFSET, - y: baseY + row * branchOffset, - } - }) - } - - const allNodes = [...nodes, ...newNodes] - const nodesConnectedMap = getNodesConnectedSourceOrTargetHandleIdsMap( - newEdges.map(edge => ({ type: 'add', edge })), - allNodes, - ) - - const updatedNodes = allNodes.map((node) => { - const connected = nodesConnectedMap[node.id] - if (!connected) - return node - - return { - ...node, - data: { - ...node.data, - ...connected, - _connectedSourceHandleIds: dedupeHandles(connected._connectedSourceHandleIds), - _connectedTargetHandleIds: dedupeHandles(connected._connectedTargetHandleIds), - }, - } - }) - - setNodes(updatedNodes) - setEdges([...edges, ...newEdges]) - saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: newNodes[0].id }) - handleSyncWorkflowDraft() + if (skipPanelPreview) + await applyFlowchartToWorkflow(mermaidCode) } finally { isGeneratingRef.current = false @@ -685,17 +748,47 @@ export const useWorkflowVibe = () => { toolOptions, ]) + const handleRegenerate = useCallback(async () => { + if (!lastInstructionRef.current) { + Toast.notify({ type: 'error', message: t('workflow.vibe.missingInstruction') }) + return + } + + await handleVibeCommand(lastInstructionRef.current, false) + }, [handleVibeCommand, t]) + + const handleAccept = useCallback(async (vibePanelMermaidCode: string | undefined) => { + if (!vibePanelMermaidCode) { + Toast.notify({ type: 'error', message: t('workflow.vibe.noFlowchart') }) + return + } + + await applyFlowchartToWorkflow(vibePanelMermaidCode) + }, [applyFlowchartToWorkflow, t]) + useEffect(() => { const handler = (event: CustomEvent<VibeCommandDetail>) => { - handleVibeCommand(event.detail?.dsl) + handleVibeCommand(event.detail?.dsl, false) + } + + const regenerateHandler = () => { + handleRegenerate() + } + + const acceptHandler = (event: CustomEvent<VibeCommandDetail>) => { + handleAccept(event.detail?.dsl) } document.addEventListener(VIBE_COMMAND_EVENT, handler as EventListener) + document.addEventListener(VIBE_REGENERATE_EVENT, regenerateHandler as EventListener) + document.addEventListener(VIBE_ACCEPT_EVENT, acceptHandler as EventListener) return () => { document.removeEventListener(VIBE_COMMAND_EVENT, handler as EventListener) + document.removeEventListener(VIBE_REGENERATE_EVENT, regenerateHandler as EventListener) + document.removeEventListener(VIBE_ACCEPT_EVENT, acceptHandler as EventListener) } - }, [handleVibeCommand]) + }, [handleVibeCommand, handleRegenerate]) return null } diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 990c8c950d..c3e1df5961 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -471,12 +471,14 @@ export const useNodesReadOnly = () => { const workflowRunningData = useStore(s => s.workflowRunningData) const historyWorkflowData = useStore(s => s.historyWorkflowData) const isRestoring = useStore(s => s.isRestoring) + // const showVibePanel = useStore(s => s.showVibePanel) const getNodesReadOnly = useCallback((): boolean => { const { workflowRunningData, historyWorkflowData, isRestoring, + // showVibePanel, } = workflowStore.getState() return !!(workflowRunningData?.result.status === WorkflowRunningStatus.Running || historyWorkflowData || isRestoring) diff --git a/web/app/components/workflow/panel/index.tsx b/web/app/components/workflow/panel/index.tsx index 88ada8b11e..8b7ebfda63 100644 --- a/web/app/components/workflow/panel/index.tsx +++ b/web/app/components/workflow/panel/index.tsx @@ -8,6 +8,7 @@ import { cn } from '@/utils/classnames' import { Panel as NodePanel } from '../nodes' import { useStore } from '../store' import EnvPanel from './env-panel' +import VibePanel from './vibe-panel' const VersionHistoryPanel = dynamic(() => import('@/app/components/workflow/panel/version-history-panel'), { ssr: false, @@ -85,6 +86,7 @@ const Panel: FC<PanelProps> = ({ const showEnvPanel = useStore(s => s.showEnvPanel) const isRestoring = useStore(s => s.isRestoring) const showWorkflowVersionHistoryPanel = useStore(s => s.showWorkflowVersionHistoryPanel) + const showVibePanel = useStore(s => s.showVibePanel) // widths used for adaptive layout const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) @@ -124,33 +126,36 @@ const Panel: FC<PanelProps> = ({ ) return ( - <div - ref={rightPanelRef} - tabIndex={-1} - className={cn('absolute bottom-1 right-0 top-14 z-10 flex outline-none')} - key={`${isRestoring}`} - > - {components?.left} - {!!selectedNode && <NodePanel {...selectedNode} />} + <> <div - className="relative" - ref={otherPanelRef} + ref={rightPanelRef} + tabIndex={-1} + className={cn('absolute bottom-1 right-0 top-14 z-10 flex outline-none')} + key={`${isRestoring}`} > - { - components?.right - } - { - showWorkflowVersionHistoryPanel && ( - <VersionHistoryPanel {...versionHistoryPanelProps} /> - ) - } - { - showEnvPanel && ( - <EnvPanel /> - ) - } + {components?.left} + {!!selectedNode && <NodePanel {...selectedNode} />} + <div + className="relative" + ref={otherPanelRef} + > + { + components?.right + } + { + showWorkflowVersionHistoryPanel && ( + <VersionHistoryPanel {...versionHistoryPanelProps} /> + ) + } + { + showEnvPanel && ( + <EnvPanel /> + ) + } + </div> </div> - </div> + {showVibePanel && <VibePanel />} + </> ) } diff --git a/web/app/components/workflow/panel/vibe-panel/index.tsx b/web/app/components/workflow/panel/vibe-panel/index.tsx new file mode 100644 index 0000000000..af5b947bdc --- /dev/null +++ b/web/app/components/workflow/panel/vibe-panel/index.tsx @@ -0,0 +1,122 @@ +'use client' + +import type { FC } from 'react' +import { RiCheckLine, RiCloseLine, RiLoader2Line, RiRefreshLine } from '@remixicon/react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Flowchart from '@/app/components/base/mermaid' +import { cn } from '@/utils/classnames' +import { VIBE_ACCEPT_EVENT, VIBE_REGENERATE_EVENT } from '../../constants' +import { useStore } from '../../store' + +const VibePanel: FC = () => { + const { t } = useTranslation() + const showVibePanel = useStore(s => s.showVibePanel) + const setShowVibePanel = useStore(s => s.setShowVibePanel) + const vibePanelMermaidCode = useStore(s => s.vibePanelMermaidCode) + const setVibePanelMermaidCode = useStore(s => s.setVibePanelMermaidCode) + const isVibeGenerating = useStore(s => s.isVibeGenerating) + const setIsVibeGenerating = useStore(s => s.setIsVibeGenerating) + + const handleClose = useCallback(() => { + setShowVibePanel(false) + setVibePanelMermaidCode('') + setIsVibeGenerating(false) + }, [setShowVibePanel, setVibePanelMermaidCode, setIsVibeGenerating]) + + const handleAccept = useCallback(() => { + if (vibePanelMermaidCode) { + const event = new CustomEvent(VIBE_ACCEPT_EVENT, { + detail: { dsl: vibePanelMermaidCode }, + }) + document.dispatchEvent(event) + handleClose() + } + }, [vibePanelMermaidCode, handleClose]) + + const handleRegenerate = useCallback(() => { + setIsVibeGenerating(true) + const event = new CustomEvent(VIBE_REGENERATE_EVENT) + document.dispatchEvent(event) + }, [setIsVibeGenerating]) + + if (!showVibePanel) + return null + + return ( + <div + className={cn( + 'absolute bottom-0 right-0 top-0 z-20', + 'flex flex-col', + 'w-[600px] border-l border-divider-subtle', + 'bg-components-panel-bg backdrop-blur-[10px]', + 'rounded-xl shadow-xl', + )} + > + {/* Header */} + <div className="flex items-center justify-between border-b border-divider-subtle px-4 py-3"> + <div className="text-sm font-semibold text-text-primary"> + {t('workflow.vibe.panelTitle')} + </div> + <button + onClick={handleClose} + className="rounded-lg p-1 transition-colors hover:bg-state-base-hover" + > + <RiCloseLine className="h-4 w-4 text-text-tertiary" /> + </button> + </div> + + {/* Content */} + <div className="flex-1 overflow-y-auto p-4"> + {isVibeGenerating && !vibePanelMermaidCode + ? ( + <div className="flex h-full flex-col items-center justify-center gap-4"> + <RiLoader2Line className="h-4 w-4 animate-spin text-text-tertiary" /> + <div className="text-sm text-text-tertiary"> + {t('workflow.vibe.generatingFlowchart')} + </div> + </div> + ) + : vibePanelMermaidCode + ? ( + <div className="h-full"> + <Flowchart + PrimitiveCode={vibePanelMermaidCode} + theme="light" + /> + </div> + ) + : ( + <div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-text-tertiary"> + <div>{t('workflow.vibe.noFlowchartYet')}</div> + </div> + )} + </div> + + {/* Footer Actions */} + {vibePanelMermaidCode && !isVibeGenerating && ( + <div className="flex items-center justify-end gap-2 border-t border-divider-subtle px-4 py-3"> + <Button + variant="secondary" + size="medium" + onClick={handleRegenerate} + > + <RiRefreshLine className="mr-1 h-4 w-4" /> + {t('workflow.vibe.regenerate')} + </Button> + <Button + variant="primary" + size="medium" + onClick={handleAccept} + > + <RiCheckLine className="mr-1 h-4 w-4" /> + {t('workflow.vibe.accept')} + </Button> + </div> + )} + </div> + ) +} + +export default VibePanel diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index 4848beeac5..de712d8df4 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -24,6 +24,12 @@ export type PanelSliceShape = { setShowVariableInspectPanel: (showVariableInspectPanel: boolean) => void initShowLastRunTab: boolean setInitShowLastRunTab: (initShowLastRunTab: boolean) => void + showVibePanel: boolean + setShowVibePanel: (showVibePanel: boolean) => void + vibePanelMermaidCode: string + setVibePanelMermaidCode: (vibePanelMermaidCode: string) => void + isVibeGenerating: boolean + setIsVibeGenerating: (isVibeGenerating: boolean) => void } export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({ @@ -44,4 +50,10 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({ setShowVariableInspectPanel: showVariableInspectPanel => set(() => ({ showVariableInspectPanel })), initShowLastRunTab: false, setInitShowLastRunTab: initShowLastRunTab => set(() => ({ initShowLastRunTab })), + showVibePanel: false, + setShowVibePanel: showVibePanel => set(() => ({ showVibePanel })), + vibePanelMermaidCode: '', + setVibePanelMermaidCode: vibePanelMermaidCode => set(() => ({ vibePanelMermaidCode })), + isVibeGenerating: false, + setIsVibeGenerating: isVibeGenerating => set(() => ({ isVibeGenerating })), }) diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 022317d9b5..bc6b700f54 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -134,6 +134,12 @@ const translation = { toolUnavailable: 'Tool "{{tool}}" is not available in this workspace.', unknownNodeId: 'Node "{{id}}" is used before it is defined.', unsupportedEdgeLabel: 'Unsupported edge label "{{label}}". Only true/false are allowed for if/else.', + panelTitle: 'Workflow Preview', + generatingFlowchart: 'Generating flowchart preview...', + noFlowchartYet: 'No flowchart preview available', + regenerate: 'Regenerate', + accept: 'Accept', + noFlowchart: 'No flowchart provided', }, publishLimit: { startNodeTitlePrefix: 'Upgrade to', From 24c3b4e0fdf872ab324a1d4fce01dee2d0640547 Mon Sep 17 00:00:00 2001 From: crazywoola <427733928@qq.com> Date: Fri, 26 Dec 2025 12:40:38 +0800 Subject: [PATCH 07/29] feat: use new styles --- .../workflow/hooks/use-workflow-vibe.tsx | 22 +- .../workflow/panel/vibe-panel/index.tsx | 225 ++++++++++++------ .../workflow/store/workflow/panel-slice.ts | 4 + 3 files changed, 181 insertions(+), 70 deletions(-) diff --git a/web/app/components/workflow/hooks/use-workflow-vibe.tsx b/web/app/components/workflow/hooks/use-workflow-vibe.tsx index 705147cac1..33c51953ef 100644 --- a/web/app/components/workflow/hooks/use-workflow-vibe.tsx +++ b/web/app/components/workflow/hooks/use-workflow-vibe.tsx @@ -326,6 +326,20 @@ export const useWorkflowVibe = () => { } }, [defaultModel]) + const getLatestModelConfig = useCallback(() => { + if (typeof window === 'undefined') + return modelConfig + const stored = localStorage.getItem('auto-gen-model') + if (!stored) + return modelConfig + try { + return JSON.parse(stored) as Model + } + catch { + return modelConfig + } + }, [modelConfig]) + const availableNodesList = useMemo(() => { if (!nodesMetaDataMap) return [] @@ -650,7 +664,8 @@ export const useWorkflowVibe = () => { return } - if (!modelConfig && !isMermaidFlowchart(trimmed)) { + const latestModelConfig = getLatestModelConfig() + if (!latestModelConfig && !isMermaidFlowchart(trimmed)) { Toast.notify({ type: 'error', message: t('workflow.vibe.modelUnavailable') }) return } @@ -667,6 +682,7 @@ export const useWorkflowVibe = () => { showVibePanel: true, isVibeGenerating: true, vibePanelMermaidCode: '', + vibePanelInstruction: trimmed, })) try { @@ -701,7 +717,7 @@ export const useWorkflowVibe = () => { if (!isMermaidFlowchart(trimmed)) { const { error, flowchart } = await generateFlowchart({ instruction: trimmed, - model_config: modelConfig, + model_config: latestModelConfig, available_nodes: availableNodesPayload, existing_nodes: existingNodesPayload, available_tools: toolsPayload, @@ -738,7 +754,6 @@ export const useWorkflowVibe = () => { availableNodesList, getNodesReadOnly, handleSyncWorkflowDraft, - modelConfig, nodeTypeLookup, nodesMetaDataMap, saveStateToHistory, @@ -746,6 +761,7 @@ export const useWorkflowVibe = () => { t, toolLookup, toolOptions, + getLatestModelConfig, ]) const handleRegenerate = useCallback(async () => { diff --git a/web/app/components/workflow/panel/vibe-panel/index.tsx b/web/app/components/workflow/panel/vibe-panel/index.tsx index af5b947bdc..684cf4d96e 100644 --- a/web/app/components/workflow/panel/vibe-panel/index.tsx +++ b/web/app/components/workflow/panel/vibe-panel/index.tsx @@ -1,13 +1,23 @@ 'use client' import type { FC } from 'react' -import { RiCheckLine, RiCloseLine, RiLoader2Line, RiRefreshLine } from '@remixicon/react' -import { useCallback } from 'react' +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { CompletionParams, Model } from '@/types/app' +import { RiCheckLine, RiRefreshLine } from '@remixicon/react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import ResPlaceholder from '@/app/components/app/configuration/config/automatic/res-placeholder' import Button from '@/app/components/base/button' +import { Generator } from '@/app/components/base/icons/src/vender/other' +import Loading from '@/app/components/base/loading' import Flowchart from '@/app/components/base/mermaid' -import { cn } from '@/utils/classnames' -import { VIBE_ACCEPT_EVENT, VIBE_REGENERATE_EVENT } from '../../constants' +import Modal from '@/app/components/base/modal' +import Textarea from '@/app/components/base/textarea' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' +import { ModelModeType } from '@/types/app' +import { VIBE_ACCEPT_EVENT, VIBE_COMMAND_EVENT, VIBE_REGENERATE_EVENT } from '../../constants' import { useStore } from '../../store' const VibePanel: FC = () => { @@ -18,6 +28,57 @@ const VibePanel: FC = () => { const setVibePanelMermaidCode = useStore(s => s.setVibePanelMermaidCode) const isVibeGenerating = useStore(s => s.isVibeGenerating) const setIsVibeGenerating = useStore(s => s.setIsVibeGenerating) + const vibePanelInstruction = useStore(s => s.vibePanelInstruction) + const setVibePanelInstruction = useStore(s => s.setVibePanelInstruction) + + const localModel = localStorage.getItem('auto-gen-model') + ? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model + : null + const [model, setModel] = useState<Model>(localModel || { + name: '', + provider: '', + mode: ModelModeType.chat, + completion_params: {} as CompletionParams, + }) + const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) + + useEffect(() => { + if (defaultModel) { + const localModel = localStorage.getItem('auto-gen-model') + ? JSON.parse(localStorage.getItem('auto-gen-model') || '') + : null + if (localModel) { + setModel(localModel) + } + else { + setModel(prev => ({ + ...prev, + name: defaultModel.model, + provider: defaultModel.provider.provider, + })) + } + } + }, [defaultModel]) + + const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => { + const newModel = { + ...model, + provider: newValue.provider, + name: newValue.modelId, + mode: newValue.mode as ModelModeType, + } + setModel(newModel) + localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) + }, [model]) + + const handleCompletionParamsChange = useCallback((newParams: FormValue) => { + const newModel = { + ...model, + completion_params: newParams as CompletionParams, + } + setModel(newModel) + localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) + }, [model]) const handleClose = useCallback(() => { setShowVibePanel(false) @@ -25,6 +86,13 @@ const VibePanel: FC = () => { setIsVibeGenerating(false) }, [setShowVibePanel, setVibePanelMermaidCode, setIsVibeGenerating]) + const handleGenerate = useCallback(() => { + const event = new CustomEvent(VIBE_COMMAND_EVENT, { + detail: { dsl: vibePanelInstruction }, + }) + document.dispatchEvent(event) + }, [vibePanelInstruction]) + const handleAccept = useCallback(() => { if (vibePanelMermaidCode) { const event = new CustomEvent(VIBE_ACCEPT_EVENT, { @@ -44,78 +112,101 @@ const VibePanel: FC = () => { if (!showVibePanel) return null - return ( - <div - className={cn( - 'absolute bottom-0 right-0 top-0 z-20', - 'flex flex-col', - 'w-[600px] border-l border-divider-subtle', - 'bg-components-panel-bg backdrop-blur-[10px]', - 'rounded-xl shadow-xl', - )} - > - {/* Header */} - <div className="flex items-center justify-between border-b border-divider-subtle px-4 py-3"> - <div className="text-sm font-semibold text-text-primary"> - {t('workflow.vibe.panelTitle')} - </div> - <button - onClick={handleClose} - className="rounded-lg p-1 transition-colors hover:bg-state-base-hover" - > - <RiCloseLine className="h-4 w-4 text-text-tertiary" /> - </button> - </div> + const renderLoading = ( + <div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3"> + <Loading /> + <div className="text-[13px] text-text-tertiary">{t('workflow.vibe.generatingFlowchart')}</div> + </div> + ) - {/* Content */} - <div className="flex-1 overflow-y-auto p-4"> - {isVibeGenerating && !vibePanelMermaidCode - ? ( - <div className="flex h-full flex-col items-center justify-center gap-4"> - <RiLoader2Line className="h-4 w-4 animate-spin text-text-tertiary" /> - <div className="text-sm text-text-tertiary"> - {t('workflow.vibe.generatingFlowchart')} + return ( + <Modal + isShow={showVibePanel} + onClose={handleClose} + className="min-w-[1140px] !p-0" + > + <div className="flex h-[680px] flex-wrap"> + <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> + <div className="mb-5"> + <div className="text-lg font-bold leading-[28px] text-text-primary">{t('app.gotoAnything.actions.vibeTitle')}</div> + <div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('app.gotoAnything.actions.vibeDesc')}</div> + </div> + <div> + <ModelParameterModal + popupClassName="!w-[520px]" + portalToFollowElemContentClassName="z-[1000]" + isAdvancedMode={true} + provider={model.provider} + completionParams={model.completion_params} + modelId={model.name} + setModel={handleModelChange} + onCompletionParamsChange={handleCompletionParamsChange} + hideDebugWithMultipleModel + /> + </div> + <div className="mt-4"> + <div className="system-sm-semibold-uppercase mb-1.5 text-text-secondary">{t('appDebug.generate.instruction')}</div> + <Textarea + className="min-h-[240px] resize-none rounded-[10px] px-4 pt-3" + placeholder={t('workflow.vibe.missingInstruction')} + value={vibePanelInstruction} + onChange={e => setVibePanelInstruction(e.target.value)} + /> + </div> + + <div className="mt-7 flex justify-end space-x-2"> + <Button onClick={handleClose}>{t('appDebug.generate.dismiss')}</Button> + <Button + className="flex space-x-1" + variant="primary" + onClick={handleGenerate} + disabled={isVibeGenerating} + > + <Generator className="h-4 w-4" /> + <span className="text-xs font-semibold">{t('appDebug.generate.generate')}</span> + </Button> + </div> + </div> + + {!isVibeGenerating && vibePanelMermaidCode && ( + <div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0"> + <div className="flex h-full flex-col"> + <div className="mb-3 flex shrink-0 items-center justify-between"> + <div className="shrink-0 text-base font-semibold leading-[160%] text-text-secondary">{t('workflow.vibe.panelTitle')}</div> + <div className="flex items-center space-x-2"> + <Button + variant="secondary" + size="medium" + onClick={handleRegenerate} + > + <RiRefreshLine className="mr-1 h-4 w-4" /> + {t('workflow.vibe.regenerate')} + </Button> + <Button + variant="primary" + size="medium" + onClick={handleAccept} + > + <RiCheckLine className="mr-1 h-4 w-4" /> + {t('workflow.vibe.accept')} + </Button> </div> </div> - ) - : vibePanelMermaidCode - ? ( - <div className="h-full"> + <div className="flex grow flex-col overflow-y-auto pb-6"> + <div className="grow"> <Flowchart PrimitiveCode={vibePanelMermaidCode} theme="light" /> </div> - ) - : ( - <div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-text-tertiary"> - <div>{t('workflow.vibe.noFlowchartYet')}</div> - </div> - )} + </div> + </div> + </div> + )} + {isVibeGenerating && renderLoading} + {!isVibeGenerating && !vibePanelMermaidCode && <ResPlaceholder />} </div> - - {/* Footer Actions */} - {vibePanelMermaidCode && !isVibeGenerating && ( - <div className="flex items-center justify-end gap-2 border-t border-divider-subtle px-4 py-3"> - <Button - variant="secondary" - size="medium" - onClick={handleRegenerate} - > - <RiRefreshLine className="mr-1 h-4 w-4" /> - {t('workflow.vibe.regenerate')} - </Button> - <Button - variant="primary" - size="medium" - onClick={handleAccept} - > - <RiCheckLine className="mr-1 h-4 w-4" /> - {t('workflow.vibe.accept')} - </Button> - </div> - )} - </div> + </Modal> ) } diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index de712d8df4..c695854dcb 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -30,6 +30,8 @@ export type PanelSliceShape = { setVibePanelMermaidCode: (vibePanelMermaidCode: string) => void isVibeGenerating: boolean setIsVibeGenerating: (isVibeGenerating: boolean) => void + vibePanelInstruction: string + setVibePanelInstruction: (vibePanelInstruction: string) => void } export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({ @@ -56,4 +58,6 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({ setVibePanelMermaidCode: vibePanelMermaidCode => set(() => ({ vibePanelMermaidCode })), isVibeGenerating: false, setIsVibeGenerating: isVibeGenerating => set(() => ({ isVibeGenerating })), + vibePanelInstruction: '', + setVibePanelInstruction: vibePanelInstruction => set(() => ({ vibePanelInstruction })), }) From 61f3fbbe2f2ecabe8cf9cb2ab4230c6e6fa11a0d Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Fri, 26 Dec 2025 18:45:17 +0800 Subject: [PATCH 08/29] chore: some tests --- .../goto-anything/actions/banana.spec.tsx | 87 ++++++++++ .../actions/commands/vibe.spec.tsx | 150 ++++++++++++++++++ .../header/version-history-button.tsx | 2 +- 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 web/app/components/goto-anything/actions/banana.spec.tsx create mode 100644 web/app/components/goto-anything/actions/commands/vibe.spec.tsx diff --git a/web/app/components/goto-anything/actions/banana.spec.tsx b/web/app/components/goto-anything/actions/banana.spec.tsx new file mode 100644 index 0000000000..ec7cd36c8e --- /dev/null +++ b/web/app/components/goto-anything/actions/banana.spec.tsx @@ -0,0 +1,87 @@ +import type { CommandSearchResult, SearchResult } from './types' +import { isInWorkflowPage } from '@/app/components/workflow/constants' +import i18n from '@/i18n-config/i18next-config' +import { bananaAction } from './banana' + +vi.mock('@/i18n-config/i18next-config', () => ({ + default: { + t: vi.fn((key: string, options?: Record<string, unknown>) => { + if (!options) + return key + return `${key}:${JSON.stringify(options)}` + }), + }, +})) + +vi.mock('@/app/components/workflow/constants', async () => { + const actual = await vi.importActual<typeof import('@/app/components/workflow/constants')>( + '@/app/components/workflow/constants', + ) + return { + ...actual, + isInWorkflowPage: vi.fn(), + } +}) + +const mockedIsInWorkflowPage = vi.mocked(isInWorkflowPage) +const mockedT = vi.mocked(i18n.t) + +const getCommandResult = (item: SearchResult): CommandSearchResult => { + expect(item.type).toBe('command') + return item as CommandSearchResult +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +// Search behavior for the banana action. +describe('bananaAction', () => { + // Search results depend on workflow context and input content. + describe('search', () => { + it('should return no results when not on workflow page', async () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(false) + + // Act + const result = await bananaAction.search('', '', 'en') + + // Assert + expect(result).toEqual([]) + }) + + it('should return hint description when input is blank', async () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + + // Act + const result = await bananaAction.search('', ' ', 'en') + + // Assert + expect(result).toHaveLength(1) + const [item] = result + const commandItem = getCommandResult(item) + expect(item.description).toContain('app.gotoAnything.actions.vibeHint') + expect(commandItem.data.args?.dsl).toBe('') + expect(mockedT).toHaveBeenCalledWith( + 'app.gotoAnything.actions.vibeHint', + expect.objectContaining({ prompt: expect.any(String), lng: 'en' }), + ) + }) + + it('should return default description when input is provided', async () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + + // Act + const result = await bananaAction.search('', ' build a flow ', 'en') + + // Assert + expect(result).toHaveLength(1) + const [item] = result + const commandItem = getCommandResult(item) + expect(item.description).toContain('app.gotoAnything.actions.vibeDesc') + expect(commandItem.data.args?.dsl).toBe('build a flow') + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/vibe.spec.tsx b/web/app/components/goto-anything/actions/commands/vibe.spec.tsx new file mode 100644 index 0000000000..fd3320d92e --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/vibe.spec.tsx @@ -0,0 +1,150 @@ +import { isInWorkflowPage, VIBE_COMMAND_EVENT } from '@/app/components/workflow/constants' +import i18n from '@/i18n-config/i18next-config' +import { registerCommands, unregisterCommands } from './command-bus' +import { vibeCommand } from './vibe' + +vi.mock('@/i18n-config/i18next-config', () => ({ + default: { + t: vi.fn((key: string, options?: Record<string, unknown>) => { + if (!options) + return key + return `${key}:${JSON.stringify(options)}` + }), + }, +})) + +vi.mock('@/app/components/workflow/constants', async () => { + const actual = await vi.importActual<typeof import('@/app/components/workflow/constants')>( + '@/app/components/workflow/constants', + ) + return { + ...actual, + isInWorkflowPage: vi.fn(), + } +}) + +vi.mock('./command-bus', () => ({ + registerCommands: vi.fn(), + unregisterCommands: vi.fn(), +})) + +const mockedIsInWorkflowPage = vi.mocked(isInWorkflowPage) +const mockedRegisterCommands = vi.mocked(registerCommands) +const mockedUnregisterCommands = vi.mocked(unregisterCommands) +const mockedT = vi.mocked(i18n.t) + +type CommandArgs = { dsl?: string } +type CommandMap = Record<string, (args?: CommandArgs) => void | Promise<void>> + +beforeEach(() => { + vi.clearAllMocks() +}) + +// Command availability, search, and registration behavior for workflow vibe. +describe('vibeCommand', () => { + // Availability mirrors workflow page detection. + describe('availability', () => { + it('should return true when on workflow page', () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + + // Act + const available = vibeCommand.isAvailable?.() + + // Assert + expect(available).toBe(true) + }) + + it('should return false when not on workflow page', () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(false) + + // Act + const available = vibeCommand.isAvailable?.() + + // Assert + expect(available).toBe(false) + }) + }) + + // Search results depend on provided arguments. + describe('search', () => { + it('should return hint description when args are empty', async () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + + // Act + const result = await vibeCommand.search(' ', 'en') + + // Assert + expect(result).toHaveLength(1) + const [item] = result + expect(item.description).toContain('app.gotoAnything.actions.vibeHint') + expect(item.data?.args?.dsl).toBe('') + expect(mockedT).toHaveBeenCalledWith( + 'app.gotoAnything.actions.vibeHint', + expect.objectContaining({ prompt: expect.any(String), lng: 'en' }), + ) + }) + + it('should return default description when args are provided', async () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + + // Act + const result = await vibeCommand.search(' make a flow ', 'en') + + // Assert + expect(result).toHaveLength(1) + const [item] = result + expect(item.description).toContain('app.gotoAnything.actions.vibeDesc') + expect(item.data?.args?.dsl).toBe('make a flow') + }) + }) + + // Command registration and event dispatching. + describe('registration', () => { + it('should register the workflow vibe command', () => { + // Act + expect(vibeCommand.register).toBeDefined() + vibeCommand.register?.({}) + + // Assert + expect(mockedRegisterCommands).toHaveBeenCalledTimes(1) + const commands = mockedRegisterCommands.mock.calls[0]?.[0] as CommandMap + expect(commands['workflow.vibe']).toEqual(expect.any(Function)) + }) + + it('should dispatch vibe event when command handler runs', async () => { + // Arrange + const dispatchSpy = vi.spyOn(document, 'dispatchEvent') + expect(vibeCommand.register).toBeDefined() + vibeCommand.register?.({}) + expect(mockedRegisterCommands).toHaveBeenCalledTimes(1) + const commands = mockedRegisterCommands.mock.calls[0]?.[0] as CommandMap + + try { + // Act + await commands['workflow.vibe']?.({ dsl: 'hello' }) + + // Assert + expect(dispatchSpy).toHaveBeenCalledTimes(1) + const event = dispatchSpy.mock.calls[0][0] as CustomEvent + expect(event.type).toBe(VIBE_COMMAND_EVENT) + expect(event.detail).toEqual({ dsl: 'hello' }) + } + finally { + dispatchSpy.mockRestore() + } + }) + + it('should unregister workflow vibe command', () => { + // Act + expect(vibeCommand.unregister).toBeDefined() + vibeCommand.unregister?.() + + // Assert + expect(mockedUnregisterCommands).toHaveBeenCalledWith(['workflow.vibe']) + }) + }) +}) diff --git a/web/app/components/workflow/header/version-history-button.tsx b/web/app/components/workflow/header/version-history-button.tsx index ae3bd68b48..9ec9e6934e 100644 --- a/web/app/components/workflow/header/version-history-button.tsx +++ b/web/app/components/workflow/header/version-history-button.tsx @@ -61,7 +61,7 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ > <Button className={cn( - 'p-2 rounded-lg border border-transparent', + 'rounded-lg border border-transparent p-2', theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-sm', )} onClick={handleViewVersionHistory} From 9341b2c4c22f32ad26698fc0d1023d35246b1137 Mon Sep 17 00:00:00 2001 From: WTW0313 <twwu@dify.ai> Date: Sat, 27 Dec 2025 13:28:55 +0800 Subject: [PATCH 09/29] feat: update Vibe panel to use new event handling and versioning for flowcharts --- web/app/components/workflow/constants.ts | 3 +- .../workflow/hooks/use-workflow-vibe.tsx | 140 +++++++++++------- .../workflow/panel/vibe-panel/index.tsx | 107 +++++++------ .../workflow/store/workflow/index.ts | 4 + .../workflow/store/workflow/panel-slice.ts | 11 -- .../store/workflow/vibe-workflow-slice.ts | 19 +++ web/i18n/en-US/workflow.ts | 2 +- 7 files changed, 175 insertions(+), 111 deletions(-) create mode 100644 web/app/components/workflow/store/workflow/vibe-workflow-slice.ts diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 0a6bd74bff..b931facd5e 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -10,8 +10,7 @@ export const X_OFFSET = 60 export const NODE_WIDTH_X_OFFSET = NODE_WIDTH + X_OFFSET export const Y_OFFSET = 39 export const VIBE_COMMAND_EVENT = 'workflow-vibe-command' -export const VIBE_REGENERATE_EVENT = 'workflow-vibe-regenerate' -export const VIBE_ACCEPT_EVENT = 'workflow-vibe-accept' +export const VIBE_APPLY_EVENT = 'workflow-vibe-apply' export const START_INITIAL_POSITION = { x: 80, y: 282 } export const AUTO_LAYOUT_OFFSET = { x: -42, diff --git a/web/app/components/workflow/hooks/use-workflow-vibe.tsx b/web/app/components/workflow/hooks/use-workflow-vibe.tsx index 33c51953ef..57496cc1a6 100644 --- a/web/app/components/workflow/hooks/use-workflow-vibe.tsx +++ b/web/app/components/workflow/hooks/use-workflow-vibe.tsx @@ -4,6 +4,7 @@ import type { ToolDefaultValue } from '../block-selector/types' import type { Edge, Node, ToolWithProvider } from '../types' import type { Tool } from '@/app/components/tools/types' import type { Model } from '@/types/app' +import { useSessionStorageState } from 'ahooks' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' @@ -25,10 +26,10 @@ import { CUSTOM_EDGE, NODE_WIDTH, NODE_WIDTH_X_OFFSET, - VIBE_ACCEPT_EVENT, + VIBE_APPLY_EVENT, VIBE_COMMAND_EVENT, - VIBE_REGENERATE_EVENT, } from '../constants' +import { useHooksStore } from '../hooks-store' import { useWorkflowStore } from '../store' import { BlockEnum } from '../types' import { @@ -76,6 +77,11 @@ type ParseResult = { edges: ParsedEdge[] } +type FlowGraph = { + nodes: Node[] + edges: Edge[] +} + const NODE_DECLARATION = /^([A-Z][\w-]*)\s*\[(?:"([^"]+)"|([^\]]+))\]\s*$/i const EDGE_DECLARATION = /^(.+?)\s*-->\s*(?:\|([^|]+)\|\s*)?(.+)$/ @@ -276,10 +282,45 @@ const buildToolParams = (parameters?: Tool['parameters']) => { return params } +type UseVibeFlowDataParams = { + storageKey: string +} + +const keyPrefix = 'vibe-flow-' + +export const useVibeFlowData = ({ storageKey }: UseVibeFlowDataParams) => { + const [versions, setVersions] = useSessionStorageState<FlowGraph[]>(`${keyPrefix}${storageKey}-versions`, { + defaultValue: [], + }) + + const [currentVersionIndex, setCurrentVersionIndex] = useSessionStorageState<number>(`${keyPrefix}${storageKey}-version-index`, { + defaultValue: 0, + }) + + const current = versions?.[currentVersionIndex || 0] + + const addVersion = useCallback((version: FlowGraph) => { + setCurrentVersionIndex(() => versions?.length || 0) + setVersions((prev) => { + return [...prev!, version] + }) + }, [setVersions, setCurrentVersionIndex, versions?.length]) + + return { + versions, + addVersion, + currentVersionIndex, + setCurrentVersionIndex, + current, + } +} + export const useWorkflowVibe = () => { const { t } = useTranslation() const store = useStoreApi() const workflowStore = useWorkflowStore() + const configsMap = useHooksStore(s => s.configsMap) + const language = useGetLanguage() const { nodesMap: nodesMetaDataMap } = useNodesMetaData() const { handleSyncWorkflowDraft } = useNodesSyncDraft() @@ -296,6 +337,10 @@ export const useWorkflowVibe = () => { const isGeneratingRef = useRef(false) const lastInstructionRef = useRef<string>('') + const { addVersion, current: currentFlowGraph } = useVibeFlowData({ + storageKey: `${configsMap?.flowId}`, + }) + useEffect(() => { const storedModel = (() => { if (typeof window === 'undefined') @@ -427,48 +472,42 @@ export const useWorkflowVibe = () => { return map }, [nodesMetaDataMap]) - const applyFlowchartToWorkflow = useCallback(async (mermaidCode: string) => { - const { getNodes, setNodes, edges, setEdges } = store.getState() + const flowchartToWorkflowGraph = useCallback(async (mermaidCode: string): Promise<FlowGraph> => { + const { getNodes } = store.getState() const nodes = getNodes() - const { - setShowVibePanel, - } = workflowStore.getState() const parseResultToUse = parseMermaidFlowchart(mermaidCode, nodeTypeLookup, toolLookup) + const emptyGraph = { + nodes: [], + edges: [], + } if ('error' in parseResultToUse) { switch (parseResultToUse.error) { case 'missingNodeType': case 'missingNodeDefinition': Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) - setShowVibePanel(false) - return + return emptyGraph case 'unknownNodeId': Toast.notify({ type: 'error', message: t('workflow.vibe.unknownNodeId', { id: parseResultToUse.detail }) }) - setShowVibePanel(false) - return + return emptyGraph case 'unknownNodeType': Toast.notify({ type: 'error', message: t('workflow.vibe.nodeTypeUnavailable', { type: parseResultToUse.detail }) }) - setShowVibePanel(false) - return + return emptyGraph case 'unknownTool': Toast.notify({ type: 'error', message: t('workflow.vibe.toolUnavailable', { tool: parseResultToUse.detail }) }) - setShowVibePanel(false) - return + return emptyGraph case 'unsupportedEdgeLabel': Toast.notify({ type: 'error', message: t('workflow.vibe.unsupportedEdgeLabel', { label: parseResultToUse.detail }) }) - setShowVibePanel(false) - return + return emptyGraph default: Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) - setShowVibePanel(false) - return + return emptyGraph } } if (!nodesMetaDataMap) { Toast.notify({ type: 'error', message: t('workflow.vibe.nodesUnavailable') }) - setShowVibePanel(false) - return + return emptyGraph } const existingStartNode = nodes.find(node => node.data.type === BlockEnum.Start) @@ -513,7 +552,7 @@ export const useWorkflowVibe = () => { if (!newNodes.length) { Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) - return + return emptyGraph } const buildEdge = ( @@ -626,10 +665,20 @@ export const useWorkflowVibe = () => { }, } }) + return { + nodes: updatedNodes, + edges: newEdges, + } + }, [nodeTypeLookup, toolLookup]) - setNodes(updatedNodes) - setEdges([...edges, ...newEdges]) - saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: newNodes[0].id }) + const applyFlowchartToWorkflow = useCallback(() => { + const { setNodes, setEdges } = store.getState() + const vibePanelPreviewNodes = currentFlowGraph.nodes || [] + const vibePanelPreviewEdges = currentFlowGraph.edges || [] + + setNodes(vibePanelPreviewNodes) + setEdges(vibePanelPreviewEdges) + saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: vibePanelPreviewNodes[0].id }) handleSyncWorkflowDraft() workflowStore.setState(state => ({ @@ -744,8 +793,11 @@ export const useWorkflowVibe = () => { isVibeGenerating: false, })) + const workflowGraph = await flowchartToWorkflowGraph(mermaidCode) + addVersion(workflowGraph) + if (skipPanelPreview) - await applyFlowchartToWorkflow(mermaidCode) + applyFlowchartToWorkflow() } finally { isGeneratingRef.current = false @@ -764,47 +816,27 @@ export const useWorkflowVibe = () => { getLatestModelConfig, ]) - const handleRegenerate = useCallback(async () => { - if (!lastInstructionRef.current) { - Toast.notify({ type: 'error', message: t('workflow.vibe.missingInstruction') }) - return - } - - await handleVibeCommand(lastInstructionRef.current, false) - }, [handleVibeCommand, t]) - - const handleAccept = useCallback(async (vibePanelMermaidCode: string | undefined) => { - if (!vibePanelMermaidCode) { - Toast.notify({ type: 'error', message: t('workflow.vibe.noFlowchart') }) - return - } - - await applyFlowchartToWorkflow(vibePanelMermaidCode) - }, [applyFlowchartToWorkflow, t]) + const handleAccept = useCallback(() => { + applyFlowchartToWorkflow() + }, [applyFlowchartToWorkflow]) useEffect(() => { const handler = (event: CustomEvent<VibeCommandDetail>) => { handleVibeCommand(event.detail?.dsl, false) } - const regenerateHandler = () => { - handleRegenerate() - } - - const acceptHandler = (event: CustomEvent<VibeCommandDetail>) => { - handleAccept(event.detail?.dsl) + const acceptHandler = () => { + handleAccept() } document.addEventListener(VIBE_COMMAND_EVENT, handler as EventListener) - document.addEventListener(VIBE_REGENERATE_EVENT, regenerateHandler as EventListener) - document.addEventListener(VIBE_ACCEPT_EVENT, acceptHandler as EventListener) + document.addEventListener(VIBE_APPLY_EVENT, acceptHandler as EventListener) return () => { document.removeEventListener(VIBE_COMMAND_EVENT, handler as EventListener) - document.removeEventListener(VIBE_REGENERATE_EVENT, regenerateHandler as EventListener) - document.removeEventListener(VIBE_ACCEPT_EVENT, acceptHandler as EventListener) + document.removeEventListener(VIBE_APPLY_EVENT, acceptHandler as EventListener) } - }, [handleVibeCommand, handleRegenerate]) + }, [handleVibeCommand, handleAccept]) return null } diff --git a/web/app/components/workflow/panel/vibe-panel/index.tsx b/web/app/components/workflow/panel/vibe-panel/index.tsx index 684cf4d96e..119ceaa012 100644 --- a/web/app/components/workflow/panel/vibe-panel/index.tsx +++ b/web/app/components/workflow/panel/vibe-panel/index.tsx @@ -3,33 +3,42 @@ import type { FC } from 'react' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { CompletionParams, Model } from '@/types/app' -import { RiCheckLine, RiRefreshLine } from '@remixicon/react' +import { RiClipboardLine } from '@remixicon/react' +import copy from 'copy-to-clipboard' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ResPlaceholder from '@/app/components/app/configuration/config/automatic/res-placeholder' +import VersionSelector from '@/app/components/app/configuration/config/automatic/version-selector' import Button from '@/app/components/base/button' import { Generator } from '@/app/components/base/icons/src/vender/other' import Loading from '@/app/components/base/loading' -import Flowchart from '@/app/components/base/mermaid' import Modal from '@/app/components/base/modal' import Textarea from '@/app/components/base/textarea' +import Toast from '@/app/components/base/toast' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import { ModelModeType } from '@/types/app' -import { VIBE_ACCEPT_EVENT, VIBE_COMMAND_EVENT, VIBE_REGENERATE_EVENT } from '../../constants' -import { useStore } from '../../store' +import { VIBE_APPLY_EVENT, VIBE_COMMAND_EVENT } from '../../constants' +import { useHooksStore } from '../../hooks-store' +import { useVibeFlowData } from '../../hooks/use-workflow-vibe' +import { useStore, useWorkflowStore } from '../../store' +import WorkflowPreview from '../../workflow-preview' const VibePanel: FC = () => { const { t } = useTranslation() + const workflowStore = useWorkflowStore() const showVibePanel = useStore(s => s.showVibePanel) - const setShowVibePanel = useStore(s => s.setShowVibePanel) - const vibePanelMermaidCode = useStore(s => s.vibePanelMermaidCode) - const setVibePanelMermaidCode = useStore(s => s.setVibePanelMermaidCode) const isVibeGenerating = useStore(s => s.isVibeGenerating) - const setIsVibeGenerating = useStore(s => s.setIsVibeGenerating) const vibePanelInstruction = useStore(s => s.vibePanelInstruction) - const setVibePanelInstruction = useStore(s => s.setVibePanelInstruction) + const configsMap = useHooksStore(s => s.configsMap) + + const { current: currentFlowGraph, versions, currentVersionIndex, setCurrentVersionIndex } = useVibeFlowData({ + storageKey: `${configsMap?.flowId}`, + }) + + const vibePanelPreviewNodes = currentFlowGraph?.nodes || [] + const vibePanelPreviewEdges = currentFlowGraph?.edges || [] const localModel = localStorage.getItem('auto-gen-model') ? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model @@ -80,11 +89,21 @@ const VibePanel: FC = () => { localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) }, [model]) + const handleInstructionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { + workflowStore.setState(state => ({ + ...state, + vibePanelInstruction: e.target.value, + })) + }, [workflowStore]) + const handleClose = useCallback(() => { - setShowVibePanel(false) - setVibePanelMermaidCode('') - setIsVibeGenerating(false) - }, [setShowVibePanel, setVibePanelMermaidCode, setIsVibeGenerating]) + workflowStore.setState(state => ({ + ...state, + showVibePanel: false, + vibePanelMermaidCode: '', + isVibeGenerating: false, + })) + }, [workflowStore]) const handleGenerate = useCallback(() => { const event = new CustomEvent(VIBE_COMMAND_EVENT, { @@ -94,20 +113,16 @@ const VibePanel: FC = () => { }, [vibePanelInstruction]) const handleAccept = useCallback(() => { - if (vibePanelMermaidCode) { - const event = new CustomEvent(VIBE_ACCEPT_EVENT, { - detail: { dsl: vibePanelMermaidCode }, - }) - document.dispatchEvent(event) - handleClose() - } - }, [vibePanelMermaidCode, handleClose]) - - const handleRegenerate = useCallback(() => { - setIsVibeGenerating(true) - const event = new CustomEvent(VIBE_REGENERATE_EVENT) + const event = new CustomEvent(VIBE_APPLY_EVENT) document.dispatchEvent(event) - }, [setIsVibeGenerating]) + handleClose() + }, [handleClose]) + + const handleCopyMermaid = useCallback(() => { + const { vibePanelMermaidCode } = workflowStore.getState() + copy(vibePanelMermaidCode) + Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') }) + }, [workflowStore, t]) if (!showVibePanel) return null @@ -150,7 +165,7 @@ const VibePanel: FC = () => { className="min-h-[240px] resize-none rounded-[10px] px-4 pt-3" placeholder={t('workflow.vibe.missingInstruction')} value={vibePanelInstruction} - onChange={e => setVibePanelInstruction(e.target.value)} + onChange={handleInstructionChange} /> </div> @@ -163,48 +178,54 @@ const VibePanel: FC = () => { disabled={isVibeGenerating} > <Generator className="h-4 w-4" /> - <span className="text-xs font-semibold">{t('appDebug.generate.generate')}</span> + <span className="system-xs-semibold">{t('appDebug.generate.generate')}</span> </Button> </div> </div> - {!isVibeGenerating && vibePanelMermaidCode && ( + {!isVibeGenerating && vibePanelPreviewNodes.length > 0 && ( <div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0"> <div className="flex h-full flex-col"> <div className="mb-3 flex shrink-0 items-center justify-between"> - <div className="shrink-0 text-base font-semibold leading-[160%] text-text-secondary">{t('workflow.vibe.panelTitle')}</div> + <div className="flex shrink-0 flex-col"> + <div className="system-xl-semibold text-text-secondary">{t('workflow.vibe.panelTitle')}</div> + <VersionSelector + versionLen={versions.length} + value={currentVersionIndex} + onChange={setCurrentVersionIndex} + /> + </div> <div className="flex items-center space-x-2"> <Button variant="secondary" size="medium" - onClick={handleRegenerate} + onClick={handleCopyMermaid} + className="px-2" > - <RiRefreshLine className="mr-1 h-4 w-4" /> - {t('workflow.vibe.regenerate')} + <RiClipboardLine className="h-4 w-4" /> </Button> <Button variant="primary" size="medium" onClick={handleAccept} > - <RiCheckLine className="mr-1 h-4 w-4" /> - {t('workflow.vibe.accept')} + {t('workflow.vibe.apply')} </Button> </div> </div> - <div className="flex grow flex-col overflow-y-auto pb-6"> - <div className="grow"> - <Flowchart - PrimitiveCode={vibePanelMermaidCode} - theme="light" - /> - </div> + <div className="flex grow flex-col overflow-hidden pb-6"> + <WorkflowPreview + nodes={vibePanelPreviewNodes} + edges={vibePanelPreviewEdges} + viewport={{ x: 0, y: 0, zoom: 1 }} + className="rounded-lg border border-divider-subtle" + /> </div> </div> </div> )} {isVibeGenerating && renderLoading} - {!isVibeGenerating && !vibePanelMermaidCode && <ResPlaceholder />} + {!isVibeGenerating && vibePanelPreviewNodes.length === 0 && <ResPlaceholder />} </div> </Modal> ) diff --git a/web/app/components/workflow/store/workflow/index.ts b/web/app/components/workflow/store/workflow/index.ts index c2c0c00201..e9416e6e1b 100644 --- a/web/app/components/workflow/store/workflow/index.ts +++ b/web/app/components/workflow/store/workflow/index.ts @@ -12,6 +12,7 @@ import type { NodeSliceShape } from './node-slice' import type { PanelSliceShape } from './panel-slice' import type { ToolSliceShape } from './tool-slice' import type { VersionSliceShape } from './version-slice' +import type { VibeWorkflowSliceShape } from './vibe-workflow-slice' import type { WorkflowDraftSliceShape } from './workflow-draft-slice' import type { WorkflowSliceShape } from './workflow-slice' import type { RagPipelineSliceShape } from '@/app/components/rag-pipeline/store' @@ -34,6 +35,7 @@ import { createNodeSlice } from './node-slice' import { createPanelSlice } from './panel-slice' import { createToolSlice } from './tool-slice' import { createVersionSlice } from './version-slice' +import { createVibeWorkflowSlice } from './vibe-workflow-slice' import { createWorkflowDraftSlice } from './workflow-draft-slice' import { createWorkflowSlice } from './workflow-slice' @@ -56,6 +58,7 @@ export type Shape & InspectVarsSliceShape & LayoutSliceShape & SliceFromInjection + & VibeWorkflowSliceShape export type InjectWorkflowStoreSliceFn = StateCreator<SliceFromInjection> @@ -80,6 +83,7 @@ export const createWorkflowStore = (params: CreateWorkflowStoreParams) => { ...createWorkflowSlice(...args), ...createInspectVarsSlice(...args), ...createLayoutSlice(...args), + ...createVibeWorkflowSlice(...args), ...(injectWorkflowStoreSliceFn?.(...args) || {} as SliceFromInjection), })) } diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index c695854dcb..afd20be898 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -26,12 +26,6 @@ export type PanelSliceShape = { setInitShowLastRunTab: (initShowLastRunTab: boolean) => void showVibePanel: boolean setShowVibePanel: (showVibePanel: boolean) => void - vibePanelMermaidCode: string - setVibePanelMermaidCode: (vibePanelMermaidCode: string) => void - isVibeGenerating: boolean - setIsVibeGenerating: (isVibeGenerating: boolean) => void - vibePanelInstruction: string - setVibePanelInstruction: (vibePanelInstruction: string) => void } export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({ @@ -55,9 +49,4 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({ showVibePanel: false, setShowVibePanel: showVibePanel => set(() => ({ showVibePanel })), vibePanelMermaidCode: '', - setVibePanelMermaidCode: vibePanelMermaidCode => set(() => ({ vibePanelMermaidCode })), - isVibeGenerating: false, - setIsVibeGenerating: isVibeGenerating => set(() => ({ isVibeGenerating })), - vibePanelInstruction: '', - setVibePanelInstruction: vibePanelInstruction => set(() => ({ vibePanelInstruction })), }) diff --git a/web/app/components/workflow/store/workflow/vibe-workflow-slice.ts b/web/app/components/workflow/store/workflow/vibe-workflow-slice.ts new file mode 100644 index 0000000000..07bd6c4c5b --- /dev/null +++ b/web/app/components/workflow/store/workflow/vibe-workflow-slice.ts @@ -0,0 +1,19 @@ +import type { StateCreator } from 'zustand' + +export type VibeWorkflowSliceShape = { + vibePanelMermaidCode: string + setVibePanelMermaidCode: (vibePanelMermaidCode: string) => void + isVibeGenerating: boolean + setIsVibeGenerating: (isVibeGenerating: boolean) => void + vibePanelInstruction: string + setVibePanelInstruction: (vibePanelInstruction: string) => void +} + +export const createVibeWorkflowSlice: StateCreator<VibeWorkflowSliceShape> = set => ({ + vibePanelMermaidCode: '', + setVibePanelMermaidCode: vibePanelMermaidCode => set(() => ({ vibePanelMermaidCode })), + isVibeGenerating: false, + setIsVibeGenerating: isVibeGenerating => set(() => ({ isVibeGenerating })), + vibePanelInstruction: '', + setVibePanelInstruction: vibePanelInstruction => set(() => ({ vibePanelInstruction })), +}) diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index bc6b700f54..9d00be30c7 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -138,7 +138,7 @@ const translation = { generatingFlowchart: 'Generating flowchart preview...', noFlowchartYet: 'No flowchart preview available', regenerate: 'Regenerate', - accept: 'Accept', + apply: 'Apply', noFlowchart: 'No flowchart provided', }, publishLimit: { From 4548760d41c8627d2ac3afcedc324bea98b6baf8 Mon Sep 17 00:00:00 2001 From: WTW0313 <twwu@dify.ai> Date: Sat, 27 Dec 2025 13:40:58 +0800 Subject: [PATCH 10/29] fix: ensure storageKey defaults to an empty string in Vibe panel and workflow hooks --- web/app/components/workflow/hooks/use-workflow-vibe.tsx | 2 +- web/app/components/workflow/panel/vibe-panel/index.tsx | 2 +- web/app/components/workflow/store/workflow/panel-slice.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/web/app/components/workflow/hooks/use-workflow-vibe.tsx b/web/app/components/workflow/hooks/use-workflow-vibe.tsx index 57496cc1a6..a85a875747 100644 --- a/web/app/components/workflow/hooks/use-workflow-vibe.tsx +++ b/web/app/components/workflow/hooks/use-workflow-vibe.tsx @@ -338,7 +338,7 @@ export const useWorkflowVibe = () => { const lastInstructionRef = useRef<string>('') const { addVersion, current: currentFlowGraph } = useVibeFlowData({ - storageKey: `${configsMap?.flowId}`, + storageKey: configsMap?.flowId || '', }) useEffect(() => { diff --git a/web/app/components/workflow/panel/vibe-panel/index.tsx b/web/app/components/workflow/panel/vibe-panel/index.tsx index 119ceaa012..82df8ea98d 100644 --- a/web/app/components/workflow/panel/vibe-panel/index.tsx +++ b/web/app/components/workflow/panel/vibe-panel/index.tsx @@ -34,7 +34,7 @@ const VibePanel: FC = () => { const configsMap = useHooksStore(s => s.configsMap) const { current: currentFlowGraph, versions, currentVersionIndex, setCurrentVersionIndex } = useVibeFlowData({ - storageKey: `${configsMap?.flowId}`, + storageKey: configsMap?.flowId || '', }) const vibePanelPreviewNodes = currentFlowGraph?.nodes || [] diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index afd20be898..e90418823a 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -48,5 +48,4 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({ setInitShowLastRunTab: initShowLastRunTab => set(() => ({ initShowLastRunTab })), showVibePanel: false, setShowVibePanel: showVibePanel => set(() => ({ showVibePanel })), - vibePanelMermaidCode: '', }) From a31f6e031810f94d1205f3d1f8fbfb434b70fb3f Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 13:49:24 +0800 Subject: [PATCH 11/29] feat: add highPriority prop to VibePanel for improved rendering --- web/app/components/workflow/panel/vibe-panel/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/app/components/workflow/panel/vibe-panel/index.tsx b/web/app/components/workflow/panel/vibe-panel/index.tsx index 82df8ea98d..966172518c 100644 --- a/web/app/components/workflow/panel/vibe-panel/index.tsx +++ b/web/app/components/workflow/panel/vibe-panel/index.tsx @@ -139,6 +139,7 @@ const VibePanel: FC = () => { isShow={showVibePanel} onClose={handleClose} className="min-w-[1140px] !p-0" + highPriority > <div className="flex h-[680px] flex-wrap"> <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> From 5c945c3baf63fcb4754a8b9d325a7eb08fca96b1 Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 14:00:09 +0800 Subject: [PATCH 12/29] fix: correct header assignment in SSRF proxy request handling --- api/core/helper/ssrf_proxy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/core/helper/ssrf_proxy.py b/api/core/helper/ssrf_proxy.py index e390873bf4..27e118f3b2 100644 --- a/api/core/helper/ssrf_proxy.py +++ b/api/core/helper/ssrf_proxy.py @@ -123,8 +123,9 @@ def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs): # the request API to explicitly set headers before sending headers = {k: v for k, v in headers.items() if k.lower() != "host"} if user_provided_host is not None: - request.headers["Host"] = user_provided_host + headers["Host"] = user_provided_host + request = client.build_request(method, url, headers=headers, **kwargs) response = client.send(request, follow_redirects=follow_redirects) # Check for SSRF protection by Squid proxy From 09019cb9f3da84e2082d72be73db12167ade0fb8 Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 14:49:55 +0800 Subject: [PATCH 13/29] feat: implement banana command with search and registration functionality - Added `bananaCommand` to handle vibe-related actions, including search and command registration. - Updated `command-selector` to utilize new internationalization maps for slash commands and scope actions. - Removed deprecated banana action from the actions index and adjusted related filtering logic. - Added unit tests for the banana command to ensure correct behavior in various scenarios. - Deleted obsolete banana action tests and files. --- .../goto-anything/actions/banana.spec.tsx | 87 -------- .../actions/commands/banana.spec.tsx | 189 ++++++++++++++++++ .../actions/{ => commands}/banana.tsx | 42 +++- .../goto-anything/actions/commands/slash.tsx | 3 + .../components/goto-anything/actions/index.ts | 7 +- .../components/goto-anything/actions/types.ts | 2 +- .../goto-anything/command-selector.tsx | 2 +- 7 files changed, 227 insertions(+), 105 deletions(-) delete mode 100644 web/app/components/goto-anything/actions/banana.spec.tsx create mode 100644 web/app/components/goto-anything/actions/commands/banana.spec.tsx rename web/app/components/goto-anything/actions/{ => commands}/banana.tsx (53%) diff --git a/web/app/components/goto-anything/actions/banana.spec.tsx b/web/app/components/goto-anything/actions/banana.spec.tsx deleted file mode 100644 index ec7cd36c8e..0000000000 --- a/web/app/components/goto-anything/actions/banana.spec.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { CommandSearchResult, SearchResult } from './types' -import { isInWorkflowPage } from '@/app/components/workflow/constants' -import i18n from '@/i18n-config/i18next-config' -import { bananaAction } from './banana' - -vi.mock('@/i18n-config/i18next-config', () => ({ - default: { - t: vi.fn((key: string, options?: Record<string, unknown>) => { - if (!options) - return key - return `${key}:${JSON.stringify(options)}` - }), - }, -})) - -vi.mock('@/app/components/workflow/constants', async () => { - const actual = await vi.importActual<typeof import('@/app/components/workflow/constants')>( - '@/app/components/workflow/constants', - ) - return { - ...actual, - isInWorkflowPage: vi.fn(), - } -}) - -const mockedIsInWorkflowPage = vi.mocked(isInWorkflowPage) -const mockedT = vi.mocked(i18n.t) - -const getCommandResult = (item: SearchResult): CommandSearchResult => { - expect(item.type).toBe('command') - return item as CommandSearchResult -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -// Search behavior for the banana action. -describe('bananaAction', () => { - // Search results depend on workflow context and input content. - describe('search', () => { - it('should return no results when not on workflow page', async () => { - // Arrange - mockedIsInWorkflowPage.mockReturnValue(false) - - // Act - const result = await bananaAction.search('', '', 'en') - - // Assert - expect(result).toEqual([]) - }) - - it('should return hint description when input is blank', async () => { - // Arrange - mockedIsInWorkflowPage.mockReturnValue(true) - - // Act - const result = await bananaAction.search('', ' ', 'en') - - // Assert - expect(result).toHaveLength(1) - const [item] = result - const commandItem = getCommandResult(item) - expect(item.description).toContain('app.gotoAnything.actions.vibeHint') - expect(commandItem.data.args?.dsl).toBe('') - expect(mockedT).toHaveBeenCalledWith( - 'app.gotoAnything.actions.vibeHint', - expect.objectContaining({ prompt: expect.any(String), lng: 'en' }), - ) - }) - - it('should return default description when input is provided', async () => { - // Arrange - mockedIsInWorkflowPage.mockReturnValue(true) - - // Act - const result = await bananaAction.search('', ' build a flow ', 'en') - - // Assert - expect(result).toHaveLength(1) - const [item] = result - const commandItem = getCommandResult(item) - expect(item.description).toContain('app.gotoAnything.actions.vibeDesc') - expect(commandItem.data.args?.dsl).toBe('build a flow') - }) - }) -}) diff --git a/web/app/components/goto-anything/actions/commands/banana.spec.tsx b/web/app/components/goto-anything/actions/commands/banana.spec.tsx new file mode 100644 index 0000000000..47a1418aba --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/banana.spec.tsx @@ -0,0 +1,189 @@ +import { isInWorkflowPage, VIBE_COMMAND_EVENT } from '@/app/components/workflow/constants' +import i18n from '@/i18n-config/i18next-config' +import { bananaCommand } from './banana' +import { registerCommands, unregisterCommands } from './command-bus' + +vi.mock('@/i18n-config/i18next-config', () => ({ + default: { + t: vi.fn((key: string, options?: Record<string, unknown>) => { + if (!options) + return key + return `${key}:${JSON.stringify(options)}` + }), + }, +})) + +vi.mock('@/app/components/workflow/constants', async () => { + const actual = await vi.importActual<typeof import('@/app/components/workflow/constants')>( + '@/app/components/workflow/constants', + ) + return { + ...actual, + isInWorkflowPage: vi.fn(), + } +}) + +vi.mock('./command-bus', () => ({ + registerCommands: vi.fn(), + unregisterCommands: vi.fn(), +})) + +const mockedIsInWorkflowPage = vi.mocked(isInWorkflowPage) +const mockedRegisterCommands = vi.mocked(registerCommands) +const mockedUnregisterCommands = vi.mocked(unregisterCommands) +const mockedT = vi.mocked(i18n.t) + +type CommandArgs = { dsl?: string } +type CommandMap = Record<string, (args?: CommandArgs) => void | Promise<void>> + +beforeEach(() => { + vi.clearAllMocks() +}) + +// Command availability, search, and registration behavior for banana command. +describe('bananaCommand', () => { + // Command metadata mirrors the static definition. + describe('metadata', () => { + it('should expose name, mode, and description', () => { + // Assert + expect(bananaCommand.name).toBe('banana') + expect(bananaCommand.mode).toBe('submenu') + expect(bananaCommand.description).toContain('app.gotoAnything.actions.vibeDesc') + }) + }) + + // Availability mirrors workflow page detection. + describe('availability', () => { + it('should return true when on workflow page', () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + + // Act + const available = bananaCommand.isAvailable?.() + + // Assert + expect(available).toBe(true) + expect(mockedIsInWorkflowPage).toHaveBeenCalledTimes(1) + }) + + it('should return false when not on workflow page', () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(false) + + // Act + const available = bananaCommand.isAvailable?.() + + // Assert + expect(available).toBe(false) + expect(mockedIsInWorkflowPage).toHaveBeenCalledTimes(1) + }) + }) + + // Search results depend on provided arguments. + describe('search', () => { + it('should return hint description when args are empty', async () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + + // Act + const result = await bananaCommand.search(' ') + + // Assert + expect(result).toHaveLength(1) + const [item] = result + expect(item.description).toContain('app.gotoAnything.actions.vibeHint') + expect(item.data?.args?.dsl).toBe('') + expect(item.data?.command).toBe('workflow.vibe') + expect(mockedT).toHaveBeenCalledWith( + 'app.gotoAnything.actions.vibeTitle', + expect.objectContaining({ lng: 'en' }), + ) + expect(mockedT).toHaveBeenCalledWith( + 'app.gotoAnything.actions.vibeHint', + expect.objectContaining({ prompt: expect.any(String), lng: 'en' }), + ) + }) + + it('should return default description when args are provided', async () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + + // Act + const result = await bananaCommand.search(' make a flow ', 'fr') + + // Assert + expect(result).toHaveLength(1) + const [item] = result + expect(item.description).toContain('app.gotoAnything.actions.vibeDesc') + expect(item.data?.args?.dsl).toBe('make a flow') + expect(item.data?.command).toBe('workflow.vibe') + expect(mockedT).toHaveBeenCalledWith( + 'app.gotoAnything.actions.vibeTitle', + expect.objectContaining({ lng: 'fr' }), + ) + expect(mockedT).toHaveBeenCalledWith( + 'app.gotoAnything.actions.vibeDesc', + expect.objectContaining({ lng: 'fr' }), + ) + }) + + it('should fall back to Banana when title translation is empty', async () => { + // Arrange + mockedIsInWorkflowPage.mockReturnValue(true) + mockedT.mockImplementationOnce(() => '') + + // Act + const result = await bananaCommand.search('make a plan') + + // Assert + expect(result).toHaveLength(1) + expect(result[0]?.title).toBe('Banana') + }) + }) + + // Command registration and event dispatching. + describe('registration', () => { + it('should register the workflow vibe command', () => { + // Act + expect(bananaCommand.register).toBeDefined() + bananaCommand.register?.({}) + + // Assert + expect(mockedRegisterCommands).toHaveBeenCalledTimes(1) + const commands = mockedRegisterCommands.mock.calls[0]?.[0] as CommandMap + expect(commands['workflow.vibe']).toEqual(expect.any(Function)) + }) + + it('should dispatch vibe event when command handler runs', async () => { + // Arrange + const dispatchSpy = vi.spyOn(document, 'dispatchEvent') + expect(bananaCommand.register).toBeDefined() + bananaCommand.register?.({}) + expect(mockedRegisterCommands).toHaveBeenCalledTimes(1) + const commands = mockedRegisterCommands.mock.calls[0]?.[0] as CommandMap + + try { + // Act + await commands['workflow.vibe']?.({ dsl: 'hello' }) + + // Assert + expect(dispatchSpy).toHaveBeenCalledTimes(1) + const event = dispatchSpy.mock.calls[0][0] as CustomEvent + expect(event.type).toBe(VIBE_COMMAND_EVENT) + expect(event.detail).toEqual({ dsl: 'hello' }) + } + finally { + dispatchSpy.mockRestore() + } + }) + + it('should unregister workflow vibe command', () => { + // Act + expect(bananaCommand.unregister).toBeDefined() + bananaCommand.unregister?.() + + // Assert + expect(mockedUnregisterCommands).toHaveBeenCalledWith(['workflow.vibe']) + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/banana.tsx b/web/app/components/goto-anything/actions/commands/banana.tsx similarity index 53% rename from web/app/components/goto-anything/actions/banana.tsx rename to web/app/components/goto-anything/actions/commands/banana.tsx index a4b4c21023..65e9972bbf 100644 --- a/web/app/components/goto-anything/actions/banana.tsx +++ b/web/app/components/goto-anything/actions/commands/banana.tsx @@ -1,21 +1,29 @@ -import type { ActionItem } from './types' +import type { SlashCommandHandler } from './types' import { RiSparklingFill } from '@remixicon/react' import * as React from 'react' -import { isInWorkflowPage } from '@/app/components/workflow/constants' +import { isInWorkflowPage, VIBE_COMMAND_EVENT } from '@/app/components/workflow/constants' import i18n from '@/i18n-config/i18next-config' +import { registerCommands, unregisterCommands } from './command-bus' + +type BananaDeps = Record<string, never> const BANANA_PROMPT_EXAMPLE = 'Summarize a document, classify sentiment, then notify Slack' -export const bananaAction: ActionItem = { - key: '@banana', - shortcut: '@banana', - title: i18n.t('app.gotoAnything.actions.vibeTitle'), - description: i18n.t('app.gotoAnything.actions.vibeDesc'), - search: async (_query, searchTerm = '', locale) => { - if (!isInWorkflowPage()) - return [] +const dispatchVibeCommand = (input?: string) => { + if (typeof document === 'undefined') + return - const trimmed = searchTerm.trim() + document.dispatchEvent(new CustomEvent(VIBE_COMMAND_EVENT, { detail: { dsl: input } })) +} + +export const bananaCommand: SlashCommandHandler<BananaDeps> = { + name: 'banana', + description: i18n.t('app.gotoAnything.actions.vibeDesc'), + mode: 'submenu', + isAvailable: () => isInWorkflowPage(), + + async search(args: string, locale: string = 'en') { + const trimmed = args.trim() const hasInput = !!trimmed return [{ @@ -36,4 +44,16 @@ export const bananaAction: ActionItem = { }, }] }, + + register(_deps: BananaDeps) { + registerCommands({ + 'workflow.vibe': async (args) => { + dispatchVibeCommand(args?.dsl) + }, + }) + }, + + unregister() { + unregisterCommands(['workflow.vibe']) + }, } diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index 4c43b5b61e..1cab3a358c 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -5,6 +5,7 @@ import { useEffect } from 'react' import { setLocaleOnClient } from '@/i18n-config' import i18n from '@/i18n-config/i18next-config' import { accountCommand } from './account' +import { bananaCommand } from './banana' import { executeCommand } from './command-bus' import { communityCommand } from './community' import { docsCommand } from './docs' @@ -41,6 +42,7 @@ export const registerSlashCommands = (deps: Record<string, any>) => { slashCommandRegistry.register(communityCommand, {}) slashCommandRegistry.register(accountCommand, {}) slashCommandRegistry.register(zenCommand, {}) + slashCommandRegistry.register(bananaCommand, {}) } export const unregisterSlashCommands = () => { @@ -52,6 +54,7 @@ export const unregisterSlashCommands = () => { slashCommandRegistry.unregister('community') slashCommandRegistry.unregister('account') slashCommandRegistry.unregister('zen') + slashCommandRegistry.unregister('banana') } export const SlashCommandProvider = () => { diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index abf1c077f8..024b6bfd2c 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -160,12 +160,11 @@ * - `@knowledge` / `@kb` - Search knowledge bases * - `@plugin` - Search plugins * - `@node` - Search workflow nodes (workflow pages only) - * - `/` - Execute slash commands (theme, language, etc.) + * - `/` - Execute slash commands (theme, language, banana, etc.) */ import type { ActionItem, SearchResult } from './types' import { appAction } from './app' -import { bananaAction } from './banana' import { slashAction } from './commands' import { slashCommandRegistry } from './commands/registry' import { knowledgeAction } from './knowledge' @@ -192,7 +191,6 @@ export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolea else if (isWorkflowPage) { return { ...baseActions, - banana: bananaAction, node: workflowNodesAction, } } @@ -207,7 +205,6 @@ export const Actions = { app: appAction, knowledge: knowledgeAction, plugin: pluginAction, - banana: bananaAction, node: workflowNodesAction, } @@ -299,4 +296,4 @@ export const matchAction = (query: string, actions: Record<string, ActionItem>) export * from './commands' export * from './types' -export { appAction, bananaAction, knowledgeAction, pluginAction, workflowNodesAction } +export { appAction, knowledgeAction, pluginAction, workflowNodesAction } diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 7c3f77f27f..838195ad85 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -44,7 +44,7 @@ export type CommandSearchResult = { export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult export type ActionItem = { - key: '@app' | '@banana' | '@knowledge' | '@plugin' | '@node' | '/' + key: '@app' | '@knowledge' | '@plugin' | '@node' | '/' shortcut: string title: string | TypeWithI18N description: string diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index 2241e79e42..bef6bab347 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -116,6 +116,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co '/docs': 'app.gotoAnything.actions.docDesc', '/community': 'app.gotoAnything.actions.communityDesc', '/zen': 'app.gotoAnything.actions.zenDesc', + '/banana': 'app.gotoAnything.actions.vibeDesc', } return t((slashKeyMap[item.key] || item.description) as any) })() @@ -127,7 +128,6 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co '@plugin': 'app.gotoAnything.actions.searchPluginsDesc', '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc', '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc', - '@banana': 'app.gotoAnything.actions.vibeDesc', } return t(keyMap[item.key] as any) as string })() From 2c2d63f4bcd3a946271c565216bbe2c180f79010 Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 14:51:45 +0800 Subject: [PATCH 14/29] refactor: centralize action keys and internationalization mappings - Introduced a new `constants.ts` file to centralize action keys and i18n mappings for slash commands and scope actions. - Updated `command-selector` and various action files to utilize the new constants for improved maintainability and readability. - Removed hardcoded strings in favor of the new mappings, ensuring consistency across the application. --- .../components/goto-anything/actions/app.tsx | 5 +- .../actions/commands/language.tsx | 3 +- .../actions/commands/registry.ts | 29 ++++++------ .../goto-anything/actions/commands/slash.tsx | 8 ++-- .../goto-anything/actions/commands/types.ts | 31 ++++++++++++- .../components/goto-anything/actions/index.ts | 5 +- .../goto-anything/actions/knowledge.tsx | 3 +- .../goto-anything/actions/plugin.tsx | 5 +- .../actions/rag-pipeline-nodes.tsx | 5 +- .../components/goto-anything/actions/types.ts | 7 +-- .../goto-anything/actions/workflow-nodes.tsx | 5 +- .../goto-anything/command-selector.tsx | 31 ++----------- web/app/components/goto-anything/constants.ts | 46 +++++++++++++++++++ web/app/components/goto-anything/index.tsx | 3 +- 14 files changed, 125 insertions(+), 61 deletions(-) create mode 100644 web/app/components/goto-anything/constants.ts diff --git a/web/app/components/goto-anything/actions/app.tsx b/web/app/components/goto-anything/actions/app.tsx index 496475eacb..d391556604 100644 --- a/web/app/components/goto-anything/actions/app.tsx +++ b/web/app/components/goto-anything/actions/app.tsx @@ -4,6 +4,7 @@ import { fetchAppList } from '@/service/apps' import { getRedirectionPath } from '@/utils/app-redirection' import { AppTypeIcon } from '../../app/type-selector' import AppIcon from '../../base/app-icon' +import { ACTION_KEYS } from '../constants' const parser = (apps: App[]): AppSearchResult[] => { return apps.map(app => ({ @@ -36,8 +37,8 @@ const parser = (apps: App[]): AppSearchResult[] => { } export const appAction: ActionItem = { - key: '@app', - shortcut: '@app', + key: ACTION_KEYS.APP, + shortcut: ACTION_KEYS.APP, title: 'Search Applications', description: 'Search and navigate to your applications', // action, diff --git a/web/app/components/goto-anything/actions/commands/language.tsx b/web/app/components/goto-anything/actions/commands/language.tsx index f5644a0518..d66a61f722 100644 --- a/web/app/components/goto-anything/actions/commands/language.tsx +++ b/web/app/components/goto-anything/actions/commands/language.tsx @@ -1,12 +1,13 @@ import type { CommandSearchResult } from '../types' import type { SlashCommandHandler } from './types' +import type { Locale } from '@/i18n-config/language' import i18n from '@/i18n-config/i18next-config' import { languages } from '@/i18n-config/language' import { registerCommands, unregisterCommands } from './command-bus' // Language dependency types type LanguageDeps = { - setLocale?: (locale: string) => Promise<void> + setLocale?: (locale: Locale, reloadPage?: boolean) => Promise<void> } const buildLanguageCommands = (query: string): CommandSearchResult[] => { diff --git a/web/app/components/goto-anything/actions/commands/registry.ts b/web/app/components/goto-anything/actions/commands/registry.ts index 51beef4c0b..94321a1916 100644 --- a/web/app/components/goto-anything/actions/commands/registry.ts +++ b/web/app/components/goto-anything/actions/commands/registry.ts @@ -6,20 +6,21 @@ import type { SlashCommandHandler } from './types' * Responsible for managing registration, lookup, and search of all slash commands */ export class SlashCommandRegistry { - private commands = new Map<string, SlashCommandHandler>() - private commandDeps = new Map<string, any>() + private commands = new Map<string, SlashCommandHandler<unknown>>() + private commandDeps = new Map<string, unknown>() /** * Register command handler */ - register<TDeps = any>(handler: SlashCommandHandler<TDeps>, deps?: TDeps) { + register<TDeps = unknown>(handler: SlashCommandHandler<TDeps>, deps?: TDeps) { // Register main command name - this.commands.set(handler.name, handler) + // Cast to unknown first, then to SlashCommandHandler<unknown> to handle generic type variance + this.commands.set(handler.name, handler as SlashCommandHandler<unknown>) // Register aliases if (handler.aliases) { handler.aliases.forEach((alias) => { - this.commands.set(alias, handler) + this.commands.set(alias, handler as SlashCommandHandler<unknown>) }) } @@ -57,7 +58,7 @@ export class SlashCommandRegistry { /** * Find command handler */ - findCommand(commandName: string): SlashCommandHandler | undefined { + findCommand(commandName: string): SlashCommandHandler<unknown> | undefined { return this.commands.get(commandName) } @@ -65,7 +66,7 @@ export class SlashCommandRegistry { * Smart partial command matching * Prioritize alias matching, then match command name prefix */ - private findBestPartialMatch(partialName: string): SlashCommandHandler | undefined { + private findBestPartialMatch(partialName: string): SlashCommandHandler<unknown> | undefined { const lowerPartial = partialName.toLowerCase() // First check if any alias starts with this @@ -81,7 +82,7 @@ export class SlashCommandRegistry { /** * Find handler by alias prefix */ - private findHandlerByAliasPrefix(prefix: string): SlashCommandHandler | undefined { + private findHandlerByAliasPrefix(prefix: string): SlashCommandHandler<unknown> | undefined { for (const handler of this.getAllCommands()) { if (handler.aliases?.some(alias => alias.toLowerCase().startsWith(prefix))) return handler @@ -92,7 +93,7 @@ export class SlashCommandRegistry { /** * Find handler by name prefix */ - private findHandlerByNamePrefix(prefix: string): SlashCommandHandler | undefined { + private findHandlerByNamePrefix(prefix: string): SlashCommandHandler<unknown> | undefined { return this.getAllCommands().find(handler => handler.name.toLowerCase().startsWith(prefix), ) @@ -101,8 +102,8 @@ export class SlashCommandRegistry { /** * Get all registered commands (deduplicated) */ - getAllCommands(): SlashCommandHandler[] { - const uniqueCommands = new Map<string, SlashCommandHandler>() + getAllCommands(): SlashCommandHandler<unknown>[] { + const uniqueCommands = new Map<string, SlashCommandHandler<unknown>>() this.commands.forEach((handler) => { uniqueCommands.set(handler.name, handler) }) @@ -113,7 +114,7 @@ export class SlashCommandRegistry { * Get all available commands in current context (deduplicated and filtered) * Commands without isAvailable method are considered always available */ - getAvailableCommands(): SlashCommandHandler[] { + getAvailableCommands(): SlashCommandHandler<unknown>[] { return this.getAllCommands().filter(handler => this.isCommandAvailable(handler)) } @@ -228,7 +229,7 @@ export class SlashCommandRegistry { /** * Get command dependencies */ - getCommandDependencies(commandName: string): any { + getCommandDependencies(commandName: string): unknown { return this.commandDeps.get(commandName) } @@ -236,7 +237,7 @@ export class SlashCommandRegistry { * Determine if a command is available in the current context. * Defaults to true when a handler does not implement the guard. */ - private isCommandAvailable(handler: SlashCommandHandler) { + private isCommandAvailable(handler: SlashCommandHandler<unknown>) { return handler.isAvailable?.() ?? true } } diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index 1cab3a358c..2eaca0beae 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -1,9 +1,11 @@ 'use client' import type { ActionItem } from '../types' +import type { SlashCommandDependencies } from './types' import { useTheme } from 'next-themes' import { useEffect } from 'react' import { setLocaleOnClient } from '@/i18n-config' import i18n from '@/i18n-config/i18next-config' +import { ACTION_KEYS } from '../../constants' import { accountCommand } from './account' import { bananaCommand } from './banana' import { executeCommand } from './command-bus' @@ -16,8 +18,8 @@ import { themeCommand } from './theme' import { zenCommand } from './zen' export const slashAction: ActionItem = { - key: '/', - shortcut: '/', + key: ACTION_KEYS.SLASH, + shortcut: ACTION_KEYS.SLASH, title: i18n.t('app.gotoAnything.actions.slashTitle'), description: i18n.t('app.gotoAnything.actions.slashDesc'), action: (result) => { @@ -33,7 +35,7 @@ export const slashAction: ActionItem = { } // Register/unregister default handlers for slash commands with external dependencies. -export const registerSlashCommands = (deps: Record<string, any>) => { +export const registerSlashCommands = (deps: SlashCommandDependencies) => { // Register command handlers to the registry system with their respective dependencies slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme }) slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale }) diff --git a/web/app/components/goto-anything/actions/commands/types.ts b/web/app/components/goto-anything/actions/commands/types.ts index 528883c25f..ccf8cdd881 100644 --- a/web/app/components/goto-anything/actions/commands/types.ts +++ b/web/app/components/goto-anything/actions/commands/types.ts @@ -1,10 +1,11 @@ import type { CommandSearchResult } from '../types' +import type { Locale } from '@/i18n-config/language' /** * Slash command handler interface * Each slash command should implement this interface */ -export type SlashCommandHandler<TDeps = any> = { +export type SlashCommandHandler<TDeps = unknown> = { /** Command name (e.g., 'theme', 'language') */ name: string @@ -51,3 +52,31 @@ export type SlashCommandHandler<TDeps = any> = { */ unregister?: () => void } + +/** + * Theme command dependencies + */ +export type ThemeCommandDeps = { + setTheme?: (value: 'light' | 'dark' | 'system') => void +} + +/** + * Language command dependencies + */ +export type LanguageCommandDeps = { + setLocale?: (locale: Locale, reloadPage?: boolean) => Promise<void> +} + +/** + * Commands without external dependencies + */ +export type NoDepsCommandDeps = Record<string, never> + +/** + * Union type of all slash command dependencies + * Used for type-safe dependency injection in registerSlashCommands + */ +export type SlashCommandDependencies = { + setTheme?: (value: 'light' | 'dark' | 'system') => void + setLocale?: (locale: Locale, reloadPage?: boolean) => Promise<void> +} diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index 024b6bfd2c..7c091b92ef 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -164,6 +164,7 @@ */ import type { ActionItem, SearchResult } from './types' +import { ACTION_KEYS } from '../constants' import { appAction } from './app' import { slashAction } from './commands' import { slashCommandRegistry } from './commands/registry' @@ -234,7 +235,7 @@ export const searchAnything = async ( const globalSearchActions = Object.values(dynamicActions || Actions) // Exclude slash commands from general search results - .filter(action => action.key !== '/') + .filter(action => action.key !== ACTION_KEYS.SLASH) // Use Promise.allSettled to handle partial failures gracefully const searchPromises = globalSearchActions.map(async (action) => { @@ -272,7 +273,7 @@ export const searchAnything = async ( export const matchAction = (query: string, actions: Record<string, ActionItem>) => { return Object.values(actions).find((action) => { // Special handling for slash commands - if (action.key === '/') { + if (action.key === ACTION_KEYS.SLASH) { // Get all registered commands from the registry const allCommands = slashCommandRegistry.getAllCommands() diff --git a/web/app/components/goto-anything/actions/knowledge.tsx b/web/app/components/goto-anything/actions/knowledge.tsx index 9531a3551f..c338386446 100644 --- a/web/app/components/goto-anything/actions/knowledge.tsx +++ b/web/app/components/goto-anything/actions/knowledge.tsx @@ -3,6 +3,7 @@ import type { DataSet } from '@/models/datasets' import { fetchDatasets } from '@/service/datasets' import { cn } from '@/utils/classnames' import { Folder } from '../../base/icons/src/vender/solid/files' +import { ACTION_KEYS } from '../constants' const EXTERNAL_PROVIDER = 'external' as const const isExternalProvider = (provider: string): boolean => provider === EXTERNAL_PROVIDER @@ -31,7 +32,7 @@ const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => { } export const knowledgeAction: ActionItem = { - key: '@knowledge', + key: ACTION_KEYS.KNOWLEDGE, shortcut: '@kb', title: 'Search Knowledge Bases', description: 'Search and navigate to your knowledge bases', diff --git a/web/app/components/goto-anything/actions/plugin.tsx b/web/app/components/goto-anything/actions/plugin.tsx index 07197b8198..7c3baa2381 100644 --- a/web/app/components/goto-anything/actions/plugin.tsx +++ b/web/app/components/goto-anything/actions/plugin.tsx @@ -4,6 +4,7 @@ import { renderI18nObject } from '@/i18n-config' import { postMarketplace } from '@/service/base' import Icon from '../../plugins/card/base/card-icon' import { getPluginIconInMarketplace } from '../../plugins/marketplace/utils' +import { ACTION_KEYS } from '../constants' const parser = (plugins: Plugin[], locale: string): PluginSearchResult[] => { return plugins.map((plugin) => { @@ -19,8 +20,8 @@ const parser = (plugins: Plugin[], locale: string): PluginSearchResult[] => { } export const pluginAction: ActionItem = { - key: '@plugin', - shortcut: '@plugin', + key: ACTION_KEYS.PLUGIN, + shortcut: ACTION_KEYS.PLUGIN, title: 'Search Plugins', description: 'Search and navigate to your plugins', search: async (_, searchTerm = '', locale) => { diff --git a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx index dc632e4999..430b7087cb 100644 --- a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx +++ b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx @@ -1,9 +1,10 @@ import type { ActionItem } from './types' +import { ACTION_KEYS } from '../constants' // Create the RAG pipeline nodes action export const ragPipelineNodesAction: ActionItem = { - key: '@node', - shortcut: '@node', + key: ACTION_KEYS.NODE, + shortcut: ACTION_KEYS.NODE, title: 'Search RAG Pipeline Nodes', description: 'Find and jump to nodes in the current RAG pipeline by name or type', searchFn: undefined, // Will be set by useRagPipelineSearch hook diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 838195ad85..1963a808fb 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -2,12 +2,13 @@ import type { ReactNode } from 'react' import type { TypeWithI18N } from '../../base/form/types' import type { Plugin } from '../../plugins/types' import type { CommonNodeType } from '../../workflow/types' +import type { ActionKey } from '../constants' import type { DataSet } from '@/models/datasets' import type { App } from '@/types/app' export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command' -export type BaseSearchResult<T = any> = { +export type BaseSearchResult<T = unknown> = { id: string title: string description?: string @@ -39,12 +40,12 @@ export type WorkflowNodeSearchResult = { export type CommandSearchResult = { type: 'command' -} & BaseSearchResult<{ command: string, args?: Record<string, any> }> +} & BaseSearchResult<{ command: string, args?: Record<string, unknown> }> export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult export type ActionItem = { - key: '@app' | '@knowledge' | '@plugin' | '@node' | '/' + key: ActionKey shortcut: string title: string | TypeWithI18N description: string diff --git a/web/app/components/goto-anything/actions/workflow-nodes.tsx b/web/app/components/goto-anything/actions/workflow-nodes.tsx index b9aa61705b..9b743f0108 100644 --- a/web/app/components/goto-anything/actions/workflow-nodes.tsx +++ b/web/app/components/goto-anything/actions/workflow-nodes.tsx @@ -1,9 +1,10 @@ import type { ActionItem } from './types' +import { ACTION_KEYS } from '../constants' // Create the workflow nodes action export const workflowNodesAction: ActionItem = { - key: '@node', - shortcut: '@node', + key: ACTION_KEYS.NODE, + shortcut: ACTION_KEYS.NODE, title: 'Search Workflow Nodes', description: 'Find and jump to nodes in the current workflow by name or type', searchFn: undefined, // Will be set by useWorkflowSearch hook diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index bef6bab347..fbc2339b28 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation' import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { slashCommandRegistry } from './actions/commands/registry' +import { ACTION_KEYS, SCOPE_ACTION_I18N_MAP, SLASH_COMMAND_I18N_MAP } from './constants' type Props = { actions: Record<string, ActionItem> @@ -49,7 +50,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co return Object.values(actions).filter((action) => { // Exclude slash action when in @ mode - if (action.key === '/') + if (action.key === ACTION_KEYS.SLASH) return false if (!searchFilter) return true @@ -106,32 +107,8 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co </span> <span className="ml-3 text-sm text-text-secondary"> {isSlashMode - ? ( - (() => { - const slashKeyMap: Record<string, string> = { - '/theme': 'app.gotoAnything.actions.themeCategoryDesc', - '/language': 'app.gotoAnything.actions.languageChangeDesc', - '/account': 'app.gotoAnything.actions.accountDesc', - '/feedback': 'app.gotoAnything.actions.feedbackDesc', - '/docs': 'app.gotoAnything.actions.docDesc', - '/community': 'app.gotoAnything.actions.communityDesc', - '/zen': 'app.gotoAnything.actions.zenDesc', - '/banana': 'app.gotoAnything.actions.vibeDesc', - } - return t((slashKeyMap[item.key] || item.description) as any) - })() - ) - : ( - (() => { - const keyMap: Record<string, string> = { - '@app': 'app.gotoAnything.actions.searchApplicationsDesc', - '@plugin': 'app.gotoAnything.actions.searchPluginsDesc', - '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc', - '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc', - } - return t(keyMap[item.key] as any) as string - })() - )} + ? t((SLASH_COMMAND_I18N_MAP[item.key] || item.description) as any) + : t((SCOPE_ACTION_I18N_MAP[item.key] || item.description) as any)} </span> </Command.Item> ))} diff --git a/web/app/components/goto-anything/constants.ts b/web/app/components/goto-anything/constants.ts new file mode 100644 index 0000000000..39fbc601fe --- /dev/null +++ b/web/app/components/goto-anything/constants.ts @@ -0,0 +1,46 @@ +/** + * Goto Anything Constants + * Centralized constants for action keys, command mappings, and i18n keys + */ + +/** + * Action keys for scope-based searches + */ +export const ACTION_KEYS = { + APP: '@app', + KNOWLEDGE: '@knowledge', + PLUGIN: '@plugin', + NODE: '@node', + SLASH: '/', +} as const + +/** + * Type-safe action key union type + */ +export type ActionKey = typeof ACTION_KEYS[keyof typeof ACTION_KEYS] + +/** + * Slash command i18n key mappings + * Maps slash command keys to their corresponding i18n translation keys + */ +export const SLASH_COMMAND_I18N_MAP: Record<string, string> = { + '/theme': 'app.gotoAnything.actions.themeCategoryDesc', + '/language': 'app.gotoAnything.actions.languageChangeDesc', + '/account': 'app.gotoAnything.actions.accountDesc', + '/feedback': 'app.gotoAnything.actions.feedbackDesc', + '/docs': 'app.gotoAnything.actions.docDesc', + '/community': 'app.gotoAnything.actions.communityDesc', + '/zen': 'app.gotoAnything.actions.zenDesc', + '/banana': 'app.gotoAnything.actions.vibeDesc', +} as const + +/** + * Scope action i18n key mappings + * Maps scope action keys to their corresponding i18n translation keys + */ +export const SCOPE_ACTION_I18N_MAP: Record<string, string> = { + '@app': 'app.gotoAnything.actions.searchApplicationsDesc', + '@plugin': 'app.gotoAnything.actions.searchPluginsDesc', + '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc', + '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc', +} as const diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index a87340f3d2..8552ba4585 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -21,6 +21,7 @@ import { createActions, matchAction, searchAnything } from './actions' import { SlashCommandProvider } from './actions/commands' import { slashCommandRegistry } from './actions/commands/registry' import CommandSelector from './command-selector' +import { ACTION_KEYS } from './constants' import { GotoAnythingProvider, useGotoAnythingContext } from './context' type Props = { @@ -98,7 +99,7 @@ const GotoAnything: FC<Props> = ({ if (!action) return 'general' - return action.key === '/' ? '@command' : action.key + return action.key === ACTION_KEYS.SLASH ? '@command' : action.key }, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery]) const { data: searchResults = [], isLoading, isError, error } = useQuery( From e2e0eb51de020136cb26470ad34afc3eda83dbb4 Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 15:06:32 +0800 Subject: [PATCH 15/29] fix: enhance version management and validation in workflow hooks - Updated `useVibeFlowData` to prevent adding empty graphs and ensure the current version is correctly derived from available versions. - Improved error handling in `applyFlowchartToWorkflow` to notify users when the current flow graph is invalid. - Added checks to only add valid workflow graphs to the versions list, enhancing data integrity. --- .../workflow/hooks/use-workflow-vibe.tsx | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/web/app/components/workflow/hooks/use-workflow-vibe.tsx b/web/app/components/workflow/hooks/use-workflow-vibe.tsx index a85a875747..5fec88556a 100644 --- a/web/app/components/workflow/hooks/use-workflow-vibe.tsx +++ b/web/app/components/workflow/hooks/use-workflow-vibe.tsx @@ -297,14 +297,25 @@ export const useVibeFlowData = ({ storageKey }: UseVibeFlowDataParams) => { defaultValue: 0, }) - const current = versions?.[currentVersionIndex || 0] + const current = useMemo(() => { + if (!versions || versions.length === 0) + return undefined + const index = currentVersionIndex ?? 0 + return versions[index] || versions[versions.length - 1] + }, [versions, currentVersionIndex]) const addVersion = useCallback((version: FlowGraph) => { - setCurrentVersionIndex(() => versions?.length || 0) + // Prevent adding empty graphs + if (!version || !version.nodes || version.nodes.length === 0) + return + setVersions((prev) => { - return [...prev!, version] + const newVersions = [...(prev || []), version] + // Set index in setVersions callback to ensure using the latest length + setCurrentVersionIndex(newVersions.length - 1) + return newVersions }) - }, [setVersions, setCurrentVersionIndex, versions?.length]) + }, [setVersions, setCurrentVersionIndex]) return { versions, @@ -672,6 +683,11 @@ export const useWorkflowVibe = () => { }, [nodeTypeLookup, toolLookup]) const applyFlowchartToWorkflow = useCallback(() => { + if (!currentFlowGraph || !currentFlowGraph.nodes || currentFlowGraph.nodes.length === 0) { + Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') }) + return + } + const { setNodes, setEdges } = store.getState() const vibePanelPreviewNodes = currentFlowGraph.nodes || [] const vibePanelPreviewEdges = currentFlowGraph.edges || [] @@ -687,6 +703,7 @@ export const useWorkflowVibe = () => { vibePanelMermaidCode: '', })) }, [ + currentFlowGraph, handleSyncWorkflowDraft, nodeTypeLookup, nodesMetaDataMap, @@ -794,7 +811,10 @@ export const useWorkflowVibe = () => { })) const workflowGraph = await flowchartToWorkflowGraph(mermaidCode) - addVersion(workflowGraph) + // Only add to versions if workflowGraph contains nodes + if (workflowGraph && workflowGraph.nodes && workflowGraph.nodes.length > 0) { + addVersion(workflowGraph) + } if (skipPanelPreview) applyFlowchartToWorkflow() From de84b031f46b7e73177813b4ff11d7c69944cec9 Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 15:23:14 +0800 Subject: [PATCH 16/29] fix: prevent stale vibe preview on invalid flow --- .../workflow/hooks/use-workflow-vibe.tsx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/web/app/components/workflow/hooks/use-workflow-vibe.tsx b/web/app/components/workflow/hooks/use-workflow-vibe.tsx index 5fec88556a..bdf6a4ceb2 100644 --- a/web/app/components/workflow/hooks/use-workflow-vibe.tsx +++ b/web/app/components/workflow/hooks/use-workflow-vibe.tsx @@ -297,17 +297,36 @@ export const useVibeFlowData = ({ storageKey }: UseVibeFlowDataParams) => { defaultValue: 0, }) + useEffect(() => { + if (!versions || versions.length === 0) { + if (currentVersionIndex !== 0 && currentVersionIndex !== -1) + setCurrentVersionIndex(0) + return + } + + if (currentVersionIndex === -1) + return + + const normalizedIndex = Math.min(Math.max(currentVersionIndex ?? 0, 0), versions.length - 1) + if (normalizedIndex !== currentVersionIndex) + setCurrentVersionIndex(normalizedIndex) + }, [versions, currentVersionIndex, setCurrentVersionIndex]) + const current = useMemo(() => { if (!versions || versions.length === 0) return undefined const index = currentVersionIndex ?? 0 + if (index < 0) + return undefined return versions[index] || versions[versions.length - 1] }, [versions, currentVersionIndex]) const addVersion = useCallback((version: FlowGraph) => { // Prevent adding empty graphs - if (!version || !version.nodes || version.nodes.length === 0) + if (!version || !version.nodes || version.nodes.length === 0) { + setCurrentVersionIndex(-1) return + } setVersions((prev) => { const newVersions = [...(prev || []), version] @@ -811,10 +830,7 @@ export const useWorkflowVibe = () => { })) const workflowGraph = await flowchartToWorkflowGraph(mermaidCode) - // Only add to versions if workflowGraph contains nodes - if (workflowGraph && workflowGraph.nodes && workflowGraph.nodes.length > 0) { - addVersion(workflowGraph) - } + addVersion(workflowGraph) if (skipPanelPreview) applyFlowchartToWorkflow() From f4427241b185a8f1ffa38f97e8e6ae7e18705c01 Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 15:27:43 +0800 Subject: [PATCH 17/29] chore: test for vibe-panel --- .../workflow/panel/vibe-panel/index.spec.tsx | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 web/app/components/workflow/panel/vibe-panel/index.spec.tsx diff --git a/web/app/components/workflow/panel/vibe-panel/index.spec.tsx b/web/app/components/workflow/panel/vibe-panel/index.spec.tsx new file mode 100644 index 0000000000..f47a171f28 --- /dev/null +++ b/web/app/components/workflow/panel/vibe-panel/index.spec.tsx @@ -0,0 +1,323 @@ +/** + * VibePanel Component Tests + * + * Covers rendering states, user interactions, and edge cases for the vibe panel. + */ + +import type { Shape as WorkflowState } from '@/app/components/workflow/store/workflow' +import type { Edge, Node } from '@/app/components/workflow/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Toast from '@/app/components/base/toast' +import { WorkflowContext } from '@/app/components/workflow/context' +import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider' +import { createHooksStore } from '@/app/components/workflow/hooks-store/store' +import { createWorkflowStore } from '@/app/components/workflow/store/workflow' +import { BlockEnum } from '@/app/components/workflow/types' +import { VIBE_APPLY_EVENT, VIBE_COMMAND_EVENT } from '../../constants' +import VibePanel from './index' + +// ============================================================================ +// Mocks +// ============================================================================ + +const mockCopy = vi.hoisted(() => vi.fn()) +const mockUseVibeFlowData = vi.hoisted(() => vi.fn()) + +vi.mock('copy-to-clipboard', () => ({ + default: mockCopy, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ defaultModel: null }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ + __esModule: true, + default: ({ modelId, provider }: { modelId: string, provider: string }) => ( + <div data-testid="model-parameter-modal" data-model-id={modelId} data-provider={provider} /> + ), +})) + +vi.mock('@/app/components/workflow/hooks/use-workflow-vibe', () => ({ + useVibeFlowData: () => mockUseVibeFlowData(), +})) + +vi.mock('@/app/components/workflow/workflow-preview', () => ({ + __esModule: true, + default: ({ nodes, edges }: { nodes: Node[], edges: Edge[] }) => ( + <div data-testid="workflow-preview" data-nodes-count={nodes.length} data-edges-count={edges.length} /> + ), +})) + +// ============================================================================ +// Test Utilities +// ============================================================================ + +type FlowGraph = { + nodes: Node[] + edges: Edge[] +} + +type VibeFlowData = { + versions: FlowGraph[] + currentVersionIndex: number + setCurrentVersionIndex: (index: number) => void + current?: FlowGraph +} + +const createMockNode = (overrides: Partial<Node> = {}): Node => ({ + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + title: 'Start', + desc: '', + type: BlockEnum.Start, + }, + ...overrides, +}) + +const createMockEdge = (overrides: Partial<Edge> = {}): Edge => ({ + id: 'edge-1', + source: 'node-1', + target: 'node-2', + data: { + sourceType: BlockEnum.Start, + targetType: BlockEnum.End, + }, + ...overrides, +}) + +const createFlowGraph = (overrides: Partial<FlowGraph> = {}): FlowGraph => ({ + nodes: [], + edges: [], + ...overrides, +}) + +const createVibeFlowData = (overrides: Partial<VibeFlowData> = {}): VibeFlowData => ({ + versions: [], + currentVersionIndex: 0, + setCurrentVersionIndex: vi.fn(), + current: undefined, + ...overrides, +}) + +const renderVibePanel = ({ + workflowState, + vibeFlowData, +}: { + workflowState?: Partial<WorkflowState> + vibeFlowData?: VibeFlowData +} = {}) => { + if (vibeFlowData) + mockUseVibeFlowData.mockReturnValue(vibeFlowData) + + const workflowStore = createWorkflowStore({}) + workflowStore.setState({ + showVibePanel: true, + isVibeGenerating: false, + vibePanelInstruction: '', + vibePanelMermaidCode: '', + ...workflowState, + }) + + const hooksStore = createHooksStore({}) + + return { + workflowStore, + ...render( + <WorkflowContext.Provider value={workflowStore}> + <HooksStoreContext.Provider value={hooksStore}> + <VibePanel /> + </HooksStoreContext.Provider> + </WorkflowContext.Provider>, + ), + } +} + +const getCopyButton = () => { + const buttons = screen.getAllByRole('button') + const copyButton = buttons.find(button => button.textContent?.trim() === '' && button.querySelector('svg')) + if (!copyButton) + throw new Error('Copy button not found') + return copyButton +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('VibePanel', () => { + let toastNotifySpy: ReturnType<typeof vi.spyOn> + + beforeEach(() => { + vi.clearAllMocks() + mockUseVibeFlowData.mockReturnValue(createVibeFlowData()) + toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + }) + + afterEach(() => { + toastNotifySpy.mockRestore() + }) + + // -------------------------------------------------------------------------- + // Rendering: default visibility and primary view states. + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render nothing when panel is hidden', () => { + renderVibePanel({ workflowState: { showVibePanel: false } }) + + expect(screen.queryByText(/app\.gotoAnything\.actions\.vibeTitle/i)).not.toBeInTheDocument() + }) + + it('should render placeholder when no preview data and not generating', () => { + renderVibePanel({ + workflowState: { showVibePanel: true, isVibeGenerating: false }, + vibeFlowData: createVibeFlowData({ current: undefined }), + }) + + expect(screen.getByText(/appDebug\.generate\.newNoDataLine1/i)).toBeInTheDocument() + }) + + it('should render loading state when generating', () => { + renderVibePanel({ + workflowState: { showVibePanel: true, isVibeGenerating: true }, + }) + + expect(screen.getByText(/workflow\.vibe\.generatingFlowchart/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'appDebug.generate.generate' })).toBeDisabled() + }) + + it('should render preview panel when nodes exist', () => { + const flowGraph = createFlowGraph({ + nodes: [createMockNode()], + edges: [createMockEdge()], + }) + + renderVibePanel({ + vibeFlowData: createVibeFlowData({ + current: flowGraph, + versions: [flowGraph], + }), + }) + + expect(screen.getByTestId('workflow-preview')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'workflow.vibe.apply' })).toBeInTheDocument() + expect(screen.getByText(/appDebug\.generate\.version/i)).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Props: store-driven inputs that toggle behavior. + // -------------------------------------------------------------------------- + describe('Props', () => { + it('should render modal content when showVibePanel is true', () => { + renderVibePanel({ workflowState: { showVibePanel: true } }) + + expect(screen.getByText(/app\.gotoAnything\.actions\.vibeTitle/i)).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // User Interactions: input edits and action triggers. + // -------------------------------------------------------------------------- + describe('User Interactions', () => { + it('should update instruction in store when typing', async () => { + const user = userEvent.setup() + const { workflowStore } = renderVibePanel() + + const textarea = screen.getByPlaceholderText('workflow.vibe.missingInstruction') + await user.type(textarea, 'Build a vibe flow') + + expect(workflowStore.getState().vibePanelInstruction).toBe('Build a vibe flow') + }) + + it('should dispatch command event with instruction when generate clicked', async () => { + const user = userEvent.setup() + const { workflowStore } = renderVibePanel({ + workflowState: { vibePanelInstruction: 'Generate a workflow' }, + }) + + const handler = vi.fn() + document.addEventListener(VIBE_COMMAND_EVENT, handler) + + await user.click(screen.getByRole('button', { name: 'appDebug.generate.generate' })) + + expect(handler).toHaveBeenCalledTimes(1) + const event = handler.mock.calls[0][0] as CustomEvent<{ dsl?: string }> + expect(event.detail).toEqual({ dsl: workflowStore.getState().vibePanelInstruction }) + + document.removeEventListener(VIBE_COMMAND_EVENT, handler) + }) + + it('should close panel when dismiss clicked', async () => { + const user = userEvent.setup() + const { workflowStore } = renderVibePanel({ + workflowState: { + vibePanelMermaidCode: 'graph TD', + isVibeGenerating: true, + }, + }) + + await user.click(screen.getByRole('button', { name: 'appDebug.generate.dismiss' })) + + const state = workflowStore.getState() + expect(state.showVibePanel).toBe(false) + expect(state.vibePanelMermaidCode).toBe('') + expect(state.isVibeGenerating).toBe(false) + }) + + it('should dispatch apply event and close panel when apply clicked', async () => { + const user = userEvent.setup() + const flowGraph = createFlowGraph({ + nodes: [createMockNode()], + edges: [createMockEdge()], + }) + const { workflowStore } = renderVibePanel({ + workflowState: { vibePanelMermaidCode: 'graph TD' }, + vibeFlowData: createVibeFlowData({ + current: flowGraph, + versions: [flowGraph], + }), + }) + + const handler = vi.fn() + document.addEventListener(VIBE_APPLY_EVENT, handler) + + await user.click(screen.getByRole('button', { name: 'workflow.vibe.apply' })) + + expect(handler).toHaveBeenCalledTimes(1) + const state = workflowStore.getState() + expect(state.showVibePanel).toBe(false) + expect(state.vibePanelMermaidCode).toBe('') + expect(state.isVibeGenerating).toBe(false) + + document.removeEventListener(VIBE_APPLY_EVENT, handler) + }) + + it('should copy mermaid and notify when copy clicked', async () => { + const user = userEvent.setup() + const flowGraph = createFlowGraph({ + nodes: [createMockNode()], + edges: [createMockEdge()], + }) + + renderVibePanel({ + workflowState: { vibePanelMermaidCode: 'graph TD' }, + vibeFlowData: createVibeFlowData({ + current: flowGraph, + versions: [flowGraph], + }), + }) + + await user.click(getCopyButton()) + + expect(mockCopy).toHaveBeenCalledWith('graph TD') + expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + message: 'common.actionMsg.copySuccessfully', + })) + }) + }) +}) From 6b6d6e9434c01d4ff4ae838c98d4fda761a5403d Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 15:36:02 +0800 Subject: [PATCH 18/29] fix: sync vibe apply immediately --- web/app/components/workflow/hooks/use-workflow-vibe.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/workflow/hooks/use-workflow-vibe.tsx b/web/app/components/workflow/hooks/use-workflow-vibe.tsx index bdf6a4ceb2..010e5ca53c 100644 --- a/web/app/components/workflow/hooks/use-workflow-vibe.tsx +++ b/web/app/components/workflow/hooks/use-workflow-vibe.tsx @@ -714,7 +714,7 @@ export const useWorkflowVibe = () => { setNodes(vibePanelPreviewNodes) setEdges(vibePanelPreviewEdges) saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: vibePanelPreviewNodes[0].id }) - handleSyncWorkflowDraft() + handleSyncWorkflowDraft(true, true) workflowStore.setState(state => ({ ...state, From 0fd8d5c4e46e8a2e6e8997bc16e2be575341ab21 Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 22:12:37 +0800 Subject: [PATCH 19/29] chore: remove duplicate tests --- .../goto-anything/command-selector.test.tsx | 326 ------------------ .../goto-anything/match-action.test.ts | 236 ------------- 2 files changed, 562 deletions(-) delete mode 100644 web/__tests__/goto-anything/command-selector.test.tsx delete mode 100644 web/__tests__/goto-anything/match-action.test.ts diff --git a/web/__tests__/goto-anything/command-selector.test.tsx b/web/__tests__/goto-anything/command-selector.test.tsx deleted file mode 100644 index f0168ab3be..0000000000 --- a/web/__tests__/goto-anything/command-selector.test.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import type { ActionItem } from '../../app/components/goto-anything/actions/types' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import CommandSelector from '../../app/components/goto-anything/command-selector' - -vi.mock('cmdk', () => ({ - Command: { - Group: ({ children, className }: any) => <div className={className}>{children}</div>, - Item: ({ children, onSelect, value, className }: any) => ( - <div - className={className} - onClick={() => onSelect?.()} - data-value={value} - data-testid={`command-item-${value}`} - > - {children} - </div> - ), - }, -})) - -describe('CommandSelector', () => { - const mockActions: Record<string, ActionItem> = { - app: { - key: '@app', - shortcut: '@app', - title: 'Search Applications', - description: 'Search apps', - search: vi.fn(), - }, - knowledge: { - key: '@knowledge', - shortcut: '@kb', - title: 'Search Knowledge', - description: 'Search knowledge bases', - search: vi.fn(), - }, - plugin: { - key: '@plugin', - shortcut: '@plugin', - title: 'Search Plugins', - description: 'Search plugins', - search: vi.fn(), - }, - node: { - key: '@node', - shortcut: '@node', - title: 'Search Nodes', - description: 'Search workflow nodes', - search: vi.fn(), - }, - } - - const mockOnCommandSelect = vi.fn() - const mockOnCommandValueChange = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Basic Rendering', () => { - it('should render all actions when no filter is provided', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - />, - ) - - expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() - expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument() - expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument() - expect(screen.getByTestId('command-item-@node')).toBeInTheDocument() - }) - - it('should render empty filter as showing all actions', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="" - />, - ) - - expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() - expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument() - expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument() - expect(screen.getByTestId('command-item-@node')).toBeInTheDocument() - }) - }) - - describe('Filtering Functionality', () => { - it('should filter actions based on searchFilter - single match', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="k" - />, - ) - - expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument() - expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument() - expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument() - expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument() - }) - - it('should filter actions with multiple matches', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="p" - />, - ) - - expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() - expect(screen.queryByTestId('command-item-@kb')).not.toBeInTheDocument() - expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument() - expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument() - }) - - it('should be case-insensitive when filtering', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="APP" - />, - ) - - expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() - expect(screen.queryByTestId('command-item-@kb')).not.toBeInTheDocument() - }) - - it('should match partial strings', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="od" - />, - ) - - expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument() - expect(screen.queryByTestId('command-item-@kb')).not.toBeInTheDocument() - expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument() - expect(screen.getByTestId('command-item-@node')).toBeInTheDocument() - }) - }) - - describe('Empty State', () => { - it('should show empty state when no matches found', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="xyz" - />, - ) - - expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument() - expect(screen.queryByTestId('command-item-@kb')).not.toBeInTheDocument() - expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument() - expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument() - - expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() - expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument() - }) - - it('should not show empty state when filter is empty', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="" - />, - ) - - expect(screen.queryByText('app.gotoAnything.noMatchingCommands')).not.toBeInTheDocument() - }) - }) - - describe('Selection and Highlight Management', () => { - it('should call onCommandValueChange when filter changes and first item differs', () => { - const { rerender } = render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="" - commandValue="@app" - onCommandValueChange={mockOnCommandValueChange} - />, - ) - - rerender( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="k" - commandValue="@app" - onCommandValueChange={mockOnCommandValueChange} - />, - ) - - expect(mockOnCommandValueChange).toHaveBeenCalledWith('@kb') - }) - - it('should not call onCommandValueChange if current value still exists', () => { - const { rerender } = render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="" - commandValue="@app" - onCommandValueChange={mockOnCommandValueChange} - />, - ) - - rerender( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="a" - commandValue="@app" - onCommandValueChange={mockOnCommandValueChange} - />, - ) - - expect(mockOnCommandValueChange).not.toHaveBeenCalled() - }) - - it('should handle onCommandSelect callback correctly', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="k" - />, - ) - - const knowledgeItem = screen.getByTestId('command-item-@kb') - fireEvent.click(knowledgeItem) - - expect(mockOnCommandSelect).toHaveBeenCalledWith('@kb') - }) - }) - - describe('Edge Cases', () => { - it('should handle empty actions object', () => { - render( - <CommandSelector - actions={{}} - onCommandSelect={mockOnCommandSelect} - searchFilter="" - />, - ) - - expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() - }) - - it('should handle special characters in filter', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="@" - />, - ) - - expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() - expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument() - expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument() - expect(screen.getByTestId('command-item-@node')).toBeInTheDocument() - }) - - it('should handle undefined onCommandValueChange gracefully', () => { - const { rerender } = render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="" - />, - ) - - expect(() => { - rerender( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="k" - />, - ) - }).not.toThrow() - }) - }) - - describe('Backward Compatibility', () => { - it('should work without searchFilter prop (backward compatible)', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - />, - ) - - expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() - expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument() - expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument() - expect(screen.getByTestId('command-item-@node')).toBeInTheDocument() - }) - - it('should work without commandValue and onCommandValueChange props', () => { - render( - <CommandSelector - actions={mockActions} - onCommandSelect={mockOnCommandSelect} - searchFilter="k" - />, - ) - - expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument() - expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument() - }) - }) -}) diff --git a/web/__tests__/goto-anything/match-action.test.ts b/web/__tests__/goto-anything/match-action.test.ts deleted file mode 100644 index 66b170d45e..0000000000 --- a/web/__tests__/goto-anything/match-action.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { Mock } from 'vitest' -import type { ActionItem } from '../../app/components/goto-anything/actions/types' - -// Import after mocking to get mocked version -import { matchAction } from '../../app/components/goto-anything/actions' -import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry' - -// Mock the entire actions module to avoid import issues -vi.mock('../../app/components/goto-anything/actions', () => ({ - matchAction: vi.fn(), -})) - -vi.mock('../../app/components/goto-anything/actions/commands/registry') - -// Implement the actual matchAction logic for testing -const actualMatchAction = (query: string, actions: Record<string, ActionItem>) => { - const result = Object.values(actions).find((action) => { - // Special handling for slash commands - if (action.key === '/') { - // Get all registered commands from the registry - const allCommands = slashCommandRegistry.getAllCommands() - - // Check if query matches any registered command - return allCommands.some((cmd) => { - const cmdPattern = `/${cmd.name}` - - // For direct mode commands, don't match (keep in command selector) - if (cmd.mode === 'direct') - return false - - // For submenu mode commands, match when complete command is entered - return query === cmdPattern || query.startsWith(`${cmdPattern} `) - }) - } - - const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`) - return reg.test(query) - }) - return result -} - -// Replace mock with actual implementation -;(matchAction as Mock).mockImplementation(actualMatchAction) - -describe('matchAction Logic', () => { - const mockActions: Record<string, ActionItem> = { - app: { - key: '@app', - shortcut: '@a', - title: 'Search Applications', - description: 'Search apps', - search: vi.fn(), - }, - knowledge: { - key: '@knowledge', - shortcut: '@kb', - title: 'Search Knowledge', - description: 'Search knowledge bases', - search: vi.fn(), - }, - slash: { - key: '/', - shortcut: '/', - title: 'Commands', - description: 'Execute commands', - search: vi.fn(), - }, - } - - beforeEach(() => { - vi.clearAllMocks() - ;(slashCommandRegistry.getAllCommands as Mock).mockReturnValue([ - { name: 'docs', mode: 'direct' }, - { name: 'community', mode: 'direct' }, - { name: 'feedback', mode: 'direct' }, - { name: 'account', mode: 'direct' }, - { name: 'theme', mode: 'submenu' }, - { name: 'language', mode: 'submenu' }, - ]) - }) - - describe('@ Actions Matching', () => { - it('should match @app with key', () => { - const result = matchAction('@app', mockActions) - expect(result).toBe(mockActions.app) - }) - - it('should match @app with shortcut', () => { - const result = matchAction('@a', mockActions) - expect(result).toBe(mockActions.app) - }) - - it('should match @knowledge with key', () => { - const result = matchAction('@knowledge', mockActions) - expect(result).toBe(mockActions.knowledge) - }) - - it('should match @knowledge with shortcut @kb', () => { - const result = matchAction('@kb', mockActions) - expect(result).toBe(mockActions.knowledge) - }) - - it('should match with text after action', () => { - const result = matchAction('@app search term', mockActions) - expect(result).toBe(mockActions.app) - }) - - it('should not match partial @ actions', () => { - const result = matchAction('@ap', mockActions) - expect(result).toBeUndefined() - }) - }) - - describe('Slash Commands Matching', () => { - describe('Direct Mode Commands', () => { - it('should not match direct mode commands', () => { - const result = matchAction('/docs', mockActions) - expect(result).toBeUndefined() - }) - - it('should not match direct mode with arguments', () => { - const result = matchAction('/docs something', mockActions) - expect(result).toBeUndefined() - }) - - it('should not match any direct mode command', () => { - expect(matchAction('/community', mockActions)).toBeUndefined() - expect(matchAction('/feedback', mockActions)).toBeUndefined() - expect(matchAction('/account', mockActions)).toBeUndefined() - }) - }) - - describe('Submenu Mode Commands', () => { - it('should match submenu mode commands exactly', () => { - const result = matchAction('/theme', mockActions) - expect(result).toBe(mockActions.slash) - }) - - it('should match submenu mode with arguments', () => { - const result = matchAction('/theme dark', mockActions) - expect(result).toBe(mockActions.slash) - }) - - it('should match all submenu commands', () => { - expect(matchAction('/language', mockActions)).toBe(mockActions.slash) - expect(matchAction('/language en', mockActions)).toBe(mockActions.slash) - }) - }) - - describe('Slash Without Command', () => { - it('should not match single slash', () => { - const result = matchAction('/', mockActions) - expect(result).toBeUndefined() - }) - - it('should not match unregistered commands', () => { - const result = matchAction('/unknown', mockActions) - expect(result).toBeUndefined() - }) - }) - }) - - describe('Edge Cases', () => { - it('should handle empty query', () => { - const result = matchAction('', mockActions) - expect(result).toBeUndefined() - }) - - it('should handle whitespace only', () => { - const result = matchAction(' ', mockActions) - expect(result).toBeUndefined() - }) - - it('should handle regular text without actions', () => { - const result = matchAction('search something', mockActions) - expect(result).toBeUndefined() - }) - - it('should handle special characters', () => { - const result = matchAction('#tag', mockActions) - expect(result).toBeUndefined() - }) - - it('should handle multiple @ or /', () => { - expect(matchAction('@@app', mockActions)).toBeUndefined() - expect(matchAction('//theme', mockActions)).toBeUndefined() - }) - }) - - describe('Mode-based Filtering', () => { - it('should filter direct mode commands from matching', () => { - ;(slashCommandRegistry.getAllCommands as Mock).mockReturnValue([ - { name: 'test', mode: 'direct' }, - ]) - - const result = matchAction('/test', mockActions) - expect(result).toBeUndefined() - }) - - it('should allow submenu mode commands to match', () => { - ;(slashCommandRegistry.getAllCommands as Mock).mockReturnValue([ - { name: 'test', mode: 'submenu' }, - ]) - - const result = matchAction('/test', mockActions) - expect(result).toBe(mockActions.slash) - }) - - it('should treat undefined mode as submenu', () => { - ;(slashCommandRegistry.getAllCommands as Mock).mockReturnValue([ - { name: 'test' }, // No mode specified - ]) - - const result = matchAction('/test', mockActions) - expect(result).toBe(mockActions.slash) - }) - }) - - describe('Registry Integration', () => { - it('should call getAllCommands when matching slash', () => { - matchAction('/theme', mockActions) - expect(slashCommandRegistry.getAllCommands).toHaveBeenCalled() - }) - - it('should not call getAllCommands for @ actions', () => { - matchAction('@app', mockActions) - expect(slashCommandRegistry.getAllCommands).not.toHaveBeenCalled() - }) - - it('should handle empty command list', () => { - ;(slashCommandRegistry.getAllCommands as Mock).mockReturnValue([]) - const result = matchAction('/anything', mockActions) - expect(result).toBeUndefined() - }) - }) -}) From 283df5df88c60398772c338715e7530097095b55 Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 22:27:10 +0800 Subject: [PATCH 20/29] refactor: Replace goto-anything actions with a new scope registry system, simplifying command management and registration. --- .../components/goto-anything/actions/index.ts | 293 +++++------------- .../actions/rag-pipeline-nodes.tsx | 45 ++- .../goto-anything/actions/scope-registry.ts | 121 ++++++++ .../components/goto-anything/actions/types.ts | 9 + .../goto-anything/actions/workflow-nodes.tsx | 45 ++- .../goto-anything/command-selector.spec.tsx | 264 +++++++++++++--- .../goto-anything/command-selector.tsx | 29 +- web/app/components/goto-anything/constants.ts | 21 ++ .../components/goto-anything/index.spec.tsx | 43 ++- web/app/components/goto-anything/index.tsx | 73 +++-- .../hooks/use-rag-pipeline-search.tsx | 9 +- .../workflow/hooks/use-workflow-search.tsx | 9 +- .../workflow/panel/vibe-panel/index.spec.tsx | 9 +- 13 files changed, 599 insertions(+), 371 deletions(-) create mode 100644 web/app/components/goto-anything/actions/scope-registry.ts diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index 7c091b92ef..f425a6f623 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -3,201 +3,60 @@ * * This file defines the action registry for the goto-anything search system. * Actions handle different types of searches: apps, knowledge bases, plugins, workflow nodes, and commands. - * - * ## How to Add a New Slash Command - * - * 1. **Create Command Handler File** (in `./commands/` directory): - * ```typescript - * // commands/my-command.ts - * import type { SlashCommandHandler } from './types' - * import type { CommandSearchResult } from '../types' - * import { registerCommands, unregisterCommands } from './command-bus' - * - * interface MyCommandDeps { - * myService?: (data: any) => Promise<void> - * } - * - * export const myCommand: SlashCommandHandler<MyCommandDeps> = { - * name: 'mycommand', - * aliases: ['mc'], // Optional aliases - * description: 'My custom command description', - * - * async search(args: string, locale: string = 'en') { - * // Return search results based on args - * return [{ - * id: 'my-result', - * title: 'My Command Result', - * description: 'Description of the result', - * type: 'command' as const, - * data: { command: 'my.action', args: { value: args } } - * }] - * }, - * - * register(deps: MyCommandDeps) { - * registerCommands({ - * 'my.action': async (args) => { - * await deps.myService?.(args?.value) - * } - * }) - * }, - * - * unregister() { - * unregisterCommands(['my.action']) - * } - * } - * ``` - * - * **Example for Self-Contained Command (no external dependencies):** - * ```typescript - * // commands/calculator-command.ts - * export const calculatorCommand: SlashCommandHandler = { - * name: 'calc', - * aliases: ['calculator'], - * description: 'Simple calculator', - * - * async search(args: string) { - * if (!args.trim()) return [] - * try { - * // Safe math evaluation (implement proper parser in real use) - * const result = Function('"use strict"; return (' + args + ')')() - * return [{ - * id: 'calc-result', - * title: `${args} = ${result}`, - * description: 'Calculator result', - * type: 'command' as const, - * data: { command: 'calc.copy', args: { result: result.toString() } } - * }] - * } catch { - * return [{ - * id: 'calc-error', - * title: 'Invalid expression', - * description: 'Please enter a valid math expression', - * type: 'command' as const, - * data: { command: 'calc.noop', args: {} } - * }] - * } - * }, - * - * register() { - * registerCommands({ - * 'calc.copy': (args) => navigator.clipboard.writeText(args.result), - * 'calc.noop': () => {} // No operation - * }) - * }, - * - * unregister() { - * unregisterCommands(['calc.copy', 'calc.noop']) - * } - * } - * ``` - * - * 2. **Register Command** (in `./commands/slash.tsx`): - * ```typescript - * import { myCommand } from './my-command' - * import { calculatorCommand } from './calculator-command' // For self-contained commands - * - * export const registerSlashCommands = (deps: Record<string, any>) => { - * slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme }) - * slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale }) - * slashCommandRegistry.register(myCommand, { myService: deps.myService }) // With dependencies - * slashCommandRegistry.register(calculatorCommand) // Self-contained, no dependencies - * } - * - * export const unregisterSlashCommands = () => { - * slashCommandRegistry.unregister('theme') - * slashCommandRegistry.unregister('language') - * slashCommandRegistry.unregister('mycommand') - * slashCommandRegistry.unregister('calc') // Add this line - * } - * ``` - * - * - * 3. **Update SlashCommandProvider** (in `./commands/slash.tsx`): - * ```typescript - * export const SlashCommandProvider = () => { - * const theme = useTheme() - * const myService = useMyService() // Add external dependency if needed - * - * useEffect(() => { - * registerSlashCommands({ - * setTheme: theme.setTheme, // Required for theme command - * setLocale: setLocaleOnClient, // Required for language command - * myService: myService, // Required for your custom command - * // Note: calculatorCommand doesn't need dependencies, so not listed here - * }) - * return () => unregisterSlashCommands() - * }, [theme.setTheme, myService]) // Update dependency array for all dynamic deps - * - * return null - * } - * ``` - * - * **Note:** Self-contained commands (like calculator) don't require dependencies but are - * still registered through the same system for consistent lifecycle management. - * - * 4. **Usage**: Users can now type `/mycommand` or `/mc` to use your command - * - * ## Command System Architecture - * - Commands are registered via `SlashCommandRegistry` - * - Each command is self-contained with its own dependencies - * - Commands support aliases for easier access - * - Command execution is handled by the command bus system - * - All commands should be registered through `SlashCommandProvider` for consistent lifecycle management - * - * ## Command Types - * **Commands with External Dependencies:** - * - Require external services, APIs, or React hooks - * - Must provide dependencies in `SlashCommandProvider` - * - Example: theme commands (needs useTheme), API commands (needs service) - * - * **Self-Contained Commands:** - * - Pure logic operations, no external dependencies - * - Still recommended to register through `SlashCommandProvider` for consistency - * - Example: calculator, text manipulation commands - * - * ## Available Actions - * - `@app` - Search applications - * - `@knowledge` / `@kb` - Search knowledge bases - * - `@plugin` - Search plugins - * - `@node` - Search workflow nodes (workflow pages only) - * - `/` - Execute slash commands (theme, language, banana, etc.) */ -import type { ActionItem, SearchResult } from './types' +import type { ActionItem, ScopeDescriptor, SearchResult } from './types' import { ACTION_KEYS } from '../constants' import { appAction } from './app' import { slashAction } from './commands' import { slashCommandRegistry } from './commands/registry' import { knowledgeAction } from './knowledge' import { pluginAction } from './plugin' -import { ragPipelineNodesAction } from './rag-pipeline-nodes' -import { workflowNodesAction } from './workflow-nodes' +import { scopeRegistry } from './scope-registry' -// Create dynamic Actions based on context -export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolean) => { - const baseActions = { - slash: slashAction, - app: appAction, - knowledge: knowledgeAction, - plugin: pluginAction, - } +let defaultScopesRegistered = false - // Add appropriate node search based on context - if (isRagPipelinePage) { - return { - ...baseActions, - node: ragPipelineNodesAction, - } - } - else if (isWorkflowPage) { - return { - ...baseActions, - node: workflowNodesAction, - } - } +export const registerDefaultScopes = () => { + if (defaultScopesRegistered) + return - // Default actions without node search - return baseActions + defaultScopesRegistered = true + + scopeRegistry.register({ + id: 'slash', + shortcut: ACTION_KEYS.SLASH, + title: 'Commands', + description: 'Execute commands', + search: slashAction.search, + isAvailable: () => true, + }) + + scopeRegistry.register({ + id: 'app', + shortcut: ACTION_KEYS.APP, + title: 'Search Applications', + description: 'Search and navigate to your applications', + search: appAction.search, + isAvailable: () => true, + }) + + scopeRegistry.register({ + id: 'knowledge', + shortcut: ACTION_KEYS.KNOWLEDGE, + title: 'Search Knowledge Bases', + description: 'Search and navigate to your knowledge bases', + search: knowledgeAction.search, + isAvailable: () => true, + }) + + scopeRegistry.register({ + id: 'plugin', + shortcut: ACTION_KEYS.PLUGIN, + title: 'Search Plugins', + description: 'Search and navigate to your plugins', + search: pluginAction.search, + isAvailable: () => true, + }) } // Legacy export for backward compatibility @@ -206,26 +65,36 @@ export const Actions = { app: appAction, knowledge: knowledgeAction, plugin: pluginAction, - node: workflowNodesAction, } +const getScopeId = (scope: ScopeDescriptor | ActionItem) => ('id' in scope ? scope.id : scope.key) + +const isSlashScope = (scope: ScopeDescriptor | ActionItem) => scope.shortcut === ACTION_KEYS.SLASH + export const searchAnything = async ( locale: string, query: string, - actionItem?: ActionItem, - dynamicActions?: Record<string, ActionItem>, + scope?: ScopeDescriptor | ActionItem, + scopes?: (ScopeDescriptor | ActionItem)[], ): Promise<SearchResult[]> => { + registerDefaultScopes() const trimmedQuery = query.trim() - if (actionItem) { + // Backwards compatibility: if scopes is not provided or empty, use non-page-specific scopes + const effectiveScopes = (scopes && scopes.length > 0) + ? scopes + : scopeRegistry.getScopes({ isWorkflowPage: false, isRagPipelinePage: false }) + + if (scope) { const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const prefixPattern = new RegExp(`^(${escapeRegExp(actionItem.key)}|${escapeRegExp(actionItem.shortcut)})\\s*`) + const scopeId = getScopeId(scope) + const prefixPattern = new RegExp(`^(${escapeRegExp(scope.shortcut)})\\s*`) const searchTerm = trimmedQuery.replace(prefixPattern, '').trim() try { - return await actionItem.search(query, searchTerm, locale) + return await scope.search(query, searchTerm, locale) } catch (error) { - console.warn(`Search failed for ${actionItem.key}:`, error) + console.warn(`Search failed for ${scopeId}:`, error) return [] } } @@ -233,19 +102,19 @@ export const searchAnything = async ( if (trimmedQuery.startsWith('@') || trimmedQuery.startsWith('/')) return [] - const globalSearchActions = Object.values(dynamicActions || Actions) - // Exclude slash commands from general search results - .filter(action => action.key !== ACTION_KEYS.SLASH) + // Filter out slash commands from general search + const searchScopes = effectiveScopes.filter(scope => !isSlashScope(scope)) // Use Promise.allSettled to handle partial failures gracefully - const searchPromises = globalSearchActions.map(async (action) => { + const searchPromises = searchScopes.map(async (action) => { + const actionId = getScopeId(action) try { const results = await action.search(query, query, locale) - return { success: true, data: results, actionType: action.key } + return { success: true, data: results, actionType: actionId } } catch (error) { - console.warn(`Search failed for ${action.key}:`, error) - return { success: false, data: [], actionType: action.key, error } + console.warn(`Search failed for ${actionId}:`, error) + return { success: false, data: [], actionType: actionId, error } } }) @@ -259,7 +128,7 @@ export const searchAnything = async ( allResults.push(...result.value.data) } else { - const actionKey = globalSearchActions[index]?.key || 'unknown' + const actionKey = getScopeId(searchScopes[index]) || 'unknown' failedActions.push(actionKey) } }) @@ -270,31 +139,31 @@ export const searchAnything = async ( return allResults } -export const matchAction = (query: string, actions: Record<string, ActionItem>) => { - return Object.values(actions).find((action) => { - // Special handling for slash commands - if (action.key === ACTION_KEYS.SLASH) { - // Get all registered commands from the registry - const allCommands = slashCommandRegistry.getAllCommands() +// ... - // Check if query matches any registered command +export const matchAction = (query: string, scopes: ScopeDescriptor[]) => { + registerDefaultScopes() + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return scopes.find((scope) => { + // Special handling for slash commands + if (scope.shortcut === ACTION_KEYS.SLASH) { + const allCommands = slashCommandRegistry.getAllCommands() return allCommands.some((cmd) => { const cmdPattern = `/${cmd.name}` - - // For direct mode commands, don't match (keep in command selector) if (cmd.mode === 'direct') return false - - // For submenu mode commands, match when complete command is entered return query === cmdPattern || query.startsWith(`${cmdPattern} `) }) } - const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`) + // Check if query matches shortcut (exact or prefix) + // Only match if it's the full shortcut followed by space + const reg = new RegExp(`^(${escapeRegExp(scope.shortcut)})(?:\\s|$)`) return reg.test(query) }) } export * from './commands' +export * from './scope-registry' export * from './types' -export { appAction, knowledgeAction, pluginAction, workflowNodesAction } +export { appAction, knowledgeAction, pluginAction } diff --git a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx index 430b7087cb..7afc784ffc 100644 --- a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx +++ b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx @@ -1,25 +1,40 @@ -import type { ActionItem } from './types' +import type { ScopeSearchHandler } from './scope-registry' +import type { SearchResult } from './types' import { ACTION_KEYS } from '../constants' +import { scopeRegistry } from './scope-registry' -// Create the RAG pipeline nodes action -export const ragPipelineNodesAction: ActionItem = { - key: ACTION_KEYS.NODE, - shortcut: ACTION_KEYS.NODE, - title: 'Search RAG Pipeline Nodes', - description: 'Find and jump to nodes in the current RAG pipeline by name or type', - searchFn: undefined, // Will be set by useRagPipelineSearch hook - search: async (_, searchTerm = '', _locale) => { +const scopeId = 'rag-pipeline-node' + +const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): ScopeSearchHandler => { + return async (_, searchTerm = '', _locale) => { try { - // Use the searchFn if available (set by useRagPipelineSearch hook) - if (ragPipelineNodesAction.searchFn) - return ragPipelineNodesAction.searchFn(searchTerm) - - // If not in RAG pipeline context, return empty array + if (searchFn) + return searchFn(searchTerm) return [] } catch (error) { console.warn('RAG pipeline nodes search failed:', error) return [] } - }, + } +} + +export const setRagPipelineNodesSearchFn = (fn: (searchTerm: string) => SearchResult[]) => { + scopeRegistry.updateSearchHandler(scopeId, buildSearchHandler(fn)) +} + +// Register the RAG pipeline nodes action +scopeRegistry.register({ + id: scopeId, + shortcut: ACTION_KEYS.NODE, + title: 'Search RAG Pipeline Nodes', + description: 'Find and jump to nodes in the current RAG pipeline by name or type', + isAvailable: context => context.isRagPipelinePage, + search: buildSearchHandler(), +}) + +// Legacy export +export const ragPipelineNodesAction = { + key: ACTION_KEYS.NODE, + search: async () => [], } diff --git a/web/app/components/goto-anything/actions/scope-registry.ts b/web/app/components/goto-anything/actions/scope-registry.ts new file mode 100644 index 0000000000..f007fadb3b --- /dev/null +++ b/web/app/components/goto-anything/actions/scope-registry.ts @@ -0,0 +1,121 @@ +import type { SearchResult } from './types' + +import { useCallback, useMemo, useSyncExternalStore } from 'react' + +export type ScopeContext = { + isWorkflowPage: boolean + isRagPipelinePage: boolean + isAdmin?: boolean +} + +export type ScopeSearchHandler = ( + query: string, + searchTerm: string, + locale?: string, +) => Promise<SearchResult[]> | SearchResult[] + +export type ScopeDescriptor = { + /** + * Unique identifier for the scope (e.g. 'app', 'plugin') + */ + id: string + /** + * Shortcut to trigger this scope (e.g. '@app') + */ + shortcut: string + /** + * I18n key or string for the scope title + */ + title: string + /** + * Description for help text + */ + description: string + /** + * Search handler function + */ + search: ScopeSearchHandler + /** + * Predicate to check if this scope is available in current context + */ + isAvailable?: (context: ScopeContext) => boolean +} + +type Listener = () => void + +class ScopeRegistry { + private scopes: Map<string, ScopeDescriptor> = new Map() + private listeners: Set<Listener> = new Set() + private version = 0 + + register(scope: ScopeDescriptor) { + this.scopes.set(scope.id, scope) + this.notify() + } + + unregister(id: string) { + if (this.scopes.delete(id)) + this.notify() + } + + getScope(id: string) { + return this.scopes.get(id) + } + + getScopes(context: ScopeContext): ScopeDescriptor[] { + return Array.from(this.scopes.values()) + .filter(scope => !scope.isAvailable || scope.isAvailable(context)) + .sort((a, b) => a.shortcut.localeCompare(b.shortcut)) + } + + updateSearchHandler(id: string, search: ScopeSearchHandler) { + const scope = this.scopes.get(id) + if (!scope) + return + this.scopes.set(id, { ...scope, search }) + this.notify() + } + + getVersion() { + return this.version + } + + subscribe(listener: Listener) { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + private notify() { + this.version += 1 + this.listeners.forEach(listener => listener()) + } +} + +export const scopeRegistry = new ScopeRegistry() + +export const useScopeRegistry = (context: ScopeContext, initialize?: () => void) => { + initialize?.() + + const subscribe = useCallback( + (listener: Listener) => scopeRegistry.subscribe(listener), + [], + ) + + const getSnapshot = useCallback( + () => scopeRegistry.getVersion(), + [], + ) + + const version = useSyncExternalStore( + subscribe, + getSnapshot, + getSnapshot, + ) + + return useMemo( + () => scopeRegistry.getScopes(context), + [version, context.isWorkflowPage, context.isRagPipelinePage, context.isAdmin], + ) +} diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 1963a808fb..56c745ade9 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -44,12 +44,19 @@ export type CommandSearchResult = { export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult +// Legacy ActionItem for backward compatibility if needed, but we should move to ScopeDescriptor export type ActionItem = { key: ActionKey shortcut: string title: string | TypeWithI18N description: string + /** + * @deprecated use search() instead + */ action?: (data: SearchResult) => void + /** + * @deprecated use search() instead + */ searchFn?: (searchTerm: string) => SearchResult[] search: ( query: string, @@ -57,3 +64,5 @@ export type ActionItem = { locale?: string, ) => (Promise<SearchResult[]> | SearchResult[]) } + +export type { ScopeContext, ScopeDescriptor } from './scope-registry' diff --git a/web/app/components/goto-anything/actions/workflow-nodes.tsx b/web/app/components/goto-anything/actions/workflow-nodes.tsx index 9b743f0108..107830c2ea 100644 --- a/web/app/components/goto-anything/actions/workflow-nodes.tsx +++ b/web/app/components/goto-anything/actions/workflow-nodes.tsx @@ -1,25 +1,40 @@ -import type { ActionItem } from './types' +import type { ScopeSearchHandler } from './scope-registry' +import type { SearchResult } from './types' import { ACTION_KEYS } from '../constants' +import { scopeRegistry } from './scope-registry' -// Create the workflow nodes action -export const workflowNodesAction: ActionItem = { - key: ACTION_KEYS.NODE, - shortcut: ACTION_KEYS.NODE, - title: 'Search Workflow Nodes', - description: 'Find and jump to nodes in the current workflow by name or type', - searchFn: undefined, // Will be set by useWorkflowSearch hook - search: async (_, searchTerm = '', _locale) => { +const scopeId = 'workflow-node' + +const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): ScopeSearchHandler => { + return async (_, searchTerm = '', _locale) => { try { - // Use the searchFn if available (set by useWorkflowSearch hook) - if (workflowNodesAction.searchFn) - return workflowNodesAction.searchFn(searchTerm) - - // If not in workflow context, return empty array + if (searchFn) + return searchFn(searchTerm) return [] } catch (error) { console.warn('Workflow nodes search failed:', error) return [] } - }, + } +} + +export const setWorkflowNodesSearchFn = (fn: (searchTerm: string) => SearchResult[]) => { + scopeRegistry.updateSearchHandler(scopeId, buildSearchHandler(fn)) +} + +// Register the workflow nodes action +scopeRegistry.register({ + id: scopeId, + shortcut: ACTION_KEYS.NODE, + title: 'Search Workflow Nodes', + description: 'Find and jump to nodes in the current workflow by name or type', + isAvailable: context => context.isWorkflowPage, + search: buildSearchHandler(), +}) + +// Legacy export if needed (though we should migrate away from it) +export const workflowNodesAction = { + key: ACTION_KEYS.NODE, + search: async () => [], // Dummy implementation } diff --git a/web/app/components/goto-anything/command-selector.spec.tsx b/web/app/components/goto-anything/command-selector.spec.tsx index 0ee2086058..bf9d72b9f2 100644 --- a/web/app/components/goto-anything/command-selector.spec.tsx +++ b/web/app/components/goto-anything/command-selector.spec.tsx @@ -1,5 +1,5 @@ -import type { ActionItem } from './actions/types' -import { render, screen } from '@testing-library/react' +import type { ScopeDescriptor } from './actions/scope-registry' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Command } from 'cmdk' import * as React from 'react' @@ -22,63 +22,229 @@ vi.mock('./actions/commands/registry', () => ({ }, })) -const createActions = (): Record<string, ActionItem> => ({ - app: { - key: '@app', +type CommandSelectorProps = React.ComponentProps<typeof CommandSelector> + +const mockScopes: ScopeDescriptor[] = [ + { + id: 'app', shortcut: '@app', - title: 'Apps', + title: 'Search Applications', + description: 'Search apps', search: vi.fn(), - description: '', - } as ActionItem, - plugin: { - key: '@plugin', + }, + { + id: 'knowledge', + shortcut: '@knowledge', + title: 'Search Knowledge Bases', + description: 'Search knowledge bases', + search: vi.fn(), + }, + { + id: 'plugin', shortcut: '@plugin', - title: 'Plugins', + title: 'Search Plugins', + description: 'Search plugins', search: vi.fn(), - description: '', - } as ActionItem, -}) + }, + { + id: 'workflow-node', + shortcut: '@node', + title: 'Search Nodes', + description: 'Search workflow nodes', + search: vi.fn(), + }, +] + +const mockOnCommandSelect = vi.fn() +const mockOnCommandValueChange = vi.fn() + +const buildCommandSelector = (props: Partial<CommandSelectorProps> = {}) => ( + <Command> + <Command.List> + <CommandSelector + scopes={mockScopes} + onCommandSelect={mockOnCommandSelect} + {...props} + /> + </Command.List> + </Command> +) + +const renderCommandSelector = (props: Partial<CommandSelectorProps> = {}) => { + return render(buildCommandSelector(props)) +} describe('CommandSelector', () => { - it('should list contextual search actions and notify selection', async () => { - const actions = createActions() - const onSelect = vi.fn() - - render( - <Command> - <CommandSelector - actions={actions} - onCommandSelect={onSelect} - searchFilter="app" - originalQuery="@app" - /> - </Command>, - ) - - const actionButton = screen.getByText('app.gotoAnything.actions.searchApplicationsDesc') - await userEvent.click(actionButton) - - expect(onSelect).toHaveBeenCalledWith('@app') + beforeEach(() => { + vi.clearAllMocks() }) - it('should render slash commands when query starts with slash', async () => { - const actions = createActions() - const onSelect = vi.fn() + describe('Basic Rendering', () => { + it('should render all scopes when no filter is provided', () => { + renderCommandSelector() - render( - <Command> - <CommandSelector - actions={actions} - onCommandSelect={onSelect} - searchFilter="zen" - originalQuery="/zen" - /> - </Command>, - ) + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.getByText('@knowledge')).toBeInTheDocument() + expect(screen.getByText('@plugin')).toBeInTheDocument() + expect(screen.getByText('@node')).toBeInTheDocument() + }) - const slashItem = await screen.findByText('app.gotoAnything.actions.zenDesc') - await userEvent.click(slashItem) + it('should render empty filter as showing all scopes', () => { + renderCommandSelector({ searchFilter: '' }) - expect(onSelect).toHaveBeenCalledWith('/zen') + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.getByText('@knowledge')).toBeInTheDocument() + expect(screen.getByText('@plugin')).toBeInTheDocument() + expect(screen.getByText('@node')).toBeInTheDocument() + }) + }) + + describe('Filtering Functionality', () => { + it('should filter scopes based on searchFilter - single match', () => { + renderCommandSelector({ searchFilter: 'k' }) + + expect(screen.queryByText('@app')).not.toBeInTheDocument() + expect(screen.getByText('@knowledge')).toBeInTheDocument() + expect(screen.queryByText('@plugin')).not.toBeInTheDocument() + expect(screen.queryByText('@node')).not.toBeInTheDocument() + }) + + it('should filter scopes with multiple matches', () => { + renderCommandSelector({ searchFilter: 'p' }) + + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.queryByText('@knowledge')).not.toBeInTheDocument() + expect(screen.getByText('@plugin')).toBeInTheDocument() + expect(screen.queryByText('@node')).not.toBeInTheDocument() + }) + + it('should be case-insensitive when filtering', () => { + renderCommandSelector({ searchFilter: 'APP' }) + + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.queryByText('@knowledge')).not.toBeInTheDocument() + }) + + it('should match partial strings', () => { + renderCommandSelector({ searchFilter: 'od' }) + + expect(screen.queryByText('@app')).not.toBeInTheDocument() + expect(screen.queryByText('@knowledge')).not.toBeInTheDocument() + expect(screen.queryByText('@plugin')).not.toBeInTheDocument() + expect(screen.getByText('@node')).toBeInTheDocument() + }) + }) + + describe('Empty State', () => { + it('should show empty state when no matches found', () => { + renderCommandSelector({ searchFilter: 'xyz' }) + + expect(screen.queryByText('@app')).not.toBeInTheDocument() + expect(screen.queryByText('@knowledge')).not.toBeInTheDocument() + expect(screen.queryByText('@plugin')).not.toBeInTheDocument() + expect(screen.queryByText('@node')).not.toBeInTheDocument() + + expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument() + }) + + it('should not show empty state when filter is empty', () => { + renderCommandSelector({ searchFilter: '' }) + + expect(screen.queryByText('app.gotoAnything.noMatchingCommands')).not.toBeInTheDocument() + }) + }) + + describe('Selection and Highlight Management', () => { + it('should call onCommandValueChange when filter changes and first item differs', async () => { + const { rerender } = renderCommandSelector({ + searchFilter: '', + commandValue: '@app', + onCommandValueChange: mockOnCommandValueChange, + }) + + rerender(buildCommandSelector({ + searchFilter: 'k', + commandValue: '@app', + onCommandValueChange: mockOnCommandValueChange, + })) + + await waitFor(() => { + expect(mockOnCommandValueChange).toHaveBeenCalledWith('@knowledge') + }) + }) + + it('should not call onCommandValueChange if current value still exists', async () => { + const { rerender } = renderCommandSelector({ + searchFilter: '', + commandValue: '@app', + onCommandValueChange: mockOnCommandValueChange, + }) + + rerender(buildCommandSelector({ + searchFilter: 'a', + commandValue: '@app', + onCommandValueChange: mockOnCommandValueChange, + })) + + await waitFor(() => { + expect(mockOnCommandValueChange).not.toHaveBeenCalled() + }) + }) + + it('should handle onCommandSelect callback correctly', async () => { + const user = userEvent.setup() + renderCommandSelector({ searchFilter: 'k' }) + + await user.click(screen.getByText('@knowledge')) + + expect(mockOnCommandSelect).toHaveBeenCalledWith('@knowledge') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty scopes array', () => { + renderCommandSelector({ scopes: [] }) + + expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() + }) + + it('should handle special characters in filter', () => { + renderCommandSelector({ searchFilter: '@' }) + + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.getByText('@knowledge')).toBeInTheDocument() + expect(screen.getByText('@plugin')).toBeInTheDocument() + expect(screen.getByText('@node')).toBeInTheDocument() + }) + + it('should handle undefined onCommandValueChange gracefully', () => { + const { rerender } = renderCommandSelector({ searchFilter: '' }) + + expect(() => { + rerender(buildCommandSelector({ searchFilter: 'k' })) + }).not.toThrow() + }) + }) + + describe('User Interactions', () => { + it('should list contextual scopes and notify selection', async () => { + const user = userEvent.setup() + renderCommandSelector({ searchFilter: 'app', originalQuery: '@app' }) + + await user.click(screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')) + + expect(mockOnCommandSelect).toHaveBeenCalledWith('@app') + }) + + it('should render slash commands when query starts with slash', async () => { + const user = userEvent.setup() + renderCommandSelector({ searchFilter: 'zen', originalQuery: '/zen' }) + + const slashItem = await screen.findByText('app.gotoAnything.actions.zenDesc') + await user.click(slashItem) + + expect(mockOnCommandSelect).toHaveBeenCalledWith('/zen') + }) }) }) diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index fbc2339b28..411ae73528 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react' -import type { ActionItem } from './actions/types' +import type { ScopeDescriptor } from './actions/scope-registry' import { Command } from 'cmdk' import { usePathname } from 'next/navigation' import { useEffect, useMemo } from 'react' @@ -8,7 +8,7 @@ import { slashCommandRegistry } from './actions/commands/registry' import { ACTION_KEYS, SCOPE_ACTION_I18N_MAP, SLASH_COMMAND_I18N_MAP } from './constants' type Props = { - actions: Record<string, ActionItem> + scopes: ScopeDescriptor[] onCommandSelect: (commandKey: string) => void searchFilter?: string commandValue?: string @@ -16,7 +16,7 @@ type Props = { originalQuery?: string } -const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, commandValue, onCommandValueChange, originalQuery }) => { +const CommandSelector: FC<Props> = ({ scopes, onCommandSelect, searchFilter, commandValue, onCommandValueChange, originalQuery }) => { const { t } = useTranslation() const pathname = usePathname() @@ -44,22 +44,29 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co })) }, [isSlashMode, searchFilter, pathname]) - const filteredActions = useMemo(() => { + const filteredScopes = useMemo(() => { if (isSlashMode) return [] - return Object.values(actions).filter((action) => { + return scopes.filter((scope) => { // Exclude slash action when in @ mode - if (action.key === ACTION_KEYS.SLASH) + if (scope.id === 'slash' || scope.shortcut === ACTION_KEYS.SLASH) return false if (!searchFilter) return true - const filterLower = searchFilter.toLowerCase() - return action.shortcut.toLowerCase().includes(filterLower) - }) - }, [actions, searchFilter, isSlashMode]) - const allItems = isSlashMode ? slashCommands : filteredActions + // Match against shortcut or title + return scope.shortcut.toLowerCase().includes(searchFilter.toLowerCase()) + || scope.title.toLowerCase().includes(searchFilter.toLowerCase()) + }).map(scope => ({ + key: scope.shortcut, // Map to shortcut for UI display consistency + shortcut: scope.shortcut, + title: scope.title, + description: scope.description, + })) + }, [scopes, searchFilter, isSlashMode]) + + const allItems = isSlashMode ? slashCommands : filteredScopes useEffect(() => { if (allItems.length > 0 && onCommandValueChange) { diff --git a/web/app/components/goto-anything/constants.ts b/web/app/components/goto-anything/constants.ts index 39fbc601fe..c7109f0a38 100644 --- a/web/app/components/goto-anything/constants.ts +++ b/web/app/components/goto-anything/constants.ts @@ -44,3 +44,24 @@ export const SCOPE_ACTION_I18N_MAP: Record<string, string> = { '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc', '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc', } as const + +/** + * Empty state i18n key mappings + */ +export const EMPTY_STATE_I18N_MAP: Record<string, string> = { + app: 'app.gotoAnything.emptyState.noAppsFound', + plugin: 'app.gotoAnything.emptyState.noPluginsFound', + knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound', + node: 'app.gotoAnything.emptyState.noWorkflowNodesFound', +} as const + +/** + * Group heading i18n key mappings + */ +export const GROUP_HEADING_I18N_MAP: Record<string, string> = { + 'app': 'app.gotoAnything.groups.apps', + 'plugin': 'app.gotoAnything.groups.plugins', + 'knowledge': 'app.gotoAnything.groups.knowledgeBases', + 'workflow-node': 'app.gotoAnything.groups.workflowNodes', + 'command': 'app.gotoAnything.groups.commands', +} as const diff --git a/web/app/components/goto-anything/index.spec.tsx b/web/app/components/goto-anything/index.spec.tsx index 7a8c1ead11..059b290593 100644 --- a/web/app/components/goto-anything/index.spec.tsx +++ b/web/app/components/goto-anything/index.spec.tsx @@ -1,4 +1,5 @@ -import type { ActionItem, SearchResult } from './actions/types' +import type { ScopeDescriptor } from './actions/scope-registry' +import type { SearchResult } from './actions/types' import { act, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' @@ -47,34 +48,38 @@ vi.mock('./context', () => ({ GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, })) -const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({ - key, +const createScope = (id: ScopeDescriptor['id'], shortcut: string): ScopeDescriptor => ({ + id, shortcut, - title: `${key} title`, - description: `${key} desc`, - action: vi.fn(), + title: `${id} title`, + description: `${id} desc`, search: vi.fn(), }) -const actionsMock = { - slash: createActionItem('/', '/'), - app: createActionItem('@app', '@app'), - plugin: createActionItem('@plugin', '@plugin'), -} +const scopesMock = [ + createScope('slash', '/'), + createScope('app', '@app'), + createScope('plugin', '@plugin'), +] -const createActionsMock = vi.fn(() => actionsMock) -const matchActionMock = vi.fn(() => undefined) -const searchAnythingMock = vi.fn(async () => mockQueryResult.data) +type MatchAction = typeof import('./actions').matchAction +type SearchAnything = typeof import('./actions').searchAnything + +const useScopeRegistryMock = vi.fn(() => scopesMock) +const matchActionMock = vi.fn<MatchAction>(() => undefined) +const searchAnythingMock = vi.fn<SearchAnything>(async () => mockQueryResult.data) +const registerDefaultScopesMock = vi.fn() vi.mock('./actions', () => ({ __esModule: true, - createActions: () => createActionsMock(), - matchAction: () => matchActionMock(), - searchAnything: () => searchAnythingMock(), + matchAction: (...args: Parameters<MatchAction>) => matchActionMock(...args), + searchAnything: (...args: Parameters<SearchAnything>) => searchAnythingMock(...args), + registerDefaultScopes: () => registerDefaultScopesMock(), })) vi.mock('./actions/commands', () => ({ SlashCommandProvider: () => null, + executeCommand: vi.fn(), })) vi.mock('./actions/commands/registry', () => ({ @@ -85,6 +90,10 @@ vi.mock('./actions/commands/registry', () => ({ }, })) +vi.mock('./actions/scope-registry', () => ({ + useScopeRegistry: () => useScopeRegistryMock(), +})) + vi.mock('@/app/components/workflow/utils/common', () => ({ getKeyboardKeyCodeBySystem: () => 'ctrl', isEventTargetInputArea: () => false, diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index 8552ba4585..13dab864fc 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -17,11 +17,12 @@ import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' import { useGetLanguage } from '@/context/i18n' import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace' -import { createActions, matchAction, searchAnything } from './actions' -import { SlashCommandProvider } from './actions/commands' +import { matchAction, registerDefaultScopes, searchAnything } from './actions' +import { executeCommand, SlashCommandProvider } from './actions/commands' import { slashCommandRegistry } from './actions/commands/registry' +import { useScopeRegistry } from './actions/scope-registry' import CommandSelector from './command-selector' -import { ACTION_KEYS } from './constants' +import { ACTION_KEYS, EMPTY_STATE_I18N_MAP, GROUP_HEADING_I18N_MAP } from './constants' import { GotoAnythingProvider, useGotoAnythingContext } from './context' type Props = { @@ -39,11 +40,8 @@ const GotoAnything: FC<Props> = ({ const [cmdVal, setCmdVal] = useState<string>('_') const inputRef = useRef<HTMLInputElement>(null) - // Filter actions based on context - const Actions = useMemo(() => { - // Create actions based on current page context - return createActions(isWorkflowPage, isRagPipelinePage) - }, [isWorkflowPage, isRagPipelinePage]) + // Fetch scopes from registry based on context + const scopes = useScopeRegistry({ isWorkflowPage, isRagPipelinePage }, registerDefaultScopes) const [activePlugin, setActivePlugin] = useState<Plugin>() @@ -80,8 +78,8 @@ const GotoAnything: FC<Props> = ({ }) const isCommandsMode = searchQuery.trim() === '@' || searchQuery.trim() === '/' - || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions)) - || (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions)) + || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), scopes)) + || (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), scopes)) const searchMode = useMemo(() => { if (isCommandsMode) { @@ -94,13 +92,16 @@ const GotoAnything: FC<Props> = ({ } const query = searchQueryDebouncedValue.toLowerCase() - const action = matchAction(query, Actions) + const action = matchAction(query, scopes) if (!action) return 'general' - return action.key === ACTION_KEYS.SLASH ? '@command' : action.key - }, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery]) + if (action.id === 'slash' || action.shortcut === ACTION_KEYS.SLASH) + return '@command' + + return action.shortcut + }, [searchQueryDebouncedValue, scopes, isCommandsMode, searchQuery]) const { data: searchResults = [], isLoading, isError, error } = useQuery( { @@ -112,12 +113,12 @@ const GotoAnything: FC<Props> = ({ isWorkflowPage, isRagPipelinePage, defaultLocale, - Object.keys(Actions).sort().join(','), + scopes.map(s => s.id).sort().join(','), ], queryFn: async () => { const query = searchQueryDebouncedValue.toLowerCase() - const action = matchAction(query, Actions) - return await searchAnything(defaultLocale, query, action, Actions) + const scope = matchAction(query, scopes) + return await searchAnything(defaultLocale, query, scope, scopes) }, enabled: !!searchQueryDebouncedValue && !isCommandsMode, staleTime: 30000, @@ -167,9 +168,18 @@ const GotoAnything: FC<Props> = ({ break } - // Execute slash commands - const action = Actions.slash - action?.action?.(result) + // Execute slash commands using the command bus + // This handles both direct execution and submenu commands with args + const { command, args } = result.data + + // Try executing via command bus first (preferred for submenu commands with args) + // We can't easily check if it exists in bus without potentially running it if we were to try/catch + // but typically search results point to valid bus commands. + executeCommand(command, args) + + // Note: We previously checked slashCommandRegistry handlers here, but search results + // should return executable command strings (like 'theme.set') that are registered in the bus. + // The registry is mainly for the top-level command matching (e.g. /theme). break } case 'plugin': @@ -246,25 +256,19 @@ const GotoAnything: FC<Props> = ({ <div className="text-sm font-medium"> {isCommandSearch ? (() => { - const keyMap: Record<string, string> = { - app: 'app.gotoAnything.emptyState.noAppsFound', - plugin: 'app.gotoAnything.emptyState.noPluginsFound', - knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound', - node: 'app.gotoAnything.emptyState.noWorkflowNodesFound', - } - return t((keyMap[commandType] || 'app.gotoAnything.noResults') as any) + return t((EMPTY_STATE_I18N_MAP[commandType] || 'app.gotoAnything.noResults') as any) })() : t('app.gotoAnything.noResults')} </div> <div className="mt-1 text-xs text-text-quaternary"> {isCommandSearch ? t('app.gotoAnything.emptyState.tryDifferentTerm') - : t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') })} + : t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: scopes.map(s => s.shortcut).join(', ') })} </div> </div> </div> ) - }, [dedupedResults, searchQuery, Actions, searchMode, isLoading, isError, isCommandsMode]) + }, [dedupedResults, searchQuery, scopes, searchMode, isLoading, isError, isCommandsMode]) const defaultUI = useMemo(() => { if (searchQuery.trim()) @@ -282,7 +286,7 @@ const GotoAnything: FC<Props> = ({ </div> </div> ) - }, [searchQuery, Actions]) + }, [searchQuery, scopes]) useEffect(() => { if (show) { @@ -399,7 +403,7 @@ const GotoAnything: FC<Props> = ({ {isCommandsMode ? ( <CommandSelector - actions={Actions} + scopes={scopes} onCommandSelect={handleCommandSelect} searchFilter={searchQuery.trim().substring(1)} commandValue={cmdVal} @@ -412,14 +416,7 @@ const GotoAnything: FC<Props> = ({ <Command.Group key={groupIndex} heading={(() => { - const typeMap: Record<string, string> = { - 'app': 'app.gotoAnything.groups.apps', - 'plugin': 'app.gotoAnything.groups.plugins', - 'knowledge': 'app.gotoAnything.groups.knowledgeBases', - 'workflow-node': 'app.gotoAnything.groups.workflowNodes', - 'command': 'app.gotoAnything.groups.commands', - } - return t((typeMap[type] || `${type}s`) as any) + return t((GROUP_HEADING_I18N_MAP[type] || `${type}s`) as any) })()} className="p-2 capitalize text-text-secondary" > diff --git a/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx b/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx index b999f5ccc8..43479f3ea2 100644 --- a/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx +++ b/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx @@ -5,7 +5,7 @@ import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types' import type { CommonNodeType } from '@/app/components/workflow/types' import { useCallback, useEffect, useMemo } from 'react' -import { ragPipelineNodesAction } from '@/app/components/goto-anything/actions/rag-pipeline-nodes' +import { setRagPipelineNodesSearchFn } from '@/app/components/goto-anything/actions/rag-pipeline-nodes' import BlockIcon from '@/app/components/workflow/block-icon' import { useNodesInteractions } from '@/app/components/workflow/hooks/use-nodes-interactions' import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon' @@ -153,16 +153,15 @@ export const useRagPipelineSearch = () => { return results }, [searchableNodes, calculateScore]) - // Directly set the search function on the action object + // Directly set the search function using the setter useEffect(() => { if (searchableNodes.length > 0) { - // Set the search function directly on the action - ragPipelineNodesAction.searchFn = searchRagPipelineNodes + setRagPipelineNodesSearchFn(searchRagPipelineNodes) } return () => { // Clean up when component unmounts - ragPipelineNodesAction.searchFn = undefined + setRagPipelineNodesSearchFn(() => []) } }, [searchableNodes, searchRagPipelineNodes]) diff --git a/web/app/components/workflow/hooks/use-workflow-search.tsx b/web/app/components/workflow/hooks/use-workflow-search.tsx index 8ca597f94e..8f7b1e59c7 100644 --- a/web/app/components/workflow/hooks/use-workflow-search.tsx +++ b/web/app/components/workflow/hooks/use-workflow-search.tsx @@ -5,7 +5,7 @@ import type { CommonNodeType } from '../types' import type { Emoji } from '@/app/components/tools/types' import { useCallback, useEffect, useMemo } from 'react' import { useNodes } from 'reactflow' -import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes' +import { setWorkflowNodesSearchFn } from '@/app/components/goto-anything/actions/workflow-nodes' import { CollectionType } from '@/app/components/tools/types' import BlockIcon from '@/app/components/workflow/block-icon' import { @@ -183,16 +183,15 @@ export const useWorkflowSearch = () => { return results }, [searchableNodes, calculateScore]) - // Directly set the search function on the action object + // Directly set the search function using the setter useEffect(() => { if (searchableNodes.length > 0) { - // Set the search function directly on the action - workflowNodesAction.searchFn = searchWorkflowNodes + setWorkflowNodesSearchFn(searchWorkflowNodes) } return () => { // Clean up when component unmounts - workflowNodesAction.searchFn = undefined + setWorkflowNodesSearchFn(() => []) } }, [searchableNodes, searchWorkflowNodes]) diff --git a/web/app/components/workflow/panel/vibe-panel/index.spec.tsx b/web/app/components/workflow/panel/vibe-panel/index.spec.tsx index f47a171f28..7b2cb4bd8f 100644 --- a/web/app/components/workflow/panel/vibe-panel/index.spec.tsx +++ b/web/app/components/workflow/panel/vibe-panel/index.spec.tsx @@ -6,7 +6,7 @@ import type { Shape as WorkflowState } from '@/app/components/workflow/store/workflow' import type { Edge, Node } from '@/app/components/workflow/types' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import Toast from '@/app/components/base/toast' import { WorkflowContext } from '@/app/components/workflow/context' @@ -224,13 +224,14 @@ describe('VibePanel', () => { // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should update instruction in store when typing', async () => { - const user = userEvent.setup() const { workflowStore } = renderVibePanel() const textarea = screen.getByPlaceholderText('workflow.vibe.missingInstruction') - await user.type(textarea, 'Build a vibe flow') + fireEvent.change(textarea, { target: { value: 'Build a vibe flow' } }) - expect(workflowStore.getState().vibePanelInstruction).toBe('Build a vibe flow') + await waitFor(() => { + expect(workflowStore.getState().vibePanelInstruction).toBe('Build a vibe flow') + }) }) it('should dispatch command event with instruction when generate clicked', async () => { From d413bea1c9017aa9607f814e7ac7acfdede3ab9b Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 22:31:38 +0800 Subject: [PATCH 21/29] refactor goto-anything scopes and registry --- .../search-error-handling.test.ts | 40 +++---- .../components/goto-anything/actions/app.tsx | 6 +- .../goto-anything/actions/commands/index.ts | 2 +- .../goto-anything/actions/commands/slash.tsx | 13 +-- .../components/goto-anything/actions/index.ts | 110 +++++++----------- .../goto-anything/actions/knowledge.tsx | 9 +- .../goto-anything/actions/plugin.tsx | 6 +- .../actions/rag-pipeline-nodes.tsx | 33 +++--- .../goto-anything/actions/scope-registry.ts | 8 +- .../components/goto-anything/actions/types.ts | 23 ---- .../goto-anything/actions/workflow-nodes.tsx | 33 +++--- .../goto-anything/command-selector.tsx | 8 +- .../components/goto-anything/index.spec.tsx | 56 ++++----- web/app/components/goto-anything/index.tsx | 5 +- 14 files changed, 146 insertions(+), 206 deletions(-) diff --git a/web/__tests__/goto-anything/search-error-handling.test.ts b/web/__tests__/goto-anything/search-error-handling.test.ts index 3a495834cd..0e4062edcb 100644 --- a/web/__tests__/goto-anything/search-error-handling.test.ts +++ b/web/__tests__/goto-anything/search-error-handling.test.ts @@ -9,7 +9,7 @@ import type { MockedFunction } from 'vitest' * 4. Ensure errors don't propagate to UI layer causing "search failed" */ -import { Actions, searchAnything } from '@/app/components/goto-anything/actions' +import { appScope, knowledgeScope, pluginScope, searchAnything } from '@/app/components/goto-anything/actions' import { fetchAppList } from '@/service/apps' import { postMarketplace } from '@/service/base' import { fetchDatasets } from '@/service/datasets' @@ -30,6 +30,7 @@ vi.mock('@/service/datasets', () => ({ const mockPostMarketplace = postMarketplace as MockedFunction<typeof postMarketplace> const mockFetchAppList = fetchAppList as MockedFunction<typeof fetchAppList> const mockFetchDatasets = fetchDatasets as MockedFunction<typeof fetchDatasets> +const searchScopes = [appScope, knowledgeScope, pluginScope] describe('GotoAnything Search Error Handling', () => { beforeEach(() => { @@ -49,10 +50,7 @@ describe('GotoAnything Search Error Handling', () => { // Mock marketplace API failure (403 permission denied) mockPostMarketplace.mockRejectedValue(new Error('HTTP 403: Forbidden')) - const pluginAction = Actions.plugin - - // Directly call plugin action's search method - const result = await pluginAction.search('@plugin', 'test', 'en') + const result = await pluginScope.search('@plugin', 'test', 'en') // Should return empty array instead of throwing error expect(result).toEqual([]) @@ -72,8 +70,7 @@ describe('GotoAnything Search Error Handling', () => { data: { plugins: [] }, }) - const pluginAction = Actions.plugin - const result = await pluginAction.search('@plugin', '', 'en') + const result = await pluginScope.search('@plugin', '', 'en') expect(result).toEqual([]) }) @@ -84,8 +81,7 @@ describe('GotoAnything Search Error Handling', () => { data: null, }) - const pluginAction = Actions.plugin - const result = await pluginAction.search('@plugin', 'test', 'en') + const result = await pluginScope.search('@plugin', 'test', 'en') expect(result).toEqual([]) }) @@ -96,8 +92,7 @@ describe('GotoAnything Search Error Handling', () => { // Mock app API failure mockFetchAppList.mockRejectedValue(new Error('API Error')) - const appAction = Actions.app - const result = await appAction.search('@app', 'test', 'en') + const result = await appScope.search('@app', 'test', 'en') expect(result).toEqual([]) }) @@ -106,8 +101,7 @@ describe('GotoAnything Search Error Handling', () => { // Mock knowledge API failure mockFetchDatasets.mockRejectedValue(new Error('API Error')) - const knowledgeAction = Actions.knowledge - const result = await knowledgeAction.search('@knowledge', 'test', 'en') + const result = await knowledgeScope.search('@knowledge', 'test', 'en') expect(result).toEqual([]) }) @@ -120,7 +114,7 @@ describe('GotoAnything Search Error Handling', () => { mockFetchDatasets.mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 }) mockPostMarketplace.mockRejectedValue(new Error('Plugin API failed')) - const result = await searchAnything('en', 'test') + const result = await searchAnything('en', 'test', undefined, searchScopes) // Should return successful results even if plugin search fails expect(result).toEqual([]) @@ -131,8 +125,7 @@ describe('GotoAnything Search Error Handling', () => { // Mock plugin API failure mockPostMarketplace.mockRejectedValue(new Error('Plugin service unavailable')) - const pluginAction = Actions.plugin - const result = await searchAnything('en', '@plugin test', pluginAction) + const result = await searchAnything('en', '@plugin test', pluginScope, searchScopes) // Should return empty array instead of throwing error expect(result).toEqual([]) @@ -142,8 +135,7 @@ describe('GotoAnything Search Error Handling', () => { // Mock app API failure mockFetchAppList.mockRejectedValue(new Error('App service unavailable')) - const appAction = Actions.app - const result = await searchAnything('en', '@app test', appAction) + const result = await searchAnything('en', '@app test', appScope, searchScopes) expect(result).toEqual([]) }) @@ -157,9 +149,9 @@ describe('GotoAnything Search Error Handling', () => { mockFetchDatasets.mockRejectedValue(new Error('Dataset API failed')) const actions = [ - { name: '@plugin', action: Actions.plugin }, - { name: '@app', action: Actions.app }, - { name: '@knowledge', action: Actions.knowledge }, + { name: '@plugin', action: pluginScope }, + { name: '@app', action: appScope }, + { name: '@knowledge', action: knowledgeScope }, ] for (const { name, action } of actions) { @@ -173,7 +165,7 @@ describe('GotoAnything Search Error Handling', () => { it('empty search term should be handled properly', async () => { mockPostMarketplace.mockResolvedValue({ data: { plugins: [] } }) - const result = await searchAnything('en', '@plugin ', Actions.plugin) + const result = await searchAnything('en', '@plugin ', pluginScope, searchScopes) expect(result).toEqual([]) }) @@ -183,7 +175,7 @@ describe('GotoAnything Search Error Handling', () => { mockPostMarketplace.mockRejectedValue(timeoutError) - const result = await searchAnything('en', '@plugin test', Actions.plugin) + const result = await searchAnything('en', '@plugin test', pluginScope, searchScopes) expect(result).toEqual([]) }) @@ -191,7 +183,7 @@ describe('GotoAnything Search Error Handling', () => { const parseError = new SyntaxError('Unexpected token in JSON') mockPostMarketplace.mockRejectedValue(parseError) - const result = await searchAnything('en', '@plugin test', Actions.plugin) + const result = await searchAnything('en', '@plugin test', pluginScope, searchScopes) expect(result).toEqual([]) }) }) diff --git a/web/app/components/goto-anything/actions/app.tsx b/web/app/components/goto-anything/actions/app.tsx index d391556604..bf7d6be220 100644 --- a/web/app/components/goto-anything/actions/app.tsx +++ b/web/app/components/goto-anything/actions/app.tsx @@ -1,4 +1,4 @@ -import type { ActionItem, AppSearchResult } from './types' +import type { AppSearchResult, ScopeDescriptor } from './types' import type { App } from '@/types/app' import { fetchAppList } from '@/service/apps' import { getRedirectionPath } from '@/utils/app-redirection' @@ -36,8 +36,8 @@ const parser = (apps: App[]): AppSearchResult[] => { })) } -export const appAction: ActionItem = { - key: ACTION_KEYS.APP, +export const appScope: ScopeDescriptor = { + id: 'app', shortcut: ACTION_KEYS.APP, title: 'Search Applications', description: 'Search and navigate to your applications', diff --git a/web/app/components/goto-anything/actions/commands/index.ts b/web/app/components/goto-anything/actions/commands/index.ts index 72388f6565..7258840d7e 100644 --- a/web/app/components/goto-anything/actions/commands/index.ts +++ b/web/app/components/goto-anything/actions/commands/index.ts @@ -9,7 +9,7 @@ export { export { slashCommandRegistry, SlashCommandRegistry } from './registry' // Command system exports -export { slashAction } from './slash' +export { slashScope } from './slash' export { registerSlashCommands, SlashCommandProvider, unregisterSlashCommands } from './slash' export type { SlashCommandHandler } from './types' diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index 2eaca0beae..cfc38bf068 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -1,5 +1,5 @@ 'use client' -import type { ActionItem } from '../types' +import type { ScopeDescriptor } from '../types' import type { SlashCommandDependencies } from './types' import { useTheme } from 'next-themes' import { useEffect } from 'react' @@ -8,7 +8,6 @@ import i18n from '@/i18n-config/i18next-config' import { ACTION_KEYS } from '../../constants' import { accountCommand } from './account' import { bananaCommand } from './banana' -import { executeCommand } from './command-bus' import { communityCommand } from './community' import { docsCommand } from './docs' import { forumCommand } from './forum' @@ -17,17 +16,11 @@ import { slashCommandRegistry } from './registry' import { themeCommand } from './theme' import { zenCommand } from './zen' -export const slashAction: ActionItem = { - key: ACTION_KEYS.SLASH, +export const slashScope: ScopeDescriptor = { + id: 'slash', shortcut: ACTION_KEYS.SLASH, title: i18n.t('app.gotoAnything.actions.slashTitle'), description: i18n.t('app.gotoAnything.actions.slashDesc'), - action: (result) => { - if (result.type !== 'command') - return - const { command, args } = result.data - executeCommand(command, args) - }, search: async (query, _searchTerm = '') => { // Delegate all search logic to the command registry system return slashCommandRegistry.search(query, i18n.language) diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index f425a6f623..b8866475be 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -5,96 +5,64 @@ * Actions handle different types of searches: apps, knowledge bases, plugins, workflow nodes, and commands. */ -import type { ActionItem, ScopeDescriptor, SearchResult } from './types' +import type { ScopeContext, ScopeDescriptor, SearchResult } from './types' import { ACTION_KEYS } from '../constants' -import { appAction } from './app' -import { slashAction } from './commands' +import { appScope } from './app' +import { slashScope } from './commands' import { slashCommandRegistry } from './commands/registry' -import { knowledgeAction } from './knowledge' -import { pluginAction } from './plugin' -import { scopeRegistry } from './scope-registry' +import { knowledgeScope } from './knowledge' +import { pluginScope } from './plugin' +import { registerRagPipelineNodeScope } from './rag-pipeline-nodes' +import { scopeRegistry, useScopeRegistry } from './scope-registry' +import { registerWorkflowNodeScope } from './workflow-nodes' -let defaultScopesRegistered = false +let scopesInitialized = false -export const registerDefaultScopes = () => { - if (defaultScopesRegistered) +export const initGotoAnythingScopes = () => { + if (scopesInitialized) return - defaultScopesRegistered = true + scopesInitialized = true - scopeRegistry.register({ - id: 'slash', - shortcut: ACTION_KEYS.SLASH, - title: 'Commands', - description: 'Execute commands', - search: slashAction.search, - isAvailable: () => true, - }) - - scopeRegistry.register({ - id: 'app', - shortcut: ACTION_KEYS.APP, - title: 'Search Applications', - description: 'Search and navigate to your applications', - search: appAction.search, - isAvailable: () => true, - }) - - scopeRegistry.register({ - id: 'knowledge', - shortcut: ACTION_KEYS.KNOWLEDGE, - title: 'Search Knowledge Bases', - description: 'Search and navigate to your knowledge bases', - search: knowledgeAction.search, - isAvailable: () => true, - }) - - scopeRegistry.register({ - id: 'plugin', - shortcut: ACTION_KEYS.PLUGIN, - title: 'Search Plugins', - description: 'Search and navigate to your plugins', - search: pluginAction.search, - isAvailable: () => true, - }) + scopeRegistry.register(slashScope) + scopeRegistry.register(appScope) + scopeRegistry.register(knowledgeScope) + scopeRegistry.register(pluginScope) + registerWorkflowNodeScope() + registerRagPipelineNodeScope() } -// Legacy export for backward compatibility -export const Actions = { - slash: slashAction, - app: appAction, - knowledge: knowledgeAction, - plugin: pluginAction, +export const useGotoAnythingScopes = (context: ScopeContext) => { + initGotoAnythingScopes() + return useScopeRegistry(context) } -const getScopeId = (scope: ScopeDescriptor | ActionItem) => ('id' in scope ? scope.id : scope.key) +const isSlashScope = (scope: ScopeDescriptor) => { + if (scope.shortcut === ACTION_KEYS.SLASH) + return true + return scope.aliases?.includes(ACTION_KEYS.SLASH) ?? false +} -const isSlashScope = (scope: ScopeDescriptor | ActionItem) => scope.shortcut === ACTION_KEYS.SLASH +const getScopeShortcuts = (scope: ScopeDescriptor) => [scope.shortcut, ...(scope.aliases ?? [])] export const searchAnything = async ( locale: string, query: string, - scope?: ScopeDescriptor | ActionItem, - scopes?: (ScopeDescriptor | ActionItem)[], + scope: ScopeDescriptor | undefined, + scopes: ScopeDescriptor[], ): Promise<SearchResult[]> => { - registerDefaultScopes() const trimmedQuery = query.trim() - // Backwards compatibility: if scopes is not provided or empty, use non-page-specific scopes - const effectiveScopes = (scopes && scopes.length > 0) - ? scopes - : scopeRegistry.getScopes({ isWorkflowPage: false, isRagPipelinePage: false }) - if (scope) { const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const scopeId = getScopeId(scope) - const prefixPattern = new RegExp(`^(${escapeRegExp(scope.shortcut)})\\s*`) + const shortcuts = getScopeShortcuts(scope).map(escapeRegExp) + const prefixPattern = new RegExp(`^(${shortcuts.join('|')})\\s*`) const searchTerm = trimmedQuery.replace(prefixPattern, '').trim() try { return await scope.search(query, searchTerm, locale) } catch (error) { - console.warn(`Search failed for ${scopeId}:`, error) + console.warn(`Search failed for ${scope.id}:`, error) return [] } } @@ -103,11 +71,11 @@ export const searchAnything = async ( return [] // Filter out slash commands from general search - const searchScopes = effectiveScopes.filter(scope => !isSlashScope(scope)) + const searchScopes = scopes.filter(scope => !isSlashScope(scope)) // Use Promise.allSettled to handle partial failures gracefully const searchPromises = searchScopes.map(async (action) => { - const actionId = getScopeId(action) + const actionId = action.id try { const results = await action.search(query, query, locale) return { success: true, data: results, actionType: actionId } @@ -128,7 +96,7 @@ export const searchAnything = async ( allResults.push(...result.value.data) } else { - const actionKey = getScopeId(searchScopes[index]) || 'unknown' + const actionKey = searchScopes[index]?.id || 'unknown' failedActions.push(actionKey) } }) @@ -142,11 +110,10 @@ export const searchAnything = async ( // ... export const matchAction = (query: string, scopes: ScopeDescriptor[]) => { - registerDefaultScopes() const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return scopes.find((scope) => { // Special handling for slash commands - if (scope.shortcut === ACTION_KEYS.SLASH) { + if (isSlashScope(scope)) { const allCommands = slashCommandRegistry.getAllCommands() return allCommands.some((cmd) => { const cmdPattern = `/${cmd.name}` @@ -158,7 +125,8 @@ export const matchAction = (query: string, scopes: ScopeDescriptor[]) => { // Check if query matches shortcut (exact or prefix) // Only match if it's the full shortcut followed by space - const reg = new RegExp(`^(${escapeRegExp(scope.shortcut)})(?:\\s|$)`) + const shortcuts = getScopeShortcuts(scope).map(escapeRegExp) + const reg = new RegExp(`^(${shortcuts.join('|')})(?:\\s|$)`) return reg.test(query) }) } @@ -166,4 +134,4 @@ export const matchAction = (query: string, scopes: ScopeDescriptor[]) => { export * from './commands' export * from './scope-registry' export * from './types' -export { appAction, knowledgeAction, pluginAction } +export { appScope, knowledgeScope, pluginScope } diff --git a/web/app/components/goto-anything/actions/knowledge.tsx b/web/app/components/goto-anything/actions/knowledge.tsx index c338386446..11188ab468 100644 --- a/web/app/components/goto-anything/actions/knowledge.tsx +++ b/web/app/components/goto-anything/actions/knowledge.tsx @@ -1,4 +1,4 @@ -import type { ActionItem, KnowledgeSearchResult } from './types' +import type { KnowledgeSearchResult, ScopeDescriptor } from './types' import type { DataSet } from '@/models/datasets' import { fetchDatasets } from '@/service/datasets' import { cn } from '@/utils/classnames' @@ -31,9 +31,10 @@ const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => { }) } -export const knowledgeAction: ActionItem = { - key: ACTION_KEYS.KNOWLEDGE, - shortcut: '@kb', +export const knowledgeScope: ScopeDescriptor = { + id: 'knowledge', + shortcut: ACTION_KEYS.KNOWLEDGE, + aliases: ['@kb'], title: 'Search Knowledge Bases', description: 'Search and navigate to your knowledge bases', // action, diff --git a/web/app/components/goto-anything/actions/plugin.tsx b/web/app/components/goto-anything/actions/plugin.tsx index 7c3baa2381..f9602775dd 100644 --- a/web/app/components/goto-anything/actions/plugin.tsx +++ b/web/app/components/goto-anything/actions/plugin.tsx @@ -1,5 +1,5 @@ import type { Plugin, PluginsFromMarketplaceResponse } from '../../plugins/types' -import type { ActionItem, PluginSearchResult } from './types' +import type { PluginSearchResult, ScopeDescriptor } from './types' import { renderI18nObject } from '@/i18n-config' import { postMarketplace } from '@/service/base' import Icon from '../../plugins/card/base/card-icon' @@ -19,8 +19,8 @@ const parser = (plugins: Plugin[], locale: string): PluginSearchResult[] => { }) } -export const pluginAction: ActionItem = { - key: ACTION_KEYS.PLUGIN, +export const pluginScope: ScopeDescriptor = { + id: 'plugin', shortcut: ACTION_KEYS.PLUGIN, title: 'Search Plugins', description: 'Search and navigate to your plugins', diff --git a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx index 7afc784ffc..14a4f8c3f3 100644 --- a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx +++ b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx @@ -4,6 +4,7 @@ import { ACTION_KEYS } from '../constants' import { scopeRegistry } from './scope-registry' const scopeId = 'rag-pipeline-node' +let scopeRegistered = false const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): ScopeSearchHandler => { return async (_, searchTerm = '', _locale) => { @@ -19,22 +20,22 @@ const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): } } +export const registerRagPipelineNodeScope = () => { + if (scopeRegistered) + return + + scopeRegistered = true + scopeRegistry.register({ + id: scopeId, + shortcut: ACTION_KEYS.NODE, + title: 'Search RAG Pipeline Nodes', + description: 'Find and jump to nodes in the current RAG pipeline by name or type', + isAvailable: context => context.isRagPipelinePage, + search: buildSearchHandler(), + }) +} + export const setRagPipelineNodesSearchFn = (fn: (searchTerm: string) => SearchResult[]) => { + registerRagPipelineNodeScope() scopeRegistry.updateSearchHandler(scopeId, buildSearchHandler(fn)) } - -// Register the RAG pipeline nodes action -scopeRegistry.register({ - id: scopeId, - shortcut: ACTION_KEYS.NODE, - title: 'Search RAG Pipeline Nodes', - description: 'Find and jump to nodes in the current RAG pipeline by name or type', - isAvailable: context => context.isRagPipelinePage, - search: buildSearchHandler(), -}) - -// Legacy export -export const ragPipelineNodesAction = { - key: ACTION_KEYS.NODE, - search: async () => [], -} diff --git a/web/app/components/goto-anything/actions/scope-registry.ts b/web/app/components/goto-anything/actions/scope-registry.ts index f007fadb3b..fc27c3b9fb 100644 --- a/web/app/components/goto-anything/actions/scope-registry.ts +++ b/web/app/components/goto-anything/actions/scope-registry.ts @@ -23,6 +23,10 @@ export type ScopeDescriptor = { * Shortcut to trigger this scope (e.g. '@app') */ shortcut: string + /** + * Additional shortcuts that map to this scope (e.g. ['@kb']) + */ + aliases?: string[] /** * I18n key or string for the scope title */ @@ -95,9 +99,7 @@ class ScopeRegistry { export const scopeRegistry = new ScopeRegistry() -export const useScopeRegistry = (context: ScopeContext, initialize?: () => void) => { - initialize?.() - +export const useScopeRegistry = (context: ScopeContext) => { const subscribe = useCallback( (listener: Listener) => scopeRegistry.subscribe(listener), [], diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 56c745ade9..9e04832cd4 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -1,8 +1,6 @@ import type { ReactNode } from 'react' -import type { TypeWithI18N } from '../../base/form/types' import type { Plugin } from '../../plugins/types' import type { CommonNodeType } from '../../workflow/types' -import type { ActionKey } from '../constants' import type { DataSet } from '@/models/datasets' import type { App } from '@/types/app' @@ -44,25 +42,4 @@ export type CommandSearchResult = { export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult -// Legacy ActionItem for backward compatibility if needed, but we should move to ScopeDescriptor -export type ActionItem = { - key: ActionKey - shortcut: string - title: string | TypeWithI18N - description: string - /** - * @deprecated use search() instead - */ - action?: (data: SearchResult) => void - /** - * @deprecated use search() instead - */ - searchFn?: (searchTerm: string) => SearchResult[] - search: ( - query: string, - searchTerm: string, - locale?: string, - ) => (Promise<SearchResult[]> | SearchResult[]) -} - export type { ScopeContext, ScopeDescriptor } from './scope-registry' diff --git a/web/app/components/goto-anything/actions/workflow-nodes.tsx b/web/app/components/goto-anything/actions/workflow-nodes.tsx index 107830c2ea..d4de980011 100644 --- a/web/app/components/goto-anything/actions/workflow-nodes.tsx +++ b/web/app/components/goto-anything/actions/workflow-nodes.tsx @@ -4,6 +4,7 @@ import { ACTION_KEYS } from '../constants' import { scopeRegistry } from './scope-registry' const scopeId = 'workflow-node' +let scopeRegistered = false const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): ScopeSearchHandler => { return async (_, searchTerm = '', _locale) => { @@ -19,22 +20,22 @@ const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): } } +export const registerWorkflowNodeScope = () => { + if (scopeRegistered) + return + + scopeRegistered = true + scopeRegistry.register({ + id: scopeId, + shortcut: ACTION_KEYS.NODE, + title: 'Search Workflow Nodes', + description: 'Find and jump to nodes in the current workflow by name or type', + isAvailable: context => context.isWorkflowPage, + search: buildSearchHandler(), + }) +} + export const setWorkflowNodesSearchFn = (fn: (searchTerm: string) => SearchResult[]) => { + registerWorkflowNodeScope() scopeRegistry.updateSearchHandler(scopeId, buildSearchHandler(fn)) } - -// Register the workflow nodes action -scopeRegistry.register({ - id: scopeId, - shortcut: ACTION_KEYS.NODE, - title: 'Search Workflow Nodes', - description: 'Find and jump to nodes in the current workflow by name or type', - isAvailable: context => context.isWorkflowPage, - search: buildSearchHandler(), -}) - -// Legacy export if needed (though we should migrate away from it) -export const workflowNodesAction = { - key: ACTION_KEYS.NODE, - search: async () => [], // Dummy implementation -} diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index 411ae73528..86a8b1690c 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -55,9 +55,11 @@ const CommandSelector: FC<Props> = ({ scopes, onCommandSelect, searchFilter, com if (!searchFilter) return true - // Match against shortcut or title - return scope.shortcut.toLowerCase().includes(searchFilter.toLowerCase()) - || scope.title.toLowerCase().includes(searchFilter.toLowerCase()) + // Match against shortcut/aliases or title + const filterLower = searchFilter.toLowerCase() + const shortcuts = [scope.shortcut, ...(scope.aliases || [])] + return shortcuts.some(shortcut => shortcut.toLowerCase().includes(filterLower)) + || scope.title.toLowerCase().includes(filterLower) }).map(scope => ({ key: scope.shortcut, // Map to shortcut for UI display consistency shortcut: scope.shortcut, diff --git a/web/app/components/goto-anything/index.spec.tsx b/web/app/components/goto-anything/index.spec.tsx index 059b290593..adcadfa1fa 100644 --- a/web/app/components/goto-anything/index.spec.tsx +++ b/web/app/components/goto-anything/index.spec.tsx @@ -48,33 +48,25 @@ vi.mock('./context', () => ({ GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, })) -const createScope = (id: ScopeDescriptor['id'], shortcut: string): ScopeDescriptor => ({ - id, - shortcut, - title: `${id} title`, - description: `${id} desc`, - search: vi.fn(), -}) - -const scopesMock = [ - createScope('slash', '/'), - createScope('app', '@app'), - createScope('plugin', '@plugin'), -] - type MatchAction = typeof import('./actions').matchAction type SearchAnything = typeof import('./actions').searchAnything -const useScopeRegistryMock = vi.fn(() => scopesMock) -const matchActionMock = vi.fn<MatchAction>(() => undefined) -const searchAnythingMock = vi.fn<SearchAnything>(async () => mockQueryResult.data) -const registerDefaultScopesMock = vi.fn() +const mockState = vi.hoisted(() => { + const state = { + scopes: [] as ScopeDescriptor[], + useGotoAnythingScopesMock: vi.fn(() => state.scopes), + matchActionMock: vi.fn<MatchAction>(() => undefined), + searchAnythingMock: vi.fn<SearchAnything>(async () => []), + } + + return state +}) vi.mock('./actions', () => ({ __esModule: true, - matchAction: (...args: Parameters<MatchAction>) => matchActionMock(...args), - searchAnything: (...args: Parameters<SearchAnything>) => searchAnythingMock(...args), - registerDefaultScopes: () => registerDefaultScopesMock(), + matchAction: (...args: Parameters<MatchAction>) => mockState.matchActionMock(...args), + searchAnything: (...args: Parameters<SearchAnything>) => mockState.searchAnythingMock(...args), + useGotoAnythingScopes: () => mockState.useGotoAnythingScopesMock(), })) vi.mock('./actions/commands', () => ({ @@ -90,9 +82,19 @@ vi.mock('./actions/commands/registry', () => ({ }, })) -vi.mock('./actions/scope-registry', () => ({ - useScopeRegistry: () => useScopeRegistryMock(), -})) +const createScope = (id: ScopeDescriptor['id'], shortcut: string): ScopeDescriptor => ({ + id, + shortcut, + title: `${id} title`, + description: `${id} desc`, + search: vi.fn(), +}) + +const scopesMock = [ + createScope('slash', '/'), + createScope('app', '@app'), + createScope('plugin', '@plugin'), +] vi.mock('@/app/components/workflow/utils/common', () => ({ getKeyboardKeyCodeBySystem: () => 'ctrl', @@ -118,8 +120,10 @@ describe('GotoAnything', () => { routerPush.mockClear() Object.keys(keyPressHandlers).forEach(key => delete keyPressHandlers[key]) mockQueryResult = { data: [], isLoading: false, isError: false, error: null } - matchActionMock.mockReset() - searchAnythingMock.mockClear() + mockState.scopes = scopesMock + mockState.matchActionMock.mockReset() + mockState.searchAnythingMock.mockClear() + mockState.searchAnythingMock.mockImplementation(async () => mockQueryResult.data) }) it('should open modal via shortcut and navigate to selected result', async () => { diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index 13dab864fc..b4c71c62f2 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -17,10 +17,9 @@ import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' import { useGetLanguage } from '@/context/i18n' import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace' -import { matchAction, registerDefaultScopes, searchAnything } from './actions' +import { matchAction, searchAnything, useGotoAnythingScopes } from './actions' import { executeCommand, SlashCommandProvider } from './actions/commands' import { slashCommandRegistry } from './actions/commands/registry' -import { useScopeRegistry } from './actions/scope-registry' import CommandSelector from './command-selector' import { ACTION_KEYS, EMPTY_STATE_I18N_MAP, GROUP_HEADING_I18N_MAP } from './constants' import { GotoAnythingProvider, useGotoAnythingContext } from './context' @@ -41,7 +40,7 @@ const GotoAnything: FC<Props> = ({ const inputRef = useRef<HTMLInputElement>(null) // Fetch scopes from registry based on context - const scopes = useScopeRegistry({ isWorkflowPage, isRagPipelinePage }, registerDefaultScopes) + const scopes = useGotoAnythingScopes({ isWorkflowPage, isRagPipelinePage }) const [activePlugin, setActivePlugin] = useState<Plugin>() From 1212d98e759af7b03dc46eada7fdd03200152e95 Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 22:46:59 +0800 Subject: [PATCH 22/29] feat: improve search UX by adding `keepPreviousData` to `useQuery` and clearing results for empty queries. --- web/app/components/goto-anything/index.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index b4c71c62f2..1147174af2 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -4,7 +4,7 @@ import type { FC } from 'react' import type { Plugin } from '../plugins/types' import type { SearchResult } from './actions' import { RiSearchLine } from '@remixicon/react' -import { useQuery } from '@tanstack/react-query' +import { keepPreviousData, useQuery } from '@tanstack/react-query' import { useDebounce, useKeyPress } from 'ahooks' import { Command } from 'cmdk' import { useRouter } from 'next/navigation' @@ -122,6 +122,7 @@ const GotoAnything: FC<Props> = ({ enabled: !!searchQueryDebouncedValue && !isCommandsMode, staleTime: 30000, gcTime: 300000, + placeholderData: keepPreviousData, }, ) @@ -197,6 +198,9 @@ const GotoAnything: FC<Props> = ({ }, [router]) const dedupedResults = useMemo(() => { + if (!searchQuery.trim()) + return [] + const seen = new Set<string>() return searchResults.filter((result) => { const key = `${result.type}-${result.id}` @@ -205,7 +209,7 @@ const GotoAnything: FC<Props> = ({ seen.add(key) return true }) - }, [searchResults]) + }, [searchResults, searchQuery]) // Group results by type const groupedResults = useMemo(() => dedupedResults.reduce((acc, result) => { From 300d2822b42de47c7e7cc45edd1c7b86b7805124 Mon Sep 17 00:00:00 2001 From: yyh <yuanyouhuilyz@gmail.com> Date: Sat, 27 Dec 2025 22:50:19 +0800 Subject: [PATCH 23/29] refactor: Extract Go to Anything search logic into a new `useSearch` hook. --- .../goto-anything/hooks/use-search.ts | 93 +++++++++++++++++++ web/app/components/goto-anything/index.tsx | 93 +++---------------- 2 files changed, 107 insertions(+), 79 deletions(-) create mode 100644 web/app/components/goto-anything/hooks/use-search.ts diff --git a/web/app/components/goto-anything/hooks/use-search.ts b/web/app/components/goto-anything/hooks/use-search.ts new file mode 100644 index 0000000000..f6dcae70f1 --- /dev/null +++ b/web/app/components/goto-anything/hooks/use-search.ts @@ -0,0 +1,93 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { useDebounce } from 'ahooks' +import { useMemo } from 'react' +import { useGetLanguage } from '@/context/i18n' +import { matchAction, searchAnything, useGotoAnythingScopes } from '../actions' +import { ACTION_KEYS } from '../constants' +import { useGotoAnythingContext } from '../context' + +export const useSearch = (searchQuery: string) => { + const defaultLocale = useGetLanguage() + const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext() + + // Fetch scopes from registry based on context + const scopes = useGotoAnythingScopes({ isWorkflowPage, isRagPipelinePage }) + + const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), { + wait: 300, + }) + + const isCommandsMode = searchQuery.trim() === '@' || searchQuery.trim() === '/' + || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), scopes)) + || (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), scopes)) + + const searchMode = useMemo(() => { + if (isCommandsMode) { + // Distinguish between @ (scopes) and / (commands) mode + if (searchQuery.trim().startsWith('@')) + return 'scopes' + else if (searchQuery.trim().startsWith('/')) + return 'commands' + return 'commands' // default fallback + } + + const query = searchQueryDebouncedValue.toLowerCase() + const action = matchAction(query, scopes) + + if (!action) + return 'general' + + if (action.id === 'slash' || action.shortcut === ACTION_KEYS.SLASH) + return '@command' + + return action.shortcut + }, [searchQueryDebouncedValue, scopes, isCommandsMode, searchQuery]) + + const { data: searchResults = [], isLoading, isError, error } = useQuery( + { + queryKey: [ + 'goto-anything', + 'search-result', + searchQueryDebouncedValue, + searchMode, + isWorkflowPage, + isRagPipelinePage, + defaultLocale, + scopes.map(s => s.id).sort().join(','), + ], + queryFn: async () => { + const query = searchQueryDebouncedValue.toLowerCase() + const scope = matchAction(query, scopes) + return await searchAnything(defaultLocale, query, scope, scopes) + }, + enabled: !!searchQueryDebouncedValue && !isCommandsMode, + staleTime: 30000, + gcTime: 300000, + placeholderData: keepPreviousData, + }, + ) + + const dedupedResults = useMemo(() => { + if (!searchQuery.trim()) + return [] + + const seen = new Set<string>() + return searchResults.filter((result) => { + const key = `${result.type}-${result.id}` + if (seen.has(key)) + return false + seen.add(key) + return true + }) + }, [searchResults, searchQuery]) + + return { + scopes, + searchResults: dedupedResults, + isLoading, + isError, + error, + searchMode, + isCommandsMode, + } +} diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index 1147174af2..7443d5f2f5 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -4,8 +4,7 @@ import type { FC } from 'react' import type { Plugin } from '../plugins/types' import type { SearchResult } from './actions' import { RiSearchLine } from '@remixicon/react' -import { keepPreviousData, useQuery } from '@tanstack/react-query' -import { useDebounce, useKeyPress } from 'ahooks' +import { useKeyPress } from 'ahooks' import { Command } from 'cmdk' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -15,14 +14,13 @@ import Modal from '@/app/components/base/modal' import { VIBE_COMMAND_EVENT } from '@/app/components/workflow/constants' import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app/components/workflow/utils/common' import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' -import { useGetLanguage } from '@/context/i18n' import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace' -import { matchAction, searchAnything, useGotoAnythingScopes } from './actions' import { executeCommand, SlashCommandProvider } from './actions/commands' import { slashCommandRegistry } from './actions/commands/registry' import CommandSelector from './command-selector' -import { ACTION_KEYS, EMPTY_STATE_I18N_MAP, GROUP_HEADING_I18N_MAP } from './constants' -import { GotoAnythingProvider, useGotoAnythingContext } from './context' +import { EMPTY_STATE_I18N_MAP, GROUP_HEADING_I18N_MAP } from './constants' +import { GotoAnythingProvider } from './context' +import { useSearch } from './hooks/use-search' type Props = { onHide?: () => void @@ -31,16 +29,21 @@ const GotoAnything: FC<Props> = ({ onHide, }) => { const router = useRouter() - const defaultLocale = useGetLanguage() - const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext() const { t } = useTranslation() const [show, setShow] = useState<boolean>(false) const [searchQuery, setSearchQuery] = useState<string>('') const [cmdVal, setCmdVal] = useState<string>('_') const inputRef = useRef<HTMLInputElement>(null) - // Fetch scopes from registry based on context - const scopes = useGotoAnythingScopes({ isWorkflowPage, isRagPipelinePage }) + const { + scopes, + searchResults: dedupedResults, + isLoading, + isError, + error, + searchMode, + isCommandsMode, + } = useSearch(searchQuery) const [activePlugin, setActivePlugin] = useState<Plugin>() @@ -72,60 +75,6 @@ const GotoAnything: FC<Props> = ({ } }) - const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), { - wait: 300, - }) - - const isCommandsMode = searchQuery.trim() === '@' || searchQuery.trim() === '/' - || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), scopes)) - || (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), scopes)) - - const searchMode = useMemo(() => { - if (isCommandsMode) { - // Distinguish between @ (scopes) and / (commands) mode - if (searchQuery.trim().startsWith('@')) - return 'scopes' - else if (searchQuery.trim().startsWith('/')) - return 'commands' - return 'commands' // default fallback - } - - const query = searchQueryDebouncedValue.toLowerCase() - const action = matchAction(query, scopes) - - if (!action) - return 'general' - - if (action.id === 'slash' || action.shortcut === ACTION_KEYS.SLASH) - return '@command' - - return action.shortcut - }, [searchQueryDebouncedValue, scopes, isCommandsMode, searchQuery]) - - const { data: searchResults = [], isLoading, isError, error } = useQuery( - { - queryKey: [ - 'goto-anything', - 'search-result', - searchQueryDebouncedValue, - searchMode, - isWorkflowPage, - isRagPipelinePage, - defaultLocale, - scopes.map(s => s.id).sort().join(','), - ], - queryFn: async () => { - const query = searchQueryDebouncedValue.toLowerCase() - const scope = matchAction(query, scopes) - return await searchAnything(defaultLocale, query, scope, scopes) - }, - enabled: !!searchQueryDebouncedValue && !isCommandsMode, - staleTime: 30000, - gcTime: 300000, - placeholderData: keepPreviousData, - }, - ) - // Prevent automatic selection of the first option when cmdVal is not set const clearSelection = () => { setCmdVal('_') @@ -197,20 +146,6 @@ const GotoAnything: FC<Props> = ({ } }, [router]) - const dedupedResults = useMemo(() => { - if (!searchQuery.trim()) - return [] - - const seen = new Set<string>() - return searchResults.filter((result) => { - const key = `${result.type}-${result.id}` - if (seen.has(key)) - return false - seen.add(key) - return true - }) - }, [searchResults, searchQuery]) - // Group results by type const groupedResults = useMemo(() => dedupedResults.reduce((acc, result) => { if (!acc[result.type]) @@ -396,7 +331,7 @@ const GotoAnything: FC<Props> = ({ <div> <div className="text-sm font-medium text-red-500">{t('app.gotoAnything.searchFailed')}</div> <div className="mt-1 text-xs text-text-quaternary"> - {error.message} + {error?.message} </div> </div> </div> From 1e1efb48138d61b66c6a908658b97e7bd8051a17 Mon Sep 17 00:00:00 2001 From: GuanMu <ballmanjq@gmail.com> Date: Mon, 29 Dec 2025 12:52:53 +0800 Subject: [PATCH 24/29] refactor: replace `highPriority` prop with `wrapperClassName` for VibePanel z-index. --- web/app/components/workflow/panel/vibe-panel/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/workflow/panel/vibe-panel/index.tsx b/web/app/components/workflow/panel/vibe-panel/index.tsx index 966172518c..a0dd775112 100644 --- a/web/app/components/workflow/panel/vibe-panel/index.tsx +++ b/web/app/components/workflow/panel/vibe-panel/index.tsx @@ -139,7 +139,7 @@ const VibePanel: FC = () => { isShow={showVibePanel} onClose={handleClose} className="min-w-[1140px] !p-0" - highPriority + wrapperClassName="z-[900]" > <div className="flex h-[680px] flex-wrap"> <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> From 1b0140bef75aee933bb4a718f5bfee01b1b2a109 Mon Sep 17 00:00:00 2001 From: GuanMu <ballmanjq@gmail.com> Date: Mon, 29 Dec 2025 13:47:49 +0800 Subject: [PATCH 25/29] Remove `wrapperClassName` prop from VibePanel modal. --- web/app/components/workflow/panel/vibe-panel/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/app/components/workflow/panel/vibe-panel/index.tsx b/web/app/components/workflow/panel/vibe-panel/index.tsx index a0dd775112..82df8ea98d 100644 --- a/web/app/components/workflow/panel/vibe-panel/index.tsx +++ b/web/app/components/workflow/panel/vibe-panel/index.tsx @@ -139,7 +139,6 @@ const VibePanel: FC = () => { isShow={showVibePanel} onClose={handleClose} className="min-w-[1140px] !p-0" - wrapperClassName="z-[900]" > <div className="flex h-[680px] flex-wrap"> <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> From 18babd6f81b39308b152476db3fa856c02546eb5 Mon Sep 17 00:00:00 2001 From: GuanMu <ballmanjq@gmail.com> Date: Mon, 29 Dec 2025 14:10:35 +0800 Subject: [PATCH 26/29] feat: Add `clickOutsideNotClose` and `closable` props to VibePanel. --- web/app/components/workflow/panel/vibe-panel/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/app/components/workflow/panel/vibe-panel/index.tsx b/web/app/components/workflow/panel/vibe-panel/index.tsx index 82df8ea98d..f5fb0cd5aa 100644 --- a/web/app/components/workflow/panel/vibe-panel/index.tsx +++ b/web/app/components/workflow/panel/vibe-panel/index.tsx @@ -139,6 +139,8 @@ const VibePanel: FC = () => { isShow={showVibePanel} onClose={handleClose} className="min-w-[1140px] !p-0" + clickOutsideNotClose + closable > <div className="flex h-[680px] flex-wrap"> <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> From 86054b79efc2eec4295b2c1be6ee61946e7be72f Mon Sep 17 00:00:00 2001 From: GuanMu <ballmanjq@gmail.com> Date: Mon, 29 Dec 2025 14:18:29 +0800 Subject: [PATCH 27/29] refactor: remove closable prop from VibePanel modal --- web/app/components/workflow/panel/vibe-panel/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/app/components/workflow/panel/vibe-panel/index.tsx b/web/app/components/workflow/panel/vibe-panel/index.tsx index f5fb0cd5aa..3374786ec3 100644 --- a/web/app/components/workflow/panel/vibe-panel/index.tsx +++ b/web/app/components/workflow/panel/vibe-panel/index.tsx @@ -140,7 +140,6 @@ const VibePanel: FC = () => { onClose={handleClose} className="min-w-[1140px] !p-0" clickOutsideNotClose - closable > <div className="flex h-[680px] flex-wrap"> <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> From cb5b0a98443cc3448718ccee690884169cb7d820 Mon Sep 17 00:00:00 2001 From: GuanMu <ballmanjq@gmail.com> Date: Mon, 29 Dec 2025 14:29:49 +0800 Subject: [PATCH 28/29] style: reduce vibe panel width from 570px to 300px. --- web/app/components/workflow/panel/vibe-panel/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/workflow/panel/vibe-panel/index.tsx b/web/app/components/workflow/panel/vibe-panel/index.tsx index 3374786ec3..a76f11982d 100644 --- a/web/app/components/workflow/panel/vibe-panel/index.tsx +++ b/web/app/components/workflow/panel/vibe-panel/index.tsx @@ -142,7 +142,7 @@ const VibePanel: FC = () => { clickOutsideNotClose > <div className="flex h-[680px] flex-wrap"> - <div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> + <div className="h-full w-[300px] shrink-0 overflow-y-auto border-r border-divider-regular p-6"> <div className="mb-5"> <div className="text-lg font-bold leading-[28px] text-text-primary">{t('app.gotoAnything.actions.vibeTitle')}</div> <div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('app.gotoAnything.actions.vibeDesc')}</div> From 12b39b3696556624ff7a2fc0d31d79eb4954c703 Mon Sep 17 00:00:00 2001 From: GuanMu <ballmanjq@gmail.com> Date: Mon, 29 Dec 2025 14:35:34 +0800 Subject: [PATCH 29/29] chore: Remove unnecessary wrapperClassName prop from VibePanel. --- web/app/components/workflow/panel/vibe-panel/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/app/components/workflow/panel/vibe-panel/index.tsx b/web/app/components/workflow/panel/vibe-panel/index.tsx index 77aacebf33..20587c7aed 100644 --- a/web/app/components/workflow/panel/vibe-panel/index.tsx +++ b/web/app/components/workflow/panel/vibe-panel/index.tsx @@ -140,7 +140,6 @@ const VibePanel: FC = () => { isShow={showVibePanel} onClose={handleClose} className="min-w-[1140px] !p-0" - wrapperClassName="z-[900]" clickOutsideNotClose > <div className="flex h-[680px] flex-wrap">