| @@ -2,11 +2,11 @@ export type CommandHandler = (args?: Record<string, any>) => void | Promise<void | |||
| const handlers = new Map<string, CommandHandler>() | |||
| export const registerCommand = (name: string, handler: CommandHandler) => { | |||
| const registerCommand = (name: string, handler: CommandHandler) => { | |||
| handlers.set(name, handler) | |||
| } | |||
| export const unregisterCommand = (name: string) => { | |||
| const unregisterCommand = (name: string) => { | |||
| handlers.delete(name) | |||
| } | |||
| @@ -0,0 +1,15 @@ | |||
| // Command system exports | |||
| export { slashAction } from './slash' | |||
| export { registerSlashCommands, unregisterSlashCommands, SlashCommandProvider } from './slash' | |||
| // Command registry system (for extending with custom commands) | |||
| export { slashCommandRegistry, SlashCommandRegistry } from './registry' | |||
| export type { SlashCommandHandler } from './types' | |||
| // Command bus (for extending with custom commands) | |||
| export { | |||
| executeCommand, | |||
| registerCommands, | |||
| unregisterCommands, | |||
| type CommandHandler, | |||
| } from './command-bus' | |||
| @@ -0,0 +1,53 @@ | |||
| import type { SlashCommandHandler } from './types' | |||
| import type { CommandSearchResult } from '../types' | |||
| import { languages } from '@/i18n-config/language' | |||
| import i18n from '@/i18n-config/i18next-config' | |||
| import { registerCommands, unregisterCommands } from './command-bus' | |||
| // Language dependency types | |||
| type LanguageDeps = { | |||
| setLocale?: (locale: string) => Promise<void> | |||
| } | |||
| const buildLanguageCommands = (query: string): CommandSearchResult[] => { | |||
| const q = query.toLowerCase() | |||
| const list = languages.filter(item => item.supported && ( | |||
| !q || item.name.toLowerCase().includes(q) || String(item.value).toLowerCase().includes(q) | |||
| )) | |||
| return list.map(item => ({ | |||
| id: `lang-${item.value}`, | |||
| title: item.name, | |||
| description: i18n.t('app.gotoAnything.actions.languageChangeDesc'), | |||
| type: 'command' as const, | |||
| data: { command: 'i18n.set', args: { locale: item.value } }, | |||
| })) | |||
| } | |||
| /** | |||
| * Language command handler | |||
| * Integrates UI building, search, and registration logic | |||
| */ | |||
| export const languageCommand: SlashCommandHandler<LanguageDeps> = { | |||
| name: 'language', | |||
| aliases: ['lang'], | |||
| description: 'Switch between different languages', | |||
| async search(args: string, _locale: string = 'en') { | |||
| // Return language options directly, regardless of parameters | |||
| return buildLanguageCommands(args) | |||
| }, | |||
| register(deps: LanguageDeps) { | |||
| registerCommands({ | |||
| 'i18n.set': async (args) => { | |||
| const locale = args?.locale | |||
| if (locale) | |||
| await deps.setLocale?.(locale) | |||
| }, | |||
| }) | |||
| }, | |||
| unregister() { | |||
| unregisterCommands(['i18n.set']) | |||
| }, | |||
| } | |||
| @@ -0,0 +1,233 @@ | |||
| import type { SlashCommandHandler } from './types' | |||
| import type { CommandSearchResult } from '../types' | |||
| /** | |||
| * Slash Command Registry System | |||
| * 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>() | |||
| /** | |||
| * Register command handler | |||
| */ | |||
| register<TDeps = any>(handler: SlashCommandHandler<TDeps>, deps?: TDeps) { | |||
| // Register main command name | |||
| this.commands.set(handler.name, handler) | |||
| // Register aliases | |||
| if (handler.aliases) { | |||
| handler.aliases.forEach((alias) => { | |||
| this.commands.set(alias, handler) | |||
| }) | |||
| } | |||
| // Store dependencies and call registration method | |||
| if (deps) { | |||
| this.commandDeps.set(handler.name, deps) | |||
| handler.register?.(deps) | |||
| } | |||
| } | |||
| /** | |||
| * Unregister command | |||
| */ | |||
| unregister(name: string) { | |||
| const handler = this.commands.get(name) | |||
| if (handler) { | |||
| // Call the command's unregister method | |||
| handler.unregister?.() | |||
| // Remove dependencies | |||
| this.commandDeps.delete(handler.name) | |||
| // Remove main command name | |||
| this.commands.delete(handler.name) | |||
| // Remove all aliases | |||
| if (handler.aliases) { | |||
| handler.aliases.forEach((alias) => { | |||
| this.commands.delete(alias) | |||
| }) | |||
| } | |||
| } | |||
| } | |||
| /** | |||
| * Find command handler | |||
| */ | |||
| findCommand(commandName: string): SlashCommandHandler | undefined { | |||
| return this.commands.get(commandName) | |||
| } | |||
| /** | |||
| * Smart partial command matching | |||
| * Prioritize alias matching, then match command name prefix | |||
| */ | |||
| private findBestPartialMatch(partialName: string): SlashCommandHandler | undefined { | |||
| const lowerPartial = partialName.toLowerCase() | |||
| // First check if any alias starts with this | |||
| const aliasMatch = this.findHandlerByAliasPrefix(lowerPartial) | |||
| if (aliasMatch) | |||
| return aliasMatch | |||
| // Then check if command name starts with this | |||
| return this.findHandlerByNamePrefix(lowerPartial) | |||
| } | |||
| /** | |||
| * Find handler by alias prefix | |||
| */ | |||
| private findHandlerByAliasPrefix(prefix: string): SlashCommandHandler | undefined { | |||
| for (const handler of this.getAllCommands()) { | |||
| if (handler.aliases?.some(alias => alias.toLowerCase().startsWith(prefix))) | |||
| return handler | |||
| } | |||
| return undefined | |||
| } | |||
| /** | |||
| * Find handler by name prefix | |||
| */ | |||
| private findHandlerByNamePrefix(prefix: string): SlashCommandHandler | undefined { | |||
| return this.getAllCommands().find(handler => | |||
| handler.name.toLowerCase().startsWith(prefix), | |||
| ) | |||
| } | |||
| /** | |||
| * Get all registered commands (deduplicated) | |||
| */ | |||
| getAllCommands(): SlashCommandHandler[] { | |||
| const uniqueCommands = new Map<string, SlashCommandHandler>() | |||
| this.commands.forEach((handler) => { | |||
| uniqueCommands.set(handler.name, handler) | |||
| }) | |||
| return Array.from(uniqueCommands.values()) | |||
| } | |||
| /** | |||
| * Search commands | |||
| * @param query Full query (e.g., "/theme dark" or "/lang en") | |||
| * @param locale Current language | |||
| */ | |||
| async search(query: string, locale: string = 'en'): Promise<CommandSearchResult[]> { | |||
| const trimmed = query.trim() | |||
| // Handle root level search "/" | |||
| if (trimmed === '/' || !trimmed.replace('/', '').trim()) | |||
| return await this.getRootCommands() | |||
| // Parse command and arguments | |||
| const afterSlash = trimmed.substring(1).trim() | |||
| const spaceIndex = afterSlash.indexOf(' ') | |||
| const commandName = spaceIndex === -1 ? afterSlash : afterSlash.substring(0, spaceIndex) | |||
| const args = spaceIndex === -1 ? '' : afterSlash.substring(spaceIndex + 1).trim() | |||
| // First try exact match | |||
| let handler = this.findCommand(commandName) | |||
| if (handler) { | |||
| try { | |||
| return await handler.search(args, locale) | |||
| } | |||
| catch (error) { | |||
| console.warn(`Command search failed for ${commandName}:`, error) | |||
| return [] | |||
| } | |||
| } | |||
| // If no exact match, try smart partial matching | |||
| handler = this.findBestPartialMatch(commandName) | |||
| if (handler) { | |||
| try { | |||
| return await handler.search(args, locale) | |||
| } | |||
| catch (error) { | |||
| console.warn(`Command search failed for ${handler.name}:`, error) | |||
| return [] | |||
| } | |||
| } | |||
| // Finally perform fuzzy search | |||
| return this.fuzzySearchCommands(afterSlash) | |||
| } | |||
| /** | |||
| * Get root level command list | |||
| */ | |||
| private async getRootCommands(): Promise<CommandSearchResult[]> { | |||
| const results: CommandSearchResult[] = [] | |||
| // Generate a root level item for each command | |||
| for (const handler of this.getAllCommands()) { | |||
| results.push({ | |||
| id: `root-${handler.name}`, | |||
| title: `/${handler.name}`, | |||
| description: handler.description, | |||
| type: 'command' as const, | |||
| data: { | |||
| command: `root.${handler.name}`, | |||
| args: { name: handler.name }, | |||
| }, | |||
| }) | |||
| } | |||
| return results | |||
| } | |||
| /** | |||
| * Fuzzy search commands | |||
| */ | |||
| private fuzzySearchCommands(query: string): CommandSearchResult[] { | |||
| const lowercaseQuery = query.toLowerCase() | |||
| const matches: CommandSearchResult[] = [] | |||
| this.getAllCommands().forEach((handler) => { | |||
| // Check if command name matches | |||
| if (handler.name.toLowerCase().includes(lowercaseQuery)) { | |||
| matches.push({ | |||
| id: `fuzzy-${handler.name}`, | |||
| title: `/${handler.name}`, | |||
| description: handler.description, | |||
| type: 'command' as const, | |||
| data: { | |||
| command: `root.${handler.name}`, | |||
| args: { name: handler.name }, | |||
| }, | |||
| }) | |||
| } | |||
| // Check if aliases match | |||
| if (handler.aliases) { | |||
| handler.aliases.forEach((alias) => { | |||
| if (alias.toLowerCase().includes(lowercaseQuery)) { | |||
| matches.push({ | |||
| id: `fuzzy-${alias}`, | |||
| title: `/${alias}`, | |||
| description: `${handler.description} (alias for /${handler.name})`, | |||
| type: 'command' as const, | |||
| data: { | |||
| command: `root.${handler.name}`, | |||
| args: { name: handler.name }, | |||
| }, | |||
| }) | |||
| } | |||
| }) | |||
| } | |||
| }) | |||
| return matches | |||
| } | |||
| /** | |||
| * Get command dependencies | |||
| */ | |||
| getCommandDependencies(commandName: string): any { | |||
| return this.commandDeps.get(commandName) | |||
| } | |||
| } | |||
| // Global registry instance | |||
| export const slashCommandRegistry = new SlashCommandRegistry() | |||
| @@ -0,0 +1,52 @@ | |||
| 'use client' | |||
| import { useEffect } from 'react' | |||
| import type { ActionItem } from '../types' | |||
| import { slashCommandRegistry } from './registry' | |||
| import { executeCommand } from './command-bus' | |||
| import { useTheme } from 'next-themes' | |||
| import { setLocaleOnClient } from '@/i18n-config' | |||
| import { themeCommand } from './theme' | |||
| import { languageCommand } from './language' | |||
| import i18n from '@/i18n-config/i18next-config' | |||
| export const slashAction: ActionItem = { | |||
| key: '/', | |||
| shortcut: '/', | |||
| 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) | |||
| }, | |||
| } | |||
| // Register/unregister default handlers for slash commands with external dependencies. | |||
| export const registerSlashCommands = (deps: Record<string, any>) => { | |||
| // Register command handlers to the registry system with their respective dependencies | |||
| slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme }) | |||
| slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale }) | |||
| } | |||
| export const unregisterSlashCommands = () => { | |||
| // Remove command handlers from registry system (automatically calls each command's unregister method) | |||
| slashCommandRegistry.unregister('theme') | |||
| slashCommandRegistry.unregister('language') | |||
| } | |||
| export const SlashCommandProvider = () => { | |||
| const theme = useTheme() | |||
| useEffect(() => { | |||
| registerSlashCommands({ | |||
| setTheme: theme.setTheme, | |||
| setLocale: setLocaleOnClient, | |||
| }) | |||
| return () => unregisterSlashCommands() | |||
| }, [theme.setTheme]) | |||
| return null | |||
| } | |||
| @@ -1,7 +1,15 @@ | |||
| import type { CommandSearchResult } from './types' | |||
| import type { SlashCommandHandler } from './types' | |||
| import type { CommandSearchResult } from '../types' | |||
| import type { ReactNode } from 'react' | |||
| import { RiComputerLine, RiMoonLine, RiPaletteLine, RiSunLine } from '@remixicon/react' | |||
| import React from 'react' | |||
| import { RiComputerLine, RiMoonLine, RiSunLine } from '@remixicon/react' | |||
| import i18n from '@/i18n-config/i18next-config' | |||
| import { registerCommands, unregisterCommands } from './command-bus' | |||
| // Theme dependency types | |||
| type ThemeDeps = { | |||
| setTheme?: (value: 'light' | 'dark' | 'system') => void | |||
| } | |||
| const THEME_ITEMS: { id: 'light' | 'dark' | 'system'; titleKey: string; descKey: string; icon: ReactNode }[] = [ | |||
| { | |||
| @@ -24,7 +32,7 @@ const THEME_ITEMS: { id: 'light' | 'dark' | 'system'; titleKey: string; descKey: | |||
| }, | |||
| ] | |||
| export const buildThemeCommands = (query: string, locale?: string): CommandSearchResult[] => { | |||
| const buildThemeCommands = (query: string, locale?: string): CommandSearchResult[] => { | |||
| const q = query.toLowerCase() | |||
| const list = THEME_ITEMS.filter(item => | |||
| !q | |||
| @@ -45,17 +53,28 @@ export const buildThemeCommands = (query: string, locale?: string): CommandSearc | |||
| })) | |||
| } | |||
| export const buildThemeRootItem = (): CommandSearchResult => { | |||
| return { | |||
| id: 'category-theme', | |||
| title: i18n.t('app.gotoAnything.actions.themeCategoryTitle'), | |||
| description: i18n.t('app.gotoAnything.actions.themeCategoryDesc'), | |||
| type: 'command', | |||
| icon: ( | |||
| <div className='flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg'> | |||
| <RiPaletteLine className='h-4 w-4 text-text-tertiary' /> | |||
| </div> | |||
| ), | |||
| data: { command: 'nav.search', args: { query: '@run theme ' } }, | |||
| } | |||
| /** | |||
| * Theme command handler | |||
| * Integrates UI building, search, and registration logic | |||
| */ | |||
| export const themeCommand: SlashCommandHandler<ThemeDeps> = { | |||
| name: 'theme', | |||
| description: 'Switch between light and dark themes', | |||
| async search(args: string, locale: string = 'en') { | |||
| // Return theme options directly, regardless of parameters | |||
| return buildThemeCommands(args, locale) | |||
| }, | |||
| register(deps: ThemeDeps) { | |||
| registerCommands({ | |||
| 'theme.set': async (args) => { | |||
| deps.setTheme?.(args?.value) | |||
| }, | |||
| }) | |||
| }, | |||
| unregister() { | |||
| unregisterCommands(['theme.set']) | |||
| }, | |||
| } | |||
| @@ -0,0 +1,33 @@ | |||
| import type { CommandSearchResult } from '../types' | |||
| /** | |||
| * Slash command handler interface | |||
| * Each slash command should implement this interface | |||
| */ | |||
| export type SlashCommandHandler<TDeps = any> = { | |||
| /** Command name (e.g., 'theme', 'language') */ | |||
| name: string | |||
| /** Command alias list (e.g., ['lang'] for language) */ | |||
| aliases?: string[] | |||
| /** Command description */ | |||
| description: string | |||
| /** | |||
| * Search command results | |||
| * @param args Command arguments (part after removing command name) | |||
| * @param locale Current language | |||
| */ | |||
| search: (args: string, locale?: string) => Promise<CommandSearchResult[]> | |||
| /** | |||
| * Called when registering command, passing external dependencies | |||
| */ | |||
| register?: (deps: TDeps) => void | |||
| /** | |||
| * Called when unregistering command | |||
| */ | |||
| unregister?: () => void | |||
| } | |||
| @@ -1,15 +1,180 @@ | |||
| /** | |||
| * Goto Anything - Action System | |||
| * | |||
| * 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, etc.) | |||
| */ | |||
| import { appAction } from './app' | |||
| import { knowledgeAction } from './knowledge' | |||
| import { pluginAction } from './plugin' | |||
| import { workflowNodesAction } from './workflow-nodes' | |||
| import type { ActionItem, SearchResult } from './types' | |||
| import { commandAction } from './run' | |||
| import { slashAction } from './commands' | |||
| export const Actions = { | |||
| slash: slashAction, | |||
| app: appAction, | |||
| knowledge: knowledgeAction, | |||
| plugin: pluginAction, | |||
| run: commandAction, | |||
| node: workflowNodesAction, | |||
| } | |||
| @@ -29,11 +194,13 @@ export const searchAnything = async ( | |||
| } | |||
| } | |||
| if (query.startsWith('@')) | |||
| if (query.startsWith('@') || query.startsWith('/')) | |||
| return [] | |||
| const globalSearchActions = Object.values(Actions) | |||
| // Use Promise.allSettled to handle partial failures gracefully | |||
| const searchPromises = Object.values(Actions).map(async (action) => { | |||
| const searchPromises = globalSearchActions.map(async (action) => { | |||
| try { | |||
| const results = await action.search(query, query, locale) | |||
| return { success: true, data: results, actionType: action.key } | |||
| @@ -54,7 +221,7 @@ export const searchAnything = async ( | |||
| allResults.push(...result.value.data) | |||
| } | |||
| else { | |||
| const actionKey = Object.values(Actions)[index]?.key || 'unknown' | |||
| const actionKey = globalSearchActions[index]?.key || 'unknown' | |||
| failedActions.push(actionKey) | |||
| } | |||
| }) | |||
| @@ -67,10 +234,15 @@ export const searchAnything = async ( | |||
| export const matchAction = (query: string, actions: Record<string, ActionItem>) => { | |||
| return Object.values(actions).find((action) => { | |||
| // Special handling for slash commands to allow direct /theme, /lang | |||
| if (action.key === '/') | |||
| return query.startsWith('/') | |||
| const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`) | |||
| return reg.test(query) | |||
| }) | |||
| } | |||
| export * from './types' | |||
| export * from './commands' | |||
| export { appAction, knowledgeAction, pluginAction, workflowNodesAction } | |||
| @@ -1,33 +0,0 @@ | |||
| import type { CommandSearchResult } from './types' | |||
| import { languages } from '@/i18n-config/language' | |||
| import { RiTranslate } from '@remixicon/react' | |||
| import i18n from '@/i18n-config/i18next-config' | |||
| export const buildLanguageCommands = (query: string): CommandSearchResult[] => { | |||
| const q = query.toLowerCase() | |||
| const list = languages.filter(item => item.supported && ( | |||
| !q || item.name.toLowerCase().includes(q) || String(item.value).toLowerCase().includes(q) | |||
| )) | |||
| return list.map(item => ({ | |||
| id: `lang-${item.value}`, | |||
| title: item.name, | |||
| description: i18n.t('app.gotoAnything.actions.languageChangeDesc'), | |||
| type: 'command' as const, | |||
| data: { command: 'i18n.set', args: { locale: item.value } }, | |||
| })) | |||
| } | |||
| export const buildLanguageRootItem = (): CommandSearchResult => { | |||
| return { | |||
| id: 'category-language', | |||
| title: i18n.t('app.gotoAnything.actions.languageCategoryTitle'), | |||
| description: i18n.t('app.gotoAnything.actions.languageCategoryDesc'), | |||
| type: 'command', | |||
| icon: ( | |||
| <div className='flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg'> | |||
| <RiTranslate className='h-4 w-4 text-text-tertiary' /> | |||
| </div> | |||
| ), | |||
| data: { command: 'nav.search', args: { query: '@run language ' } }, | |||
| } | |||
| } | |||
| @@ -1,97 +0,0 @@ | |||
| 'use client' | |||
| import { useEffect } from 'react' | |||
| import type { ActionItem, CommandSearchResult } from './types' | |||
| import { buildLanguageCommands, buildLanguageRootItem } from './run-language' | |||
| import { buildThemeCommands, buildThemeRootItem } from './run-theme' | |||
| import i18n from '@/i18n-config/i18next-config' | |||
| import { executeCommand, registerCommands, unregisterCommands } from './command-bus' | |||
| import { useTheme } from 'next-themes' | |||
| import { setLocaleOnClient } from '@/i18n-config' | |||
| const rootParser = (query: string): CommandSearchResult[] => { | |||
| const q = query.toLowerCase() | |||
| const items: CommandSearchResult[] = [] | |||
| if (!q || 'theme'.includes(q)) | |||
| items.push(buildThemeRootItem()) | |||
| if (!q || 'language'.includes(q) || 'lang'.includes(q)) | |||
| items.push(buildLanguageRootItem()) | |||
| return items | |||
| } | |||
| type RunContext = { | |||
| setTheme?: (value: 'light' | 'dark' | 'system') => void | |||
| setLocale?: (locale: string) => Promise<void> | |||
| search?: (query: string) => void | |||
| } | |||
| export const commandAction: ActionItem = { | |||
| key: '@run', | |||
| shortcut: '@run', | |||
| title: i18n.t('app.gotoAnything.actions.runTitle'), | |||
| description: i18n.t('app.gotoAnything.actions.runDesc'), | |||
| action: (result) => { | |||
| if (result.type !== 'command') return | |||
| const { command, args } = result.data | |||
| if (command === 'theme.set') { | |||
| executeCommand('theme.set', args) | |||
| return | |||
| } | |||
| if (command === 'i18n.set') { | |||
| executeCommand('i18n.set', args) | |||
| return | |||
| } | |||
| if (command === 'nav.search') | |||
| executeCommand('nav.search', args) | |||
| }, | |||
| search: async (_, searchTerm = '') => { | |||
| const q = searchTerm.trim() | |||
| if (q.startsWith('theme')) | |||
| return buildThemeCommands(q.replace(/^theme\s*/, ''), i18n.language) | |||
| if (q.startsWith('language') || q.startsWith('lang')) | |||
| return buildLanguageCommands(q.replace(/^(language|lang)\s*/, '')) | |||
| // root categories | |||
| return rootParser(q) | |||
| }, | |||
| } | |||
| // Register/unregister default handlers for @run commands with external dependencies. | |||
| export const registerRunCommands = (deps: { | |||
| setTheme?: (value: 'light' | 'dark' | 'system') => void | |||
| setLocale?: (locale: string) => Promise<void> | |||
| search?: (query: string) => void | |||
| }) => { | |||
| registerCommands({ | |||
| 'theme.set': async (args) => { | |||
| deps.setTheme?.(args?.value) | |||
| }, | |||
| 'i18n.set': async (args) => { | |||
| const locale = args?.locale | |||
| if (locale) | |||
| await deps.setLocale?.(locale) | |||
| }, | |||
| 'nav.search': (args) => { | |||
| const q = args?.query | |||
| if (q) | |||
| deps.search?.(q) | |||
| }, | |||
| }) | |||
| } | |||
| export const unregisterRunCommands = () => { | |||
| unregisterCommands(['theme.set', 'i18n.set', 'nav.search']) | |||
| } | |||
| export const RunCommandProvider = ({ onNavSearch }: { onNavSearch?: (q: string) => void }) => { | |||
| const theme = useTheme() | |||
| useEffect(() => { | |||
| registerRunCommands({ | |||
| setTheme: theme.setTheme, | |||
| setLocale: setLocaleOnClient, | |||
| search: onNavSearch, | |||
| }) | |||
| return () => unregisterRunCommands() | |||
| }, [theme.setTheme, onNavSearch]) | |||
| return null | |||
| } | |||
| @@ -44,7 +44,7 @@ export type CommandSearchResult = { | |||
| export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult | |||
| export type ActionItem = { | |||
| key: '@app' | '@knowledge' | '@plugin' | '@node' | '@run' | |||
| key: '@app' | '@knowledge' | '@plugin' | '@node' | '/' | |||
| shortcut: string | |||
| title: string | TypeWithI18N | |||
| description: string | |||
| @@ -69,10 +69,10 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co | |||
| <span className="ml-3 text-sm text-text-secondary"> | |||
| {(() => { | |||
| const keyMap: Record<string, string> = { | |||
| '/': 'app.gotoAnything.actions.slashDesc', | |||
| '@app': 'app.gotoAnything.actions.searchApplicationsDesc', | |||
| '@plugin': 'app.gotoAnything.actions.searchPluginsDesc', | |||
| '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc', | |||
| '@run': 'app.gotoAnything.actions.runDesc', | |||
| '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc', | |||
| } | |||
| return t(keyMap[action.key]) | |||
| @@ -18,7 +18,7 @@ import InstallFromMarketplace from '../plugins/install-plugin/install-from-marke | |||
| import type { Plugin } from '../plugins/types' | |||
| import { Command } from 'cmdk' | |||
| import CommandSelector from './command-selector' | |||
| import { RunCommandProvider } from './actions/run' | |||
| import { SlashCommandProvider } from './actions/commands' | |||
| type Props = { | |||
| onHide?: () => void | |||
| @@ -34,12 +34,7 @@ const GotoAnything: FC<Props> = ({ | |||
| const [searchQuery, setSearchQuery] = useState<string>('') | |||
| const [cmdVal, setCmdVal] = useState<string>('_') | |||
| const inputRef = useRef<HTMLInputElement>(null) | |||
| const handleNavSearch = useCallback((q: string) => { | |||
| setShow(true) | |||
| setSearchQuery(q) | |||
| setCmdVal('') | |||
| requestAnimationFrame(() => inputRef.current?.focus()) | |||
| }, []) | |||
| // Filter actions based on context | |||
| const Actions = useMemo(() => { | |||
| // Create a filtered copy of actions based on current page context | |||
| @@ -48,9 +43,8 @@ const GotoAnything: FC<Props> = ({ | |||
| return AllActions | |||
| } | |||
| else { | |||
| // Exclude node action on non-workflow pages | |||
| const { app, knowledge, plugin, run } = AllActions | |||
| return { app, knowledge, plugin, run } | |||
| const { app, knowledge, plugin, slash } = AllActions | |||
| return { app, knowledge, plugin, slash } | |||
| } | |||
| }, [isWorkflowPage]) | |||
| @@ -88,14 +82,18 @@ const GotoAnything: FC<Props> = ({ | |||
| wait: 300, | |||
| }) | |||
| const isCommandsMode = searchQuery.trim() === '@' || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions)) | |||
| const isCommandsMode = searchQuery.trim() === '@' || searchQuery.trim() === '/' | |||
| || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions)) | |||
| || (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions)) | |||
| const searchMode = useMemo(() => { | |||
| if (isCommandsMode) return 'commands' | |||
| const query = searchQueryDebouncedValue.toLowerCase() | |||
| const action = matchAction(query, Actions) | |||
| return action ? action.key : 'general' | |||
| return action | |||
| ? (action.key === '/' ? '@command' : action.key) | |||
| : 'general' | |||
| }, [searchQueryDebouncedValue, Actions, isCommandsMode]) | |||
| const { data: searchResults = [], isLoading, isError, error } = useQuery( | |||
| @@ -140,7 +138,8 @@ const GotoAnything: FC<Props> = ({ | |||
| switch (result.type) { | |||
| case 'command': { | |||
| const action = Object.values(Actions).find(a => a.key === '@run') | |||
| // Execute slash commands | |||
| const action = Actions.slash | |||
| action?.action?.(result) | |||
| break | |||
| } | |||
| @@ -208,7 +207,7 @@ const GotoAnything: FC<Props> = ({ | |||
| </div> | |||
| <div className='mt-1 text-xs text-text-quaternary'> | |||
| {isCommandSearch | |||
| ? t('app.gotoAnything.emptyState.tryDifferentTerm', { mode: searchMode }) | |||
| ? t('app.gotoAnything.emptyState.tryDifferentTerm') | |||
| : t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') }) | |||
| } | |||
| </div> | |||
| @@ -242,6 +241,7 @@ const GotoAnything: FC<Props> = ({ | |||
| return ( | |||
| <> | |||
| <SlashCommandProvider /> | |||
| <Modal | |||
| isShow={show} | |||
| onClose={() => { | |||
| @@ -270,7 +270,7 @@ const GotoAnything: FC<Props> = ({ | |||
| placeholder={t('app.gotoAnything.searchPlaceholder')} | |||
| onChange={(e) => { | |||
| setSearchQuery(e.target.value) | |||
| if (!e.target.value.startsWith('@')) | |||
| if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/')) | |||
| clearSelection() | |||
| }} | |||
| className='flex-1 !border-0 !bg-transparent !shadow-none' | |||
| @@ -330,6 +330,7 @@ const GotoAnything: FC<Props> = ({ | |||
| '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`) | |||
| })()} className='p-2 capitalize text-text-secondary'> | |||
| @@ -395,7 +396,6 @@ const GotoAnything: FC<Props> = ({ | |||
| </div> | |||
| </Modal> | |||
| <RunCommandProvider onNavSearch={handleNavSearch} /> | |||
| { | |||
| activePlugin && ( | |||
| <InstallFromMarketplace | |||
| @@ -253,7 +253,7 @@ const translation = { | |||
| maxActiveRequestsPlaceholder: 'Enter 0 for unlimited', | |||
| maxActiveRequestsTip: 'Maximum number of concurrent active requests per app (0 for unlimited)', | |||
| gotoAnything: { | |||
| searchPlaceholder: 'Search or type @ for commands...', | |||
| searchPlaceholder: 'Search or type @ or / for commands...', | |||
| searchTitle: 'Search for anything', | |||
| searching: 'Searching...', | |||
| noResults: 'No results found', | |||
| @@ -292,13 +292,14 @@ const translation = { | |||
| languageCategoryTitle: 'Language', | |||
| languageCategoryDesc: 'Switch interface language', | |||
| languageChangeDesc: 'Change UI language', | |||
| slashDesc: 'Execute commands like /theme, /lang', | |||
| }, | |||
| emptyState: { | |||
| noAppsFound: 'No apps found', | |||
| noPluginsFound: 'No plugins found', | |||
| noKnowledgeBasesFound: 'No knowledge bases found', | |||
| noWorkflowNodesFound: 'No workflow nodes found', | |||
| tryDifferentTerm: 'Try a different search term or remove the {{mode}} filter', | |||
| tryDifferentTerm: 'Try a different search term', | |||
| trySpecificSearch: 'Try {{shortcuts}} for specific searches', | |||
| }, | |||
| groups: { | |||
| @@ -306,6 +307,7 @@ const translation = { | |||
| plugins: 'Plugins', | |||
| knowledgeBases: 'Knowledge Bases', | |||
| workflowNodes: 'Workflow Nodes', | |||
| commands: 'Commands', | |||
| }, | |||
| noMatchingCommands: 'No matching commands found', | |||
| tryDifferentSearch: 'Try a different search term', | |||
| @@ -252,7 +252,7 @@ const translation = { | |||
| maxActiveRequestsPlaceholder: '0 表示不限制', | |||
| maxActiveRequestsTip: '当前应用的最大活跃请求数(0 表示不限制)', | |||
| gotoAnything: { | |||
| searchPlaceholder: '搜索或输入 @ 以使用命令...', | |||
| searchPlaceholder: '搜索或输入 @ 或 / 以使用命令...', | |||
| searchTitle: '搜索任何内容', | |||
| searching: '搜索中...', | |||
| noResults: '未找到结果', | |||
| @@ -291,13 +291,14 @@ const translation = { | |||
| languageCategoryTitle: '语言', | |||
| languageCategoryDesc: '切换界面语言', | |||
| languageChangeDesc: '更改界面语言', | |||
| slashDesc: '执行诸如 /theme、/lang 等命令', | |||
| }, | |||
| emptyState: { | |||
| noAppsFound: '未找到应用', | |||
| noPluginsFound: '未找到插件', | |||
| noKnowledgeBasesFound: '未找到知识库', | |||
| noWorkflowNodesFound: '未找到工作流节点', | |||
| tryDifferentTerm: '尝试不同的搜索词或移除 {{mode}} 过滤器', | |||
| tryDifferentTerm: '尝试不同的搜索词', | |||
| trySpecificSearch: '尝试使用 {{shortcuts}} 进行特定搜索', | |||
| }, | |||
| groups: { | |||
| @@ -305,6 +306,7 @@ const translation = { | |||
| plugins: '插件', | |||
| knowledgeBases: '知识库', | |||
| workflowNodes: '工作流节点', | |||
| commands: '命令', | |||
| }, | |||
| noMatchingCommands: '未找到匹配的命令', | |||
| tryDifferentSearch: '请尝试不同的搜索词', | |||