mirror of https://github.com/langgenius/dify.git
refactor: Replace goto-anything actions with a new scope registry system, simplifying command management and registration.
This commit is contained in:
parent
0fd8d5c4e4
commit
283df5df88
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 () => [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue