Feature/run cmd (#23822)

This commit is contained in:
GuanMu
2025-08-12 23:47:50 +08:00
committed by GitHub
parent a77dfb69b0
commit 973a390298
10 changed files with 267 additions and 6 deletions

View File

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

View File

@@ -3,11 +3,13 @@ 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'
export const Actions = { export const Actions = {
app: appAction, app: appAction,
knowledge: knowledgeAction, knowledge: knowledgeAction,
plugin: pluginAction, plugin: pluginAction,
run: commandAction,
node: workflowNodesAction, node: workflowNodesAction,
} }

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import type { Plugin } from '../../plugins/types'
import type { DataSet } from '@/models/datasets' import type { DataSet } from '@/models/datasets'
import type { CommonNodeType } from '../../workflow/types' 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> = { export type BaseSearchResult<T = any> = {
id: string id: string
@@ -37,10 +37,14 @@ export type WorkflowNodeSearchResult = {
} }
} & BaseSearchResult<CommonNodeType> } & 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 = { export type ActionItem = {
key: '@app' | '@knowledge' | '@plugin' | '@node' key: '@app' | '@knowledge' | '@plugin' | '@node' | '@run'
shortcut: string shortcut: string
title: string | TypeWithI18N title: string | TypeWithI18N
description: string description: string

View File

@@ -73,6 +73,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
'@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])

View File

@@ -18,6 +18,7 @@ import InstallFromMarketplace from '../plugins/install-plugin/install-from-marke
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'
type Props = { type Props = {
onHide?: () => void onHide?: () => void
@@ -33,7 +34,11 @@ const GotoAnything: FC<Props> = ({
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)
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
@@ -43,8 +48,8 @@ const GotoAnything: FC<Props> = ({
} }
else { else {
// Exclude node action on non-workflow pages // Exclude node action on non-workflow pages
const { app, knowledge, plugin } = AllActions const { app, knowledge, plugin, run } = AllActions
return { app, knowledge, plugin } return { app, knowledge, plugin, run }
} }
}, [isWorkflowPage]) }, [isWorkflowPage])
@@ -128,6 +133,11 @@ const GotoAnything: FC<Props> = ({
setSearchQuery('') setSearchQuery('')
switch (result.type) { switch (result.type) {
case 'command': {
const action = Object.values(Actions).find(a => a.key === '@run')
action?.action?.(result)
break
}
case 'plugin': case 'plugin':
setActivePlugin(result.data) setActivePlugin(result.data)
break break
@@ -381,6 +391,7 @@ const GotoAnything: FC<Props> = ({
</div> </div>
</Modal> </Modal>
<RunCommandProvider onNavSearch={handleNavSearch} />
{ {
activePlugin && ( activePlugin && (
<InstallFromMarketplace <InstallFromMarketplace

View File

@@ -279,6 +279,19 @@ const translation = {
searchWorkflowNodes: 'Search Workflow Nodes', searchWorkflowNodes: 'Search Workflow Nodes',
searchWorkflowNodesDesc: 'Find and jump to nodes in the current workflow by name or type', 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.', 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: { emptyState: {
noAppsFound: 'No apps found', noAppsFound: 'No apps found',

View File

@@ -278,6 +278,19 @@ const translation = {
searchWorkflowNodes: '搜索工作流节点', searchWorkflowNodes: '搜索工作流节点',
searchWorkflowNodesDesc: '按名称或类型查找并跳转到当前工作流中的节点', searchWorkflowNodesDesc: '按名称或类型查找并跳转到当前工作流中的节点',
searchWorkflowNodesHelp: '此功能仅在查看工作流时有效。首先导航到工作流。', searchWorkflowNodesHelp: '此功能仅在查看工作流时有效。首先导航到工作流。',
runTitle: '命令',
runDesc: '快速执行命令(主题、语言等)',
themeCategoryTitle: '主题',
themeCategoryDesc: '切换应用主题',
themeSystem: '系统主题',
themeSystemDesc: '跟随系统外观',
themeLight: '浅色主题',
themeLightDesc: '使用浅色外观',
themeDark: '深色主题',
themeDarkDesc: '使用深色外观',
languageCategoryTitle: '语言',
languageCategoryDesc: '切换界面语言',
languageChangeDesc: '更改界面语言',
}, },
emptyState: { emptyState: {
noAppsFound: '未找到应用', noAppsFound: '未找到应用',