This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
import type { ActionItem, AppSearchResult } from './types'
import type { App } from '@/types/app'
import { fetchAppList } from '@/service/apps'
import AppIcon from '../../base/app-icon'
import { AppTypeIcon } from '../../app/type-selector'
import { getRedirectionPath } from '@/utils/app-redirection'
const parser = (apps: App[]): AppSearchResult[] => {
return apps.map(app => ({
id: app.id,
title: app.name,
description: app.description,
type: 'app' as const,
path: getRedirectionPath(true, {
id: app.id,
mode: app.mode,
}),
icon: (
<div className='relative shrink-0'>
<AppIcon
size='large'
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<AppTypeIcon wrapperClassName='absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-[4px] border border-divider-regular outline outline-components-panel-on-panel-item-bg'
className='h-3 w-3' type={app.mode} />
</div>
),
data: app,
}))
}
export const appAction: ActionItem = {
key: '@app',
shortcut: '@app',
title: 'Search Applications',
description: 'Search and navigate to your applications',
// action,
search: async (_, searchTerm = '', _locale) => {
try {
const response = await fetchAppList({
url: 'apps',
params: {
page: 1,
name: searchTerm,
},
})
const apps = response?.data || []
return parser(apps)
}
catch (error) {
console.warn('App search failed:', error)
return []
}
},
}

View File

@@ -0,0 +1,50 @@
import type { SlashCommandHandler } from './types'
import React from 'react'
import { RiUser3Line } from '@remixicon/react'
import i18n from '@/i18n-config/i18next-config'
import { registerCommands, unregisterCommands } from './command-bus'
// Account command dependency types - no external dependencies needed
type AccountDeps = Record<string, never>
/**
* Account command - Navigates to account page
*/
export const accountCommand: SlashCommandHandler<AccountDeps> = {
name: 'account',
description: 'Navigate to account page',
mode: 'direct',
// Direct execution function
execute: () => {
window.location.href = '/account'
},
async search(args: string, locale: string = 'en') {
return [{
id: 'account',
title: i18n.t('common.account.account', { lng: locale }),
description: i18n.t('app.gotoAnything.actions.accountDesc', { lng: locale }),
type: 'command' as const,
icon: (
<div className='flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg'>
<RiUser3Line className='h-4 w-4 text-text-tertiary' />
</div>
),
data: { command: 'navigation.account', args: {} },
}]
},
register(_deps: AccountDeps) {
registerCommands({
'navigation.account': async (_args) => {
// Navigate to account page
window.location.href = '/account'
},
})
},
unregister() {
unregisterCommands(['navigation.account'])
},
}

View File

@@ -0,0 +1,26 @@
export type CommandHandler = (args?: Record<string, any>) => void | Promise<void>
const handlers = new Map<string, CommandHandler>()
const registerCommand = (name: string, handler: CommandHandler) => {
handlers.set(name, handler)
}
const unregisterCommand = (name: string) => {
handlers.delete(name)
}
export const executeCommand = async (name: string, args?: Record<string, any>) => {
const handler = handlers.get(name)
if (!handler)
return
await handler(args)
}
export const registerCommands = (map: Record<string, CommandHandler>) => {
Object.entries(map).forEach(([name, handler]) => registerCommand(name, handler))
}
export const unregisterCommands = (names: string[]) => {
names.forEach(unregisterCommand)
}

View File

@@ -0,0 +1,51 @@
import type { SlashCommandHandler } from './types'
import React from 'react'
import { RiDiscordLine } from '@remixicon/react'
import i18n from '@/i18n-config/i18next-config'
import { registerCommands, unregisterCommands } from './command-bus'
// Community command dependency types
type CommunityDeps = Record<string, never>
/**
* Community command - Opens Discord community
*/
export const communityCommand: SlashCommandHandler<CommunityDeps> = {
name: 'community',
description: 'Open community Discord',
mode: 'direct',
// Direct execution function
execute: () => {
const url = 'https://discord.gg/5AEfbxcd9k'
window.open(url, '_blank', 'noopener,noreferrer')
},
async search(args: string, locale: string = 'en') {
return [{
id: 'community',
title: i18n.t('common.userProfile.community', { lng: locale }),
description: i18n.t('app.gotoAnything.actions.communityDesc', { lng: locale }) || 'Open Discord community',
type: 'command' as const,
icon: (
<div className='flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg'>
<RiDiscordLine className='h-4 w-4 text-text-tertiary' />
</div>
),
data: { command: 'navigation.community', args: { url: 'https://discord.gg/5AEfbxcd9k' } },
}]
},
register(_deps: CommunityDeps) {
registerCommands({
'navigation.community': async (args) => {
const url = args?.url || 'https://discord.gg/5AEfbxcd9k'
window.open(url, '_blank', 'noopener,noreferrer')
},
})
},
unregister() {
unregisterCommands(['navigation.community'])
},
}

View File

@@ -0,0 +1,58 @@
import type { SlashCommandHandler } from './types'
import React from 'react'
import { RiBookOpenLine } from '@remixicon/react'
import i18n from '@/i18n-config/i18next-config'
import { registerCommands, unregisterCommands } from './command-bus'
import { defaultDocBaseUrl } from '@/context/i18n'
import { getDocLanguage } from '@/i18n-config/language'
// Documentation command dependency types - no external dependencies needed
type DocDeps = Record<string, never>
/**
* Documentation command - Opens help documentation
*/
export const docsCommand: SlashCommandHandler<DocDeps> = {
name: 'docs',
description: 'Open documentation',
mode: 'direct',
// Direct execution function
execute: () => {
const currentLocale = i18n.language
const docLanguage = getDocLanguage(currentLocale)
const url = `${defaultDocBaseUrl}/${docLanguage}`
window.open(url, '_blank', 'noopener,noreferrer')
},
async search(args: string, locale: string = 'en') {
return [{
id: 'doc',
title: i18n.t('common.userProfile.helpCenter', { lng: locale }),
description: i18n.t('app.gotoAnything.actions.docDesc', { lng: locale }) || 'Open help documentation',
type: 'command' as const,
icon: (
<div className='flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg'>
<RiBookOpenLine className='h-4 w-4 text-text-tertiary' />
</div>
),
data: { command: 'navigation.doc', args: {} },
}]
},
register(_deps: DocDeps) {
registerCommands({
'navigation.doc': async (_args) => {
// Get the current language from i18n
const currentLocale = i18n.language
const docLanguage = getDocLanguage(currentLocale)
const url = `${defaultDocBaseUrl}/${docLanguage}`
window.open(url, '_blank', 'noopener,noreferrer')
},
})
},
unregister() {
unregisterCommands(['navigation.doc'])
},
}

View File

@@ -0,0 +1,51 @@
import type { SlashCommandHandler } from './types'
import React from 'react'
import { RiFeedbackLine } from '@remixicon/react'
import i18n from '@/i18n-config/i18next-config'
import { registerCommands, unregisterCommands } from './command-bus'
// Forum command dependency types
type ForumDeps = Record<string, never>
/**
* Forum command - Opens Dify community forum
*/
export const forumCommand: SlashCommandHandler<ForumDeps> = {
name: 'forum',
description: 'Open Dify community forum',
mode: 'direct',
// Direct execution function
execute: () => {
const url = 'https://forum.dify.ai'
window.open(url, '_blank', 'noopener,noreferrer')
},
async search(args: string, locale: string = 'en') {
return [{
id: 'forum',
title: i18n.t('common.userProfile.forum', { lng: locale }),
description: i18n.t('app.gotoAnything.actions.feedbackDesc', { lng: locale }) || 'Open community feedback discussions',
type: 'command' as const,
icon: (
<div className='flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg'>
<RiFeedbackLine className='h-4 w-4 text-text-tertiary' />
</div>
),
data: { command: 'navigation.forum', args: { url: 'https://forum.dify.ai' } },
}]
},
register(_deps: ForumDeps) {
registerCommands({
'navigation.forum': async (args) => {
const url = args?.url || 'https://forum.dify.ai'
window.open(url, '_blank', 'noopener,noreferrer')
},
})
},
unregister() {
unregisterCommands(['navigation.forum'])
},
}

View File

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

View File

@@ -0,0 +1,54 @@
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',
mode: 'submenu', // Explicitly set submenu mode
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'])
},
}

View File

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

View File

@@ -0,0 +1,64 @@
'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 { forumCommand } from './forum'
import { docsCommand } from './docs'
import { communityCommand } from './community'
import { accountCommand } from './account'
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 })
slashCommandRegistry.register(forumCommand, {})
slashCommandRegistry.register(docsCommand, {})
slashCommandRegistry.register(communityCommand, {})
slashCommandRegistry.register(accountCommand, {})
}
export const unregisterSlashCommands = () => {
// Remove command handlers from registry system (automatically calls each command's unregister method)
slashCommandRegistry.unregister('theme')
slashCommandRegistry.unregister('language')
slashCommandRegistry.unregister('forum')
slashCommandRegistry.unregister('docs')
slashCommandRegistry.unregister('community')
slashCommandRegistry.unregister('account')
}
export const SlashCommandProvider = () => {
const theme = useTheme()
useEffect(() => {
registerSlashCommands({
setTheme: theme.setTheme,
setLocale: setLocaleOnClient,
})
return () => unregisterSlashCommands()
}, [theme.setTheme])
return null
}

View File

@@ -0,0 +1,81 @@
import type { SlashCommandHandler } from './types'
import type { CommandSearchResult } from '../types'
import type { ReactNode } from '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 }[] = [
{
id: 'system',
titleKey: 'app.gotoAnything.actions.themeSystem',
descKey: 'app.gotoAnything.actions.themeSystemDesc',
icon: <RiComputerLine className='h-4 w-4 text-text-tertiary' />,
},
{
id: 'light',
titleKey: 'app.gotoAnything.actions.themeLight',
descKey: 'app.gotoAnything.actions.themeLightDesc',
icon: <RiSunLine className='h-4 w-4 text-text-tertiary' />,
},
{
id: 'dark',
titleKey: 'app.gotoAnything.actions.themeDark',
descKey: 'app.gotoAnything.actions.themeDarkDesc',
icon: <RiMoonLine className='h-4 w-4 text-text-tertiary' />,
},
]
const buildThemeCommands = (query: string, locale?: string): CommandSearchResult[] => {
const q = query.toLowerCase()
const list = THEME_ITEMS.filter(item =>
!q
|| i18n.t(item.titleKey, { lng: locale }).toLowerCase().includes(q)
|| item.id.includes(q),
)
return list.map(item => ({
id: item.id,
title: i18n.t(item.titleKey, { lng: locale }),
description: i18n.t(item.descKey, { lng: locale }),
type: 'command' as const,
icon: (
<div className='flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg'>
{item.icon}
</div>
),
data: { command: 'theme.set', args: { value: item.id } },
}))
}
/**
* Theme command handler
* Integrates UI building, search, and registration logic
*/
export const themeCommand: SlashCommandHandler<ThemeDeps> = {
name: 'theme',
description: 'Switch between light and dark themes',
mode: 'submenu', // Explicitly set submenu mode
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'])
},
}

View File

@@ -0,0 +1,46 @@
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
/**
* Command mode:
* - 'direct': Execute immediately when selected (e.g., /docs, /community)
* - 'submenu': Show submenu options (e.g., /theme, /language)
*/
mode?: 'direct' | 'submenu'
/**
* Direct execution function for 'direct' mode commands
* Called when the command is selected and should execute immediately
*/
execute?: () => void | Promise<void>
/**
* Search command results (for 'submenu' mode or showing options)
* @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
}

View File

@@ -0,0 +1,293 @@
/**
* 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 { ragPipelineNodesAction } from './rag-pipeline-nodes'
import type { ActionItem, SearchResult } from './types'
import { slashAction } from './commands'
import { slashCommandRegistry } from './commands/registry'
// Create dynamic Actions based on context
export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
const baseActions = {
slash: slashAction,
app: appAction,
knowledge: knowledgeAction,
plugin: pluginAction,
}
// Add appropriate node search based on context
if (isRagPipelinePage) {
return {
...baseActions,
node: ragPipelineNodesAction,
}
}
else if (isWorkflowPage) {
return {
...baseActions,
node: workflowNodesAction,
}
}
// Default actions without node search
return baseActions
}
// Legacy export for backward compatibility
export const Actions = {
slash: slashAction,
app: appAction,
knowledge: knowledgeAction,
plugin: pluginAction,
node: workflowNodesAction,
}
export const searchAnything = async (
locale: string,
query: string,
actionItem?: ActionItem,
dynamicActions?: Record<string, ActionItem>,
): Promise<SearchResult[]> => {
if (actionItem) {
const searchTerm = query.replace(actionItem.key, '').replace(actionItem.shortcut, '').trim()
try {
return await actionItem.search(query, searchTerm, locale)
}
catch (error) {
console.warn(`Search failed for ${actionItem.key}:`, error)
return []
}
}
if (query.startsWith('@') || query.startsWith('/'))
return []
const globalSearchActions = Object.values(dynamicActions || Actions)
// Use Promise.allSettled to handle partial failures gracefully
const searchPromises = globalSearchActions.map(async (action) => {
try {
const results = await action.search(query, query, locale)
return { success: true, data: results, actionType: action.key }
}
catch (error) {
console.warn(`Search failed for ${action.key}:`, error)
return { success: false, data: [], actionType: action.key, error }
}
})
const settledResults = await Promise.allSettled(searchPromises)
const allResults: SearchResult[] = []
const failedActions: string[] = []
settledResults.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value.success) {
allResults.push(...result.value.data)
}
else {
const actionKey = globalSearchActions[index]?.key || 'unknown'
failedActions.push(actionKey)
}
})
if (failedActions.length > 0)
console.warn(`Some search actions failed: ${failedActions.join(', ')}`)
return allResults
}
export const matchAction = (query: string, actions: Record<string, ActionItem>) => {
return Object.values(actions).find((action) => {
// Special handling for slash commands
if (action.key === '/') {
// Get all registered commands from the registry
const allCommands = slashCommandRegistry.getAllCommands()
// Check if query matches any registered command
return allCommands.some((cmd) => {
const cmdPattern = `/${cmd.name}`
// For direct mode commands, don't match (keep in command selector)
if (cmd.mode === 'direct')
return false
// For submenu mode commands, match when complete command is entered
return query === cmdPattern || query.startsWith(`${cmdPattern} `)
})
}
const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`)
return reg.test(query)
})
}
export * from './types'
export * from './commands'
export { appAction, knowledgeAction, pluginAction, workflowNodesAction }

View File

@@ -0,0 +1,56 @@
import type { ActionItem, KnowledgeSearchResult } from './types'
import type { DataSet } from '@/models/datasets'
import { fetchDatasets } from '@/service/datasets'
import { Folder } from '../../base/icons/src/vender/solid/files'
import cn from '@/utils/classnames'
const EXTERNAL_PROVIDER = 'external' as const
const isExternalProvider = (provider: string): boolean => provider === EXTERNAL_PROVIDER
const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => {
return datasets.map((dataset) => {
const path = isExternalProvider(dataset.provider) ? `/datasets/${dataset.id}/hitTesting` : `/datasets/${dataset.id}/documents`
return {
id: dataset.id,
title: dataset.name,
description: dataset.description,
type: 'knowledge' as const,
path,
icon: (
<div className={cn(
'flex shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#E0EAFF] bg-[#F5F8FF] p-2.5',
!dataset.embedding_available && 'opacity-50 hover:opacity-100',
)}>
<Folder className='h-5 w-5 text-[#444CE7]' />
</div>
),
data: dataset,
}
})
}
export const knowledgeAction: ActionItem = {
key: '@knowledge',
shortcut: '@kb',
title: 'Search Knowledge Bases',
description: 'Search and navigate to your knowledge bases',
// action,
search: async (_, searchTerm = '', _locale) => {
try {
const response = await fetchDatasets({
url: '/datasets',
params: {
page: 1,
limit: 10,
keyword: searchTerm,
},
})
const datasets = response?.data || []
return parser(datasets)
}
catch (error) {
console.warn('Knowledge search failed:', error)
return []
}
},
}

View File

@@ -0,0 +1,53 @@
import type { ActionItem, PluginSearchResult } from './types'
import { renderI18nObject } from '@/i18n-config'
import Icon from '../../plugins/card/base/card-icon'
import { postMarketplace } from '@/service/base'
import type { Plugin, PluginsFromMarketplaceResponse } from '../../plugins/types'
import { getPluginIconInMarketplace } from '../../plugins/marketplace/utils'
const parser = (plugins: Plugin[], locale: string): PluginSearchResult[] => {
return plugins.map((plugin) => {
return {
id: plugin.name,
title: renderI18nObject(plugin.label, locale) || plugin.name,
description: renderI18nObject(plugin.brief, locale) || '',
type: 'plugin' as const,
icon: <Icon src={plugin.icon} />,
data: plugin,
}
})
}
export const pluginAction: ActionItem = {
key: '@plugin',
shortcut: '@plugin',
title: 'Search Plugins',
description: 'Search and navigate to your plugins',
search: async (_, searchTerm = '', locale) => {
try {
const response = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>('/plugins/search/advanced', {
body: {
page: 1,
page_size: 10,
query: searchTerm,
type: 'plugin',
},
})
if (!response?.data?.plugins) {
console.warn('Plugin search: Unexpected response structure', response)
return []
}
const list = response.data.plugins.map(plugin => ({
...plugin,
icon: getPluginIconInMarketplace(plugin),
}))
return parser(list, locale!)
}
catch (error) {
console.warn('Plugin search failed:', error)
return []
}
},
}

View File

@@ -0,0 +1,24 @@
import type { ActionItem } from './types'
// Create the RAG pipeline nodes action
export const ragPipelineNodesAction: ActionItem = {
key: '@node',
shortcut: '@node',
title: 'Search RAG Pipeline Nodes',
description: 'Find and jump to nodes in the current RAG pipeline by name or type',
searchFn: undefined, // Will be set by useRagPipelineSearch hook
search: async (_, searchTerm = '', _locale) => {
try {
// Use the searchFn if available (set by useRagPipelineSearch hook)
if (ragPipelineNodesAction.searchFn)
return ragPipelineNodesAction.searchFn(searchTerm)
// If not in RAG pipeline context, return empty array
return []
}
catch (error) {
console.warn('RAG pipeline nodes search failed:', error)
return []
}
},
}

View File

@@ -0,0 +1,58 @@
import type { ReactNode } from 'react'
import type { TypeWithI18N } from '../../base/form/types'
import type { App } from '@/types/app'
import type { Plugin } from '../../plugins/types'
import type { DataSet } from '@/models/datasets'
import type { CommonNodeType } from '../../workflow/types'
export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command'
export type BaseSearchResult<T = any> = {
id: string
title: string
description?: string
type: SearchResultType
path?: string
icon?: ReactNode
data: T
}
export type AppSearchResult = {
type: 'app'
} & BaseSearchResult<App>
export type PluginSearchResult = {
type: 'plugin'
} & BaseSearchResult<Plugin>
export type KnowledgeSearchResult = {
type: 'knowledge'
} & BaseSearchResult<DataSet>
export type WorkflowNodeSearchResult = {
type: 'workflow-node'
metadata?: {
nodeId: string
nodeData: CommonNodeType
}
} & BaseSearchResult<CommonNodeType>
export type CommandSearchResult = {
type: 'command'
} & BaseSearchResult<{ command: string; args?: Record<string, any> }>
export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult
export type ActionItem = {
key: '@app' | '@knowledge' | '@plugin' | '@node' | '/'
shortcut: string
title: string | TypeWithI18N
description: string
action?: (data: SearchResult) => void
searchFn?: (searchTerm: string) => SearchResult[]
search: (
query: string,
searchTerm: string,
locale?: string,
) => (Promise<SearchResult[]> | SearchResult[])
}

View File

@@ -0,0 +1,24 @@
import type { ActionItem } from './types'
// Create the workflow nodes action
export const workflowNodesAction: ActionItem = {
key: '@node',
shortcut: '@node',
title: 'Search Workflow Nodes',
description: 'Find and jump to nodes in the current workflow by name or type',
searchFn: undefined, // Will be set by useWorkflowSearch hook
search: async (_, searchTerm = '', _locale) => {
try {
// Use the searchFn if available (set by useWorkflowSearch hook)
if (workflowNodesAction.searchFn)
return workflowNodesAction.searchFn(searchTerm)
// If not in workflow context, return empty array
return []
}
catch (error) {
console.warn('Workflow nodes search failed:', error)
return []
}
},
}

View File

@@ -0,0 +1,132 @@
import type { FC } from 'react'
import { useEffect, useMemo } from 'react'
import { Command } from 'cmdk'
import { useTranslation } from 'react-i18next'
import type { ActionItem } from './actions/types'
import { slashCommandRegistry } from './actions/commands/registry'
type Props = {
actions: Record<string, ActionItem>
onCommandSelect: (commandKey: string) => void
searchFilter?: string
commandValue?: string
onCommandValueChange?: (value: string) => void
originalQuery?: string
}
const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, commandValue, onCommandValueChange, originalQuery }) => {
const { t } = useTranslation()
// Check if we're in slash command mode
const isSlashMode = originalQuery?.trim().startsWith('/') || false
// Get slash commands from registry
const slashCommands = useMemo(() => {
if (!isSlashMode) return []
const allCommands = slashCommandRegistry.getAllCommands()
const filter = searchFilter?.toLowerCase() || '' // searchFilter already has '/' removed
return allCommands.filter((cmd) => {
if (!filter) return true
return cmd.name.toLowerCase().includes(filter)
}).map(cmd => ({
key: `/${cmd.name}`,
shortcut: `/${cmd.name}`,
title: cmd.name,
description: cmd.description,
}))
}, [isSlashMode, searchFilter])
const filteredActions = useMemo(() => {
if (isSlashMode) return []
return Object.values(actions).filter((action) => {
// Exclude slash action when in @ mode
if (action.key === '/') return false
if (!searchFilter)
return true
const filterLower = searchFilter.toLowerCase()
return action.shortcut.toLowerCase().includes(filterLower)
})
}, [actions, searchFilter, isSlashMode])
const allItems = isSlashMode ? slashCommands : filteredActions
useEffect(() => {
if (allItems.length > 0 && onCommandValueChange) {
const currentValueExists = allItems.some(item => item.shortcut === commandValue)
if (!currentValueExists)
onCommandValueChange(allItems[0].shortcut)
}
}, [searchFilter, allItems.length])
if (allItems.length === 0) {
return (
<div className="p-4">
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className="text-sm font-medium text-text-tertiary">
{t('app.gotoAnything.noMatchingCommands')}
</div>
<div className="mt-1 text-xs text-text-quaternary">
{t('app.gotoAnything.tryDifferentSearch')}
</div>
</div>
</div>
</div>
)
}
return (
<div className="px-4 py-3">
<div className="mb-2 text-left text-sm font-medium text-text-secondary">
{isSlashMode ? t('app.gotoAnything.groups.commands') : t('app.gotoAnything.selectSearchType')}
</div>
<Command.Group className="space-y-1">
{allItems.map(item => (
<Command.Item
key={item.key}
value={item.shortcut}
className="flex cursor-pointer items-center rounded-md
p-2
transition-all
duration-150 hover:bg-state-base-hover aria-[selected=true]:bg-state-base-hover-alt"
onSelect={() => onCommandSelect(item.shortcut)}
>
<span className="min-w-[4.5rem] text-left font-mono text-xs text-text-tertiary">
{item.shortcut}
</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',
}
return t(slashKeyMap[item.key] || item.description)
})()
) : (
(() => {
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])
})()
)}
</span>
</Command.Item>
))}
</Command.Group>
</div>
)
}
export default CommandSelector

View File

@@ -0,0 +1,67 @@
'use client'
import type { ReactNode } from 'react'
import React, { createContext, useContext, useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { isInWorkflowPage } from '../workflow/constants'
/**
* Interface for the GotoAnything context
*/
type GotoAnythingContextType = {
/**
* Whether the current page is a workflow page
*/
isWorkflowPage: boolean
/**
* Whether the current page is a RAG pipeline page
*/
isRagPipelinePage: boolean
}
// Create context with default values
const GotoAnythingContext = createContext<GotoAnythingContextType>({
isWorkflowPage: false,
isRagPipelinePage: false,
})
/**
* Hook to use the GotoAnything context
*/
export const useGotoAnythingContext = () => useContext(GotoAnythingContext)
type GotoAnythingProviderProps = {
children: ReactNode
}
/**
* Provider component for GotoAnything context
*/
export const GotoAnythingProvider: React.FC<GotoAnythingProviderProps> = ({ children }) => {
const [isWorkflowPage, setIsWorkflowPage] = useState(false)
const [isRagPipelinePage, setIsRagPipelinePage] = useState(false)
const pathname = usePathname()
// Update context based on current pathname using more robust route matching
useEffect(() => {
if (!pathname) {
setIsWorkflowPage(false)
setIsRagPipelinePage(false)
return
}
// Workflow pages: /app/[appId]/workflow or /workflow/[token] (shared)
const isWorkflow = isInWorkflowPage()
// RAG Pipeline pages: /datasets/[datasetId]/pipeline
const isRagPipeline = /^\/datasets\/[^/]+\/pipeline$/.test(pathname)
setIsWorkflowPage(isWorkflow)
setIsRagPipelinePage(isRagPipeline)
}, [pathname])
return (
<GotoAnythingContext.Provider value={{ isWorkflowPage, isRagPipelinePage }}>
{children}
</GotoAnythingContext.Provider>
)
}

View File

@@ -0,0 +1,493 @@
'use client'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import Modal from '@/app/components/base/modal'
import Input from '@/app/components/base/input'
import { useDebounce, useKeyPress } from 'ahooks'
import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app/components/workflow/utils/common'
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
import { RiSearchLine } from '@remixicon/react'
import { type SearchResult, createActions, matchAction, searchAnything } from './actions'
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
import { slashCommandRegistry } from './actions/commands/registry'
import { useQuery } from '@tanstack/react-query'
import { useGetLanguage } from '@/context/i18n'
import { useTranslation } from 'react-i18next'
import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace'
import type { Plugin } from '../plugins/types'
import { Command } from 'cmdk'
import CommandSelector from './command-selector'
import { SlashCommandProvider } from './actions/commands'
type Props = {
onHide?: () => void
}
const GotoAnything: FC<Props> = ({
onHide,
}) => {
const router = useRouter()
const defaultLocale = useGetLanguage()
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
const { t } = useTranslation()
const [show, setShow] = useState<boolean>(false)
const [searchQuery, setSearchQuery] = useState<string>('')
const [cmdVal, setCmdVal] = useState<string>('_')
const inputRef = useRef<HTMLInputElement>(null)
// Filter actions based on context
const Actions = useMemo(() => {
// Create actions based on current page context
return createActions(isWorkflowPage, isRagPipelinePage)
}, [isWorkflowPage, isRagPipelinePage])
const [activePlugin, setActivePlugin] = useState<Plugin>()
// Handle keyboard shortcuts
const handleToggleModal = useCallback((e: KeyboardEvent) => {
// Allow closing when modal is open, even if focus is in the search input
if (!show && isEventTargetInputArea(e.target as HTMLElement))
return
e.preventDefault()
setShow((prev) => {
if (!prev) {
// Opening modal - reset search state
setSearchQuery('')
}
return !prev
})
}, [show])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleToggleModal, {
exactMatch: true,
useCapture: true,
})
useKeyPress(['esc'], (e) => {
if (show) {
e.preventDefault()
setShow(false)
setSearchQuery('')
}
})
const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), {
wait: 300,
})
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) {
// Distinguish between @ (scopes) and / (commands) mode
if (searchQuery.trim().startsWith('@'))
return 'scopes'
else if (searchQuery.trim().startsWith('/'))
return 'commands'
return 'commands' // default fallback
}
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
if (!action)
return 'general'
return action.key === '/' ? '@command' : action.key
}, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
const { data: searchResults = [], isLoading, isError, error } = useQuery(
{
queryKey: [
'goto-anything',
'search-result',
searchQueryDebouncedValue,
searchMode,
isWorkflowPage,
isRagPipelinePage,
defaultLocale,
Object.keys(Actions).sort().join(','),
],
queryFn: async () => {
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
return await searchAnything(defaultLocale, query, action, Actions)
},
enabled: !!searchQueryDebouncedValue && !isCommandsMode,
staleTime: 30000,
gcTime: 300000,
},
)
// Prevent automatic selection of the first option when cmdVal is not set
const clearSelection = () => {
setCmdVal('_')
}
const handleCommandSelect = useCallback((commandKey: string) => {
// Check if it's a slash command
if (commandKey.startsWith('/')) {
const commandName = commandKey.substring(1)
const handler = slashCommandRegistry.findCommand(commandName)
// If it's a direct mode command, execute immediately
if (handler?.mode === 'direct' && handler.execute) {
handler.execute()
setShow(false)
setSearchQuery('')
return
}
}
// Otherwise, proceed with the normal flow (submenu mode)
setSearchQuery(`${commandKey} `)
clearSelection()
setTimeout(() => {
inputRef.current?.focus()
}, 0)
}, [])
// Handle navigation to selected result
const handleNavigate = useCallback((result: SearchResult) => {
setShow(false)
setSearchQuery('')
switch (result.type) {
case 'command': {
// Execute slash commands
const action = Actions.slash
action?.action?.(result)
break
}
case 'plugin':
setActivePlugin(result.data)
break
case 'workflow-node':
// Handle workflow node selection and navigation
if (result.metadata?.nodeId)
selectWorkflowNode(result.metadata.nodeId, true)
break
default:
if (result.path)
router.push(result.path)
}
}, [router])
// Group results by type
const groupedResults = useMemo(() => searchResults.reduce((acc, result) => {
if (!acc[result.type])
acc[result.type] = []
acc[result.type].push(result)
return acc
}, {} as { [key: string]: SearchResult[] }),
[searchResults])
const emptyResult = useMemo(() => {
if (searchResults.length || !searchQuery.trim() || isLoading || isCommandsMode)
return null
const isCommandSearch = searchMode !== 'general'
const commandType = isCommandSearch ? searchMode.replace('@', '') : ''
if (isError) {
return (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className='text-sm font-medium text-red-500'>{t('app.gotoAnything.searchTemporarilyUnavailable')}</div>
<div className='mt-1 text-xs text-text-quaternary'>
{t('app.gotoAnything.servicesUnavailableMessage')}
</div>
</div>
</div>
)
}
return (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className='text-sm font-medium'>
{isCommandSearch
? (() => {
const keyMap: Record<string, string> = {
app: 'app.gotoAnything.emptyState.noAppsFound',
plugin: 'app.gotoAnything.emptyState.noPluginsFound',
knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound',
node: 'app.gotoAnything.emptyState.noWorkflowNodesFound',
}
return t(keyMap[commandType] || 'app.gotoAnything.noResults')
})()
: t('app.gotoAnything.noResults')
}
</div>
<div className='mt-1 text-xs text-text-quaternary'>
{isCommandSearch
? t('app.gotoAnything.emptyState.tryDifferentTerm')
: t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') })
}
</div>
</div>
</div>
)
}, [searchResults, searchQuery, Actions, searchMode, isLoading, isError, isCommandsMode])
const defaultUI = useMemo(() => {
if (searchQuery.trim())
return null
return (<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className='text-sm font-medium'>{t('app.gotoAnything.searchTitle')}</div>
<div className='mt-3 space-y-1 text-xs text-text-quaternary'>
<div>{t('app.gotoAnything.searchHint')}</div>
<div>{t('app.gotoAnything.commandHint')}</div>
<div>{t('app.gotoAnything.slashHint')}</div>
</div>
</div>
</div>)
}, [searchQuery, Actions])
useEffect(() => {
if (show) {
requestAnimationFrame(() => {
inputRef.current?.focus()
})
}
}, [show])
return (
<>
<SlashCommandProvider />
<Modal
isShow={show}
onClose={() => {
setShow(false)
setSearchQuery('')
clearSelection()
onHide?.()
}}
closable={false}
className='!w-[480px] !p-0'
highPriority={true}
>
<div className='flex flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl'>
<Command
className='outline-none'
value={cmdVal}
onValueChange={setCmdVal}
disablePointerSelection
loop
>
<div className='flex items-center gap-3 border-b border-divider-subtle bg-components-panel-bg-blur px-4 py-3'>
<RiSearchLine className='h-4 w-4 text-text-quaternary' />
<div className='flex flex-1 items-center gap-2'>
<Input
ref={inputRef}
value={searchQuery}
placeholder={t('app.gotoAnything.searchPlaceholder')}
onChange={(e) => {
setSearchQuery(e.target.value)
if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/'))
clearSelection()
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const query = searchQuery.trim()
// Check if it's a complete slash command
if (query.startsWith('/')) {
const commandName = query.substring(1).split(' ')[0]
const handler = slashCommandRegistry.findCommand(commandName)
// If it's a direct mode command, execute immediately
if (handler?.mode === 'direct' && handler.execute) {
e.preventDefault()
handler.execute()
setShow(false)
setSearchQuery('')
}
}
}
}}
className='flex-1 !border-0 !bg-transparent !shadow-none'
wrapperClassName='flex-1 !border-0 !bg-transparent'
autoFocus
/>
{searchMode !== 'general' && (
<div className='flex items-center gap-1 rounded bg-gray-100 px-2 py-[2px] text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300'>
<span>{(() => {
if (searchMode === 'scopes')
return 'SCOPES'
else if (searchMode === 'commands')
return 'COMMANDS'
else
return searchMode.replace('@', '').toUpperCase()
})()}</span>
</div>
)}
</div>
<div className='text-xs text-text-quaternary'>
<span className='system-kbd rounded bg-gray-200 px-1 py-[2px] font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-100'>
{isMac() ? '⌘' : 'Ctrl'}
</span>
<span className='system-kbd ml-1 rounded bg-gray-200 px-1 py-[2px] font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-100'>
K
</span>
</div>
</div>
<Command.List className='h-[240px] overflow-y-auto'>
{isLoading && (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600"></div>
<span className="text-sm">{t('app.gotoAnything.searching')}</span>
</div>
</div>
)}
{isError && (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className="text-sm font-medium text-red-500">{t('app.gotoAnything.searchFailed')}</div>
<div className="mt-1 text-xs text-text-quaternary">
{error.message}
</div>
</div>
</div>
)}
{!isLoading && !isError && (
<>
{isCommandsMode ? (
<CommandSelector
actions={Actions}
onCommandSelect={handleCommandSelect}
searchFilter={searchQuery.trim().substring(1)}
commandValue={cmdVal}
onCommandValueChange={setCmdVal}
originalQuery={searchQuery.trim()}
/>
) : (
Object.entries(groupedResults).map(([type, results], groupIndex) => (
<Command.Group key={groupIndex} heading={(() => {
const typeMap: Record<string, string> = {
'app': 'app.gotoAnything.groups.apps',
'plugin': 'app.gotoAnything.groups.plugins',
'knowledge': 'app.gotoAnything.groups.knowledgeBases',
'workflow-node': 'app.gotoAnything.groups.workflowNodes',
'command': 'app.gotoAnything.groups.commands',
}
return t(typeMap[type] || `${type}s`)
})()} className='p-2 capitalize text-text-secondary'>
{results.map(result => (
<Command.Item
key={`${result.type}-${result.id}`}
value={`${result.type}-${result.id}`}
className='flex cursor-pointer items-center gap-3 rounded-md p-3 will-change-[background-color] aria-[selected=true]:bg-state-base-hover data-[selected=true]:bg-state-base-hover'
onSelect={() => handleNavigate(result)}
>
{result.icon}
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-text-secondary'>
{result.title}
</div>
{result.description && (
<div className='mt-0.5 truncate text-xs text-text-quaternary'>
{result.description}
</div>
)}
</div>
<div className='text-xs capitalize text-text-quaternary'>
{result.type}
</div>
</Command.Item>
))}
</Command.Group>
))
)}
{!isCommandsMode && emptyResult}
{!isCommandsMode && defaultUI}
</>
)}
</Command.List>
{/* Always show footer to prevent height jumping */}
<div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'>
<div className='flex min-h-[16px] items-center justify-between'>
{(!!searchResults.length || isError) ? (
<>
<span>
{isError ? (
<span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span>
) : (
<>
{t('app.gotoAnything.resultCount', { count: searchResults.length })}
{searchMode !== 'general' && (
<span className='ml-2 opacity-60'>
{t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}
</span>
)}
</>
)}
</span>
<span className='opacity-60'>
{searchMode !== 'general'
? t('app.gotoAnything.clearToSearchAll')
: t('app.gotoAnything.useAtForSpecific')
}
</span>
</>
) : (
<>
<span className='opacity-60'>
{(() => {
if (isCommandsMode)
return t('app.gotoAnything.selectToNavigate')
if (searchQuery.trim())
return t('app.gotoAnything.searching')
return t('app.gotoAnything.startTyping')
})()}
</span>
<span className='opacity-60'>
{searchQuery.trim() || isCommandsMode
? t('app.gotoAnything.tips')
: t('app.gotoAnything.pressEscToClose')}
</span>
</>
)}
</div>
</div>
</Command>
</div>
</Modal>
{
activePlugin && (
<InstallFromMarketplace
manifest={activePlugin}
uniqueIdentifier={activePlugin.latest_package_identifier}
onClose={() => setActivePlugin(undefined)}
onSuccess={() => setActivePlugin(undefined)}
/>
)
}
</>
)
}
/**
* GotoAnything component with context provider
*/
const GotoAnythingWithContext: FC<Props> = (props) => {
return (
<GotoAnythingProvider>
<GotoAnything {...props} />
</GotoAnythingProvider>
)
}
export default GotoAnythingWithContext