| const handlers = new Map<string, CommandHandler>() | const handlers = new Map<string, CommandHandler>() | ||||
| export const registerCommand = (name: string, handler: CommandHandler) => { | |||||
| const registerCommand = (name: string, handler: CommandHandler) => { | |||||
| handlers.set(name, handler) | handlers.set(name, handler) | ||||
| } | } | ||||
| export const unregisterCommand = (name: string) => { | |||||
| const unregisterCommand = (name: string) => { | |||||
| handlers.delete(name) | handlers.delete(name) | ||||
| } | } | ||||
| // 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' |
| 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']) | |||||
| }, | |||||
| } |
| 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() |
| '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 | |||||
| } |
| import type { CommandSearchResult } from './types' | |||||
| import type { SlashCommandHandler } from './types' | |||||
| import type { CommandSearchResult } from '../types' | |||||
| import type { ReactNode } from 'react' | 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 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 }[] = [ | const THEME_ITEMS: { id: 'light' | 'dark' | 'system'; titleKey: string; descKey: string; icon: ReactNode }[] = [ | ||||
| { | { | ||||
| }, | }, | ||||
| ] | ] | ||||
| export const buildThemeCommands = (query: string, locale?: string): CommandSearchResult[] => { | |||||
| const buildThemeCommands = (query: string, locale?: string): CommandSearchResult[] => { | |||||
| const q = query.toLowerCase() | const q = query.toLowerCase() | ||||
| const list = THEME_ITEMS.filter(item => | const list = THEME_ITEMS.filter(item => | ||||
| !q | !q | ||||
| })) | })) | ||||
| } | } | ||||
| 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']) | |||||
| }, | |||||
| } | } |
| 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 | |||||
| } |
| /** | |||||
| * 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 { appAction } from './app' | ||||
| import { knowledgeAction } from './knowledge' | import { knowledgeAction } from './knowledge' | ||||
| import { pluginAction } from './plugin' | import { pluginAction } from './plugin' | ||||
| import { workflowNodesAction } from './workflow-nodes' | import { workflowNodesAction } from './workflow-nodes' | ||||
| import type { ActionItem, SearchResult } from './types' | import type { ActionItem, SearchResult } from './types' | ||||
| import { commandAction } from './run' | |||||
| import { slashAction } from './commands' | |||||
| export const Actions = { | export const Actions = { | ||||
| slash: slashAction, | |||||
| app: appAction, | app: appAction, | ||||
| knowledge: knowledgeAction, | knowledge: knowledgeAction, | ||||
| plugin: pluginAction, | plugin: pluginAction, | ||||
| run: commandAction, | |||||
| node: workflowNodesAction, | node: workflowNodesAction, | ||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| if (query.startsWith('@')) | |||||
| if (query.startsWith('@') || query.startsWith('/')) | |||||
| return [] | return [] | ||||
| const globalSearchActions = Object.values(Actions) | |||||
| // Use Promise.allSettled to handle partial failures gracefully | // Use Promise.allSettled to handle partial failures gracefully | ||||
| const searchPromises = Object.values(Actions).map(async (action) => { | |||||
| const searchPromises = globalSearchActions.map(async (action) => { | |||||
| try { | try { | ||||
| const results = await action.search(query, query, locale) | const results = await action.search(query, query, locale) | ||||
| return { success: true, data: results, actionType: action.key } | return { success: true, data: results, actionType: action.key } | ||||
| allResults.push(...result.value.data) | allResults.push(...result.value.data) | ||||
| } | } | ||||
| else { | else { | ||||
| const actionKey = Object.values(Actions)[index]?.key || 'unknown' | |||||
| const actionKey = globalSearchActions[index]?.key || 'unknown' | |||||
| failedActions.push(actionKey) | failedActions.push(actionKey) | ||||
| } | } | ||||
| }) | }) | ||||
| export const matchAction = (query: string, actions: Record<string, ActionItem>) => { | export const matchAction = (query: string, actions: Record<string, ActionItem>) => { | ||||
| return Object.values(actions).find((action) => { | 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|$)`) | const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`) | ||||
| return reg.test(query) | return reg.test(query) | ||||
| }) | }) | ||||
| } | } | ||||
| export * from './types' | export * from './types' | ||||
| export * from './commands' | |||||
| export { appAction, knowledgeAction, pluginAction, workflowNodesAction } | export { appAction, knowledgeAction, pluginAction, workflowNodesAction } |
| 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 ' } }, | |||||
| } | |||||
| } |
| '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 | |||||
| } |
| export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult | export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult | ||||
| export type ActionItem = { | export type ActionItem = { | ||||
| key: '@app' | '@knowledge' | '@plugin' | '@node' | '@run' | |||||
| key: '@app' | '@knowledge' | '@plugin' | '@node' | '/' | |||||
| shortcut: string | shortcut: string | ||||
| title: string | TypeWithI18N | title: string | TypeWithI18N | ||||
| description: string | description: string |
| <span className="ml-3 text-sm text-text-secondary"> | <span className="ml-3 text-sm text-text-secondary"> | ||||
| {(() => { | {(() => { | ||||
| const keyMap: Record<string, string> = { | const keyMap: Record<string, string> = { | ||||
| '/': 'app.gotoAnything.actions.slashDesc', | |||||
| '@app': 'app.gotoAnything.actions.searchApplicationsDesc', | '@app': 'app.gotoAnything.actions.searchApplicationsDesc', | ||||
| '@plugin': 'app.gotoAnything.actions.searchPluginsDesc', | '@plugin': 'app.gotoAnything.actions.searchPluginsDesc', | ||||
| '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc', | '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc', | ||||
| '@run': 'app.gotoAnything.actions.runDesc', | |||||
| '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc', | '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc', | ||||
| } | } | ||||
| return t(keyMap[action.key]) | return t(keyMap[action.key]) |
| import type { Plugin } from '../plugins/types' | import type { Plugin } from '../plugins/types' | ||||
| import { Command } from 'cmdk' | import { Command } from 'cmdk' | ||||
| import CommandSelector from './command-selector' | import CommandSelector from './command-selector' | ||||
| import { RunCommandProvider } from './actions/run' | |||||
| import { SlashCommandProvider } from './actions/commands' | |||||
| type Props = { | type Props = { | ||||
| onHide?: () => void | onHide?: () => void | ||||
| const [searchQuery, setSearchQuery] = useState<string>('') | const [searchQuery, setSearchQuery] = useState<string>('') | ||||
| const [cmdVal, setCmdVal] = useState<string>('_') | const [cmdVal, setCmdVal] = useState<string>('_') | ||||
| const inputRef = useRef<HTMLInputElement>(null) | const inputRef = useRef<HTMLInputElement>(null) | ||||
| const handleNavSearch = useCallback((q: string) => { | |||||
| setShow(true) | |||||
| setSearchQuery(q) | |||||
| setCmdVal('') | |||||
| requestAnimationFrame(() => inputRef.current?.focus()) | |||||
| }, []) | |||||
| // Filter actions based on context | // Filter actions based on context | ||||
| const Actions = useMemo(() => { | const Actions = useMemo(() => { | ||||
| // Create a filtered copy of actions based on current page context | // Create a filtered copy of actions based on current page context | ||||
| return AllActions | return AllActions | ||||
| } | } | ||||
| else { | 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]) | }, [isWorkflowPage]) | ||||
| wait: 300, | 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(() => { | const searchMode = useMemo(() => { | ||||
| if (isCommandsMode) return 'commands' | if (isCommandsMode) return 'commands' | ||||
| const query = searchQueryDebouncedValue.toLowerCase() | const query = searchQueryDebouncedValue.toLowerCase() | ||||
| const action = matchAction(query, Actions) | const action = matchAction(query, Actions) | ||||
| return action ? action.key : 'general' | |||||
| return action | |||||
| ? (action.key === '/' ? '@command' : action.key) | |||||
| : 'general' | |||||
| }, [searchQueryDebouncedValue, Actions, isCommandsMode]) | }, [searchQueryDebouncedValue, Actions, isCommandsMode]) | ||||
| const { data: searchResults = [], isLoading, isError, error } = useQuery( | const { data: searchResults = [], isLoading, isError, error } = useQuery( | ||||
| switch (result.type) { | switch (result.type) { | ||||
| case 'command': { | case 'command': { | ||||
| const action = Object.values(Actions).find(a => a.key === '@run') | |||||
| // Execute slash commands | |||||
| const action = Actions.slash | |||||
| action?.action?.(result) | action?.action?.(result) | ||||
| break | break | ||||
| } | } | ||||
| </div> | </div> | ||||
| <div className='mt-1 text-xs text-text-quaternary'> | <div className='mt-1 text-xs text-text-quaternary'> | ||||
| {isCommandSearch | {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(', ') }) | : t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') }) | ||||
| } | } | ||||
| </div> | </div> | ||||
| return ( | return ( | ||||
| <> | <> | ||||
| <SlashCommandProvider /> | |||||
| <Modal | <Modal | ||||
| isShow={show} | isShow={show} | ||||
| onClose={() => { | onClose={() => { | ||||
| placeholder={t('app.gotoAnything.searchPlaceholder')} | placeholder={t('app.gotoAnything.searchPlaceholder')} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| setSearchQuery(e.target.value) | setSearchQuery(e.target.value) | ||||
| if (!e.target.value.startsWith('@')) | |||||
| if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/')) | |||||
| clearSelection() | clearSelection() | ||||
| }} | }} | ||||
| className='flex-1 !border-0 !bg-transparent !shadow-none' | className='flex-1 !border-0 !bg-transparent !shadow-none' | ||||
| 'plugin': 'app.gotoAnything.groups.plugins', | 'plugin': 'app.gotoAnything.groups.plugins', | ||||
| 'knowledge': 'app.gotoAnything.groups.knowledgeBases', | 'knowledge': 'app.gotoAnything.groups.knowledgeBases', | ||||
| 'workflow-node': 'app.gotoAnything.groups.workflowNodes', | 'workflow-node': 'app.gotoAnything.groups.workflowNodes', | ||||
| 'command': 'app.gotoAnything.groups.commands', | |||||
| } | } | ||||
| return t(typeMap[type] || `${type}s`) | return t(typeMap[type] || `${type}s`) | ||||
| })()} className='p-2 capitalize text-text-secondary'> | })()} className='p-2 capitalize text-text-secondary'> | ||||
| </div> | </div> | ||||
| </Modal> | </Modal> | ||||
| <RunCommandProvider onNavSearch={handleNavSearch} /> | |||||
| { | { | ||||
| activePlugin && ( | activePlugin && ( | ||||
| <InstallFromMarketplace | <InstallFromMarketplace |
| maxActiveRequestsPlaceholder: 'Enter 0 for unlimited', | maxActiveRequestsPlaceholder: 'Enter 0 for unlimited', | ||||
| maxActiveRequestsTip: 'Maximum number of concurrent active requests per app (0 for unlimited)', | maxActiveRequestsTip: 'Maximum number of concurrent active requests per app (0 for unlimited)', | ||||
| gotoAnything: { | gotoAnything: { | ||||
| searchPlaceholder: 'Search or type @ for commands...', | |||||
| searchPlaceholder: 'Search or type @ or / for commands...', | |||||
| searchTitle: 'Search for anything', | searchTitle: 'Search for anything', | ||||
| searching: 'Searching...', | searching: 'Searching...', | ||||
| noResults: 'No results found', | noResults: 'No results found', | ||||
| languageCategoryTitle: 'Language', | languageCategoryTitle: 'Language', | ||||
| languageCategoryDesc: 'Switch interface language', | languageCategoryDesc: 'Switch interface language', | ||||
| languageChangeDesc: 'Change UI language', | languageChangeDesc: 'Change UI language', | ||||
| slashDesc: 'Execute commands like /theme, /lang', | |||||
| }, | }, | ||||
| emptyState: { | emptyState: { | ||||
| noAppsFound: 'No apps found', | noAppsFound: 'No apps found', | ||||
| noPluginsFound: 'No plugins found', | noPluginsFound: 'No plugins found', | ||||
| noKnowledgeBasesFound: 'No knowledge bases found', | noKnowledgeBasesFound: 'No knowledge bases found', | ||||
| noWorkflowNodesFound: 'No workflow nodes 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', | trySpecificSearch: 'Try {{shortcuts}} for specific searches', | ||||
| }, | }, | ||||
| groups: { | groups: { | ||||
| plugins: 'Plugins', | plugins: 'Plugins', | ||||
| knowledgeBases: 'Knowledge Bases', | knowledgeBases: 'Knowledge Bases', | ||||
| workflowNodes: 'Workflow Nodes', | workflowNodes: 'Workflow Nodes', | ||||
| commands: 'Commands', | |||||
| }, | }, | ||||
| noMatchingCommands: 'No matching commands found', | noMatchingCommands: 'No matching commands found', | ||||
| tryDifferentSearch: 'Try a different search term', | tryDifferentSearch: 'Try a different search term', |
| maxActiveRequestsPlaceholder: '0 表示不限制', | maxActiveRequestsPlaceholder: '0 表示不限制', | ||||
| maxActiveRequestsTip: '当前应用的最大活跃请求数(0 表示不限制)', | maxActiveRequestsTip: '当前应用的最大活跃请求数(0 表示不限制)', | ||||
| gotoAnything: { | gotoAnything: { | ||||
| searchPlaceholder: '搜索或输入 @ 以使用命令...', | |||||
| searchPlaceholder: '搜索或输入 @ 或 / 以使用命令...', | |||||
| searchTitle: '搜索任何内容', | searchTitle: '搜索任何内容', | ||||
| searching: '搜索中...', | searching: '搜索中...', | ||||
| noResults: '未找到结果', | noResults: '未找到结果', | ||||
| languageCategoryTitle: '语言', | languageCategoryTitle: '语言', | ||||
| languageCategoryDesc: '切换界面语言', | languageCategoryDesc: '切换界面语言', | ||||
| languageChangeDesc: '更改界面语言', | languageChangeDesc: '更改界面语言', | ||||
| slashDesc: '执行诸如 /theme、/lang 等命令', | |||||
| }, | }, | ||||
| emptyState: { | emptyState: { | ||||
| noAppsFound: '未找到应用', | noAppsFound: '未找到应用', | ||||
| noPluginsFound: '未找到插件', | noPluginsFound: '未找到插件', | ||||
| noKnowledgeBasesFound: '未找到知识库', | noKnowledgeBasesFound: '未找到知识库', | ||||
| noWorkflowNodesFound: '未找到工作流节点', | noWorkflowNodesFound: '未找到工作流节点', | ||||
| tryDifferentTerm: '尝试不同的搜索词或移除 {{mode}} 过滤器', | |||||
| tryDifferentTerm: '尝试不同的搜索词', | |||||
| trySpecificSearch: '尝试使用 {{shortcuts}} 进行特定搜索', | trySpecificSearch: '尝试使用 {{shortcuts}} 进行特定搜索', | ||||
| }, | }, | ||||
| groups: { | groups: { | ||||
| plugins: '插件', | plugins: '插件', | ||||
| knowledgeBases: '知识库', | knowledgeBases: '知识库', | ||||
| workflowNodes: '工作流节点', | workflowNodes: '工作流节点', | ||||
| commands: '命令', | |||||
| }, | }, | ||||
| noMatchingCommands: '未找到匹配的命令', | noMatchingCommands: '未找到匹配的命令', | ||||
| tryDifferentSearch: '请尝试不同的搜索词', | tryDifferentSearch: '请尝试不同的搜索词', |