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.
This commit is contained in:
yyh 2025-12-27 14:51:45 +08:00 committed by crazywoola
parent 68c220d25e
commit 75b7d269e1
14 changed files with 125 additions and 61 deletions

View File

@ -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,

View File

@ -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[] => {

View File

@ -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
}
}

View File

@ -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 })

View File

@ -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>
}

View File

@ -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()

View File

@ -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',

View File

@ -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) => {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>
))}

View File

@ -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

View File

@ -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(