mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
resolve: conflict
This commit is contained in:
@ -10,9 +10,15 @@ type VersionSelectorProps = {
|
||||
versionLen: number
|
||||
value: number
|
||||
onChange: (index: number) => void
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
const VersionSelector: React.FC<VersionSelectorProps> = ({ versionLen, value, onChange }) => {
|
||||
const VersionSelector: React.FC<VersionSelectorProps> = ({
|
||||
versionLen,
|
||||
value,
|
||||
onChange,
|
||||
contentClassName,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, {
|
||||
setFalse: handleOpenFalse,
|
||||
@ -64,6 +70,7 @@ const VersionSelector: React.FC<VersionSelectorProps> = ({ versionLen, value, on
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={cn(
|
||||
'z-[99]',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { ActionItem, AppSearchResult } from './types'
|
||||
import type { AppSearchResult, ScopeDescriptor } from './types'
|
||||
import type { App } from '@/types/app'
|
||||
import { fetchAppList } from '@/service/apps'
|
||||
import { searchApps } from '@/service/use-goto-anything'
|
||||
import { getRedirectionPath } from '@/utils/app-redirection'
|
||||
import { AppTypeIcon } from '../../app/type-selector'
|
||||
import AppIcon from '../../base/app-icon'
|
||||
import { ACTION_KEYS } from '../constants'
|
||||
|
||||
const parser = (apps: App[]): AppSearchResult[] => {
|
||||
return apps.map(app => ({
|
||||
@ -35,21 +36,14 @@ const parser = (apps: App[]): AppSearchResult[] => {
|
||||
}))
|
||||
}
|
||||
|
||||
export const appAction: ActionItem = {
|
||||
key: '@app',
|
||||
shortcut: '@app',
|
||||
export const appScope: ScopeDescriptor = {
|
||||
id: 'app',
|
||||
shortcut: ACTION_KEYS.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 response = await searchApps(searchTerm)
|
||||
const apps = response?.data || []
|
||||
return parser(apps)
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ export {
|
||||
export { slashCommandRegistry, SlashCommandRegistry } from './registry'
|
||||
|
||||
// Command system exports
|
||||
export { slashAction } from './slash'
|
||||
export { slashScope } from './slash'
|
||||
export { registerSlashCommands, SlashCommandProvider, unregisterSlashCommands } from './slash'
|
||||
|
||||
export type { SlashCommandHandler } from './types'
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import type { CommandSearchResult } from '../types'
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { Locale } from '@/i18n-config/language'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Language dependency types
|
||||
type LanguageDeps = {
|
||||
setLocale?: (locale: string) => Promise<void>
|
||||
setLocale?: (locale: Locale, reloadPage?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
const buildLanguageCommands = (query: string): CommandSearchResult[] => {
|
||||
|
||||
@ -6,20 +6,21 @@ import type { SlashCommandHandler } from './types'
|
||||
* 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>()
|
||||
private commands = new Map<string, SlashCommandHandler<unknown>>()
|
||||
private commandDeps = new Map<string, unknown>()
|
||||
|
||||
/**
|
||||
* Register command handler
|
||||
*/
|
||||
register<TDeps = any>(handler: SlashCommandHandler<TDeps>, deps?: TDeps) {
|
||||
register<TDeps = unknown>(handler: SlashCommandHandler<TDeps>, deps?: TDeps) {
|
||||
// Register main command name
|
||||
this.commands.set(handler.name, handler)
|
||||
// Cast to unknown first, then to SlashCommandHandler<unknown> to handle generic type variance
|
||||
this.commands.set(handler.name, handler as SlashCommandHandler<unknown>)
|
||||
|
||||
// Register aliases
|
||||
if (handler.aliases) {
|
||||
handler.aliases.forEach((alias) => {
|
||||
this.commands.set(alias, handler)
|
||||
this.commands.set(alias, handler as SlashCommandHandler<unknown>)
|
||||
})
|
||||
}
|
||||
|
||||
@ -57,7 +58,7 @@ export class SlashCommandRegistry {
|
||||
/**
|
||||
* Find command handler
|
||||
*/
|
||||
findCommand(commandName: string): SlashCommandHandler | undefined {
|
||||
findCommand(commandName: string): SlashCommandHandler<unknown> | undefined {
|
||||
return this.commands.get(commandName)
|
||||
}
|
||||
|
||||
@ -65,7 +66,7 @@ export class SlashCommandRegistry {
|
||||
* Smart partial command matching
|
||||
* Prioritize alias matching, then match command name prefix
|
||||
*/
|
||||
private findBestPartialMatch(partialName: string): SlashCommandHandler | undefined {
|
||||
private findBestPartialMatch(partialName: string): SlashCommandHandler<unknown> | undefined {
|
||||
const lowerPartial = partialName.toLowerCase()
|
||||
|
||||
// First check if any alias starts with this
|
||||
@ -81,7 +82,7 @@ export class SlashCommandRegistry {
|
||||
/**
|
||||
* Find handler by alias prefix
|
||||
*/
|
||||
private findHandlerByAliasPrefix(prefix: string): SlashCommandHandler | undefined {
|
||||
private findHandlerByAliasPrefix(prefix: string): SlashCommandHandler<unknown> | undefined {
|
||||
for (const handler of this.getAllCommands()) {
|
||||
if (handler.aliases?.some(alias => alias.toLowerCase().startsWith(prefix)))
|
||||
return handler
|
||||
@ -92,7 +93,7 @@ export class SlashCommandRegistry {
|
||||
/**
|
||||
* Find handler by name prefix
|
||||
*/
|
||||
private findHandlerByNamePrefix(prefix: string): SlashCommandHandler | undefined {
|
||||
private findHandlerByNamePrefix(prefix: string): SlashCommandHandler<unknown> | undefined {
|
||||
return this.getAllCommands().find(handler =>
|
||||
handler.name.toLowerCase().startsWith(prefix),
|
||||
)
|
||||
@ -101,8 +102,8 @@ export class SlashCommandRegistry {
|
||||
/**
|
||||
* Get all registered commands (deduplicated)
|
||||
*/
|
||||
getAllCommands(): SlashCommandHandler[] {
|
||||
const uniqueCommands = new Map<string, SlashCommandHandler>()
|
||||
getAllCommands(): SlashCommandHandler<unknown>[] {
|
||||
const uniqueCommands = new Map<string, SlashCommandHandler<unknown>>()
|
||||
this.commands.forEach((handler) => {
|
||||
uniqueCommands.set(handler.name, handler)
|
||||
})
|
||||
@ -113,7 +114,7 @@ export class SlashCommandRegistry {
|
||||
* Get all available commands in current context (deduplicated and filtered)
|
||||
* Commands without isAvailable method are considered always available
|
||||
*/
|
||||
getAvailableCommands(): SlashCommandHandler[] {
|
||||
getAvailableCommands(): SlashCommandHandler<unknown>[] {
|
||||
return this.getAllCommands().filter(handler => this.isCommandAvailable(handler))
|
||||
}
|
||||
|
||||
@ -228,7 +229,7 @@ export class SlashCommandRegistry {
|
||||
/**
|
||||
* Get command dependencies
|
||||
*/
|
||||
getCommandDependencies(commandName: string): any {
|
||||
getCommandDependencies(commandName: string): unknown {
|
||||
return this.commandDeps.get(commandName)
|
||||
}
|
||||
|
||||
@ -236,7 +237,7 @@ export class SlashCommandRegistry {
|
||||
* Determine if a command is available in the current context.
|
||||
* Defaults to true when a handler does not implement the guard.
|
||||
*/
|
||||
private isCommandAvailable(handler: SlashCommandHandler) {
|
||||
private isCommandAvailable(handler: SlashCommandHandler<unknown>) {
|
||||
return handler.isAvailable?.() ?? true
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
import type { ActionItem } from '../types'
|
||||
import type { ScopeDescriptor } from '../types'
|
||||
import type { SlashCommandDependencies } from './types'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect } from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { setLocaleOnClient } from '@/i18n-config'
|
||||
import { ACTION_KEYS } from '../../constants'
|
||||
import { accountCommand } from './account'
|
||||
import { bananaCommand } from './banana'
|
||||
import { executeCommand } from './command-bus'
|
||||
import { communityCommand } from './community'
|
||||
import { docsCommand } from './docs'
|
||||
import { forumCommand } from './forum'
|
||||
@ -17,17 +18,11 @@ import { zenCommand } from './zen'
|
||||
|
||||
const i18n = getI18n()
|
||||
|
||||
export const slashAction: ActionItem = {
|
||||
key: '/',
|
||||
shortcut: '/',
|
||||
export const slashScope: ScopeDescriptor = {
|
||||
id: 'slash',
|
||||
shortcut: ACTION_KEYS.SLASH,
|
||||
title: i18n.t('gotoAnything.actions.slashTitle', { ns: 'app' }),
|
||||
description: i18n.t('gotoAnything.actions.slashDesc', { ns: 'app' }),
|
||||
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)
|
||||
@ -35,7 +30,7 @@ export const slashAction: ActionItem = {
|
||||
}
|
||||
|
||||
// Register/unregister default handlers for slash commands with external dependencies.
|
||||
export const registerSlashCommands = (deps: Record<string, any>) => {
|
||||
export const registerSlashCommands = (deps: SlashCommandDependencies) => {
|
||||
// Register command handlers to the registry system with their respective dependencies
|
||||
slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme })
|
||||
slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale })
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import type { CommandSearchResult } from '../types'
|
||||
import type { Locale } from '@/i18n-config/language'
|
||||
|
||||
/**
|
||||
* Slash command handler interface
|
||||
* Each slash command should implement this interface
|
||||
*/
|
||||
export type SlashCommandHandler<TDeps = any> = {
|
||||
export type SlashCommandHandler<TDeps = unknown> = {
|
||||
/** Command name (e.g., 'theme', 'language') */
|
||||
name: string
|
||||
|
||||
@ -51,3 +52,31 @@ export type SlashCommandHandler<TDeps = any> = {
|
||||
*/
|
||||
unregister?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme command dependencies
|
||||
*/
|
||||
export type ThemeCommandDeps = {
|
||||
setTheme?: (value: 'light' | 'dark' | 'system') => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Language command dependencies
|
||||
*/
|
||||
export type LanguageCommandDeps = {
|
||||
setLocale?: (locale: Locale, reloadPage?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands without external dependencies
|
||||
*/
|
||||
export type NoDepsCommandDeps = Record<string, never>
|
||||
|
||||
/**
|
||||
* Union type of all slash command dependencies
|
||||
* Used for type-safe dependency injection in registerSlashCommands
|
||||
*/
|
||||
export type SlashCommandDependencies = {
|
||||
setTheme?: (value: 'light' | 'dark' | 'system') => void
|
||||
setLocale?: (locale: Locale, reloadPage?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
@ -3,228 +3,66 @@
|
||||
*
|
||||
* 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, banana, etc.)
|
||||
*/
|
||||
|
||||
import type { ActionItem, SearchResult } from './types'
|
||||
import { appAction } from './app'
|
||||
import { slashAction } from './commands'
|
||||
import type { ScopeContext, ScopeDescriptor, SearchResult } from './types'
|
||||
import { ACTION_KEYS } from '../constants'
|
||||
import { appScope } from './app'
|
||||
import { slashScope } from './commands'
|
||||
import { slashCommandRegistry } from './commands/registry'
|
||||
import { knowledgeAction } from './knowledge'
|
||||
import { pluginAction } from './plugin'
|
||||
import { ragPipelineNodesAction } from './rag-pipeline-nodes'
|
||||
import { workflowNodesAction } from './workflow-nodes'
|
||||
import { knowledgeScope } from './knowledge'
|
||||
import { pluginScope } from './plugin'
|
||||
import { registerRagPipelineNodeScope } from './rag-pipeline-nodes'
|
||||
import { scopeRegistry, useScopeRegistry } from './scope-registry'
|
||||
import { registerWorkflowNodeScope } from './workflow-nodes'
|
||||
|
||||
// Create dynamic Actions based on context
|
||||
export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
|
||||
const baseActions = {
|
||||
slash: slashAction,
|
||||
app: appAction,
|
||||
knowledge: knowledgeAction,
|
||||
plugin: pluginAction,
|
||||
}
|
||||
let scopesInitialized = false
|
||||
|
||||
// Add appropriate node search based on context
|
||||
if (isRagPipelinePage) {
|
||||
return {
|
||||
...baseActions,
|
||||
node: ragPipelineNodesAction,
|
||||
}
|
||||
}
|
||||
else if (isWorkflowPage) {
|
||||
return {
|
||||
...baseActions,
|
||||
node: workflowNodesAction,
|
||||
}
|
||||
}
|
||||
export const initGotoAnythingScopes = () => {
|
||||
if (scopesInitialized)
|
||||
return
|
||||
|
||||
// Default actions without node search
|
||||
return baseActions
|
||||
scopesInitialized = true
|
||||
|
||||
scopeRegistry.register(slashScope)
|
||||
scopeRegistry.register(appScope)
|
||||
scopeRegistry.register(knowledgeScope)
|
||||
scopeRegistry.register(pluginScope)
|
||||
registerWorkflowNodeScope()
|
||||
registerRagPipelineNodeScope()
|
||||
}
|
||||
|
||||
// Legacy export for backward compatibility
|
||||
export const Actions = {
|
||||
slash: slashAction,
|
||||
app: appAction,
|
||||
knowledge: knowledgeAction,
|
||||
plugin: pluginAction,
|
||||
node: workflowNodesAction,
|
||||
export const useGotoAnythingScopes = (context: ScopeContext) => {
|
||||
initGotoAnythingScopes()
|
||||
return useScopeRegistry(context)
|
||||
}
|
||||
|
||||
const isSlashScope = (scope: ScopeDescriptor) => {
|
||||
if (scope.shortcut === ACTION_KEYS.SLASH)
|
||||
return true
|
||||
return scope.aliases?.includes(ACTION_KEYS.SLASH) ?? false
|
||||
}
|
||||
|
||||
const getScopeShortcuts = (scope: ScopeDescriptor) => [scope.shortcut, ...(scope.aliases ?? [])]
|
||||
|
||||
export const searchAnything = async (
|
||||
locale: string,
|
||||
query: string,
|
||||
actionItem?: ActionItem,
|
||||
dynamicActions?: Record<string, ActionItem>,
|
||||
scope: ScopeDescriptor | undefined,
|
||||
scopes: ScopeDescriptor[],
|
||||
): Promise<SearchResult[]> => {
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
if (actionItem) {
|
||||
if (scope) {
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const prefixPattern = new RegExp(`^(${escapeRegExp(actionItem.key)}|${escapeRegExp(actionItem.shortcut)})\\s*`)
|
||||
const shortcuts = getScopeShortcuts(scope).map(escapeRegExp)
|
||||
const prefixPattern = new RegExp(`^(${shortcuts.join('|')})\\s*`)
|
||||
const searchTerm = trimmedQuery.replace(prefixPattern, '').trim()
|
||||
try {
|
||||
return await actionItem.search(query, searchTerm, locale)
|
||||
return await scope.search(query, searchTerm, locale)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Search failed for ${actionItem.key}:`, error)
|
||||
console.warn(`Search failed for ${scope.id}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@ -232,19 +70,19 @@ export const searchAnything = async (
|
||||
if (trimmedQuery.startsWith('@') || trimmedQuery.startsWith('/'))
|
||||
return []
|
||||
|
||||
const globalSearchActions = Object.values(dynamicActions || Actions)
|
||||
// Exclude slash commands from general search results
|
||||
.filter(action => action.key !== '/')
|
||||
// Filter out slash commands from general search
|
||||
const searchScopes = scopes.filter(scope => !isSlashScope(scope))
|
||||
|
||||
// Use Promise.allSettled to handle partial failures gracefully
|
||||
const searchPromises = globalSearchActions.map(async (action) => {
|
||||
const searchPromises = searchScopes.map(async (action) => {
|
||||
const actionId = action.id
|
||||
try {
|
||||
const results = await action.search(query, query, locale)
|
||||
return { success: true, data: results, actionType: action.key }
|
||||
return { success: true, data: results, actionType: actionId }
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Search failed for ${action.key}:`, error)
|
||||
return { success: false, data: [], actionType: action.key, error }
|
||||
console.warn(`Search failed for ${actionId}:`, error)
|
||||
return { success: false, data: [], actionType: actionId, error }
|
||||
}
|
||||
})
|
||||
|
||||
@ -258,7 +96,7 @@ export const searchAnything = async (
|
||||
allResults.push(...result.value.data)
|
||||
}
|
||||
else {
|
||||
const actionKey = globalSearchActions[index]?.key || 'unknown'
|
||||
const actionKey = searchScopes[index]?.id || 'unknown'
|
||||
failedActions.push(actionKey)
|
||||
}
|
||||
})
|
||||
@ -269,31 +107,31 @@ export const searchAnything = async (
|
||||
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
|
||||
export const matchAction = (query: string, scopes: ScopeDescriptor[]) => {
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return scopes.find((scope) => {
|
||||
// Special handling for slash commands
|
||||
if (isSlashScope(scope)) {
|
||||
const allCommands = slashCommandRegistry.getAllCommands()
|
||||
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|$)`)
|
||||
// Check if query matches shortcut (exact or prefix)
|
||||
// Only match if it's the full shortcut followed by space
|
||||
const shortcuts = getScopeShortcuts(scope).map(escapeRegExp)
|
||||
const reg = new RegExp(`^(${shortcuts.join('|')})(?:\\s|$)`)
|
||||
return reg.test(query)
|
||||
})
|
||||
}
|
||||
|
||||
export * from './commands'
|
||||
export * from './scope-registry'
|
||||
export * from './types'
|
||||
export { appAction, knowledgeAction, pluginAction, workflowNodesAction }
|
||||
export { appScope, knowledgeScope, pluginScope }
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { ActionItem, KnowledgeSearchResult } from './types'
|
||||
import type { KnowledgeSearchResult, ScopeDescriptor } from './types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fetchDatasets } from '@/service/datasets'
|
||||
import { searchDatasets } from '@/service/use-goto-anything'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { Folder } from '../../base/icons/src/vender/solid/files'
|
||||
import { ACTION_KEYS } from '../constants'
|
||||
|
||||
const EXTERNAL_PROVIDER = 'external' as const
|
||||
const isExternalProvider = (provider: string): boolean => provider === EXTERNAL_PROVIDER
|
||||
@ -30,22 +31,15 @@ const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => {
|
||||
})
|
||||
}
|
||||
|
||||
export const knowledgeAction: ActionItem = {
|
||||
key: '@knowledge',
|
||||
shortcut: '@kb',
|
||||
export const knowledgeScope: ScopeDescriptor = {
|
||||
id: 'knowledge',
|
||||
shortcut: ACTION_KEYS.KNOWLEDGE,
|
||||
aliases: ['@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 response = await searchDatasets(searchTerm)
|
||||
const datasets = response?.data || []
|
||||
return parser(datasets)
|
||||
}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { Plugin, PluginsFromMarketplaceResponse } from '../../plugins/types'
|
||||
import type { ActionItem, PluginSearchResult } from './types'
|
||||
import type { Plugin } from '../../plugins/types'
|
||||
import type { PluginSearchResult, ScopeDescriptor } from './types'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import { postMarketplace } from '@/service/base'
|
||||
import { searchPlugins } from '@/service/use-goto-anything'
|
||||
import Icon from '../../plugins/card/base/card-icon'
|
||||
import { getPluginIconInMarketplace } from '../../plugins/marketplace/utils'
|
||||
import { ACTION_KEYS } from '../constants'
|
||||
|
||||
const parser = (plugins: Plugin[], locale: string): PluginSearchResult[] => {
|
||||
return plugins.map((plugin) => {
|
||||
@ -18,21 +19,14 @@ const parser = (plugins: Plugin[], locale: string): PluginSearchResult[] => {
|
||||
})
|
||||
}
|
||||
|
||||
export const pluginAction: ActionItem = {
|
||||
key: '@plugin',
|
||||
shortcut: '@plugin',
|
||||
export const pluginScope: ScopeDescriptor = {
|
||||
id: 'plugin',
|
||||
shortcut: ACTION_KEYS.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',
|
||||
},
|
||||
})
|
||||
const response = await searchPlugins(searchTerm)
|
||||
|
||||
if (!response?.data?.plugins) {
|
||||
console.warn('Plugin search: Unexpected response structure', response)
|
||||
|
||||
@ -1,24 +1,41 @@
|
||||
import type { ActionItem } from './types'
|
||||
import type { ScopeSearchHandler } from './scope-registry'
|
||||
import type { SearchResult } from './types'
|
||||
import { ACTION_KEYS } from '../constants'
|
||||
import { scopeRegistry } from './scope-registry'
|
||||
|
||||
// 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) => {
|
||||
const scopeId = 'rag-pipeline-node'
|
||||
let scopeRegistered = false
|
||||
|
||||
const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): ScopeSearchHandler => {
|
||||
return 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
|
||||
if (searchFn)
|
||||
return searchFn(searchTerm)
|
||||
return []
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('RAG pipeline nodes search failed:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const registerRagPipelineNodeScope = () => {
|
||||
if (scopeRegistered)
|
||||
return
|
||||
|
||||
scopeRegistered = true
|
||||
scopeRegistry.register({
|
||||
id: scopeId,
|
||||
shortcut: ACTION_KEYS.NODE,
|
||||
title: 'Search RAG Pipeline Nodes',
|
||||
description: 'Find and jump to nodes in the current RAG pipeline by name or type',
|
||||
isAvailable: context => context.isRagPipelinePage,
|
||||
search: buildSearchHandler(),
|
||||
})
|
||||
}
|
||||
|
||||
export const setRagPipelineNodesSearchFn = (fn: (searchTerm: string) => SearchResult[]) => {
|
||||
registerRagPipelineNodeScope()
|
||||
scopeRegistry.updateSearchHandler(scopeId, buildSearchHandler(fn))
|
||||
}
|
||||
|
||||
123
web/app/components/goto-anything/actions/scope-registry.ts
Normal file
123
web/app/components/goto-anything/actions/scope-registry.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import type { SearchResult } from './types'
|
||||
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
||||
|
||||
export type ScopeContext = {
|
||||
isWorkflowPage: boolean
|
||||
isRagPipelinePage: boolean
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export type ScopeSearchHandler = (
|
||||
query: string,
|
||||
searchTerm: string,
|
||||
locale?: string,
|
||||
) => Promise<SearchResult[]> | SearchResult[]
|
||||
|
||||
export type ScopeDescriptor = {
|
||||
/**
|
||||
* Unique identifier for the scope (e.g. 'app', 'plugin')
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* Shortcut to trigger this scope (e.g. '@app')
|
||||
*/
|
||||
shortcut: string
|
||||
/**
|
||||
* Additional shortcuts that map to this scope (e.g. ['@kb'])
|
||||
*/
|
||||
aliases?: string[]
|
||||
/**
|
||||
* I18n key or string for the scope title
|
||||
*/
|
||||
title: string
|
||||
/**
|
||||
* Description for help text
|
||||
*/
|
||||
description: string
|
||||
/**
|
||||
* Search handler function
|
||||
*/
|
||||
search: ScopeSearchHandler
|
||||
/**
|
||||
* Predicate to check if this scope is available in current context
|
||||
*/
|
||||
isAvailable?: (context: ScopeContext) => boolean
|
||||
}
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
class ScopeRegistry {
|
||||
private scopes: Map<string, ScopeDescriptor> = new Map()
|
||||
private listeners: Set<Listener> = new Set()
|
||||
private version = 0
|
||||
|
||||
register(scope: ScopeDescriptor) {
|
||||
this.scopes.set(scope.id, scope)
|
||||
this.notify()
|
||||
}
|
||||
|
||||
unregister(id: string) {
|
||||
if (this.scopes.delete(id))
|
||||
this.notify()
|
||||
}
|
||||
|
||||
getScope(id: string) {
|
||||
return this.scopes.get(id)
|
||||
}
|
||||
|
||||
getScopes(context: ScopeContext): ScopeDescriptor[] {
|
||||
return Array.from(this.scopes.values())
|
||||
.filter(scope => !scope.isAvailable || scope.isAvailable(context))
|
||||
.sort((a, b) => a.shortcut.localeCompare(b.shortcut))
|
||||
}
|
||||
|
||||
updateSearchHandler(id: string, search: ScopeSearchHandler) {
|
||||
const scope = this.scopes.get(id)
|
||||
if (!scope)
|
||||
return
|
||||
this.scopes.set(id, { ...scope, search })
|
||||
this.notify()
|
||||
}
|
||||
|
||||
getVersion() {
|
||||
return this.version
|
||||
}
|
||||
|
||||
subscribe(listener: Listener) {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
private notify() {
|
||||
this.version += 1
|
||||
this.listeners.forEach(listener => listener())
|
||||
}
|
||||
}
|
||||
|
||||
export const scopeRegistry = new ScopeRegistry()
|
||||
|
||||
export const useScopeRegistry = (context: ScopeContext) => {
|
||||
const subscribe = useCallback(
|
||||
(listener: Listener) => scopeRegistry.subscribe(listener),
|
||||
[],
|
||||
)
|
||||
|
||||
const getSnapshot = useCallback(
|
||||
() => scopeRegistry.getVersion(),
|
||||
[],
|
||||
)
|
||||
|
||||
const version = useSyncExternalStore(
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getSnapshot,
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => scopeRegistry.getScopes(context),
|
||||
[version, context.isWorkflowPage, context.isRagPipelinePage, context.isAdmin],
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { TypeWithI18N } from '../../base/form/types'
|
||||
import type { Plugin } from '../../plugins/types'
|
||||
import type { CommonNodeType } from '../../workflow/types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
@ -7,7 +6,7 @@ import type { App } from '@/types/app'
|
||||
|
||||
export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command'
|
||||
|
||||
export type BaseSearchResult<T = any> = {
|
||||
export type BaseSearchResult<T = unknown> = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
@ -39,20 +38,8 @@ export type WorkflowNodeSearchResult = {
|
||||
|
||||
export type CommandSearchResult = {
|
||||
type: 'command'
|
||||
} & BaseSearchResult<{ command: string, args?: Record<string, any> }>
|
||||
} & BaseSearchResult<{ command: string, args?: Record<string, unknown> }>
|
||||
|
||||
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[])
|
||||
}
|
||||
export type { ScopeContext, ScopeDescriptor } from './scope-registry'
|
||||
|
||||
@ -1,24 +1,41 @@
|
||||
import type { ActionItem } from './types'
|
||||
import type { ScopeSearchHandler } from './scope-registry'
|
||||
import type { SearchResult } from './types'
|
||||
import { ACTION_KEYS } from '../constants'
|
||||
import { scopeRegistry } from './scope-registry'
|
||||
|
||||
// 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) => {
|
||||
const scopeId = 'workflow-node'
|
||||
let scopeRegistered = false
|
||||
|
||||
const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): ScopeSearchHandler => {
|
||||
return 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
|
||||
if (searchFn)
|
||||
return searchFn(searchTerm)
|
||||
return []
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Workflow nodes search failed:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const registerWorkflowNodeScope = () => {
|
||||
if (scopeRegistered)
|
||||
return
|
||||
|
||||
scopeRegistered = true
|
||||
scopeRegistry.register({
|
||||
id: scopeId,
|
||||
shortcut: ACTION_KEYS.NODE,
|
||||
title: 'Search Workflow Nodes',
|
||||
description: 'Find and jump to nodes in the current workflow by name or type',
|
||||
isAvailable: context => context.isWorkflowPage,
|
||||
search: buildSearchHandler(),
|
||||
})
|
||||
}
|
||||
|
||||
export const setWorkflowNodesSearchFn = (fn: (searchTerm: string) => SearchResult[]) => {
|
||||
registerWorkflowNodeScope()
|
||||
scopeRegistry.updateSearchHandler(scopeId, buildSearchHandler(fn))
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ActionItem } from './actions/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import type { ScopeDescriptor } from './actions/scope-registry'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Command } from 'cmdk'
|
||||
import * as React from 'react'
|
||||
@ -22,263 +22,315 @@ vi.mock('./actions/commands/registry', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const createActions = (): Record<string, ActionItem> => ({
|
||||
app: {
|
||||
key: '@app',
|
||||
type CommandSelectorProps = React.ComponentProps<typeof CommandSelector>
|
||||
|
||||
const mockScopes: ScopeDescriptor[] = [
|
||||
{
|
||||
id: 'app',
|
||||
shortcut: '@app',
|
||||
title: 'Apps',
|
||||
title: 'Search Applications',
|
||||
description: 'Search apps',
|
||||
search: vi.fn(),
|
||||
description: '',
|
||||
} as ActionItem,
|
||||
plugin: {
|
||||
key: '@plugin',
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
shortcut: '@knowledge',
|
||||
title: 'Search Knowledge Bases',
|
||||
description: 'Search knowledge bases',
|
||||
search: vi.fn(),
|
||||
},
|
||||
{
|
||||
id: 'plugin',
|
||||
shortcut: '@plugin',
|
||||
title: 'Plugins',
|
||||
title: 'Search Plugins',
|
||||
description: 'Search plugins',
|
||||
search: vi.fn(),
|
||||
description: '',
|
||||
} as ActionItem,
|
||||
})
|
||||
},
|
||||
{
|
||||
id: 'workflow-node',
|
||||
shortcut: '@node',
|
||||
title: 'Search Nodes',
|
||||
description: 'Search workflow nodes',
|
||||
search: vi.fn(),
|
||||
},
|
||||
]
|
||||
|
||||
const mockOnCommandSelect = vi.fn()
|
||||
const mockOnCommandValueChange = vi.fn()
|
||||
|
||||
const buildCommandSelector = (props: Partial<CommandSelectorProps> = {}) => (
|
||||
<Command>
|
||||
<Command.List>
|
||||
<CommandSelector
|
||||
scopes={mockScopes}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
{...props}
|
||||
/>
|
||||
</Command.List>
|
||||
</Command>
|
||||
)
|
||||
|
||||
const renderCommandSelector = (props: Partial<CommandSelectorProps> = {}) => {
|
||||
return render(buildCommandSelector(props))
|
||||
}
|
||||
|
||||
describe('CommandSelector', () => {
|
||||
it('should list contextual search actions and notify selection', async () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter="app"
|
||||
originalQuery="@app"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
const actionButton = screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')
|
||||
await userEvent.click(actionButton)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('@app')
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render slash commands when query starts with slash', async () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render all scopes when no filter is provided', () => {
|
||||
renderCommandSelector()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter="zen"
|
||||
originalQuery="/zen"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
expect(screen.getByText('@app')).toBeInTheDocument()
|
||||
expect(screen.getByText('@knowledge')).toBeInTheDocument()
|
||||
expect(screen.getByText('@plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('@node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const slashItem = await screen.findByText('app.gotoAnything.actions.zenDesc')
|
||||
await userEvent.click(slashItem)
|
||||
it('should render empty filter as showing all scopes', () => {
|
||||
renderCommandSelector({ searchFilter: '' })
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('/zen')
|
||||
expect(screen.getByText('@app')).toBeInTheDocument()
|
||||
expect(screen.getByText('@knowledge')).toBeInTheDocument()
|
||||
expect(screen.getByText('@plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('@node')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Filtering Functionality', () => {
|
||||
it('should filter scopes based on searchFilter - single match', () => {
|
||||
renderCommandSelector({ searchFilter: 'k' })
|
||||
|
||||
expect(screen.queryByText('@app')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('@knowledge')).toBeInTheDocument()
|
||||
expect(screen.queryByText('@plugin')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('@node')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter scopes with multiple matches', () => {
|
||||
renderCommandSelector({ searchFilter: 'p' })
|
||||
|
||||
expect(screen.getByText('@app')).toBeInTheDocument()
|
||||
expect(screen.queryByText('@knowledge')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('@plugin')).toBeInTheDocument()
|
||||
expect(screen.queryByText('@node')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be case-insensitive when filtering', () => {
|
||||
renderCommandSelector({ searchFilter: 'APP' })
|
||||
|
||||
expect(screen.getByText('@app')).toBeInTheDocument()
|
||||
expect(screen.queryByText('@knowledge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should match partial strings', () => {
|
||||
renderCommandSelector({ searchFilter: 'od' })
|
||||
|
||||
expect(screen.queryByText('@app')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('@knowledge')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('@plugin')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('@node')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show empty state when no matches found', () => {
|
||||
renderCommandSelector({ searchFilter: 'xyz' })
|
||||
|
||||
expect(screen.queryByText('@app')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('@knowledge')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('@plugin')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('@node')).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show empty state when filter is empty', () => {
|
||||
renderCommandSelector({ searchFilter: '' })
|
||||
|
||||
expect(screen.queryByText('app.gotoAnything.noMatchingCommands')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection and Highlight Management', () => {
|
||||
it('should call onCommandValueChange when filter changes and first item differs', async () => {
|
||||
const { rerender } = renderCommandSelector({
|
||||
searchFilter: '',
|
||||
commandValue: '@app',
|
||||
onCommandValueChange: mockOnCommandValueChange,
|
||||
})
|
||||
|
||||
rerender(buildCommandSelector({
|
||||
searchFilter: 'k',
|
||||
commandValue: '@app',
|
||||
onCommandValueChange: mockOnCommandValueChange,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCommandValueChange).toHaveBeenCalledWith('@knowledge')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onCommandValueChange if current value still exists', async () => {
|
||||
const { rerender } = renderCommandSelector({
|
||||
searchFilter: '',
|
||||
commandValue: '@app',
|
||||
onCommandValueChange: mockOnCommandValueChange,
|
||||
})
|
||||
|
||||
rerender(buildCommandSelector({
|
||||
searchFilter: 'a',
|
||||
commandValue: '@app',
|
||||
onCommandValueChange: mockOnCommandValueChange,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCommandValueChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle onCommandSelect callback correctly', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCommandSelector({ searchFilter: 'k' })
|
||||
|
||||
await user.click(screen.getByText('@knowledge'))
|
||||
|
||||
expect(mockOnCommandSelect).toHaveBeenCalledWith('@knowledge')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty scopes array', () => {
|
||||
renderCommandSelector({ scopes: [] })
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in filter', () => {
|
||||
renderCommandSelector({ searchFilter: '@' })
|
||||
|
||||
expect(screen.getByText('@app')).toBeInTheDocument()
|
||||
expect(screen.getByText('@knowledge')).toBeInTheDocument()
|
||||
expect(screen.getByText('@plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('@node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined onCommandValueChange gracefully', () => {
|
||||
const { rerender } = renderCommandSelector({ searchFilter: '' })
|
||||
|
||||
expect(() => {
|
||||
rerender(buildCommandSelector({ searchFilter: 'k' }))
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should list contextual scopes and notify selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCommandSelector({ searchFilter: 'app', originalQuery: '@app' })
|
||||
|
||||
await user.click(screen.getByText('app.gotoAnything.actions.searchApplicationsDesc'))
|
||||
|
||||
expect(mockOnCommandSelect).toHaveBeenCalledWith('@app')
|
||||
})
|
||||
|
||||
it('should render slash commands when query starts with slash', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCommandSelector({ searchFilter: 'zen', originalQuery: '/zen' })
|
||||
|
||||
const slashItem = await screen.findByText('app.gotoAnything.actions.zenDesc')
|
||||
await user.click(slashItem)
|
||||
|
||||
expect(mockOnCommandSelect).toHaveBeenCalledWith('/zen')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show all slash commands when no filter provided', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="/"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
renderCommandSelector({ searchFilter: '', originalQuery: '/' })
|
||||
|
||||
// Should show the zen command from mock
|
||||
expect(screen.getByText('/zen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should exclude slash action when in @ mode', () => {
|
||||
const actions = {
|
||||
...createActions(),
|
||||
slash: {
|
||||
key: '/',
|
||||
it('should exclude slash scope when in @ mode', () => {
|
||||
const scopesWithSlash: ScopeDescriptor[] = [
|
||||
...mockScopes,
|
||||
{
|
||||
id: 'slash',
|
||||
shortcut: '/',
|
||||
title: 'Slash',
|
||||
search: vi.fn(),
|
||||
description: '',
|
||||
} as ActionItem,
|
||||
}
|
||||
const onSelect = vi.fn()
|
||||
search: vi.fn(),
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="@"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
renderCommandSelector({ scopes: scopesWithSlash, searchFilter: '', originalQuery: '@' })
|
||||
|
||||
// Should show @ commands but not /
|
||||
expect(screen.getByText('@app')).toBeInTheDocument()
|
||||
expect(screen.queryByText('/')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show all actions when no filter in @ mode', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="@"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
it('should show all scopes when no filter in @ mode', () => {
|
||||
renderCommandSelector({ searchFilter: '', originalQuery: '@' })
|
||||
|
||||
expect(screen.getByText('@app')).toBeInTheDocument()
|
||||
expect(screen.getByText('@plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set default command value when items exist but value does not', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
const onCommandValueChange = vi.fn()
|
||||
renderCommandSelector({
|
||||
searchFilter: '',
|
||||
originalQuery: '@',
|
||||
commandValue: 'non-existent',
|
||||
onCommandValueChange: mockOnCommandValueChange,
|
||||
})
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="@"
|
||||
commandValue="non-existent"
|
||||
onCommandValueChange={onCommandValueChange}
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
expect(onCommandValueChange).toHaveBeenCalledWith('@app')
|
||||
expect(mockOnCommandValueChange).toHaveBeenCalledWith('@app')
|
||||
})
|
||||
|
||||
it('should NOT set command value when value already exists in items', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
const onCommandValueChange = vi.fn()
|
||||
renderCommandSelector({
|
||||
searchFilter: '',
|
||||
originalQuery: '@',
|
||||
commandValue: '@app',
|
||||
onCommandValueChange: mockOnCommandValueChange,
|
||||
})
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="@"
|
||||
commandValue="@app"
|
||||
onCommandValueChange={onCommandValueChange}
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
expect(onCommandValueChange).not.toHaveBeenCalled()
|
||||
expect(mockOnCommandValueChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show no matching commands message when filter has no results', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter="nonexistent"
|
||||
originalQuery="@nonexistent"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
renderCommandSelector({ searchFilter: 'nonexistent', originalQuery: '@nonexistent' })
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show no matching commands for slash mode with no results', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter="nonexistentcommand"
|
||||
originalQuery="/nonexistentcommand"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
renderCommandSelector({ searchFilter: 'nonexistentcommand', originalQuery: '/nonexistentcommand' })
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description for @ commands', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="@"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
renderCommandSelector({ searchFilter: '', originalQuery: '@' })
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.actions.searchPluginsDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render group header for @ mode', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="@"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
renderCommandSelector({ searchFilter: '', originalQuery: '@' })
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.selectSearchType')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render group header for slash mode', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="/"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
renderCommandSelector({ searchFilter: '', originalQuery: '/' })
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.groups.commands')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ActionItem } from './actions/types'
|
||||
import type { ScopeDescriptor } from './actions/scope-registry'
|
||||
import { Command } from 'cmdk'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { slashCommandRegistry } from './actions/commands/registry'
|
||||
import { ACTION_KEYS } from './constants'
|
||||
|
||||
type Props = {
|
||||
actions: Record<string, ActionItem>
|
||||
scopes: ScopeDescriptor[]
|
||||
onCommandSelect: (commandKey: string) => void
|
||||
searchFilter?: string
|
||||
commandValue?: string
|
||||
@ -15,7 +16,7 @@ type Props = {
|
||||
originalQuery?: string
|
||||
}
|
||||
|
||||
const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, commandValue, onCommandValueChange, originalQuery }) => {
|
||||
const CommandSelector: FC<Props> = ({ scopes, onCommandSelect, searchFilter, commandValue, onCommandValueChange, originalQuery }) => {
|
||||
const { t } = useTranslation()
|
||||
const pathname = usePathname()
|
||||
|
||||
@ -43,22 +44,31 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
|
||||
}))
|
||||
}, [isSlashMode, searchFilter, pathname])
|
||||
|
||||
const filteredActions = useMemo(() => {
|
||||
const filteredScopes = useMemo(() => {
|
||||
if (isSlashMode)
|
||||
return []
|
||||
|
||||
return Object.values(actions).filter((action) => {
|
||||
return scopes.filter((scope) => {
|
||||
// Exclude slash action when in @ mode
|
||||
if (action.key === '/')
|
||||
if (scope.id === 'slash' || scope.shortcut === ACTION_KEYS.SLASH)
|
||||
return false
|
||||
if (!searchFilter)
|
||||
return true
|
||||
const filterLower = searchFilter.toLowerCase()
|
||||
return action.shortcut.toLowerCase().includes(filterLower)
|
||||
})
|
||||
}, [actions, searchFilter, isSlashMode])
|
||||
|
||||
const allItems = isSlashMode ? slashCommands : filteredActions
|
||||
// Match against shortcut/aliases or title
|
||||
const filterLower = searchFilter.toLowerCase()
|
||||
const shortcuts = [scope.shortcut, ...(scope.aliases || [])]
|
||||
return shortcuts.some(shortcut => shortcut.toLowerCase().includes(filterLower))
|
||||
|| scope.title.toLowerCase().includes(filterLower)
|
||||
}).map(scope => ({
|
||||
key: scope.shortcut, // Map to shortcut for UI display consistency
|
||||
shortcut: scope.shortcut,
|
||||
title: scope.title,
|
||||
description: scope.description,
|
||||
}))
|
||||
}, [scopes, searchFilter, isSlashMode])
|
||||
|
||||
const allItems = isSlashMode ? slashCommands : filteredScopes
|
||||
|
||||
useEffect(() => {
|
||||
if (allItems.length > 0 && onCommandValueChange) {
|
||||
|
||||
@ -83,10 +83,10 @@ describe('EmptyState', () => {
|
||||
})
|
||||
|
||||
it('should show specific search hint with shortcuts', () => {
|
||||
const Actions = {
|
||||
app: { key: '@app', shortcut: '@app' },
|
||||
plugin: { key: '@plugin', shortcut: '@plugin' },
|
||||
} as unknown as Record<string, import('../actions/types').ActionItem>
|
||||
const Actions = [
|
||||
{ id: 'app', shortcut: '@app', title: 'App', description: '', search: vi.fn() },
|
||||
{ id: 'plugin', shortcut: '@plugin', title: 'Plugin', description: '', search: vi.fn() },
|
||||
] as import('../actions/types').ScopeDescriptor[]
|
||||
render(<EmptyState variant="no-results" searchMode="general" Actions={Actions} />)
|
||||
|
||||
expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:@app, @plugin')).toBeInTheDocument()
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { ActionItem } from '../actions/types'
|
||||
import type { ScopeDescriptor } from '../actions/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type EmptyStateVariant = 'no-results' | 'error' | 'default' | 'loading'
|
||||
@ -10,14 +10,14 @@ export type EmptyStateProps = {
|
||||
variant: EmptyStateVariant
|
||||
searchMode?: string
|
||||
error?: Error | null
|
||||
Actions?: Record<string, ActionItem>
|
||||
Actions?: ScopeDescriptor[]
|
||||
}
|
||||
|
||||
const EmptyState: FC<EmptyStateProps> = ({
|
||||
variant,
|
||||
searchMode = 'general',
|
||||
error,
|
||||
Actions = {},
|
||||
Actions = [],
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -88,7 +88,7 @@ const EmptyState: FC<EmptyStateProps> = ({
|
||||
return t('gotoAnything.emptyState.tryDifferentTerm', { ns: 'app' })
|
||||
}
|
||||
|
||||
const shortcuts = Object.values(Actions).map(action => action.shortcut).join(', ')
|
||||
const shortcuts = Actions.map(scope => scope.shortcut).join(', ')
|
||||
return t('gotoAnything.emptyState.trySpecificSearch', { ns: 'app', shortcuts })
|
||||
}
|
||||
|
||||
|
||||
20
web/app/components/goto-anything/constants.ts
Normal file
20
web/app/components/goto-anything/constants.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Goto Anything Constants
|
||||
* Centralized constants for action keys
|
||||
*/
|
||||
|
||||
/**
|
||||
* Action keys for scope-based searches
|
||||
*/
|
||||
export const ACTION_KEYS = {
|
||||
APP: '@app',
|
||||
KNOWLEDGE: '@knowledge',
|
||||
PLUGIN: '@plugin',
|
||||
NODE: '@node',
|
||||
SLASH: '/',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Type-safe action key union type
|
||||
*/
|
||||
export type ActionKey = typeof ACTION_KEYS[keyof typeof ACTION_KEYS]
|
||||
@ -32,23 +32,17 @@ vi.mock('../actions/commands/registry', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const createMockActionItem = (
|
||||
key: '@app' | '@knowledge' | '@plugin' | '@node' | '/',
|
||||
extra: Record<string, unknown> = {},
|
||||
) => ({
|
||||
key,
|
||||
shortcut: key,
|
||||
title: `${key} title`,
|
||||
description: `${key} description`,
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
...extra,
|
||||
})
|
||||
const mockExecuteCommand = vi.fn()
|
||||
|
||||
vi.mock('../actions/commands', () => ({
|
||||
executeCommand: (...args: unknown[]) => mockExecuteCommand(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
VIBE_COMMAND_EVENT: 'vibe-command',
|
||||
}))
|
||||
|
||||
const createMockOptions = (overrides = {}) => ({
|
||||
Actions: {
|
||||
slash: createMockActionItem('/', { action: vi.fn() }),
|
||||
app: createMockActionItem('@app'),
|
||||
},
|
||||
setSearchQuery: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
inputRef: { current: { focus: vi.fn() } } as unknown as React.RefObject<HTMLInputElement>,
|
||||
@ -60,6 +54,7 @@ describe('useGotoAnythingNavigation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFindCommandResult = null
|
||||
mockExecuteCommand.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
@ -221,13 +216,8 @@ describe('useGotoAnythingNavigation', () => {
|
||||
expect(mockRouterPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute slash command action for command type', () => {
|
||||
const actionMock = vi.fn()
|
||||
const options = createMockOptions({
|
||||
Actions: {
|
||||
slash: { key: '/', shortcut: '/', action: actionMock },
|
||||
},
|
||||
})
|
||||
it('should execute command via executeCommand for command type', () => {
|
||||
const options = createMockOptions()
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
@ -242,7 +232,7 @@ describe('useGotoAnythingNavigation', () => {
|
||||
result.current.handleNavigate(commandResult)
|
||||
})
|
||||
|
||||
expect(actionMock).toHaveBeenCalledWith(commandResult)
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith('theme.set', { theme: 'dark' })
|
||||
})
|
||||
|
||||
it('should set activePlugin for plugin type', () => {
|
||||
@ -368,10 +358,8 @@ describe('useGotoAnythingNavigation', () => {
|
||||
// No error should occur
|
||||
})
|
||||
|
||||
it('should handle missing slash action', () => {
|
||||
const options = createMockOptions({
|
||||
Actions: {},
|
||||
})
|
||||
it('should handle command execution without error', () => {
|
||||
const options = createMockOptions()
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
@ -385,7 +373,7 @@ describe('useGotoAnythingNavigation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// No error should occur
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith('test-command', undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import type { Plugin } from '../../plugins/types'
|
||||
import type { ActionItem, SearchResult } from '../actions/types'
|
||||
import type { SearchResult } from '../actions/types'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { VIBE_COMMAND_EVENT } from '@/app/components/workflow/constants'
|
||||
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
|
||||
import { executeCommand } from '../actions/commands'
|
||||
import { slashCommandRegistry } from '../actions/commands/registry'
|
||||
|
||||
export type UseGotoAnythingNavigationReturn = {
|
||||
@ -16,7 +18,6 @@ export type UseGotoAnythingNavigationReturn = {
|
||||
}
|
||||
|
||||
export type UseGotoAnythingNavigationOptions = {
|
||||
Actions: Record<string, ActionItem>
|
||||
setSearchQuery: (query: string) => void
|
||||
clearSelection: () => void
|
||||
inputRef: RefObject<HTMLInputElement | null>
|
||||
@ -27,7 +28,6 @@ export const useGotoAnythingNavigation = (
|
||||
options: UseGotoAnythingNavigationOptions,
|
||||
): UseGotoAnythingNavigationReturn => {
|
||||
const {
|
||||
Actions,
|
||||
setSearchQuery,
|
||||
clearSelection,
|
||||
inputRef,
|
||||
@ -67,9 +67,16 @@ export const useGotoAnythingNavigation = (
|
||||
|
||||
switch (result.type) {
|
||||
case 'command': {
|
||||
// Execute slash commands
|
||||
const action = Actions.slash
|
||||
action?.action?.(result)
|
||||
if (result.data.command === 'workflow.vibe') {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.dispatchEvent(new CustomEvent(VIBE_COMMAND_EVENT, { detail: { dsl: result.data.args?.dsl } }))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Execute slash commands using the command bus
|
||||
const { command, args } = result.data
|
||||
executeCommand(command, args)
|
||||
break
|
||||
}
|
||||
case 'plugin':
|
||||
@ -79,13 +86,12 @@ export const useGotoAnythingNavigation = (
|
||||
// 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, Actions, onClose, setSearchQuery])
|
||||
}, [router, onClose, setSearchQuery])
|
||||
|
||||
return {
|
||||
handleCommandSelect,
|
||||
|
||||
@ -35,11 +35,11 @@ vi.mock('../actions', () => ({
|
||||
searchAnything: (...args: unknown[]) => mockSearchAnything(...args),
|
||||
}))
|
||||
|
||||
const createMockActionItem = (key: '@app' | '@knowledge' | '@plugin' | '@node' | '/') => ({
|
||||
key,
|
||||
shortcut: key,
|
||||
title: `${key} title`,
|
||||
description: `${key} description`,
|
||||
const createMockScopeDescriptor = (id: string, shortcut: string) => ({
|
||||
id,
|
||||
shortcut,
|
||||
title: `${shortcut} title`,
|
||||
description: `${shortcut} description`,
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
})
|
||||
|
||||
@ -47,7 +47,7 @@ const createMockOptions = (overrides = {}) => ({
|
||||
searchQueryDebouncedValue: '',
|
||||
searchMode: 'general',
|
||||
isCommandsMode: false,
|
||||
Actions: { app: createMockActionItem('@app') },
|
||||
scopes: [createMockScopeDescriptor('app', '@app')],
|
||||
isWorkflowPage: false,
|
||||
isRagPipelinePage: false,
|
||||
cmdVal: '_',
|
||||
@ -300,36 +300,36 @@ describe('useGotoAnythingResults', () => {
|
||||
|
||||
describe('queryFn execution', () => {
|
||||
it('should call matchAction with lowercased query', async () => {
|
||||
const mockActions = { app: createMockActionItem('@app') }
|
||||
mockMatchAction.mockReturnValue({ key: '@app' })
|
||||
const mockScopes = [createMockScopeDescriptor('app', '@app')]
|
||||
mockMatchAction.mockReturnValue(mockScopes[0])
|
||||
mockSearchAnything.mockResolvedValue([])
|
||||
|
||||
renderHook(() => useGotoAnythingResults(createMockOptions({
|
||||
searchQueryDebouncedValue: 'TEST QUERY',
|
||||
Actions: mockActions,
|
||||
scopes: mockScopes,
|
||||
})))
|
||||
|
||||
expect(capturedQueryFn).toBeDefined()
|
||||
await capturedQueryFn!()
|
||||
|
||||
expect(mockMatchAction).toHaveBeenCalledWith('test query', mockActions)
|
||||
expect(mockMatchAction).toHaveBeenCalledWith('test query', mockScopes)
|
||||
})
|
||||
|
||||
it('should call searchAnything with correct parameters', async () => {
|
||||
const mockActions = { app: createMockActionItem('@app') }
|
||||
const mockAction = { key: '@app' }
|
||||
const mockScopes = [createMockScopeDescriptor('app', '@app')]
|
||||
const mockAction = mockScopes[0]
|
||||
mockMatchAction.mockReturnValue(mockAction)
|
||||
mockSearchAnything.mockResolvedValue([{ id: '1', type: 'app', title: 'Result' }])
|
||||
|
||||
renderHook(() => useGotoAnythingResults(createMockOptions({
|
||||
searchQueryDebouncedValue: 'My Query',
|
||||
Actions: mockActions,
|
||||
scopes: mockScopes,
|
||||
})))
|
||||
|
||||
expect(capturedQueryFn).toBeDefined()
|
||||
const result = await capturedQueryFn!()
|
||||
|
||||
expect(mockSearchAnything).toHaveBeenCalledWith('en_US', 'my query', mockAction, mockActions)
|
||||
expect(mockSearchAnything).toHaveBeenCalledWith('en_US', 'my query', mockAction, mockScopes)
|
||||
expect(result).toEqual([{ id: '1', type: 'app', title: 'Result' }])
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { ActionItem, SearchResult } from '../actions/types'
|
||||
import type { ScopeDescriptor, SearchResult } from '../actions/types'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
@ -19,7 +19,7 @@ export type UseGotoAnythingResultsOptions = {
|
||||
searchQueryDebouncedValue: string
|
||||
searchMode: string
|
||||
isCommandsMode: boolean
|
||||
Actions: Record<string, ActionItem>
|
||||
scopes: ScopeDescriptor[]
|
||||
isWorkflowPage: boolean
|
||||
isRagPipelinePage: boolean
|
||||
cmdVal: string
|
||||
@ -33,7 +33,7 @@ export const useGotoAnythingResults = (
|
||||
searchQueryDebouncedValue,
|
||||
searchMode,
|
||||
isCommandsMode,
|
||||
Actions,
|
||||
scopes,
|
||||
isWorkflowPage,
|
||||
isRagPipelinePage,
|
||||
cmdVal,
|
||||
@ -42,13 +42,9 @@ export const useGotoAnythingResults = (
|
||||
|
||||
const defaultLocale = useGetLanguage()
|
||||
|
||||
// Use action keys as stable cache key instead of the full Actions object
|
||||
// (Actions contains functions which are not serializable)
|
||||
const actionKeys = useMemo(() => Object.keys(Actions).sort(), [Actions])
|
||||
|
||||
const { data: searchResults = [], isLoading, isError, error } = useQuery(
|
||||
{
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Actions intentionally excluded: contains non-serializable functions; actionKeys provides stable representation
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- scopes intentionally excluded: contains non-serializable functions; scope IDs provide stable representation
|
||||
queryKey: [
|
||||
'goto-anything',
|
||||
'search-result',
|
||||
@ -57,12 +53,12 @@ export const useGotoAnythingResults = (
|
||||
isWorkflowPage,
|
||||
isRagPipelinePage,
|
||||
defaultLocale,
|
||||
actionKeys,
|
||||
scopes.map(s => s.id).sort().join(','),
|
||||
],
|
||||
queryFn: async () => {
|
||||
const query = searchQueryDebouncedValue.toLowerCase()
|
||||
const action = matchAction(query, Actions)
|
||||
return await searchAnything(defaultLocale, query, action, Actions)
|
||||
const scope = matchAction(query, scopes)
|
||||
return await searchAnything(defaultLocale, query, scope, scopes)
|
||||
},
|
||||
enabled: !!searchQueryDebouncedValue && !isCommandsMode,
|
||||
staleTime: 30000,
|
||||
|
||||
@ -1,9 +1,25 @@
|
||||
import type { ActionItem } from '../actions/types'
|
||||
import type { ScopeDescriptor } from '../actions/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useGotoAnythingSearch } from './use-goto-anything-search'
|
||||
|
||||
let mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
|
||||
let mockMatchActionResult: Partial<ActionItem> | undefined
|
||||
let mockMatchActionResult: ScopeDescriptor | undefined
|
||||
|
||||
const baseScopesMock: ScopeDescriptor[] = [
|
||||
{ id: 'slash', shortcut: '/', title: 'Slash', description: 'Slash commands', search: vi.fn() },
|
||||
{ id: 'app', shortcut: '@app', title: 'App', description: 'Search apps', search: vi.fn() },
|
||||
{ id: 'knowledge', shortcut: '@knowledge', title: 'Knowledge', description: 'Search KB', search: vi.fn() },
|
||||
]
|
||||
|
||||
const workflowScopesMock: ScopeDescriptor[] = [
|
||||
...baseScopesMock,
|
||||
{ id: 'node', shortcut: '@node', title: 'Node', description: 'Search nodes', search: vi.fn() },
|
||||
]
|
||||
|
||||
const ragScopesMock: ScopeDescriptor[] = [
|
||||
...baseScopesMock,
|
||||
{ id: 'ragNode', shortcut: '@node', title: 'RAG Node', description: 'Search RAG nodes', search: vi.fn() },
|
||||
]
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: <T>(value: T) => value,
|
||||
@ -14,19 +30,12 @@ vi.mock('../context', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
createActions: (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
|
||||
const base = {
|
||||
slash: { key: '/', shortcut: '/' },
|
||||
app: { key: '@app', shortcut: '@app' },
|
||||
knowledge: { key: '@knowledge', shortcut: '@kb' },
|
||||
}
|
||||
if (isWorkflowPage) {
|
||||
return { ...base, node: { key: '@node', shortcut: '@node' } }
|
||||
}
|
||||
if (isRagPipelinePage) {
|
||||
return { ...base, ragNode: { key: '@node', shortcut: '@node' } }
|
||||
}
|
||||
return base
|
||||
useGotoAnythingScopes: (context: { isWorkflowPage: boolean, isRagPipelinePage: boolean }) => {
|
||||
if (context.isWorkflowPage)
|
||||
return workflowScopesMock
|
||||
if (context.isRagPipelinePage)
|
||||
return ragScopesMock
|
||||
return baseScopesMock
|
||||
},
|
||||
matchAction: () => mockMatchActionResult,
|
||||
}))
|
||||
@ -74,30 +83,30 @@ describe('useGotoAnythingSearch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Actions', () => {
|
||||
it('should provide Actions based on context', () => {
|
||||
describe('scopes', () => {
|
||||
it('should provide scopes based on context', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.Actions).toBeDefined()
|
||||
expect(typeof result.current.Actions).toBe('object')
|
||||
expect(result.current.scopes).toBeDefined()
|
||||
expect(Array.isArray(result.current.scopes)).toBe(true)
|
||||
})
|
||||
|
||||
it('should include node action when on workflow page', () => {
|
||||
it('should include node scope when on workflow page', () => {
|
||||
mockContextValue = { isWorkflowPage: true, isRagPipelinePage: false }
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.Actions.node).toBeDefined()
|
||||
expect(result.current.scopes.find(s => s.id === 'node')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should include ragNode action when on RAG pipeline page', () => {
|
||||
it('should include ragNode scope when on RAG pipeline page', () => {
|
||||
mockContextValue = { isWorkflowPage: false, isRagPipelinePage: true }
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.Actions.ragNode).toBeDefined()
|
||||
expect(result.current.scopes.find(s => s.id === 'ragNode')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not include node actions when on regular page', () => {
|
||||
it('should not include node scopes when on regular page', () => {
|
||||
mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.Actions.node).toBeUndefined()
|
||||
expect(result.current.Actions.ragNode).toBeUndefined()
|
||||
expect(result.current.scopes.find(s => s.id === 'node')).toBeUndefined()
|
||||
expect(result.current.scopes.find(s => s.id === 'ragNode')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -145,7 +154,7 @@ describe('useGotoAnythingSearch', () => {
|
||||
})
|
||||
|
||||
it('should return false when query starts with "@" and action matches', () => {
|
||||
mockMatchActionResult = { key: '@app', shortcut: '@app' }
|
||||
mockMatchActionResult = baseScopesMock.find(s => s.id === 'app')
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
@ -206,8 +215,8 @@ describe('useGotoAnythingSearch', () => {
|
||||
expect(result.current.searchMode).toBe('general')
|
||||
})
|
||||
|
||||
it('should return action key when action matches', () => {
|
||||
mockMatchActionResult = { key: '@app', shortcut: '@app' }
|
||||
it('should return action shortcut when action matches', () => {
|
||||
mockMatchActionResult = baseScopesMock.find(s => s.id === 'app')
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
@ -217,8 +226,8 @@ describe('useGotoAnythingSearch', () => {
|
||||
expect(result.current.searchMode).toBe('@app')
|
||||
})
|
||||
|
||||
it('should return "@command" when action key is "/"', () => {
|
||||
mockMatchActionResult = { key: '/', shortcut: '/' }
|
||||
it('should return "@command" when action is slash', () => {
|
||||
mockMatchActionResult = baseScopesMock.find(s => s.id === 'slash')
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { ActionItem } from '../actions/types'
|
||||
import type { ScopeDescriptor } from '../actions/types'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { createActions, matchAction } from '../actions'
|
||||
import { matchAction, useGotoAnythingScopes } from '../actions'
|
||||
import { ACTION_KEYS } from '../constants'
|
||||
import { useGotoAnythingContext } from '../context'
|
||||
|
||||
export type UseGotoAnythingSearchReturn = {
|
||||
@ -15,7 +16,7 @@ export type UseGotoAnythingSearchReturn = {
|
||||
cmdVal: string
|
||||
setCmdVal: (val: string) => void
|
||||
clearSelection: () => void
|
||||
Actions: Record<string, ActionItem>
|
||||
scopes: ScopeDescriptor[]
|
||||
}
|
||||
|
||||
export const useGotoAnythingSearch = (): UseGotoAnythingSearchReturn => {
|
||||
@ -23,10 +24,8 @@ export const useGotoAnythingSearch = (): UseGotoAnythingSearchReturn => {
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const [cmdVal, setCmdVal] = useState<string>('_')
|
||||
|
||||
// Filter actions based on context
|
||||
const Actions = useMemo(() => {
|
||||
return createActions(isWorkflowPage, isRagPipelinePage)
|
||||
}, [isWorkflowPage, isRagPipelinePage])
|
||||
// Fetch scopes from registry based on context
|
||||
const scopes = useGotoAnythingScopes({ isWorkflowPage, isRagPipelinePage })
|
||||
|
||||
const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), {
|
||||
wait: 300,
|
||||
@ -35,28 +34,30 @@ export const useGotoAnythingSearch = (): UseGotoAnythingSearchReturn => {
|
||||
const isCommandsMode = useMemo(() => {
|
||||
const trimmed = searchQuery.trim()
|
||||
return trimmed === '@' || trimmed === '/'
|
||||
|| (trimmed.startsWith('@') && !matchAction(trimmed, Actions))
|
||||
|| (trimmed.startsWith('/') && !matchAction(trimmed, Actions))
|
||||
}, [searchQuery, Actions])
|
||||
|| (trimmed.startsWith('@') && !matchAction(trimmed, scopes))
|
||||
|| (trimmed.startsWith('/') && !matchAction(trimmed, scopes))
|
||||
}, [searchQuery, scopes])
|
||||
|
||||
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
|
||||
return 'commands'
|
||||
}
|
||||
|
||||
const query = searchQueryDebouncedValue.toLowerCase()
|
||||
const action = matchAction(query, Actions)
|
||||
const action = matchAction(query, scopes)
|
||||
|
||||
if (!action)
|
||||
return 'general'
|
||||
|
||||
return action.key === '/' ? '@command' : action.key
|
||||
}, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
|
||||
if (action.id === 'slash' || action.shortcut === ACTION_KEYS.SLASH)
|
||||
return '@command'
|
||||
|
||||
return action.shortcut
|
||||
}, [searchQueryDebouncedValue, scopes, isCommandsMode, searchQuery])
|
||||
|
||||
// Prevent automatic selection of the first option when cmdVal is not set
|
||||
const clearSelection = useCallback(() => {
|
||||
@ -72,6 +73,6 @@ export const useGotoAnythingSearch = (): UseGotoAnythingSearchReturn => {
|
||||
cmdVal,
|
||||
setCmdVal,
|
||||
clearSelection,
|
||||
Actions,
|
||||
scopes,
|
||||
}
|
||||
}
|
||||
|
||||
93
web/app/components/goto-anything/hooks/use-search.ts
Normal file
93
web/app/components/goto-anything/hooks/use-search.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useMemo } from 'react'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { matchAction, searchAnything, useGotoAnythingScopes } from '../actions'
|
||||
import { ACTION_KEYS } from '../constants'
|
||||
import { useGotoAnythingContext } from '../context'
|
||||
|
||||
export const useSearch = (searchQuery: string) => {
|
||||
const defaultLocale = useGetLanguage()
|
||||
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
|
||||
|
||||
// Fetch scopes from registry based on context
|
||||
const scopes = useGotoAnythingScopes({ isWorkflowPage, isRagPipelinePage })
|
||||
|
||||
const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), {
|
||||
wait: 300,
|
||||
})
|
||||
|
||||
const isCommandsMode = searchQuery.trim() === '@' || searchQuery.trim() === '/'
|
||||
|| (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), scopes))
|
||||
|| (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), scopes))
|
||||
|
||||
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, scopes)
|
||||
|
||||
if (!action)
|
||||
return 'general'
|
||||
|
||||
if (action.id === 'slash' || action.shortcut === ACTION_KEYS.SLASH)
|
||||
return '@command'
|
||||
|
||||
return action.shortcut
|
||||
}, [searchQueryDebouncedValue, scopes, isCommandsMode, searchQuery])
|
||||
|
||||
const { data: searchResults = [], isLoading, isError, error } = useQuery(
|
||||
{
|
||||
queryKey: [
|
||||
'goto-anything',
|
||||
'search-result',
|
||||
searchQueryDebouncedValue,
|
||||
searchMode,
|
||||
isWorkflowPage,
|
||||
isRagPipelinePage,
|
||||
defaultLocale,
|
||||
scopes.map(s => s.id).sort().join(','),
|
||||
],
|
||||
queryFn: async () => {
|
||||
const query = searchQueryDebouncedValue.toLowerCase()
|
||||
const scope = matchAction(query, scopes)
|
||||
return await searchAnything(defaultLocale, query, scope, scopes)
|
||||
},
|
||||
enabled: !!searchQueryDebouncedValue && !isCommandsMode,
|
||||
staleTime: 30000,
|
||||
gcTime: 300000,
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
)
|
||||
|
||||
const dedupedResults = useMemo(() => {
|
||||
if (!searchQuery.trim())
|
||||
return []
|
||||
|
||||
const seen = new Set<string>()
|
||||
return searchResults.filter((result) => {
|
||||
const key = `${result.type}-${result.id}`
|
||||
if (seen.has(key))
|
||||
return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}, [searchResults, searchQuery])
|
||||
|
||||
return {
|
||||
scopes,
|
||||
searchResults: dedupedResults,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
searchMode,
|
||||
isCommandsMode,
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ActionItem, SearchResult } from './actions/types'
|
||||
import type { ScopeDescriptor } from './actions/scope-registry'
|
||||
import type { SearchResult } from './actions/types'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
@ -58,6 +59,7 @@ const triggerKeyPress = (combo: string) => {
|
||||
let mockQueryResult = { data: [] as TestSearchResult[], isLoading: false, isError: false, error: null as Error | null }
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => mockQueryResult,
|
||||
keepPreviousData: (data: unknown) => data,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
@ -70,37 +72,30 @@ vi.mock('./context', () => ({
|
||||
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
getKeyboardKeyNameBySystem: (key: string) => key,
|
||||
}))
|
||||
type MatchAction = typeof import('./actions').matchAction
|
||||
type SearchAnything = typeof import('./actions').searchAnything
|
||||
|
||||
const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({
|
||||
key,
|
||||
shortcut,
|
||||
title: `${key} title`,
|
||||
description: `${key} desc`,
|
||||
action: vi.fn(),
|
||||
search: vi.fn(),
|
||||
const mockState = vi.hoisted(() => {
|
||||
const state = {
|
||||
scopes: [] as ScopeDescriptor[],
|
||||
useGotoAnythingScopesMock: vi.fn(() => state.scopes),
|
||||
matchActionMock: vi.fn<MatchAction>(() => undefined),
|
||||
searchAnythingMock: vi.fn<SearchAnything>(async () => []),
|
||||
}
|
||||
|
||||
return state
|
||||
})
|
||||
|
||||
const actionsMock = {
|
||||
slash: createActionItem('/', '/'),
|
||||
app: createActionItem('@app', '@app'),
|
||||
plugin: createActionItem('@plugin', '@plugin'),
|
||||
}
|
||||
|
||||
const createActionsMock = vi.fn(() => actionsMock)
|
||||
const matchActionMock = vi.fn(() => undefined)
|
||||
const searchAnythingMock = vi.fn(async () => mockQueryResult.data)
|
||||
|
||||
vi.mock('./actions', () => ({
|
||||
createActions: () => createActionsMock(),
|
||||
matchAction: () => matchActionMock(),
|
||||
searchAnything: () => searchAnythingMock(),
|
||||
__esModule: true,
|
||||
matchAction: (...args: Parameters<MatchAction>) => mockState.matchActionMock(...args),
|
||||
searchAnything: (...args: Parameters<SearchAnything>) => mockState.searchAnythingMock(...args),
|
||||
useGotoAnythingScopes: () => mockState.useGotoAnythingScopesMock(),
|
||||
}))
|
||||
|
||||
vi.mock('./actions/commands', () => ({
|
||||
SlashCommandProvider: () => null,
|
||||
executeCommand: vi.fn(),
|
||||
}))
|
||||
|
||||
type MockSlashCommand = {
|
||||
@ -118,6 +113,20 @@ vi.mock('./actions/commands/registry', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const createScope = (id: ScopeDescriptor['id'], shortcut: string): ScopeDescriptor => ({
|
||||
id,
|
||||
shortcut,
|
||||
title: `${id} title`,
|
||||
description: `${id} desc`,
|
||||
search: vi.fn(),
|
||||
})
|
||||
|
||||
const scopesMock = [
|
||||
createScope('slash', '/'),
|
||||
createScope('app', '@app'),
|
||||
createScope('plugin', '@plugin'),
|
||||
]
|
||||
|
||||
vi.mock('@/app/components/workflow/utils/common', () => ({
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
getKeyboardKeyNameBySystem: (key: string) => key,
|
||||
@ -144,8 +153,10 @@ describe('GotoAnything', () => {
|
||||
routerPush.mockClear()
|
||||
Object.keys(keyPressHandlers).forEach(key => delete keyPressHandlers[key])
|
||||
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
|
||||
matchActionMock.mockReset()
|
||||
searchAnythingMock.mockClear()
|
||||
mockState.scopes = scopesMock
|
||||
mockState.matchActionMock.mockReset()
|
||||
mockState.searchAnythingMock.mockClear()
|
||||
mockState.searchAnythingMock.mockImplementation(async () => mockQueryResult.data as SearchResult[])
|
||||
mockFindCommand = null
|
||||
})
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@ const GotoAnything: FC<Props> = ({
|
||||
cmdVal,
|
||||
setCmdVal,
|
||||
clearSelection,
|
||||
Actions,
|
||||
scopes,
|
||||
} = useGotoAnythingSearch()
|
||||
|
||||
// Modal state management
|
||||
@ -76,7 +76,7 @@ const GotoAnything: FC<Props> = ({
|
||||
searchQueryDebouncedValue,
|
||||
searchMode,
|
||||
isCommandsMode,
|
||||
Actions,
|
||||
scopes,
|
||||
isWorkflowPage,
|
||||
isRagPipelinePage,
|
||||
cmdVal,
|
||||
@ -90,7 +90,6 @@ const GotoAnything: FC<Props> = ({
|
||||
activePlugin,
|
||||
setActivePlugin,
|
||||
} = useGotoAnythingNavigation({
|
||||
Actions,
|
||||
setSearchQuery,
|
||||
clearSelection,
|
||||
inputRef,
|
||||
@ -179,7 +178,7 @@ const GotoAnything: FC<Props> = ({
|
||||
{isCommandsMode
|
||||
? (
|
||||
<CommandSelector
|
||||
actions={Actions}
|
||||
scopes={scopes}
|
||||
onCommandSelect={handleCommandSelect}
|
||||
searchFilter={searchQuery.trim().substring(1)}
|
||||
commandValue={cmdVal}
|
||||
@ -198,7 +197,7 @@ const GotoAnything: FC<Props> = ({
|
||||
<EmptyState
|
||||
variant="no-results"
|
||||
searchMode={searchMode}
|
||||
Actions={Actions}
|
||||
Actions={scopes}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -2052,9 +2052,6 @@ describe('CommonCreateModal', () => {
|
||||
expect(mockCreateBuilder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Flush pending state updates from createBuilder promise resolution
|
||||
await act(async () => {})
|
||||
|
||||
const input = screen.getByTestId('form-field-webhook_url')
|
||||
fireEvent.change(input, { target: { value: 'test' } })
|
||||
|
||||
|
||||
@ -145,6 +145,22 @@ vi.mock('@/app/components/workflow/constants', () => ({
|
||||
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
|
||||
}))
|
||||
|
||||
// Mock FileReader
|
||||
class MockFileReader {
|
||||
result: string | null = null
|
||||
onload: ((e: { target: { result: string | null } }) => void) | null = null
|
||||
|
||||
readAsText(_file: File) {
|
||||
// Simulate async file reading using queueMicrotask for more reliable async behavior
|
||||
queueMicrotask(() => {
|
||||
this.result = 'test file content'
|
||||
if (this.onload) {
|
||||
this.onload({ target: { result: this.result } })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
@ -154,6 +170,7 @@ describe('UpdateDSLModal', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnBackup = vi.fn()
|
||||
const mockOnImport = vi.fn()
|
||||
let originalFileReader: typeof FileReader
|
||||
|
||||
const defaultProps = {
|
||||
onCancel: mockOnCancel,
|
||||
@ -169,6 +186,14 @@ describe('UpdateDSLModal', () => {
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
})
|
||||
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
|
||||
|
||||
// Mock FileReader
|
||||
originalFileReader = globalThis.FileReader
|
||||
globalThis.FileReader = MockFileReader as unknown as typeof FileReader
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.FileReader = originalFileReader
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
@ -538,7 +563,6 @@ describe('UpdateDSLModal', () => {
|
||||
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
// Wait for FileReader to process and button to be enabled
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
@ -563,12 +587,15 @@ describe('UpdateDSLModal', () => {
|
||||
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
// Wait for FileReader to complete and button to be enabled
|
||||
// Wait for FileReader to complete (setTimeout 0) and button to be enabled
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Give extra time for the FileReader's setTimeout to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
@ -597,11 +624,6 @@ describe('UpdateDSLModal', () => {
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Flush the FileReader microtask to ensure fileContent is set
|
||||
await act(async () => {
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
@ -703,7 +725,7 @@ describe('UpdateDSLModal', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1.0.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('2.0.0')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
}, { timeout: 500 })
|
||||
})
|
||||
|
||||
it('should close error modal when cancel button is clicked', async () => {
|
||||
@ -732,7 +754,7 @@ describe('UpdateDSLModal', () => {
|
||||
// Wait for error modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
}, { timeout: 500 })
|
||||
|
||||
// Find and click cancel button in error modal - it should be the one with secondary variant
|
||||
const cancelButtons = screen.getAllByText('newApp.Cancel')
|
||||
@ -750,8 +772,6 @@ describe('UpdateDSLModal', () => {
|
||||
})
|
||||
|
||||
it('should call importDSLConfirm when confirm button is clicked in error modal', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-id',
|
||||
status: DSLImportStatus.PENDING,
|
||||
@ -769,27 +789,20 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
const fileInput = screen.getByTestId('file-input')
|
||||
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
// Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask)
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(importButton)
|
||||
// Flush the promise resolution from mockImportDSL
|
||||
await Promise.resolve()
|
||||
// Advance past the 300ms setTimeout in the component
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
})
|
||||
fireEvent.click(importButton)
|
||||
|
||||
// Wait for error modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
}, { timeout: 500 })
|
||||
|
||||
// Click confirm button
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
@ -798,8 +811,6 @@ describe('UpdateDSLModal', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-id')
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should show success notification after confirm completes', async () => {
|
||||
@ -832,7 +843,7 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
}, { timeout: 500 })
|
||||
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
@ -874,7 +885,7 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
}, { timeout: 500 })
|
||||
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
@ -913,7 +924,7 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
}, { timeout: 500 })
|
||||
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
@ -955,7 +966,7 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
}, { timeout: 500 })
|
||||
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
@ -997,7 +1008,7 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
}, { timeout: 500 })
|
||||
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
@ -1008,8 +1019,6 @@ describe('UpdateDSLModal', () => {
|
||||
})
|
||||
|
||||
it('should call handleCheckPluginDependencies after confirm', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-id',
|
||||
status: DSLImportStatus.PENDING,
|
||||
@ -1027,27 +1036,19 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
const fileInput = screen.getByTestId('file-input')
|
||||
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
// Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask)
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(importButton)
|
||||
// Flush the promise resolution from mockImportDSL
|
||||
await Promise.resolve()
|
||||
// Advance past the 300ms setTimeout in the component
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
})
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
}, { timeout: 500 })
|
||||
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
@ -1055,8 +1056,6 @@ describe('UpdateDSLModal', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('test-pipeline-id', true)
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should handle undefined imported_dsl_version and current_dsl_version', async () => {
|
||||
@ -1085,7 +1084,7 @@ describe('UpdateDSLModal', () => {
|
||||
// Should show error modal even with undefined versions
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
}, { timeout: 500 })
|
||||
})
|
||||
|
||||
it('should not call importDSLConfirm when importId is not set', async () => {
|
||||
|
||||
@ -1,49 +1,79 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ============================================================================
|
||||
// Import after mocks
|
||||
// ============================================================================
|
||||
|
||||
import { useDSL } from './use-DSL'
|
||||
|
||||
// Mock dependencies
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
const mockEventEmitter = { emit: vi.fn() }
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }),
|
||||
}))
|
||||
|
||||
const mockDoSyncWorkflowDraft = vi.fn()
|
||||
vi.mock('./use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({ doSyncWorkflowDraft: mockDoSyncWorkflowDraft }),
|
||||
}))
|
||||
|
||||
const mockGetState = vi.fn()
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({ getState: mockGetState }),
|
||||
}))
|
||||
|
||||
const mockExportPipelineConfig = vi.fn()
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useExportPipelineDSL: () => ({ mutateAsync: mockExportPipelineConfig }),
|
||||
}))
|
||||
|
||||
const mockFetchWorkflowDraft = vi.fn()
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
|
||||
}))
|
||||
|
||||
const mockDownloadBlob = vi.fn()
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
|
||||
}))
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock toast context
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock event emitter context
|
||||
const mockEmit = vi.fn()
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
emit: mockEmit,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow store
|
||||
const mockWorkflowStoreGetState = vi.fn()
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: mockWorkflowStoreGetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useNodesSyncDraft
|
||||
const mockDoSyncWorkflowDraft = vi.fn()
|
||||
vi.mock('./use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock pipeline service
|
||||
const mockExportPipelineConfig = vi.fn()
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useExportPipelineDSL: () => ({
|
||||
mutateAsync: mockExportPipelineConfig,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock download utility
|
||||
const mockDownloadBlob = vi.fn()
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
|
||||
}))
|
||||
|
||||
// Mock workflow service
|
||||
const mockFetchWorkflowDraft = vi.fn()
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
|
||||
}))
|
||||
|
||||
// Mock workflow constants
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
|
||||
}))
|
||||
@ -53,63 +83,48 @@ vi.mock('@/app/components/workflow/constants', () => ({
|
||||
// ============================================================================
|
||||
|
||||
describe('useDSL', () => {
|
||||
let mockLink: { href: string, download: string, click: ReturnType<typeof vi.fn>, style: { display: string }, remove: ReturnType<typeof vi.fn> }
|
||||
let originalCreateElement: typeof document.createElement
|
||||
let originalAppendChild: typeof document.body.appendChild
|
||||
let mockCreateObjectURL: ReturnType<typeof vi.spyOn>
|
||||
let mockRevokeObjectURL: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create a proper mock link element with all required properties for downloadBlob
|
||||
mockLink = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: vi.fn(),
|
||||
style: { display: '' },
|
||||
remove: vi.fn(),
|
||||
}
|
||||
|
||||
// Save original and mock selectively - only intercept 'a' elements
|
||||
originalCreateElement = document.createElement.bind(document)
|
||||
document.createElement = vi.fn((tagName: string) => {
|
||||
if (tagName === 'a') {
|
||||
return mockLink as unknown as HTMLElement
|
||||
}
|
||||
return originalCreateElement(tagName)
|
||||
}) as typeof document.createElement
|
||||
|
||||
// Mock document.body.appendChild for downloadBlob
|
||||
originalAppendChild = document.body.appendChild.bind(document.body)
|
||||
document.body.appendChild = vi.fn(<T extends Node>(node: T): T => node) as typeof document.body.appendChild
|
||||
|
||||
// downloadBlob uses window.URL, not URL
|
||||
mockCreateObjectURL = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:test-url')
|
||||
mockRevokeObjectURL = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
|
||||
// Default store state
|
||||
mockGetState.mockReturnValue({
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
pipelineId: 'test-pipeline-id',
|
||||
knowledgeName: 'Test Knowledge Base',
|
||||
})
|
||||
|
||||
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
|
||||
mockExportPipelineConfig.mockResolvedValue({ data: 'yaml-content' })
|
||||
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [] })
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
environment_variables: [],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.createElement = originalCreateElement
|
||||
document.body.appendChild = originalAppendChild
|
||||
mockCreateObjectURL.mockRestore()
|
||||
mockRevokeObjectURL.mockRestore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('hook initialization', () => {
|
||||
it('should return exportCheck function', () => {
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
expect(result.current.exportCheck).toBeDefined()
|
||||
expect(typeof result.current.exportCheck).toBe('function')
|
||||
})
|
||||
|
||||
it('should return handleExportDSL function', () => {
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
expect(result.current.handleExportDSL).toBeDefined()
|
||||
expect(typeof result.current.handleExportDSL).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleExportDSL', () => {
|
||||
it('should return early when pipelineId is not set', async () => {
|
||||
mockGetState.mockReturnValue({ pipelineId: null, knowledgeName: 'test' })
|
||||
it('should not export when pipelineId is missing', async () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
pipelineId: undefined,
|
||||
knowledgeName: 'Test',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
@ -118,6 +133,30 @@ describe('useDSL', () => {
|
||||
})
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockExportPipelineConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should sync workflow draft before export', async () => {
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call exportPipelineConfig with correct params', async () => {
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL(true)
|
||||
})
|
||||
|
||||
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
|
||||
pipelineId: 'test-pipeline-id',
|
||||
include: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should create and download file', async () => {
|
||||
@ -130,7 +169,7 @@ describe('useDSL', () => {
|
||||
expect(mockDownloadBlob).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set correct download filename', async () => {
|
||||
it('should use correct file extension for download', async () => {
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
@ -158,7 +197,7 @@ describe('useDSL', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle export error', async () => {
|
||||
it('should show error notification on export failure', async () => {
|
||||
mockExportPipelineConfig.mockRejectedValue(new Error('Export failed'))
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
@ -167,33 +206,19 @@ describe('useDSL', () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'exportFailed',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass include parameter', async () => {
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
|
||||
pipelineId: 'test-pipeline-id',
|
||||
include: true,
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'exportFailed',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportCheck', () => {
|
||||
it('should return early when pipelineId is not set', async () => {
|
||||
mockGetState.mockReturnValue({ pipelineId: null })
|
||||
it('should not check when pipelineId is missing', async () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
pipelineId: undefined,
|
||||
knowledgeName: 'Test',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
@ -204,8 +229,22 @@ describe('useDSL', () => {
|
||||
expect(mockFetchWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleExportDSL directly when no secret variables', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [] })
|
||||
it('should fetch workflow draft', async () => {
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft')
|
||||
})
|
||||
|
||||
it('should directly export when no secret environment variables', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
environment_variables: [
|
||||
{ id: '1', value_type: 'string', value: 'test' },
|
||||
],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
@ -213,15 +252,16 @@ describe('useDSL', () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft')
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
// Should call doSyncWorkflowDraft (which means handleExportDSL was called)
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should emit event when secret variables exist', async () => {
|
||||
const secretVars = [{ value_type: 'secret', name: 'API_KEY' }]
|
||||
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: secretVars })
|
||||
it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
environment_variables: [
|
||||
{ id: '1', value_type: 'secret', value: 'secret-value' },
|
||||
],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
@ -229,17 +269,15 @@ describe('useDSL', () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith({
|
||||
type: expect.any(String),
|
||||
payload: {
|
||||
data: secretVars,
|
||||
},
|
||||
})
|
||||
expect(mockEmit).toHaveBeenCalledWith({
|
||||
type: 'DSL_EXPORT_CHECK',
|
||||
payload: {
|
||||
data: [{ id: '1', value_type: 'secret', value: 'secret-value' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle export check error', async () => {
|
||||
it('should show error notification on check failure', async () => {
|
||||
mockFetchWorkflowDraft.mockRejectedValue(new Error('Fetch failed'))
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
@ -248,12 +286,68 @@ describe('useDSL', () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'exportFailed',
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'exportFailed',
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter only secret environment variables', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
environment_variables: [
|
||||
{ id: '1', value_type: 'string', value: 'plain' },
|
||||
{ id: '2', value_type: 'secret', value: 'secret1' },
|
||||
{ id: '3', value_type: 'number', value: '123' },
|
||||
{ id: '4', value_type: 'secret', value: 'secret2' },
|
||||
],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockEmit).toHaveBeenCalledWith({
|
||||
type: 'DSL_EXPORT_CHECK',
|
||||
payload: {
|
||||
data: [
|
||||
{ id: '2', value_type: 'secret', value: 'secret1' },
|
||||
{ id: '4', value_type: 'secret', value: 'secret2' },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty environment variables', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
environment_variables: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
// Should directly call handleExportDSL since no secrets
|
||||
expect(mockEmit).not.toHaveBeenCalled()
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle undefined environment variables', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
environment_variables: undefined,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
// Should directly call handleExportDSL since no secrets
|
||||
expect(mockEmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,7 +5,7 @@ import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { ragPipelineNodesAction } from '@/app/components/goto-anything/actions/rag-pipeline-nodes'
|
||||
import { setRagPipelineNodesSearchFn } from '@/app/components/goto-anything/actions/rag-pipeline-nodes'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { useNodesInteractions } from '@/app/components/workflow/hooks/use-nodes-interactions'
|
||||
import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon'
|
||||
@ -153,16 +153,15 @@ export const useRagPipelineSearch = () => {
|
||||
return results
|
||||
}, [searchableNodes, calculateScore])
|
||||
|
||||
// Directly set the search function on the action object
|
||||
// Directly set the search function using the setter
|
||||
useEffect(() => {
|
||||
if (searchableNodes.length > 0) {
|
||||
// Set the search function directly on the action
|
||||
ragPipelineNodesAction.searchFn = searchRagPipelineNodes
|
||||
setRagPipelineNodesSearchFn(searchRagPipelineNodes)
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up when component unmounts
|
||||
ragPipelineNodesAction.searchFn = undefined
|
||||
setRagPipelineNodesSearchFn(() => [])
|
||||
}
|
||||
}, [searchableNodes, searchRagPipelineNodes])
|
||||
|
||||
|
||||
@ -168,7 +168,6 @@ describe('EditCustomCollectionModal', () => {
|
||||
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
|
||||
fireEvent.change(schemaInput, { target: { value: '{}' } })
|
||||
|
||||
// Wait for parseParamsSchema to be called and state to be updated
|
||||
await waitFor(() => {
|
||||
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}')
|
||||
})
|
||||
@ -185,13 +184,13 @@ describe('EditCustomCollectionModal', () => {
|
||||
provider: 'provider',
|
||||
schema: '{}',
|
||||
schema_type: 'openapi',
|
||||
credentials: {
|
||||
auth_type: 'none',
|
||||
},
|
||||
icon: {
|
||||
content: '🕵️',
|
||||
background: '#FEF7C3',
|
||||
},
|
||||
credentials: {
|
||||
auth_type: 'none',
|
||||
},
|
||||
labels: [],
|
||||
}))
|
||||
expect(toastNotifySpy).not.toHaveBeenCalled()
|
||||
|
||||
@ -11,12 +11,7 @@ vi.mock('@/app/components/base/modal', () => ({
|
||||
onClose,
|
||||
children,
|
||||
closable,
|
||||
}: {
|
||||
isShow: boolean
|
||||
onClose?: () => void
|
||||
children?: React.ReactNode
|
||||
closable?: boolean
|
||||
}) {
|
||||
}: any) {
|
||||
if (!isShow)
|
||||
return null
|
||||
|
||||
@ -44,10 +39,7 @@ vi.mock('./start-node-selection-panel', () => ({
|
||||
default: function MockStartNodeSelectionPanel({
|
||||
onSelectUserInput,
|
||||
onSelectTrigger,
|
||||
}: {
|
||||
onSelectUserInput?: () => void
|
||||
onSelectTrigger?: (type: BlockEnum, config?: Record<string, unknown>) => void
|
||||
}) {
|
||||
}: any) {
|
||||
return (
|
||||
<div data-testid="start-node-selection-panel">
|
||||
<button data-testid="select-user-input" onClick={onSelectUserInput}>
|
||||
@ -55,13 +47,13 @@ vi.mock('./start-node-selection-panel', () => ({
|
||||
</button>
|
||||
<button
|
||||
data-testid="select-trigger-schedule"
|
||||
onClick={() => onSelectTrigger?.(BlockEnum.TriggerSchedule)}
|
||||
onClick={() => onSelectTrigger(BlockEnum.TriggerSchedule)}
|
||||
>
|
||||
Select Trigger Schedule
|
||||
</button>
|
||||
<button
|
||||
data-testid="select-trigger-webhook"
|
||||
onClick={() => onSelectTrigger?.(BlockEnum.TriggerWebhook, { config: 'test' })}
|
||||
onClick={() => onSelectTrigger(BlockEnum.TriggerWebhook, { config: 'test' })}
|
||||
>
|
||||
Select Trigger Webhook
|
||||
</button>
|
||||
@ -557,7 +549,7 @@ describe('WorkflowOnboardingModal', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ isShow: true })
|
||||
|
||||
// Assert - ShortcutsName component renders keys in div elements with system-kbd class
|
||||
// Assert
|
||||
const escKey = screen.getByText('workflow.onboarding.escTip.key')
|
||||
// ShortcutsName renders a <div> with class system-kbd, not a <kbd> element
|
||||
expect(escKey.closest('.system-kbd')).toBeInTheDocument()
|
||||
|
||||
@ -10,8 +10,7 @@ export const X_OFFSET = 60
|
||||
export const NODE_WIDTH_X_OFFSET = NODE_WIDTH + X_OFFSET
|
||||
export const Y_OFFSET = 39
|
||||
export const VIBE_COMMAND_EVENT = 'workflow-vibe-command'
|
||||
export const VIBE_REGENERATE_EVENT = 'workflow-vibe-regenerate'
|
||||
export const VIBE_ACCEPT_EVENT = 'workflow-vibe-accept'
|
||||
export const VIBE_APPLY_EVENT = 'workflow-vibe-apply'
|
||||
export const START_INITIAL_POSITION = { x: 80, y: 282 }
|
||||
export const AUTO_LAYOUT_OFFSET = {
|
||||
x: -42,
|
||||
|
||||
@ -160,7 +160,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
}
|
||||
}
|
||||
else {
|
||||
usedVars = getNodeUsedVars(node).filter(v => v.length > 0)
|
||||
usedVars = getNodeUsedVars(node).filter(v => v && v.length > 0)
|
||||
}
|
||||
|
||||
if (node.type === CUSTOM_NODE) {
|
||||
@ -359,7 +359,7 @@ export const useChecklistBeforePublish = () => {
|
||||
}
|
||||
}
|
||||
else {
|
||||
usedVars = getNodeUsedVars(node).filter(v => v.length > 0)
|
||||
usedVars = getNodeUsedVars(node).filter(v => v && v.length > 0)
|
||||
}
|
||||
const checkData = getCheckData(node.data, datasets)
|
||||
const { errorMessage } = nodesExtraData![node.data.type as BlockEnum].checkValid(checkData, t, moreDataForCheckValid)
|
||||
|
||||
@ -5,7 +5,7 @@ import type { CommonNodeType } from '../types'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes'
|
||||
import { setWorkflowNodesSearchFn } from '@/app/components/goto-anything/actions/workflow-nodes'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import {
|
||||
@ -183,16 +183,15 @@ export const useWorkflowSearch = () => {
|
||||
return results
|
||||
}, [searchableNodes, calculateScore])
|
||||
|
||||
// Directly set the search function on the action object
|
||||
// Directly set the search function using the setter
|
||||
useEffect(() => {
|
||||
if (searchableNodes.length > 0) {
|
||||
// Set the search function directly on the action
|
||||
workflowNodesAction.searchFn = searchWorkflowNodes
|
||||
setWorkflowNodesSearchFn(searchWorkflowNodes)
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up when component unmounts
|
||||
workflowNodesAction.searchFn = undefined
|
||||
setWorkflowNodesSearchFn(() => [])
|
||||
}
|
||||
}, [searchableNodes, searchWorkflowNodes])
|
||||
|
||||
|
||||
@ -471,12 +471,14 @@ export const useNodesReadOnly = () => {
|
||||
const workflowRunningData = useStore(s => s.workflowRunningData)
|
||||
const historyWorkflowData = useStore(s => s.historyWorkflowData)
|
||||
const isRestoring = useStore(s => s.isRestoring)
|
||||
// const showVibePanel = useStore(s => s.showVibePanel)
|
||||
|
||||
const getNodesReadOnly = useCallback((): boolean => {
|
||||
const {
|
||||
workflowRunningData,
|
||||
historyWorkflowData,
|
||||
isRestoring,
|
||||
// showVibePanel,
|
||||
} = workflowStore.getState()
|
||||
|
||||
return !!(
|
||||
|
||||
@ -68,6 +68,7 @@ import {
|
||||
useWorkflow,
|
||||
useWorkflowReadOnly,
|
||||
useWorkflowRefreshDraft,
|
||||
useWorkflowVibe,
|
||||
} from './hooks'
|
||||
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
|
||||
import { useWorkflowSearch } from './hooks/use-workflow-search'
|
||||
@ -329,6 +330,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
useShortcuts()
|
||||
// Initialize workflow node search functionality
|
||||
useWorkflowSearch()
|
||||
useWorkflowVibe()
|
||||
|
||||
// Set up scroll to node event listener using the utility function
|
||||
useEffect(() => {
|
||||
|
||||
@ -33,9 +33,9 @@ const FileUploadSetting: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
allowed_file_upload_methods,
|
||||
allowed_file_upload_methods = [],
|
||||
max_length,
|
||||
allowed_file_types,
|
||||
allowed_file_types = [],
|
||||
allowed_file_extensions,
|
||||
} = payload
|
||||
const { data: fileUploadConfigResponse } = useFileUploadConfig()
|
||||
|
||||
@ -1404,9 +1404,9 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
|
||||
payload.url,
|
||||
payload.headers,
|
||||
payload.params,
|
||||
typeof payload.body.data === 'string'
|
||||
typeof payload.body?.data === 'string'
|
||||
? payload.body.data
|
||||
: payload.body.data.map(d => d.value).join(''),
|
||||
: (payload.body?.data?.map(d => d.value).join('') ?? ''),
|
||||
])
|
||||
break
|
||||
}
|
||||
|
||||
@ -5,6 +5,9 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
const UNIQUE_ID_PREFIX = 'key-value-'
|
||||
const strToKeyValueList = (value: string) => {
|
||||
if (typeof value !== 'string' || !value)
|
||||
return []
|
||||
|
||||
return value.split('\n').map((item) => {
|
||||
const [key, ...others] = item.split(':')
|
||||
return {
|
||||
@ -16,7 +19,7 @@ const strToKeyValueList = (value: string) => {
|
||||
}
|
||||
|
||||
const useKeyValueList = (value: string, onChange: (value: string) => void, noFilter?: boolean) => {
|
||||
const [list, doSetList] = useState<KeyValue[]>(() => value ? strToKeyValueList(value) : [])
|
||||
const [list, doSetList] = useState<KeyValue[]>(() => typeof value === 'string' && value ? strToKeyValueList(value) : [])
|
||||
const setList = (l: KeyValue[]) => {
|
||||
doSetList(l.map((item) => {
|
||||
return {
|
||||
|
||||
@ -49,7 +49,7 @@ const ConditionValue = ({
|
||||
if (value === true || value === false)
|
||||
return value ? 'True' : 'False'
|
||||
|
||||
return value.replace(/\{\{#([^#]*)#\}\}/g, (a, b) => {
|
||||
return String(value).replace(/\{\{#([^#]*)#\}\}/g, (a, b) => {
|
||||
const arr: string[] = b.split('.')
|
||||
if (isSystemVar(arr))
|
||||
return `{{${b}}}`
|
||||
|
||||
@ -18,7 +18,6 @@ import {
|
||||
Group,
|
||||
} from '@/app/components/workflow/nodes/_base/components/layout'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import Split from '../_base/components/split'
|
||||
import ChunkStructure from './components/chunk-structure'
|
||||
import EmbeddingModel from './components/embedding-model'
|
||||
@ -173,7 +172,7 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
|
||||
{
|
||||
data.indexing_technique === IndexMethodEnum.QUALIFIED
|
||||
&& [ChunkStructureEnum.general, ChunkStructureEnum.parent_child].includes(data.chunk_structure)
|
||||
&& IS_CE_EDITION && (
|
||||
&& (
|
||||
<>
|
||||
<SummaryIndexSetting
|
||||
summaryIndexSetting={data.summary_index_setting}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { ToolNodeType, ToolVarInputs } from './types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { capitalize } from 'es-toolkit/string'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -26,12 +25,6 @@ import {
|
||||
} from '@/service/use-tools'
|
||||
import { canFindTool } from '@/utils'
|
||||
import { useWorkflowStore } from '../../store'
|
||||
import { normalizeJsonSchemaType } from './output-schema-utils'
|
||||
|
||||
const formatDisplayType = (output: Record<string, unknown>): string => {
|
||||
const normalizedType = normalizeJsonSchemaType(output) || 'Unknown'
|
||||
return capitalize(normalizedType)
|
||||
}
|
||||
|
||||
const useConfig = (id: string, payload: ToolNodeType) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
@ -254,13 +247,20 @@ const useConfig = (id: string, payload: ToolNodeType) => {
|
||||
})
|
||||
}
|
||||
else {
|
||||
const normalizedType = normalizeJsonSchemaType(output)
|
||||
res.push({
|
||||
name: outputKey,
|
||||
type:
|
||||
normalizedType === 'array'
|
||||
? `Array[${output.items ? formatDisplayType(output.items) : 'Unknown'}]`
|
||||
: formatDisplayType(output),
|
||||
output.type === 'array'
|
||||
? `Array[${output.items?.type
|
||||
? output.items.type.slice(0, 1).toLocaleUpperCase()
|
||||
+ output.items.type.slice(1)
|
||||
: 'Unknown'
|
||||
}]`
|
||||
: `${output.type
|
||||
? output.type.slice(0, 1).toLocaleUpperCase()
|
||||
+ output.type.slice(1)
|
||||
: 'Unknown'
|
||||
}`,
|
||||
description: output.description,
|
||||
})
|
||||
}
|
||||
|
||||
@ -127,23 +127,30 @@ const NodeGroupItem = ({
|
||||
!!item.variables.length && (
|
||||
<div className="space-y-0.5">
|
||||
{
|
||||
item.variables.map((variable = [], index) => {
|
||||
const isSystem = isSystemVar(variable)
|
||||
item.variables
|
||||
.map((variable = [], index) => {
|
||||
// Ensure variable is an array
|
||||
const safeVariable = Array.isArray(variable) ? variable : []
|
||||
if (!safeVariable.length)
|
||||
return null
|
||||
|
||||
const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === variable[0])
|
||||
const varName = isSystem ? `sys.${variable[variable.length - 1]}` : variable.slice(1).join('.')
|
||||
const isException = isExceptionVariable(varName, node?.data.type)
|
||||
const isSystem = isSystemVar(safeVariable)
|
||||
|
||||
return (
|
||||
<VariableLabelInNode
|
||||
key={index}
|
||||
variables={variable}
|
||||
nodeType={node?.data.type}
|
||||
nodeTitle={node?.data.title}
|
||||
isExceptionVariable={isException}
|
||||
/>
|
||||
)
|
||||
})
|
||||
const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === safeVariable[0])
|
||||
const varName = isSystem ? `sys.${safeVariable[safeVariable.length - 1]}` : safeVariable.slice(1).join('.')
|
||||
const isException = isExceptionVariable(varName, node?.data.type)
|
||||
|
||||
return (
|
||||
<VariableLabelInNode
|
||||
key={index}
|
||||
variables={safeVariable}
|
||||
nodeType={node?.data.type}
|
||||
nodeTitle={node?.data.title}
|
||||
isExceptionVariable={isException}
|
||||
/>
|
||||
)
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -8,6 +8,7 @@ import { cn } from '@/utils/classnames'
|
||||
import { Panel as NodePanel } from '../nodes'
|
||||
import { useStore } from '../store'
|
||||
import EnvPanel from './env-panel'
|
||||
import VibePanel from './vibe-panel'
|
||||
|
||||
const VersionHistoryPanel = dynamic(() => import('@/app/components/workflow/panel/version-history-panel'), {
|
||||
ssr: false,
|
||||
@ -85,6 +86,7 @@ const Panel: FC<PanelProps> = ({
|
||||
const showEnvPanel = useStore(s => s.showEnvPanel)
|
||||
const isRestoring = useStore(s => s.isRestoring)
|
||||
const showWorkflowVersionHistoryPanel = useStore(s => s.showWorkflowVersionHistoryPanel)
|
||||
const showVibePanel = useStore(s => s.showVibePanel)
|
||||
|
||||
// widths used for adaptive layout
|
||||
const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth)
|
||||
@ -124,33 +126,36 @@ const Panel: FC<PanelProps> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rightPanelRef}
|
||||
tabIndex={-1}
|
||||
className={cn('absolute bottom-1 right-0 top-14 z-10 flex outline-none')}
|
||||
key={`${isRestoring}`}
|
||||
>
|
||||
{components?.left}
|
||||
{!!selectedNode && <NodePanel {...selectedNode} />}
|
||||
<>
|
||||
<div
|
||||
className="relative"
|
||||
ref={otherPanelRef}
|
||||
ref={rightPanelRef}
|
||||
tabIndex={-1}
|
||||
className={cn('absolute bottom-1 right-0 top-14 z-10 flex outline-none')}
|
||||
key={`${isRestoring}`}
|
||||
>
|
||||
{
|
||||
components?.right
|
||||
}
|
||||
{
|
||||
showWorkflowVersionHistoryPanel && (
|
||||
<VersionHistoryPanel {...versionHistoryPanelProps} />
|
||||
)
|
||||
}
|
||||
{
|
||||
showEnvPanel && (
|
||||
<EnvPanel />
|
||||
)
|
||||
}
|
||||
{components?.left}
|
||||
{!!selectedNode && <NodePanel {...selectedNode} />}
|
||||
<div
|
||||
className="relative"
|
||||
ref={otherPanelRef}
|
||||
>
|
||||
{
|
||||
components?.right
|
||||
}
|
||||
{
|
||||
showWorkflowVersionHistoryPanel && (
|
||||
<VersionHistoryPanel {...versionHistoryPanelProps} />
|
||||
)
|
||||
}
|
||||
{
|
||||
showEnvPanel && (
|
||||
<EnvPanel />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showVibePanel && <VibePanel />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@ import type { LayoutSliceShape } from './layout-slice'
|
||||
import type { NodeSliceShape } from './node-slice'
|
||||
import type { PanelSliceShape } from './panel-slice'
|
||||
import type { ToolSliceShape } from './tool-slice'
|
||||
import type { VibeWorkflowSliceShape } from './vibe-workflow-slice'
|
||||
import type { VersionSliceShape } from './version-slice'
|
||||
import type { VibeWorkflowSliceShape } from './vibe-workflow-slice'
|
||||
import type { WorkflowDraftSliceShape } from './workflow-draft-slice'
|
||||
import type { WorkflowSliceShape } from './workflow-slice'
|
||||
import type { RagPipelineSliceShape } from '@/app/components/rag-pipeline/store'
|
||||
@ -34,8 +34,8 @@ import { createNodeSlice } from './node-slice'
|
||||
|
||||
import { createPanelSlice } from './panel-slice'
|
||||
import { createToolSlice } from './tool-slice'
|
||||
import { createVibeWorkflowSlice } from './vibe-workflow-slice'
|
||||
import { createVersionSlice } from './version-slice'
|
||||
import { createVibeWorkflowSlice } from './vibe-workflow-slice'
|
||||
import { createWorkflowDraftSlice } from './workflow-draft-slice'
|
||||
import { createWorkflowSlice } from './workflow-slice'
|
||||
|
||||
@ -57,8 +57,8 @@ export type Shape
|
||||
& WorkflowSliceShape
|
||||
& InspectVarsSliceShape
|
||||
& LayoutSliceShape
|
||||
& VibeWorkflowSliceShape
|
||||
& SliceFromInjection
|
||||
& VibeWorkflowSliceShape
|
||||
|
||||
export type InjectWorkflowStoreSliceFn = StateCreator<SliceFromInjection>
|
||||
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { BackendEdgeSpec, BackendNodeSpec } from '@/service/debug'
|
||||
|
||||
export type VibeIntent = 'generate' | 'off_topic' | 'error' | ''
|
||||
|
||||
export type PanelSliceShape = {
|
||||
panelWidth: number
|
||||
@ -24,6 +27,26 @@ export type PanelSliceShape = {
|
||||
setShowVariableInspectPanel: (showVariableInspectPanel: boolean) => void
|
||||
initShowLastRunTab: boolean
|
||||
setInitShowLastRunTab: (initShowLastRunTab: boolean) => void
|
||||
showVibePanel: boolean
|
||||
setShowVibePanel: (showVibePanel: boolean) => void
|
||||
vibePanelMermaidCode: string
|
||||
setVibePanelMermaidCode: (vibePanelMermaidCode: string) => void
|
||||
vibePanelBackendNodes?: BackendNodeSpec[]
|
||||
setVibePanelBackendNodes: (nodes?: BackendNodeSpec[]) => void
|
||||
vibePanelBackendEdges?: BackendEdgeSpec[]
|
||||
setVibePanelBackendEdges: (edges?: BackendEdgeSpec[]) => void
|
||||
isVibeGenerating: boolean
|
||||
setIsVibeGenerating: (isVibeGenerating: boolean) => void
|
||||
vibePanelInstruction: string
|
||||
setVibePanelInstruction: (vibePanelInstruction: string) => void
|
||||
vibePanelIntent: VibeIntent
|
||||
setVibePanelIntent: (vibePanelIntent: VibeIntent) => void
|
||||
vibePanelMessage: string
|
||||
setVibePanelMessage: (vibePanelMessage: string) => void
|
||||
vibePanelSuggestions: string[]
|
||||
setVibePanelSuggestions: (vibePanelSuggestions: string[]) => void
|
||||
vibePanelLastWarnings: string[]
|
||||
setVibePanelLastWarnings: (vibePanelLastWarnings: string[]) => void
|
||||
}
|
||||
|
||||
export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
|
||||
@ -44,4 +67,24 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
|
||||
setShowVariableInspectPanel: showVariableInspectPanel => set(() => ({ showVariableInspectPanel })),
|
||||
initShowLastRunTab: false,
|
||||
setInitShowLastRunTab: initShowLastRunTab => set(() => ({ initShowLastRunTab })),
|
||||
showVibePanel: false,
|
||||
setShowVibePanel: showVibePanel => set(() => ({ showVibePanel })),
|
||||
vibePanelMermaidCode: '',
|
||||
setVibePanelMermaidCode: vibePanelMermaidCode => set(() => ({ vibePanelMermaidCode })),
|
||||
vibePanelBackendNodes: undefined,
|
||||
setVibePanelBackendNodes: vibePanelBackendNodes => set(() => ({ vibePanelBackendNodes })),
|
||||
vibePanelBackendEdges: undefined,
|
||||
setVibePanelBackendEdges: vibePanelBackendEdges => set(() => ({ vibePanelBackendEdges })),
|
||||
isVibeGenerating: false,
|
||||
setIsVibeGenerating: isVibeGenerating => set(() => ({ isVibeGenerating })),
|
||||
vibePanelInstruction: '',
|
||||
setVibePanelInstruction: vibePanelInstruction => set(() => ({ vibePanelInstruction })),
|
||||
vibePanelIntent: '',
|
||||
setVibePanelIntent: vibePanelIntent => set(() => ({ vibePanelIntent })),
|
||||
vibePanelMessage: '',
|
||||
setVibePanelMessage: vibePanelMessage => set(() => ({ vibePanelMessage })),
|
||||
vibePanelSuggestions: [],
|
||||
setVibePanelSuggestions: vibePanelSuggestions => set(() => ({ vibePanelSuggestions })),
|
||||
vibePanelLastWarnings: [],
|
||||
setVibePanelLastWarnings: vibePanelLastWarnings => set(() => ({ vibePanelLastWarnings })),
|
||||
})
|
||||
|
||||
@ -111,8 +111,8 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
const currentNode = nodes[i] as Node<IterationNodeType | LoopNodeType>
|
||||
|
||||
if (currentNode.data.type === BlockEnum.Iteration) {
|
||||
if (currentNode.data.start_node_id) {
|
||||
if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_ITERATION_START_NODE)
|
||||
if (currentNode.data.start_node_id && nodesMap[currentNode.data.start_node_id]) {
|
||||
if (nodesMap[currentNode.data.start_node_id].type !== CUSTOM_ITERATION_START_NODE)
|
||||
iterationNodesWithStartNode.push(currentNode)
|
||||
}
|
||||
else {
|
||||
@ -121,8 +121,8 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
}
|
||||
|
||||
if (currentNode.data.type === BlockEnum.Loop) {
|
||||
if (currentNode.data.start_node_id) {
|
||||
if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_LOOP_START_NODE)
|
||||
if (currentNode.data.start_node_id && nodesMap[currentNode.data.start_node_id]) {
|
||||
if (nodesMap[currentNode.data.start_node_id].type !== CUSTOM_LOOP_START_NODE)
|
||||
loopNodesWithStartNode.push(currentNode)
|
||||
}
|
||||
else {
|
||||
|
||||
@ -65,7 +65,7 @@ const IfElseNode: FC<NodeProps<IfElseNodeType>> = (props) => {
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{caseItem.conditions.map((condition, i) => (
|
||||
<div key={condition.id} className="relative">
|
||||
<div key={condition.id || i} className="relative">
|
||||
{
|
||||
checkIsConditionSet(condition)
|
||||
? (
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import type {
|
||||
EdgeChange,
|
||||
FitViewOptions,
|
||||
NodeChange,
|
||||
Viewport,
|
||||
} from 'reactflow'
|
||||
@ -59,8 +60,10 @@ const edgeTypes = {
|
||||
type WorkflowPreviewProps = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
viewport: Viewport
|
||||
viewport?: Viewport
|
||||
className?: string
|
||||
fitView?: boolean
|
||||
fitViewOptions?: FitViewOptions
|
||||
miniMapToRight?: boolean
|
||||
}
|
||||
const WorkflowPreview = ({
|
||||
@ -68,6 +71,8 @@ const WorkflowPreview = ({
|
||||
edges,
|
||||
viewport,
|
||||
className,
|
||||
fitView,
|
||||
fitViewOptions,
|
||||
miniMapToRight,
|
||||
}: WorkflowPreviewProps) => {
|
||||
const [nodesData, setNodesData] = useState(() => initialNodes(nodes, edges))
|
||||
@ -125,6 +130,8 @@ const WorkflowPreview = ({
|
||||
selectionKeyCode={null}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
minZoom={0.25}
|
||||
fitView={fitView}
|
||||
fitViewOptions={fitViewOptions}
|
||||
>
|
||||
<Background
|
||||
gap={[14, 14]}
|
||||
|
||||
87
web/contract/console/goto-anything.ts
Normal file
87
web/contract/console/goto-anything.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { DataSetListResponse } from '@/models/datasets'
|
||||
import type { BackendEdgeSpec, BackendNodeSpec, FlowchartGenRes } from '@/service/debug'
|
||||
import { type } from '@orpc/contract'
|
||||
import { base } from '../base'
|
||||
|
||||
// Search APIs
|
||||
export const searchAppsContract = base
|
||||
.route({
|
||||
path: '/apps',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{
|
||||
query?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
name?: string
|
||||
}
|
||||
}>())
|
||||
.output(type<AppListResponse>())
|
||||
|
||||
export const searchDatasetsContract = base
|
||||
.route({
|
||||
path: '/datasets',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{
|
||||
query?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
keyword?: string
|
||||
}
|
||||
}>())
|
||||
.output(type<DataSetListResponse>())
|
||||
|
||||
// Vibe Workflow API
|
||||
export type GenerateFlowchartInput = {
|
||||
instruction: string
|
||||
model_config: {
|
||||
provider: string
|
||||
name: string
|
||||
mode: string
|
||||
completion_params: Record<string, unknown>
|
||||
} | null
|
||||
available_nodes: Array<{
|
||||
type: string
|
||||
title?: string
|
||||
description?: string
|
||||
}>
|
||||
existing_nodes?: Array<{
|
||||
id: string
|
||||
type: string
|
||||
title?: string
|
||||
}>
|
||||
existing_edges?: BackendEdgeSpec[]
|
||||
available_tools: Array<{
|
||||
provider_id: string
|
||||
provider_name?: string
|
||||
provider_type?: string
|
||||
tool_name: string
|
||||
tool_label?: string
|
||||
tool_key?: string
|
||||
tool_description?: string
|
||||
}>
|
||||
selected_node_ids?: string[]
|
||||
previous_workflow?: {
|
||||
nodes: BackendNodeSpec[]
|
||||
edges: BackendEdgeSpec[]
|
||||
warnings?: string[]
|
||||
}
|
||||
regenerate_mode?: boolean
|
||||
language: string
|
||||
available_models?: Array<{
|
||||
provider: string
|
||||
model: string
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateFlowchartContract = base
|
||||
.route({
|
||||
path: '/flowchart-generate',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(type<{
|
||||
body: GenerateFlowchartInput
|
||||
}>())
|
||||
.output(type<FlowchartGenRes>())
|
||||
@ -1,5 +1,6 @@
|
||||
import type { InferContractRouterInputs } from '@orpc/contract'
|
||||
import { bindPartnerStackContract, invoicesContract } from './console/billing'
|
||||
import { generateFlowchartContract, searchAppsContract, searchDatasetsContract } from './console/goto-anything'
|
||||
import { systemFeaturesContract } from './console/system'
|
||||
import {
|
||||
triggerOAuthConfigContract,
|
||||
@ -58,6 +59,11 @@ export const consoleRouterContract = {
|
||||
oauthDelete: triggerOAuthDeleteContract,
|
||||
oauthInitiate: triggerOAuthInitiateContract,
|
||||
},
|
||||
gotoAnything: {
|
||||
searchApps: searchAppsContract,
|
||||
searchDatasets: searchDatasetsContract,
|
||||
generateFlowchart: generateFlowchartContract,
|
||||
},
|
||||
}
|
||||
|
||||
export type ConsoleInputs = InferContractRouterInputs<typeof consoleRouterContract>
|
||||
|
||||
@ -269,6 +269,9 @@
|
||||
}
|
||||
},
|
||||
"app/components/app/app-publisher/index.tsx": {
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
},
|
||||
@ -3204,6 +3207,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/share/text-generation/result/header.tsx": {
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/share/text-generation/result/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 3
|
||||
@ -3643,6 +3651,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/nodes/_base/components/before-run-form/panel-wrap.tsx": {
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
@ -5447,4 +5460,4 @@
|
||||
"count": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,6 +75,9 @@
|
||||
"gotoAnything.actions.themeLightDesc": "Use light appearance",
|
||||
"gotoAnything.actions.themeSystem": "System Theme",
|
||||
"gotoAnything.actions.themeSystemDesc": "Follow your OS appearance",
|
||||
"gotoAnything.actions.vibeDesc": "Generate workflow from natural language",
|
||||
"gotoAnything.actions.vibeHint": "Try: {{prompt}}",
|
||||
"gotoAnything.actions.vibeTitle": "Vibe",
|
||||
"gotoAnything.actions.zenDesc": "Toggle canvas focus mode",
|
||||
"gotoAnything.actions.zenTitle": "Zen Mode",
|
||||
"gotoAnything.clearToSearchAll": "Clear @ to search all",
|
||||
|
||||
@ -1150,5 +1150,26 @@
|
||||
"versionHistory.nameThisVersion": "Name this version",
|
||||
"versionHistory.releaseNotesPlaceholder": "Describe what changed",
|
||||
"versionHistory.restorationTip": "After version restoration, the current draft will be overwritten.",
|
||||
"versionHistory.title": "Versions"
|
||||
"versionHistory.title": "Versions",
|
||||
"vibe.apply": "Apply",
|
||||
"vibe.generateError": "Failed to generate workflow. Please try again.",
|
||||
"vibe.generatingFlowchart": "Generating flowchart preview...",
|
||||
"vibe.invalidFlowchart": "The generated flowchart could not be parsed.",
|
||||
"vibe.missingFlowchart": "No flowchart was generated.",
|
||||
"vibe.missingInstruction": "Describe the workflow you want to build.",
|
||||
"vibe.modelUnavailable": "No model available for flowchart generation.",
|
||||
"vibe.noFlowchart": "No flowchart provided",
|
||||
"vibe.noFlowchartYet": "No flowchart preview available",
|
||||
"vibe.nodeTypeUnavailable": "Node type \"{{type}}\" is not available in this workflow.",
|
||||
"vibe.nodesUnavailable": "Workflow nodes are not available yet.",
|
||||
"vibe.offTopicDefault": "I'm the Dify workflow design assistant. I can help you create AI automation workflows, but I can't answer general questions. Would you like to create a workflow instead?",
|
||||
"vibe.offTopicTitle": "Off-Topic Request",
|
||||
"vibe.panelTitle": "Workflow Preview",
|
||||
"vibe.readOnly": "This workflow is read-only.",
|
||||
"vibe.regenerate": "Regenerate",
|
||||
"vibe.regenerateReminder": "Please verify your input and re-generate.",
|
||||
"vibe.toolUnavailable": "Tool \"{{tool}}\" is not available in this workspace.",
|
||||
"vibe.trySuggestion": "Try one of these suggestions:",
|
||||
"vibe.unknownNodeId": "Node \"{{id}}\" is used before it is defined.",
|
||||
"vibe.unsupportedEdgeLabel": "Unsupported edge label \"{{label}}\". Only true/false are allowed for if/else."
|
||||
}
|
||||
|
||||
@ -1150,5 +1150,6 @@
|
||||
"versionHistory.nameThisVersion": "命名",
|
||||
"versionHistory.releaseNotesPlaceholder": "请描述变更",
|
||||
"versionHistory.restorationTip": "版本回滚后,当前草稿将被覆盖。",
|
||||
"versionHistory.title": "版本"
|
||||
"versionHistory.title": "版本",
|
||||
"vibe.regenerateReminder": "请检查输入并重新生成。"
|
||||
}
|
||||
|
||||
@ -236,7 +236,8 @@
|
||||
"vite": "7.3.1",
|
||||
"vite-tsconfig-paths": "6.0.4",
|
||||
"vitest": "4.0.17",
|
||||
"vitest-canvas-mock": "1.1.3"
|
||||
"vitest-canvas-mock": "1.1.3",
|
||||
"vitest-tiny-reporter": "1.3.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
|
||||
15
web/pnpm-lock.yaml
generated
15
web/pnpm-lock.yaml
generated
@ -585,6 +585,9 @@ importers:
|
||||
vitest-canvas-mock:
|
||||
specifier: 1.1.3
|
||||
version: 1.1.3(vitest@4.0.17)
|
||||
vitest-tiny-reporter:
|
||||
specifier: 1.3.1
|
||||
version: 1.3.1(@vitest/runner@4.0.17)(vitest@4.0.17)
|
||||
|
||||
packages:
|
||||
|
||||
@ -7291,6 +7294,12 @@ packages:
|
||||
peerDependencies:
|
||||
vitest: ^3.0.0 || ^4.0.0
|
||||
|
||||
vitest-tiny-reporter@1.3.1:
|
||||
resolution: {integrity: sha512-9WfLruQBbxm4EqMIS0jDZmQjvMgsWgHUso9mHQWgjA6hM3tEVhjdG8wYo7ePFh1XbwEFzEo3XUQqkGoKZ/Td2Q==}
|
||||
peerDependencies:
|
||||
'@vitest/runner': ^2.0.0 || ^3.0.2 || ^4.0.0
|
||||
vitest: ^2.0.0 || ^3.0.2 || ^4.0.0
|
||||
|
||||
vitest@4.0.17:
|
||||
resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
@ -15342,6 +15351,12 @@ snapshots:
|
||||
moo-color: 1.0.3
|
||||
vitest: 4.0.17(@types/node@18.15.0)(@vitest/browser-playwright@4.0.17)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
vitest-tiny-reporter@1.3.1(@vitest/runner@4.0.17)(vitest@4.0.17):
|
||||
dependencies:
|
||||
'@vitest/runner': 4.0.17
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.17(@types/node@18.15.0)(@vitest/browser-playwright@4.0.17)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
vitest@4.0.17(@types/node@18.15.0)(@vitest/browser-playwright@4.0.17)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.17
|
||||
|
||||
@ -19,6 +19,48 @@ export type GenRes = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ToolRecommendation = {
|
||||
requested_capability: string
|
||||
unconfigured_tools: Array<{
|
||||
provider_id: string
|
||||
tool_name: string
|
||||
description: string
|
||||
}>
|
||||
configured_alternatives: Array<{
|
||||
provider_id: string
|
||||
tool_name: string
|
||||
description: string
|
||||
}>
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
export type BackendNodeSpec = {
|
||||
id: string
|
||||
type: string
|
||||
title?: string
|
||||
config?: Record<string, any>
|
||||
position?: { x: number, y: number }
|
||||
}
|
||||
|
||||
export type BackendEdgeSpec = {
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}
|
||||
|
||||
export type FlowchartGenRes = {
|
||||
intent?: 'generate' | 'off_topic' | 'error'
|
||||
flowchart: string
|
||||
nodes?: BackendNodeSpec[]
|
||||
edges?: BackendEdgeSpec[]
|
||||
message?: string
|
||||
warnings?: string[]
|
||||
suggestions?: string[]
|
||||
tool_recommendations?: ToolRecommendation[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type CodeGenRes = {
|
||||
code: string
|
||||
language: string[]
|
||||
@ -75,6 +117,12 @@ export const generateRule = (body: Record<string, any>) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const generateFlowchart = (body: Record<string, any>) => {
|
||||
return post<FlowchartGenRes>('/flowchart-generate', {
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export const fetchModelParams = (providerName: string, modelId: string) => {
|
||||
return get(`workspaces/current/model-providers/${providerName}/models/parameter-rules`, {
|
||||
params: {
|
||||
|
||||
50
web/service/use-goto-anything.ts
Normal file
50
web/service/use-goto-anything.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import type { GenerateFlowchartInput } from '@/contract/console/goto-anything'
|
||||
import { consoleClient, consoleQuery, marketplaceClient, marketplaceQuery } from '@/service/client'
|
||||
|
||||
// Search APIs
|
||||
export const searchAppsQueryKey = consoleQuery.gotoAnything.searchApps.queryKey
|
||||
|
||||
export const searchApps = async (name?: string) => {
|
||||
return consoleClient.gotoAnything.searchApps({
|
||||
query: {
|
||||
page: 1,
|
||||
name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const searchDatasetsQueryKey = consoleQuery.gotoAnything.searchDatasets.queryKey
|
||||
|
||||
export const searchDatasets = async (keyword?: string) => {
|
||||
return consoleClient.gotoAnything.searchDatasets({
|
||||
query: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const searchPluginsQueryKey = marketplaceQuery.searchAdvanced.queryKey
|
||||
|
||||
export const searchPlugins = async (query?: string) => {
|
||||
return marketplaceClient.searchAdvanced({
|
||||
params: {
|
||||
kind: 'plugins',
|
||||
},
|
||||
body: {
|
||||
query: query || '',
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Vibe Workflow API
|
||||
export const generateFlowchartMutationKey = consoleQuery.gotoAnything.generateFlowchart.mutationKey
|
||||
|
||||
export const generateFlowchart = async (input: GenerateFlowchartInput) => {
|
||||
return consoleClient.gotoAnything.generateFlowchart({
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user