diff --git a/web/app/components/goto-anything/actions/command-bus.ts b/web/app/components/goto-anything/actions/command-bus.ts new file mode 100644 index 000000000..6ff7e81a2 --- /dev/null +++ b/web/app/components/goto-anything/actions/command-bus.ts @@ -0,0 +1,26 @@ +export type CommandHandler = (args?: Record) => void | Promise + +const handlers = new Map() + +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) => { + const handler = handlers.get(name) + if (!handler) + return + await handler(args) +} + +export const registerCommands = (map: Record) => { + Object.entries(map).forEach(([name, handler]) => registerCommand(name, handler)) +} + +export const unregisterCommands = (names: string[]) => { + names.forEach(unregisterCommand) +} diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index 87369784a..0f25194db 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -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, } diff --git a/web/app/components/goto-anything/actions/run-language.tsx b/web/app/components/goto-anything/actions/run-language.tsx new file mode 100644 index 000000000..0076fec0a --- /dev/null +++ b/web/app/components/goto-anything/actions/run-language.tsx @@ -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: ( +
+ +
+ ), + data: { command: 'nav.search', args: { query: '@run language ' } }, + } +} diff --git a/web/app/components/goto-anything/actions/run-theme.tsx b/web/app/components/goto-anything/actions/run-theme.tsx new file mode 100644 index 000000000..9f72844ee --- /dev/null +++ b/web/app/components/goto-anything/actions/run-theme.tsx @@ -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: , + }, + { + id: 'light', + titleKey: 'app.gotoAnything.actions.themeLight', + descKey: 'app.gotoAnything.actions.themeLightDesc', + icon: , + }, + { + id: 'dark', + titleKey: 'app.gotoAnything.actions.themeDark', + descKey: 'app.gotoAnything.actions.themeDarkDesc', + icon: , + }, +] + +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: ( +
+ {item.icon} +
+ ), + 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: ( +
+ +
+ ), + data: { command: 'nav.search', args: { query: '@run theme ' } }, + } +} diff --git a/web/app/components/goto-anything/actions/run.tsx b/web/app/components/goto-anything/actions/run.tsx new file mode 100644 index 000000000..624cf942d --- /dev/null +++ b/web/app/components/goto-anything/actions/run.tsx @@ -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 + 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 + 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 +} diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 7a838737c..a95e28eec 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -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 = { id: string @@ -37,10 +37,14 @@ export type WorkflowNodeSearchResult = { } } & BaseSearchResult -export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult +export type CommandSearchResult = { + type: 'command' +} & BaseSearchResult<{ command: string; args?: Record }> + +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 diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index c2a266969..2b62c92a5 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -73,6 +73,7 @@ const CommandSelector: FC = ({ 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]) diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index bdd84f4f2..2d2d56eea 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -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 = ({ const [searchQuery, setSearchQuery] = useState('') const [cmdVal, setCmdVal] = useState('') const inputRef = useRef(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 = ({ } 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 = ({ 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 = ({ + { activePlugin && (