| @@ -0,0 +1,26 @@ | |||
| export type CommandHandler = (args?: Record<string, any>) => void | Promise<void> | |||
| const handlers = new Map<string, CommandHandler>() | |||
| export const registerCommand = (name: string, handler: CommandHandler) => { | |||
| handlers.set(name, handler) | |||
| } | |||
| export 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) | |||
| } | |||
| @@ -3,11 +3,13 @@ import { knowledgeAction } from './knowledge' | |||
| import { pluginAction } from './plugin' | |||
| import { workflowNodesAction } from './workflow-nodes' | |||
| import type { ActionItem, SearchResult } from './types' | |||
| import { commandAction } from './run' | |||
| export const Actions = { | |||
| app: appAction, | |||
| knowledge: knowledgeAction, | |||
| plugin: pluginAction, | |||
| run: commandAction, | |||
| node: workflowNodesAction, | |||
| } | |||
| @@ -0,0 +1,33 @@ | |||
| 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 ' } }, | |||
| } | |||
| } | |||
| @@ -0,0 +1,61 @@ | |||
| import type { CommandSearchResult } from './types' | |||
| import type { ReactNode } from 'react' | |||
| import { RiComputerLine, RiMoonLine, RiPaletteLine, RiSunLine } from '@remixicon/react' | |||
| import i18n from '@/i18n-config/i18next-config' | |||
| 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' />, | |||
| }, | |||
| ] | |||
| export 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 } }, | |||
| })) | |||
| } | |||
| 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 ' } }, | |||
| } | |||
| } | |||
| @@ -0,0 +1,97 @@ | |||
| '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 | |||
| } | |||
| @@ -5,7 +5,7 @@ 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' | |||
| export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command' | |||
| export type BaseSearchResult<T = any> = { | |||
| id: string | |||
| @@ -37,10 +37,14 @@ export type WorkflowNodeSearchResult = { | |||
| } | |||
| } & BaseSearchResult<CommonNodeType> | |||
| export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | |||
| 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' | |||
| key: '@app' | '@knowledge' | '@plugin' | '@node' | '@run' | |||
| shortcut: string | |||
| title: string | TypeWithI18N | |||
| description: string | |||
| @@ -73,6 +73,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co | |||
| '@app': 'app.gotoAnything.actions.searchApplicationsDesc', | |||
| '@plugin': 'app.gotoAnything.actions.searchPluginsDesc', | |||
| '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc', | |||
| '@run': 'app.gotoAnything.actions.runDesc', | |||
| '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc', | |||
| } | |||
| return t(keyMap[action.key]) | |||
| @@ -18,6 +18,7 @@ import InstallFromMarketplace from '../plugins/install-plugin/install-from-marke | |||
| import type { Plugin } from '../plugins/types' | |||
| import { Command } from 'cmdk' | |||
| import CommandSelector from './command-selector' | |||
| import { RunCommandProvider } from './actions/run' | |||
| type Props = { | |||
| onHide?: () => void | |||
| @@ -33,7 +34,11 @@ const GotoAnything: FC<Props> = ({ | |||
| const [searchQuery, setSearchQuery] = useState<string>('') | |||
| const [cmdVal, setCmdVal] = useState<string>('') | |||
| const inputRef = useRef<HTMLInputElement>(null) | |||
| const handleNavSearch = useCallback((q: string) => { | |||
| setShow(true) | |||
| setSearchQuery(q) | |||
| requestAnimationFrame(() => inputRef.current?.focus()) | |||
| }, []) | |||
| // Filter actions based on context | |||
| const Actions = useMemo(() => { | |||
| // Create a filtered copy of actions based on current page context | |||
| @@ -43,8 +48,8 @@ const GotoAnything: FC<Props> = ({ | |||
| } | |||
| else { | |||
| // Exclude node action on non-workflow pages | |||
| const { app, knowledge, plugin } = AllActions | |||
| return { app, knowledge, plugin } | |||
| const { app, knowledge, plugin, run } = AllActions | |||
| return { app, knowledge, plugin, run } | |||
| } | |||
| }, [isWorkflowPage]) | |||
| @@ -128,6 +133,11 @@ const GotoAnything: FC<Props> = ({ | |||
| setSearchQuery('') | |||
| switch (result.type) { | |||
| case 'command': { | |||
| const action = Object.values(Actions).find(a => a.key === '@run') | |||
| action?.action?.(result) | |||
| break | |||
| } | |||
| case 'plugin': | |||
| setActivePlugin(result.data) | |||
| break | |||
| @@ -381,6 +391,7 @@ const GotoAnything: FC<Props> = ({ | |||
| </div> | |||
| </Modal> | |||
| <RunCommandProvider onNavSearch={handleNavSearch} /> | |||
| { | |||
| activePlugin && ( | |||
| <InstallFromMarketplace | |||
| @@ -279,6 +279,19 @@ const translation = { | |||
| searchWorkflowNodes: 'Search Workflow Nodes', | |||
| searchWorkflowNodesDesc: 'Find and jump to nodes in the current workflow by name or type', | |||
| searchWorkflowNodesHelp: 'This feature only works when viewing a workflow. Navigate to a workflow first.', | |||
| runTitle: 'Commands', | |||
| runDesc: 'Run quick commands (theme, language, ...)', | |||
| themeCategoryTitle: 'Theme', | |||
| themeCategoryDesc: 'Switch application theme', | |||
| themeSystem: 'System Theme', | |||
| themeSystemDesc: 'Follow your OS appearance', | |||
| themeLight: 'Light Theme', | |||
| themeLightDesc: 'Use light appearance', | |||
| themeDark: 'Dark Theme', | |||
| themeDarkDesc: 'Use dark appearance', | |||
| languageCategoryTitle: 'Language', | |||
| languageCategoryDesc: 'Switch interface language', | |||
| languageChangeDesc: 'Change UI language', | |||
| }, | |||
| emptyState: { | |||
| noAppsFound: 'No apps found', | |||
| @@ -278,6 +278,19 @@ const translation = { | |||
| searchWorkflowNodes: '搜索工作流节点', | |||
| searchWorkflowNodesDesc: '按名称或类型查找并跳转到当前工作流中的节点', | |||
| searchWorkflowNodesHelp: '此功能仅在查看工作流时有效。首先导航到工作流。', | |||
| runTitle: '命令', | |||
| runDesc: '快速执行命令(主题、语言等)', | |||
| themeCategoryTitle: '主题', | |||
| themeCategoryDesc: '切换应用主题', | |||
| themeSystem: '系统主题', | |||
| themeSystemDesc: '跟随系统外观', | |||
| themeLight: '浅色主题', | |||
| themeLightDesc: '使用浅色外观', | |||
| themeDark: '深色主题', | |||
| themeDarkDesc: '使用深色外观', | |||
| languageCategoryTitle: '语言', | |||
| languageCategoryDesc: '切换界面语言', | |||
| languageChangeDesc: '更改界面语言', | |||
| }, | |||
| emptyState: { | |||
| noAppsFound: '未找到应用', | |||