mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
refactor(goto-anything): simplify command context and execution (#43136)
This commit is contained in:
62 files changed
+745
-2288
No files matched your search
@@ -1940,11 +1940,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/goto-anything/actions/commands/command-bus.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/data-source-page-new/hooks/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 2
|
||||
|
||||
@@ -20,4 +20,6 @@ Search actions adapt application, knowledge, plugin, workflow, and RAG owners in
|
||||
- Ordinary text searches localized command names, existing aliases, submenu choices, and resources. Commands stay usable while remote searches load or fail.
|
||||
- Grid navigation follows the displayed rows; ordinary results use a listbox. Pointer and keyboard use the same highlighted state, while DOM focus stays in the input.
|
||||
- Selecting a submenu or scope keeps the palette open and the input editable. Clearing the input returns to the home view. Escape dismisses the dialog.
|
||||
- The registry publishes command registration changes so the first open can show commands without requiring a keystroke. Command handlers retain execution and availability ownership.
|
||||
- A fixed command catalog is available on the first render. Its lookup registry only indexes definitions; it has no registration effects, subscriptions, or separate execution bus.
|
||||
- React owns the current language, workspace availability, and Studio app context. Search and execution receive this context explicitly; command definitions never retain user or request state. Execution rechecks availability.
|
||||
- Browser effects (navigation, external windows, theme/language changes, generator opening) run only when a result is activated. The palette remains lazily loaded on the client; importing/searching the catalog is safe during server rendering.
|
||||
@@ -6,7 +6,9 @@ import { detectPlatform } from '@tanstack/react-hotkeys'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { goCommand } from '../actions/commands/go'
|
||||
import { gotoAnythingDialogHandle } from '../dialog-handle'
|
||||
import { GotoAnything } from '../index'
|
||||
|
||||
@@ -203,10 +205,11 @@ const actionsMock = {
|
||||
|
||||
const createActionsMock = vi.fn(
|
||||
(
|
||||
_slash: ActionItem,
|
||||
_isWorkflowPage?: boolean,
|
||||
_isRagPipelinePage?: boolean,
|
||||
_availability?: { agents: boolean; skills: boolean },
|
||||
) => actionsMock,
|
||||
): Record<string, ActionItem> => actionsMock,
|
||||
)
|
||||
const matchActionMock = vi.fn<
|
||||
(query: string, actions: Record<string, ActionItem>) => ActionItem | undefined
|
||||
@@ -218,10 +221,6 @@ vi.mock('../actions', () => ({
|
||||
matchActionMock(query, actions),
|
||||
}))
|
||||
|
||||
vi.mock('../actions/commands/slash-provider', () => ({
|
||||
SlashCommandProvider: () => null,
|
||||
}))
|
||||
|
||||
type MockSlashCommand = {
|
||||
mode: SlashCommand['mode']
|
||||
execute?: () => void
|
||||
@@ -230,18 +229,18 @@ type MockSlashCommand = {
|
||||
|
||||
let mockFindCommand: MockSlashCommand = null
|
||||
let mockAvailableCommands: SlashCommand[] = []
|
||||
vi.mock('../actions/commands/registry', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../actions/commands/registry')>()
|
||||
const registry = new actual.SlashCommandRegistry()
|
||||
registry.getSnapshot = () => mockAvailableCommands
|
||||
registry.getAllCommands = () => mockAvailableCommands
|
||||
vi.mock('../actions/commands/catalog', async () => {
|
||||
const { SlashCommandRegistry } = await import('../actions/commands/registry')
|
||||
const registry = new SlashCommandRegistry([])
|
||||
registry.getAvailableCommands = (context) =>
|
||||
mockAvailableCommands.filter((command) => command.isAvailable?.(context) ?? true)
|
||||
registry.findCommand = (name) =>
|
||||
mockFindCommand
|
||||
? { name, description: '', search: () => [], ...mockFindCommand }
|
||||
? { name, description: '', search: () => [], execute: vi.fn(), ...mockFindCommand }
|
||||
: mockAvailableCommands.find(
|
||||
(command) => command.name === name || command.aliases?.includes(name),
|
||||
)
|
||||
return { ...actual, slashCommandRegistry: registry }
|
||||
return { slashCommandRegistry: registry }
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
|
||||
@@ -289,6 +288,7 @@ describe('GotoAnything', () => {
|
||||
enabledRemoteSearches = []
|
||||
previousRemoteData = {}
|
||||
matchActionMock.mockReset()
|
||||
createActionsMock.mockImplementation(() => actionsMock)
|
||||
visibilityState.agentEnabled = true
|
||||
visibilityState.datasetOperator = false
|
||||
visibilityState.enableSkill = true
|
||||
@@ -488,6 +488,7 @@ describe('GotoAnything', () => {
|
||||
{
|
||||
name: 'theme',
|
||||
description: 'Theme',
|
||||
execute: vi.fn(),
|
||||
search: () => {
|
||||
throw new Error('Unavailable')
|
||||
},
|
||||
@@ -507,8 +508,20 @@ describe('GotoAnything', () => {
|
||||
|
||||
it('shows available actions before search scopes without requiring a prefix', async () => {
|
||||
mockAvailableCommands = [
|
||||
{ name: 'models', description: 'Models', mode: 'direct', search: () => [] },
|
||||
{ name: 'refine', description: 'Refine', isAvailable: () => false, search: () => [] },
|
||||
{
|
||||
name: 'models',
|
||||
description: 'Models',
|
||||
mode: 'direct',
|
||||
execute: vi.fn(),
|
||||
search: () => [],
|
||||
},
|
||||
{
|
||||
name: 'refine',
|
||||
description: 'Refine',
|
||||
isAvailable: () => false,
|
||||
execute: vi.fn(),
|
||||
search: () => [],
|
||||
},
|
||||
]
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerSearchShortcut()
|
||||
@@ -523,7 +536,9 @@ describe('GotoAnything', () => {
|
||||
|
||||
it('opens a command submenu and returns home by clearing the input', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAvailableCommands = [{ name: 'theme', description: 'Theme', search: () => [] }]
|
||||
mockAvailableCommands = [
|
||||
{ name: 'theme', description: 'Theme', execute: vi.fn(), search: () => [] },
|
||||
]
|
||||
matchActionMock.mockImplementation((query) =>
|
||||
query.startsWith('/theme ') ? actionsMock.slash : undefined,
|
||||
)
|
||||
@@ -575,7 +590,13 @@ describe('GotoAnything', () => {
|
||||
data: { command: 'theme.set', args: { value: 'dark' } },
|
||||
}
|
||||
mockAvailableCommands = [
|
||||
{ name: 'theme', description: 'Theme', mode: 'submenu', search: () => [result] },
|
||||
{
|
||||
name: 'theme',
|
||||
description: 'Theme',
|
||||
mode: 'submenu',
|
||||
execute: vi.fn(),
|
||||
search: () => [result],
|
||||
},
|
||||
]
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerSearchShortcut()
|
||||
@@ -612,6 +633,56 @@ describe('GotoAnything', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows feedback when an activated command rejects and allows reopening', async () => {
|
||||
const user = userEvent.setup()
|
||||
const failure = new Error('Execution failed')
|
||||
const toastError = vi.spyOn(toast, 'error').mockReturnValue('command-error')
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockAvailableCommands = [
|
||||
{
|
||||
name: 'models',
|
||||
description: 'Models',
|
||||
mode: 'direct',
|
||||
search: () => [],
|
||||
execute: () => Promise.reject(failure),
|
||||
},
|
||||
]
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerSearchShortcut()
|
||||
await user.click(await screen.findByRole('gridcell', { name: /\/models/ }))
|
||||
await waitFor(() =>
|
||||
expect(toastError).toHaveBeenCalledExactlyOnceWith('common.api.actionFailed'),
|
||||
)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
triggerSearchShortcut()
|
||||
expect(await screen.findByRole('combobox')).toHaveValue('')
|
||||
toastError.mockRestore()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('updates an open command submenu when workspace access changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAvailableCommands = [goCommand]
|
||||
createActionsMock.mockImplementation((slash) => ({ ...actionsMock, slash }))
|
||||
matchActionMock.mockImplementation((query, actions) =>
|
||||
actions.slash?.matches?.(query) ? actions.slash : undefined,
|
||||
)
|
||||
const { rerender } = renderGotoAnything(<GotoAnything />)
|
||||
triggerSearchShortcut()
|
||||
const input = await screen.findByRole('combobox')
|
||||
await user.type(input, '/go ')
|
||||
expect(screen.getByRole('option', { name: 'Agents /agents' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: 'Skills /skills' })).toBeInTheDocument()
|
||||
visibilityState.agentEnabled = false
|
||||
visibilityState.datasetOperator = true
|
||||
rerender(<GotoAnything />)
|
||||
expect(screen.queryByRole('option', { name: 'Agents /agents' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: 'Skills /skills' })).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('option', { name: 'Apps /apps' }))
|
||||
expect(routerPush).toHaveBeenCalledExactlyOnceWith('/apps')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
describe('search functionality', () => {
|
||||
it.each([
|
||||
[{ agentEnabled: true, datasetOperator: false, enableSkill: true }, true, true],
|
||||
@@ -625,7 +696,10 @@ describe('GotoAnything', () => {
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
|
||||
expect(createActionsMock).toHaveBeenCalledWith(false, false, { agents, skills })
|
||||
expect(createActionsMock).toHaveBeenCalledWith(expect.any(Object), false, false, {
|
||||
agents,
|
||||
skills,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -690,8 +764,8 @@ describe('GotoAnything', () => {
|
||||
it('should navigate and loop within a command grid row with ArrowRight', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAvailableCommands = [
|
||||
{ search: () => [], name: 'theme', description: 'Change theme' },
|
||||
{ search: () => [], name: 'language', description: 'Change language' },
|
||||
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
|
||||
{ execute: vi.fn(), search: () => [], name: 'language', description: 'Change language' },
|
||||
]
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
@@ -716,8 +790,8 @@ describe('GotoAnything', () => {
|
||||
it('should announce the displayed command count', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAvailableCommands = [
|
||||
{ search: () => [], name: 'theme', description: 'Change theme' },
|
||||
{ search: () => [], name: 'language', description: 'Change language' },
|
||||
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
|
||||
{ execute: vi.fn(), search: () => [], name: 'language', description: 'Change language' },
|
||||
]
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
@@ -770,7 +844,7 @@ describe('GotoAnything', () => {
|
||||
it('shows the localized system model name for /models', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAvailableCommands = [
|
||||
{ search: () => [], name: 'models', description: 'Fallback description' },
|
||||
{ execute: vi.fn(), search: () => [], name: 'models', description: 'Fallback description' },
|
||||
]
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerSearchShortcut()
|
||||
@@ -788,7 +862,9 @@ describe('GotoAnything', () => {
|
||||
|
||||
it('keeps an exact submenu command in the grid until selection commits it', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAvailableCommands = [{ search: () => [], name: 'theme', description: 'Change theme' }]
|
||||
mockAvailableCommands = [
|
||||
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
|
||||
]
|
||||
matchActionMock.mockImplementation((query: string) =>
|
||||
query.startsWith('/theme ') ? actionsMock.slash : undefined,
|
||||
)
|
||||
@@ -822,7 +898,9 @@ describe('GotoAnything', () => {
|
||||
|
||||
it('keeps a submenu root result visible while the committed delimiter catches up', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAvailableCommands = [{ search: () => [], name: 'theme', description: 'Change theme' }]
|
||||
mockAvailableCommands = [
|
||||
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
|
||||
]
|
||||
matchActionMock.mockImplementation((query: string) =>
|
||||
query.startsWith('/theme ') ? actionsMock.slash : undefined,
|
||||
)
|
||||
@@ -858,7 +936,9 @@ describe('GotoAnything', () => {
|
||||
|
||||
it('does not leak a pending remote search into command or local-result contexts', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAvailableCommands = [{ search: () => [], name: 'theme', description: 'Change theme' }]
|
||||
mockAvailableCommands = [
|
||||
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
|
||||
]
|
||||
matchActionMock.mockImplementation((query: string) =>
|
||||
query.startsWith('/theme ') ? actionsMock.slash : undefined,
|
||||
)
|
||||
@@ -1429,7 +1509,9 @@ describe('GotoAnything', () => {
|
||||
execute: executeMock,
|
||||
isAvailable: () => true,
|
||||
}
|
||||
mockAvailableCommands = [{ search: () => [], name: 'theme', description: 'Change theme' }]
|
||||
mockAvailableCommands = [
|
||||
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
|
||||
]
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerSearchShortcut()
|
||||
@@ -1480,7 +1562,7 @@ describe('GotoAnything', () => {
|
||||
execute: executeMock,
|
||||
}
|
||||
mockAvailableCommands = [
|
||||
{ search: () => [], name: 'language', description: 'Change language' },
|
||||
{ execute: vi.fn(), search: () => [], name: 'language', description: 'Change language' },
|
||||
]
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
@@ -1506,7 +1588,9 @@ describe('GotoAnything', () => {
|
||||
execute: vi.fn(),
|
||||
isAvailable: () => true,
|
||||
}
|
||||
mockAvailableCommands = [{ search: () => [], name: 'theme', description: 'Change theme' }]
|
||||
mockAvailableCommands = [
|
||||
{ execute: vi.fn(), search: () => [], name: 'theme', description: 'Change theme' },
|
||||
]
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerSearchShortcut()
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ActionItem } from '../types'
|
||||
import { slashCommandRegistry } from '../commands/registry'
|
||||
import { createCommandContext } from '../commands/__tests__/context'
|
||||
import { createSlashAction } from '../commands/slash'
|
||||
import { createActions, getActionSearchTerm, matchAction } from '../index'
|
||||
|
||||
const slash = createSlashAction(createCommandContext())
|
||||
|
||||
vi.mock('../app', () => ({
|
||||
appAction: {
|
||||
key: '@app',
|
||||
@@ -52,17 +55,6 @@ vi.mock('../agent', () => ({
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
vi.mock('../commands/slash', () => ({
|
||||
slashAction: {
|
||||
key: '/',
|
||||
shortcut: '/',
|
||||
title: 'Commands',
|
||||
description: 'Slash commands',
|
||||
source: 'local',
|
||||
search: vi.fn(() => []),
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
vi.mock('../workflow-nodes', () => ({
|
||||
workflowNodesAction: {
|
||||
key: '@node',
|
||||
@@ -85,33 +77,31 @@ vi.mock('../rag-pipeline-nodes', () => ({
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
vi.mock('../commands/registry')
|
||||
|
||||
describe('createActions', () => {
|
||||
it('returns only global actions outside graph pages', () => {
|
||||
expect(createActions(false, false)).toEqual(
|
||||
expect(createActions(slash, false, false)).toEqual(
|
||||
expect.objectContaining({ slash: expect.any(Object), app: expect.any(Object) }),
|
||||
)
|
||||
expect(createActions(false, false)).not.toHaveProperty('node')
|
||||
expect(createActions(false, false)).toHaveProperty('skill')
|
||||
expect(createActions(false, false)).not.toHaveProperty('agent')
|
||||
expect(createActions(slash, false, false)).not.toHaveProperty('node')
|
||||
expect(createActions(slash, false, false)).toHaveProperty('skill')
|
||||
expect(createActions(slash, false, false)).not.toHaveProperty('agent')
|
||||
})
|
||||
|
||||
it('applies workspace availability to skill and agent scopes', () => {
|
||||
const actions = createActions(false, false, { agents: true, skills: false })
|
||||
const actions = createActions(slash, false, false, { agents: true, skills: false })
|
||||
|
||||
expect(actions).toHaveProperty('agent')
|
||||
expect(actions).not.toHaveProperty('skill')
|
||||
})
|
||||
|
||||
it('uses the workflow-owned node action on workflow pages', () => {
|
||||
expect((createActions(true, false) as Record<string, ActionItem>).node!.title).toBe(
|
||||
expect((createActions(slash, true, false) as Record<string, ActionItem>).node!.title).toBe(
|
||||
'Workflow Nodes',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the RAG-owned node action when both graph flags are true', () => {
|
||||
expect((createActions(true, true) as Record<string, ActionItem>).node!.title).toBe(
|
||||
expect((createActions(slash, true, true) as Record<string, ActionItem>).node!.title).toBe(
|
||||
'RAG Pipeline Nodes',
|
||||
)
|
||||
})
|
||||
@@ -119,7 +109,7 @@ describe('createActions', () => {
|
||||
|
||||
describe('getActionSearchTerm', () => {
|
||||
it('removes either the action key or shortcut', () => {
|
||||
const action = createActions(false, false).knowledge
|
||||
const action = createActions(slash, false, false).knowledge
|
||||
|
||||
expect(getActionSearchTerm('@knowledge vector store', action)).toBe('vector store')
|
||||
expect(getActionSearchTerm('@kb vector store', action)).toBe('vector store')
|
||||
@@ -127,11 +117,7 @@ describe('getActionSearchTerm', () => {
|
||||
})
|
||||
|
||||
describe('matchAction', () => {
|
||||
const actions = createActions(false, false, { agents: true, skills: true })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(slashCommandRegistry.getAvailableCommands).mockReturnValue([])
|
||||
})
|
||||
const actions = createActions(slash, false, false, { agents: true, skills: true })
|
||||
|
||||
it.each([
|
||||
['@app query', '@app'],
|
||||
@@ -149,21 +135,10 @@ describe('matchAction', () => {
|
||||
})
|
||||
|
||||
it('requires a delimiter for submenu commands and leaves direct commands in the picker', () => {
|
||||
vi.mocked(slashCommandRegistry.getAvailableCommands).mockReturnValue([
|
||||
{
|
||||
name: 'theme',
|
||||
aliases: ['appearance'],
|
||||
mode: 'submenu',
|
||||
description: '',
|
||||
search: vi.fn(() => []),
|
||||
},
|
||||
{ name: 'docs', mode: 'direct', description: '', search: vi.fn(() => []) },
|
||||
])
|
||||
|
||||
expect(matchAction('/theme', actions)).toBeUndefined()
|
||||
expect(matchAction('/theme ', actions)?.key).toBe('/')
|
||||
expect(matchAction('/theme dark', actions)?.key).toBe('/')
|
||||
expect(matchAction('/appearance dark', actions)?.key).toBe('/')
|
||||
expect(matchAction('/lang english', actions)?.key).toBe('/')
|
||||
expect(matchAction('/docs', actions)).toBeUndefined()
|
||||
expect(matchAction('/the', actions)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { executeCommand, registerCommands, unregisterCommands } from '../command-bus'
|
||||
|
||||
describe('command-bus', () => {
|
||||
afterEach(() => {
|
||||
unregisterCommands(['test.a', 'test.b', 'test.c', 'async.cmd', 'noop'])
|
||||
})
|
||||
|
||||
describe('registerCommands / executeCommand', () => {
|
||||
it('registers and executes a sync command', async () => {
|
||||
const handler = vi.fn()
|
||||
registerCommands({ 'test.a': handler })
|
||||
|
||||
await executeCommand('test.a', { value: 42 })
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ value: 42 })
|
||||
})
|
||||
|
||||
it('registers and executes an async command', async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined)
|
||||
registerCommands({ 'async.cmd': handler })
|
||||
|
||||
await executeCommand('async.cmd')
|
||||
|
||||
expect(handler).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers multiple commands at once', async () => {
|
||||
const handlerA = vi.fn()
|
||||
const handlerB = vi.fn()
|
||||
registerCommands({ 'test.a': handlerA, 'test.b': handlerB })
|
||||
|
||||
await executeCommand('test.a')
|
||||
await executeCommand('test.b')
|
||||
|
||||
expect(handlerA).toHaveBeenCalled()
|
||||
expect(handlerB).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('silently ignores unregistered command names', async () => {
|
||||
await expect(executeCommand('nonexistent')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes undefined args when not provided', async () => {
|
||||
const handler = vi.fn()
|
||||
registerCommands({ 'test.c': handler })
|
||||
|
||||
await executeCommand('test.c')
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unregisterCommands', () => {
|
||||
it('removes commands so they can no longer execute', async () => {
|
||||
const handler = vi.fn()
|
||||
registerCommands({ 'test.a': handler })
|
||||
|
||||
unregisterCommands(['test.a'])
|
||||
await executeCommand('test.a')
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles unregistering non-existent commands gracefully', () => {
|
||||
expect(() => unregisterCommands(['nope'])).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { CommandContext } from '../types'
|
||||
import { createInstance } from 'i18next'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { accountCommand } from '../account'
|
||||
import { slashCommandRegistry } from '../catalog'
|
||||
import { discordCommand } from '../discord'
|
||||
import { docsCommand } from '../docs'
|
||||
import { goCommand } from '../go'
|
||||
import { languageCommand } from '../language'
|
||||
import { modelsCommand, SYSTEM_MODELS_PATH } from '../models'
|
||||
import { refineCommand } from '../refine'
|
||||
import { SlashCommandRegistry } from '../registry'
|
||||
import { createSlashAction } from '../slash'
|
||||
import { themeCommand } from '../theme'
|
||||
import { createCommandContext } from './context'
|
||||
|
||||
const executeResult = (query: string, context: CommandContext) => {
|
||||
const action = createSlashAction(context)
|
||||
const result = slashCommandRegistry.search(query, context)[0]!
|
||||
action.action?.(result)
|
||||
}
|
||||
|
||||
describe('command journeys', () => {
|
||||
it('lists and filters themes, and activates the selected theme', () => {
|
||||
const context = createCommandContext()
|
||||
expect(slashCommandRegistry.search('/theme', context).map((result) => result.id)).toEqual([
|
||||
'system',
|
||||
'light',
|
||||
'dark',
|
||||
])
|
||||
executeResult('/theme dark', context)
|
||||
expect(context.setTheme).toHaveBeenCalledExactlyOnceWith('dark')
|
||||
})
|
||||
|
||||
it('finds a language by its native name and activates it through the alias', async () => {
|
||||
const context = createCommandContext()
|
||||
const result = slashCommandRegistry.search('/lang 简体中文', context)[0]!
|
||||
expect(result.title).toBe('简体中文')
|
||||
await slashCommandRegistry.execute(result.data.command, result.data.args ?? {}, context)
|
||||
expect(context.setLocale).toHaveBeenCalledExactlyOnceWith('zh-Hans')
|
||||
})
|
||||
|
||||
it('uses the current destination for direct and search-result activation', async () => {
|
||||
for (const command of [accountCommand, modelsCommand, docsCommand, discordCommand]) {
|
||||
const context = createCommandContext({
|
||||
getDocsHomeUrl: () => 'https://enterprise.example/help/zh',
|
||||
})
|
||||
const registry = new SlashCommandRegistry([command])
|
||||
await registry.execute(command.name, {}, context)
|
||||
const result = registry.search(`/${command.name}`, context)[0]!
|
||||
await registry.execute(result.data.command, result.data.args ?? {}, context)
|
||||
const callback =
|
||||
command === accountCommand || command === modelsCommand
|
||||
? context.navigate
|
||||
: context.openExternal
|
||||
const destination =
|
||||
command === accountCommand
|
||||
? '/account'
|
||||
: command === modelsCommand
|
||||
? SYSTEM_MODELS_PATH
|
||||
: command === docsCommand
|
||||
? 'https://enterprise.example/help/zh'
|
||||
: 'https://discord.gg/5AEfbxcd9k'
|
||||
expect(callback).toHaveBeenCalledTimes(2)
|
||||
expect(callback).toHaveBeenLastCalledWith(destination)
|
||||
}
|
||||
})
|
||||
|
||||
it('hides unavailable destinations and blocks a result selected before access changed', async () => {
|
||||
const context = createCommandContext()
|
||||
const agents = goCommand.search('agents', context)[0]!
|
||||
const restricted = createCommandContext({ agentsAvailable: false, skillsAvailable: false })
|
||||
expect(goCommand.search('', restricted).map((result) => result.id)).not.toContain('go-agents')
|
||||
expect(goCommand.search('skills', restricted)).toEqual([])
|
||||
await goCommand.execute(agents.data.args ?? {}, restricted)
|
||||
expect(restricted.navigate).not.toHaveBeenCalled()
|
||||
await goCommand.execute(agents.data.args ?? {}, context)
|
||||
expect(context.navigate).toHaveBeenCalledWith('/agents')
|
||||
})
|
||||
|
||||
it.each(['workflow', 'advanced-chat'] as const)(
|
||||
'refines only the current %s app',
|
||||
async (mode) => {
|
||||
const context = createCommandContext({ currentApp: { id: 'current-app', mode } })
|
||||
const registry = new SlashCommandRegistry([refineCommand])
|
||||
await registry.execute('refine', {}, context)
|
||||
expect(context.openGenerator).toHaveBeenCalledWith({
|
||||
intent: 'refine',
|
||||
mode,
|
||||
currentAppId: 'current-app',
|
||||
currentAppMode: mode,
|
||||
})
|
||||
const outsideStudio = createCommandContext()
|
||||
await registry.execute('refine', {}, outsideStudio)
|
||||
expect(outsideStudio.openGenerator).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps language, permissions, docs links, and callbacks isolated between contexts', async () => {
|
||||
const first = createCommandContext({
|
||||
agentsAvailable: false,
|
||||
getDocsHomeUrl: () => 'https://one.example/docs',
|
||||
})
|
||||
const second = createCommandContext({ getDocsHomeUrl: () => 'https://two.example/docs' })
|
||||
expect(goCommand.search('agents', first)).toEqual([])
|
||||
expect(goCommand.search('agents', second)).toHaveLength(1)
|
||||
await docsCommand.execute({}, first)
|
||||
await docsCommand.execute({}, second)
|
||||
executeResult('/theme dark', second)
|
||||
expect(first.openExternal).toHaveBeenCalledExactlyOnceWith('https://one.example/docs')
|
||||
expect(second.openExternal).toHaveBeenCalledExactlyOnceWith('https://two.example/docs')
|
||||
expect(first.setTheme).not.toHaveBeenCalled()
|
||||
expect(second.setTheme).toHaveBeenCalledExactlyOnceWith('dark')
|
||||
})
|
||||
|
||||
it('renders localized commands on the server without a browser or activation effects', async () => {
|
||||
const i18n = createInstance()
|
||||
await i18n.init({
|
||||
lng: 'en-US',
|
||||
resources: {
|
||||
'en-US': { app: { 'gotoAnything.actions.themeDark': 'Dark' } },
|
||||
'zh-Hans': { app: { 'gotoAnything.actions.themeDark': '深色' } },
|
||||
},
|
||||
})
|
||||
const first = createCommandContext({ t: i18n.t as CommandContext['t'] })
|
||||
const second = createCommandContext({ t: i18n.t as CommandContext['t'], locale: 'zh-Hans' })
|
||||
vi.stubGlobal('window', undefined)
|
||||
try {
|
||||
const renderCommands = (context: CommandContext) =>
|
||||
renderToString(
|
||||
<ul>
|
||||
{themeCommand.search('dark', context).map((result) => (
|
||||
<li key={result.id}>
|
||||
{result.title}
|
||||
{result.icon}
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
)
|
||||
expect(renderCommands(first)).toContain('Dark')
|
||||
expect(renderCommands(second)).toContain('深色')
|
||||
expect(renderCommands(first)).toContain('Dark')
|
||||
for (const context of [first, second]) {
|
||||
slashCommandRegistry.search('/', context)
|
||||
languageCommand.search('', context)
|
||||
expect(context.setTheme).not.toHaveBeenCalled()
|
||||
expect(context.openExternal).not.toHaveBeenCalled()
|
||||
expect(context.navigate).not.toHaveBeenCalled()
|
||||
expect(context.openGenerator).not.toHaveBeenCalled()
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CommandContext } from '../types'
|
||||
import { withSelectorKey } from '@/test/i18n-mock'
|
||||
|
||||
export function createCommandContext(overrides: Partial<CommandContext> = {}): CommandContext {
|
||||
return {
|
||||
t: withSelectorKey((key: string) => key) as CommandContext['t'],
|
||||
locale: 'en-US',
|
||||
agentsAvailable: true,
|
||||
skillsAvailable: true,
|
||||
currentApp: null,
|
||||
setTheme: vi.fn(),
|
||||
setLocale: vi.fn().mockResolvedValue(undefined),
|
||||
getDocsHomeUrl: () => 'https://docs.dify.ai/en',
|
||||
onError: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
openExternal: vi.fn(),
|
||||
openGenerator: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,15 @@
|
||||
import { executeCommand } from '../command-bus'
|
||||
import { createCommand } from '../create'
|
||||
// We spy on the store at module scope so the `create.open` handler that
|
||||
// register() pushes into the command bus can be observed by the tests.
|
||||
const mockOpenGenerator = vi.fn()
|
||||
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
|
||||
useWorkflowGeneratorStore: {
|
||||
getState: () => ({ openGenerator: mockOpenGenerator }),
|
||||
},
|
||||
}))
|
||||
|
||||
// Controllable app-store state — the handler reads `appDetail` to decide
|
||||
// whether to thread the current Studio app through to the generator. Mutated
|
||||
// per-test; getState() reads it lazily so updates land after the mock factory.
|
||||
const mockAppStore: { appDetail: { id: string; mode: string } | undefined } = {
|
||||
appDetail: undefined,
|
||||
}
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: {
|
||||
getState: () => ({ appDetail: mockAppStore.appDetail }),
|
||||
},
|
||||
}))
|
||||
import { createCommandContext } from './context'
|
||||
|
||||
let context = createCommandContext()
|
||||
describe('/create slash command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('handler metadata', () => {
|
||||
// The slash registry relies on this metadata to route /create through the
|
||||
// submenu UX rather than executing immediately.
|
||||
it('should expose submenu mode with the expected name and aliases', () => {
|
||||
expect(createCommand.mode).toBe('submenu')
|
||||
expect(createCommand.name).toBe('create')
|
||||
expect(createCommand.aliases).toEqual(['new', 'generate'])
|
||||
})
|
||||
context = createCommandContext()
|
||||
})
|
||||
|
||||
describe('search()', () => {
|
||||
// An empty arg list should surface every option (Auto first); the submenu
|
||||
// uses this to render its initial list when the user types just `/create`.
|
||||
it('should surface auto, workflow and chatflow when args is empty', async () => {
|
||||
const results = await createCommand.search('')
|
||||
const results = await createCommand.search('', context)
|
||||
expect(results.map((r) => r.id)).toEqual([
|
||||
'create-auto',
|
||||
'create-workflow',
|
||||
@@ -48,50 +17,39 @@ describe('/create slash command', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Typing a partial keyword should narrow the list and each result should
|
||||
// carry the right command-bus payload so the navigation hook can fire it.
|
||||
it('should filter by query and attach the right command payload', async () => {
|
||||
const results = await createCommand.search('chat')
|
||||
const results = await createCommand.search('chat', context)
|
||||
expect(results.map((r) => r.id)).toEqual(['create-chatflow'])
|
||||
expect(results[0]!.data.command).toBe('create.open')
|
||||
expect(results[0]!.data.command).toBe('create')
|
||||
expect(results[0]!.data.args).toEqual({ mode: 'advanced-chat', auto: false, instruction: '' })
|
||||
})
|
||||
|
||||
// The Auto option carries the auto flag so the modal opens in auto-mode.
|
||||
it('should flag the auto option so the planner picks the app type', async () => {
|
||||
const results = await createCommand.search('')
|
||||
const results = await createCommand.search('', context)
|
||||
expect(results[0]!.id).toBe('create-auto')
|
||||
expect(results[0]!.data.args).toEqual({ mode: 'advanced-chat', auto: true, instruction: '' })
|
||||
})
|
||||
|
||||
// A non-matching single-token query returns an empty list rather than
|
||||
// throwing, so the goto-anything dialog can render an empty-state.
|
||||
it('should return an empty list when a single-token query matches nothing', async () => {
|
||||
const results = await createCommand.search('zzz')
|
||||
const results = await createCommand.search('zzz', context)
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
// Labels/descriptions must be localised through i18n (ns: 'app') rather
|
||||
// than hardcoded English, so the palette renders in the user's language.
|
||||
it('should source titles and descriptions from i18n keys', async () => {
|
||||
const results = await createCommand.search('')
|
||||
const results = await createCommand.search('', context)
|
||||
expect(results[1]!.title).toBe('gotoAnything.actions.createWorkflow')
|
||||
expect(results[1]!.description).toBe('gotoAnything.actions.createWorkflowDesc')
|
||||
expect(results[2]!.title).toBe('gotoAnything.actions.createChatflow')
|
||||
expect(results[2]!.description).toBe('gotoAnything.actions.createChatflowDesc')
|
||||
})
|
||||
|
||||
// The localised label is also searchable, not just the id — a token that
|
||||
// appears only in the (mocked) title key still narrows the list.
|
||||
it('should filter by the localised label, not just the id', async () => {
|
||||
const results = await createCommand.search('createChatflow')
|
||||
const results = await createCommand.search('createChatflow', context)
|
||||
expect(results.map((r) => r.id)).toEqual(['create-chatflow'])
|
||||
})
|
||||
|
||||
// Inline capture: a leading mode word selects that option and the rest of
|
||||
// the text becomes the pre-filled instruction surfaced as the description.
|
||||
it('should capture a trailing instruction when the first word names a mode', async () => {
|
||||
const results = await createCommand.search('workflow summarize a URL')
|
||||
const results = await createCommand.search('workflow summarize a URL', context)
|
||||
expect(results.map((r) => r.id)).toEqual(['create-workflow'])
|
||||
expect(results[0]!.data.args).toEqual({
|
||||
mode: 'workflow',
|
||||
@@ -101,10 +59,8 @@ describe('/create slash command', () => {
|
||||
expect(results[0]!.description).toBe('summarize a URL')
|
||||
})
|
||||
|
||||
// Inline capture without a leading mode word keeps every option, each
|
||||
// pre-filled with the full text so the user just picks the type.
|
||||
it('should keep all options with the full text when no leading mode word', async () => {
|
||||
const results = await createCommand.search('summarize a URL')
|
||||
const results = await createCommand.search('summarize a URL', context)
|
||||
expect(results.map((r) => r.id)).toEqual([
|
||||
'create-auto',
|
||||
'create-workflow',
|
||||
@@ -116,60 +72,45 @@ describe('/create slash command', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('register() — `create.open` command-bus handler', () => {
|
||||
beforeEach(() => {
|
||||
mockAppStore.appDetail = undefined
|
||||
createCommand.register?.({} as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
createCommand.unregister?.()
|
||||
})
|
||||
|
||||
// No Studio app open — the modal opens for new-app creation only.
|
||||
describe('execute()', () => {
|
||||
it('should open the generator with the requested mode when no Studio app is open', async () => {
|
||||
await executeCommand('create.open', { mode: 'workflow' })
|
||||
await createCommand.execute({ mode: 'workflow' }, context)
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
expect(context.openGenerator).toHaveBeenCalledWith({
|
||||
mode: 'workflow',
|
||||
autoMode: false,
|
||||
initialInstruction: '',
|
||||
})
|
||||
})
|
||||
|
||||
// Inline-captured instruction threads through to the modal.
|
||||
it('should thread the captured instruction through to the generator', async () => {
|
||||
await executeCommand('create.open', { mode: 'workflow', instruction: 'summarize a URL' })
|
||||
await createCommand.execute({ mode: 'workflow', instruction: 'summarize a URL' }, context)
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
expect(context.openGenerator).toHaveBeenCalledWith({
|
||||
mode: 'workflow',
|
||||
autoMode: false,
|
||||
initialInstruction: 'summarize a URL',
|
||||
})
|
||||
})
|
||||
|
||||
// Auto-mode always creates a new app, even with a matching Studio open,
|
||||
// because the planner may resolve a different type than the open canvas.
|
||||
it('should open new-app auto-mode even when a matching Studio app is open', async () => {
|
||||
mockAppStore.appDetail = { id: 'abc-123', mode: 'advanced-chat' }
|
||||
context.currentApp = { id: 'abc-123', mode: 'advanced-chat' }
|
||||
|
||||
await executeCommand('create.open', { mode: 'advanced-chat', auto: true })
|
||||
await createCommand.execute({ mode: 'advanced-chat', auto: true }, context)
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
expect(context.openGenerator).toHaveBeenCalledWith({
|
||||
mode: 'advanced-chat',
|
||||
autoMode: true,
|
||||
initialInstruction: '',
|
||||
})
|
||||
})
|
||||
|
||||
// In-Studio create-and-apply: a matching graph-based app threads id + mode
|
||||
// through so the modal can offer "Apply to current draft".
|
||||
it('should thread the current app context when a matching Studio app is open', async () => {
|
||||
mockAppStore.appDetail = { id: 'abc-123', mode: 'workflow' }
|
||||
context.currentApp = { id: 'abc-123', mode: 'workflow' }
|
||||
|
||||
await executeCommand('create.open', { mode: 'workflow' })
|
||||
await createCommand.execute({ mode: 'workflow' }, context)
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
expect(context.openGenerator).toHaveBeenCalledWith({
|
||||
mode: 'workflow',
|
||||
currentAppId: 'abc-123',
|
||||
currentAppMode: 'workflow',
|
||||
@@ -177,60 +118,16 @@ describe('/create slash command', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Mode mismatch must NOT capture currentAppId — applying a chatflow graph
|
||||
// onto a workflow draft is the dead-end we explicitly avoid.
|
||||
it('should fall back to new-app only when the picked mode differs from the open app', async () => {
|
||||
mockAppStore.appDetail = { id: 'abc-123', mode: 'workflow' }
|
||||
context.currentApp = { id: 'abc-123', mode: 'workflow' }
|
||||
|
||||
await executeCommand('create.open', { mode: 'advanced-chat' })
|
||||
await createCommand.execute({ mode: 'advanced-chat' }, context)
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
expect(context.openGenerator).toHaveBeenCalledWith({
|
||||
mode: 'advanced-chat',
|
||||
autoMode: false,
|
||||
initialInstruction: '',
|
||||
})
|
||||
})
|
||||
|
||||
// Non-graph Studio apps (Chat / Agent / Completion) have no canvas to apply
|
||||
// onto, so the handler ignores them and opens new-app only.
|
||||
it('should ignore non-graph app modes and open new-app only', async () => {
|
||||
mockAppStore.appDetail = { id: 'abc-123', mode: 'chat' }
|
||||
|
||||
await executeCommand('create.open', { mode: 'workflow' })
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
mode: 'workflow',
|
||||
autoMode: false,
|
||||
initialInstruction: '',
|
||||
})
|
||||
})
|
||||
|
||||
// Defensive fallback: a missing mode still opens the generator safely.
|
||||
it('should default to workflow mode when no args are passed', async () => {
|
||||
await executeCommand('create.open')
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
mode: 'workflow',
|
||||
autoMode: false,
|
||||
initialInstruction: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('unregister()', () => {
|
||||
// After unregister, the bus must drop the handler so a later execute call
|
||||
// becomes a silent no-op (prevents stale references between mounts).
|
||||
it('should remove the command-bus handler so it stops firing', async () => {
|
||||
createCommand.register?.({} as never)
|
||||
createCommand.unregister?.()
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { pathname: '/apps' },
|
||||
})
|
||||
|
||||
await executeCommand('create.open', { mode: 'workflow' })
|
||||
expect(mockOpenGenerator).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,294 +0,0 @@
|
||||
/**
|
||||
* Tests for direct-mode commands that share similar patterns:
|
||||
* docs, account, discord, models
|
||||
*
|
||||
* Each command: opens a URL or navigates, has direct mode, and registers a navigation command.
|
||||
*/
|
||||
import { accountCommand } from '../account'
|
||||
import { registerCommands, unregisterCommands } from '../command-bus'
|
||||
import { discordCommand } from '../discord'
|
||||
import { docsCommand } from '../docs'
|
||||
import { modelsCommand, SYSTEM_MODELS_PATH } from '../models'
|
||||
|
||||
vi.mock('../command-bus')
|
||||
|
||||
const mockT = vi.fn((key: string) => key)
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
getI18n: () => ({
|
||||
t: withSelectorKey((key: string) => mockT(key)),
|
||||
language: 'en',
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('docsCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
docsCommand.unregister?.()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(docsCommand.name).toBe('docs')
|
||||
expect(docsCommand.mode).toBe('direct')
|
||||
expect(docsCommand.execute).toBeDefined()
|
||||
})
|
||||
|
||||
it('execute opens documentation in new tab', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
docsCommand.register?.({ getDocsHomeUrl: () => 'https://docs.dify.ai/en/home' })
|
||||
|
||||
docsCommand.execute?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://docs.dify.ai/en/home',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('execute uses the documentation URL registered by the provider', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
docsCommand.register?.({
|
||||
getDocsHomeUrl: () => 'https://enterprise-docs.dify.ai/en/',
|
||||
})
|
||||
|
||||
docsCommand.execute?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://enterprise-docs.dify.ai/en/',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('search returns a single doc result', async () => {
|
||||
const results = await docsCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'doc',
|
||||
type: 'command',
|
||||
data: { command: 'navigation.doc', args: {} },
|
||||
})
|
||||
})
|
||||
|
||||
it('search uses fallback description when i18n returns empty', async () => {
|
||||
mockT.mockImplementation((key: string) => (key.includes('docDesc') ? '' : key))
|
||||
|
||||
const results = await docsCommand.search('', 'en')
|
||||
|
||||
expect(results[0]!.description).toBe('Open help documentation')
|
||||
mockT.mockImplementation((key: string) => key)
|
||||
})
|
||||
|
||||
it('registers navigation.doc command', () => {
|
||||
docsCommand.register?.({ getDocsHomeUrl: () => 'https://docs.dify.ai/en/home' })
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.doc': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('registered handler opens doc URL with correct locale', async () => {
|
||||
docsCommand.register?.({
|
||||
getDocsHomeUrl: () => 'https://enterprise-docs.dify.ai/en/',
|
||||
})
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
|
||||
await handlers['navigation.doc']!()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://enterprise-docs.dify.ai/en/',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('unregisters navigation.doc command', () => {
|
||||
docsCommand.unregister?.()
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.doc'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('accountCommand', () => {
|
||||
let originalHref: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
originalHref = window.location.href
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', { value: { href: originalHref }, writable: true })
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(accountCommand.name).toBe('account')
|
||||
expect(accountCommand.mode).toBe('direct')
|
||||
expect(accountCommand.execute).toBeDefined()
|
||||
})
|
||||
|
||||
it('execute navigates to /account', () => {
|
||||
Object.defineProperty(window, 'location', { value: { href: '' }, writable: true })
|
||||
accountCommand.execute?.()
|
||||
expect(window.location.href).toBe('/account')
|
||||
})
|
||||
|
||||
it('search returns account result', async () => {
|
||||
const results = await accountCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'account',
|
||||
type: 'command',
|
||||
data: { command: 'navigation.account', args: {} },
|
||||
})
|
||||
})
|
||||
|
||||
it('registers navigation.account command', () => {
|
||||
accountCommand.register?.({} as Record<string, never>)
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.account': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('unregisters navigation.account command', () => {
|
||||
accountCommand.unregister?.()
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.account'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('discordCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(discordCommand.name).toBe('discord')
|
||||
expect(discordCommand.mode).toBe('direct')
|
||||
expect(discordCommand.execute).toBeDefined()
|
||||
})
|
||||
|
||||
it('execute opens Discord URL', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
discordCommand.execute?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://discord.gg/5AEfbxcd9k',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('search returns Discord result', async () => {
|
||||
const results = await discordCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'discord',
|
||||
type: 'command',
|
||||
data: { command: 'navigation.discord' },
|
||||
})
|
||||
})
|
||||
|
||||
it('search uses fallback description when i18n returns empty', async () => {
|
||||
mockT.mockImplementation((key: string) => (key.includes('discordDesc') ? '' : key))
|
||||
|
||||
const results = await discordCommand.search('', 'en')
|
||||
|
||||
expect(results[0]!.description).toBe('Open Discord community')
|
||||
mockT.mockImplementation((key: string) => key)
|
||||
})
|
||||
|
||||
it('registers navigation.discord command', () => {
|
||||
discordCommand.register?.({} as Record<string, never>)
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.discord': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('registered handler opens URL from args', async () => {
|
||||
discordCommand.register?.({} as Record<string, never>)
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
|
||||
await handlers['navigation.discord']!({ url: 'https://custom-url.com' })
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith('https://custom-url.com', '_blank', 'noopener,noreferrer')
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('registered handler falls back to default URL when no args', async () => {
|
||||
discordCommand.register?.({} as Record<string, never>)
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
|
||||
await handlers['navigation.discord']!()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://discord.gg/5AEfbxcd9k',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('unregisters navigation.discord command', () => {
|
||||
discordCommand.unregister?.()
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.discord'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('modelsCommand', () => {
|
||||
let originalHref: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
originalHref = window.location.href
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', { value: { href: originalHref }, writable: true })
|
||||
})
|
||||
|
||||
it('has direct command metadata', () => {
|
||||
expect(modelsCommand.name).toBe('models')
|
||||
expect(modelsCommand.mode).toBe('direct')
|
||||
expect(modelsCommand.execute).toBeDefined()
|
||||
})
|
||||
|
||||
it('navigates to model provider with the system model dialog URL state', () => {
|
||||
Object.defineProperty(window, 'location', { value: { href: '' }, writable: true })
|
||||
|
||||
modelsCommand.execute?.()
|
||||
|
||||
expect(window.location.href).toBe(SYSTEM_MODELS_PATH)
|
||||
})
|
||||
|
||||
it('search returns the system models result', async () => {
|
||||
const results = await modelsCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'models',
|
||||
type: 'command',
|
||||
data: { command: 'navigation.models' },
|
||||
})
|
||||
})
|
||||
|
||||
it('registers a navigation command that opens the system model route', async () => {
|
||||
Object.defineProperty(window, 'location', { value: { href: '' }, writable: true })
|
||||
modelsCommand.register?.({} as Record<string, never>)
|
||||
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
|
||||
|
||||
await handlers['navigation.models']!()
|
||||
|
||||
expect(window.location.href).toBe(SYSTEM_MODELS_PATH)
|
||||
})
|
||||
|
||||
it('unregisters navigation.models command', () => {
|
||||
modelsCommand.unregister?.()
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.models'])
|
||||
})
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
import { registerCommands, unregisterCommands } from '../command-bus'
|
||||
import { goCommand } from '../go'
|
||||
|
||||
vi.mock('../command-bus')
|
||||
|
||||
describe('goCommand', () => {
|
||||
let originalHref: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
originalHref = window.location.href
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', { value: { href: originalHref }, writable: true })
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(goCommand.name).toBe('go')
|
||||
expect(goCommand.mode).toBe('submenu')
|
||||
expect(goCommand.aliases).toEqual(['navigate', 'nav'])
|
||||
expect(goCommand.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('returns all navigation items when query is empty', async () => {
|
||||
goCommand.register?.({ agentsAvailable: true, skillsAvailable: true })
|
||||
const results = await goCommand.search('', 'en')
|
||||
|
||||
expect(results.map((r) => r.id)).toEqual([
|
||||
'go-apps',
|
||||
'go-datasets',
|
||||
'go-agents',
|
||||
'go-skills',
|
||||
'go-plugins',
|
||||
'go-tools',
|
||||
'go-home',
|
||||
'go-account',
|
||||
])
|
||||
})
|
||||
|
||||
it('hides agent and skill destinations when their navigation is unavailable', async () => {
|
||||
goCommand.register?.({ agentsAvailable: false, skillsAvailable: false })
|
||||
|
||||
const results = await goCommand.search('', 'en')
|
||||
|
||||
expect(results.map((result) => result.id)).not.toEqual(
|
||||
expect.arrayContaining(['go-agents', 'go-skills']),
|
||||
)
|
||||
})
|
||||
|
||||
it('routes Home to the consolidated home page', async () => {
|
||||
const results = await goCommand.search('home', 'en')
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'go-home',
|
||||
title: 'Home',
|
||||
description: '/',
|
||||
data: { command: 'navigation.go', args: { path: '/' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('filters by id match', async () => {
|
||||
const results = await goCommand.search('plugins', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.id).toBe('go-plugins')
|
||||
})
|
||||
|
||||
it('filters by label match (case-insensitive)', async () => {
|
||||
const results = await goCommand.search('Knowledge', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.id).toBe('go-datasets')
|
||||
expect(results[0]!.title).toBe('Knowledge')
|
||||
})
|
||||
|
||||
it('returns command results with navigation.go data', async () => {
|
||||
const results = await goCommand.search('apps', 'en')
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
type: 'command',
|
||||
title: 'Apps',
|
||||
description: '/apps',
|
||||
data: { command: 'navigation.go', args: { path: '/apps' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty list when nothing matches', async () => {
|
||||
const results = await goCommand.search('no-such-section', 'en')
|
||||
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('register / unregister', () => {
|
||||
it('registers navigation.go command', () => {
|
||||
goCommand.register?.({ agentsAvailable: false, skillsAvailable: true })
|
||||
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.go': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('unregisters navigation.go command', () => {
|
||||
goCommand.unregister?.()
|
||||
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.go'])
|
||||
})
|
||||
|
||||
it('registered handler navigates to the provided path', async () => {
|
||||
Object.defineProperty(window, 'location', { value: { href: '' }, writable: true })
|
||||
goCommand.register?.({ agentsAvailable: false, skillsAvailable: true })
|
||||
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
|
||||
|
||||
await handlers['navigation.go']!({ path: '/datasets' })
|
||||
|
||||
expect(window.location.href).toBe('/datasets')
|
||||
})
|
||||
|
||||
it('registered handler does nothing when path is missing', async () => {
|
||||
Object.defineProperty(window, 'location', { value: { href: '/current' }, writable: true })
|
||||
goCommand.register?.({ agentsAvailable: false, skillsAvailable: true })
|
||||
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
|
||||
|
||||
await handlers['navigation.go']!()
|
||||
await handlers['navigation.go']!({})
|
||||
|
||||
expect(window.location.href).toBe('/current')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { registerCommands, unregisterCommands } from '../command-bus'
|
||||
import { languageCommand } from '../language'
|
||||
|
||||
vi.mock('../command-bus')
|
||||
vi.mock('@/i18n/language', () => ({
|
||||
languages: [
|
||||
{ value: 'en-US', name: 'English', supported: true },
|
||||
{ value: 'zh-Hans', name: '简体中文', supported: true },
|
||||
{ value: 'ja-JP', name: '日本語', supported: true },
|
||||
{ value: 'unsupported', name: 'Unsupported', supported: false },
|
||||
],
|
||||
}))
|
||||
|
||||
describe('languageCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(languageCommand.name).toBe('language')
|
||||
expect(languageCommand.aliases).toEqual(['lang'])
|
||||
expect(languageCommand.mode).toBe('submenu')
|
||||
expect(languageCommand.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('returns all supported languages when query is empty', async () => {
|
||||
const results = await languageCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(3) // 3 supported languages
|
||||
expect(results.every((r) => r.type === 'command')).toBe(true)
|
||||
})
|
||||
|
||||
it('filters languages by name query', async () => {
|
||||
const results = await languageCommand.search('english', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.id).toBe('lang-en-US')
|
||||
})
|
||||
|
||||
it('filters languages by value query', async () => {
|
||||
const results = await languageCommand.search('zh', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.id).toBe('lang-zh-Hans')
|
||||
})
|
||||
|
||||
it('returns command data with i18n.set command', async () => {
|
||||
const results = await languageCommand.search('', 'en')
|
||||
|
||||
results.forEach((r) => {
|
||||
expect(r.data.command).toBe('i18n.set')
|
||||
expect(r.data.args).toHaveProperty('locale')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('register / unregister', () => {
|
||||
it('registers i18n.set command', () => {
|
||||
languageCommand.register?.({ setLocale: vi.fn() })
|
||||
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'i18n.set': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('unregisters i18n.set command', () => {
|
||||
languageCommand.unregister?.()
|
||||
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['i18n.set'])
|
||||
})
|
||||
|
||||
it('registered handler calls setLocale with correct locale', async () => {
|
||||
const setLocale = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(registerCommands).mockImplementation((map) => {
|
||||
map['i18n.set']?.({ locale: 'zh-Hans' })
|
||||
})
|
||||
|
||||
languageCommand.register?.({ setLocale })
|
||||
|
||||
expect(setLocale).toHaveBeenCalledWith('zh-Hans')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
import { executeCommand } from '../command-bus'
|
||||
import { refineCommand } from '../refine'
|
||||
// Spy on the generator store so we can observe what /refine opens it with.
|
||||
const mockOpenGenerator = vi.fn()
|
||||
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
|
||||
useWorkflowGeneratorStore: {
|
||||
getState: () => ({ openGenerator: mockOpenGenerator }),
|
||||
},
|
||||
}))
|
||||
|
||||
// Controllable app-store state — /refine reads appDetail to gate availability
|
||||
// and to pick the mode + id it refines. Mutated per-test; read lazily.
|
||||
const mockAppStore: { appDetail: { id: string; mode: string } | undefined } = {
|
||||
appDetail: undefined,
|
||||
}
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: {
|
||||
getState: () => ({ appDetail: mockAppStore.appDetail }),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('/refine slash command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppStore.appDetail = undefined
|
||||
})
|
||||
|
||||
describe('handler metadata', () => {
|
||||
it('should be a direct command named refine with the expected alias', () => {
|
||||
expect(refineCommand.mode).toBe('direct')
|
||||
expect(refineCommand.name).toBe('refine')
|
||||
expect(refineCommand.aliases).toEqual(['improve'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAvailable()', () => {
|
||||
// /refine only makes sense inside a graph-based Studio — elsewhere there's
|
||||
// no draft graph to refine, so the command must hide itself.
|
||||
it('should be unavailable when no app is open', () => {
|
||||
expect(refineCommand.isAvailable?.()).toBe(false)
|
||||
})
|
||||
|
||||
it('should be available in a Workflow Studio', () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
|
||||
expect(refineCommand.isAvailable?.()).toBe(true)
|
||||
})
|
||||
|
||||
it('should be available in an Advanced-Chat Studio', () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'advanced-chat' }
|
||||
expect(refineCommand.isAvailable?.()).toBe(true)
|
||||
})
|
||||
|
||||
it('should be unavailable for non-graph apps (chat / agent / completion)', () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'chat' }
|
||||
expect(refineCommand.isAvailable?.()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute()', () => {
|
||||
// The core behaviour: open the generator in refine intent, threading the
|
||||
// current app's id + mode so the modal fetches its draft as context.
|
||||
it('should open the generator in refine intent for a Workflow Studio', () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
|
||||
|
||||
refineCommand.execute?.()
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
intent: 'refine',
|
||||
mode: 'workflow',
|
||||
currentAppId: 'app-1',
|
||||
currentAppMode: 'workflow',
|
||||
})
|
||||
})
|
||||
|
||||
it('should map advanced-chat apps to the advanced-chat generator mode', () => {
|
||||
mockAppStore.appDetail = { id: 'app-2', mode: 'advanced-chat' }
|
||||
|
||||
refineCommand.execute?.()
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
intent: 'refine',
|
||||
mode: 'advanced-chat',
|
||||
currentAppId: 'app-2',
|
||||
currentAppMode: 'advanced-chat',
|
||||
})
|
||||
})
|
||||
|
||||
it('should be a no-op when no graph-based app is open', () => {
|
||||
mockAppStore.appDetail = { id: 'app-3', mode: 'chat' }
|
||||
|
||||
refineCommand.execute?.()
|
||||
|
||||
expect(mockOpenGenerator).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('search()', () => {
|
||||
// The submenu/result list renders one localised entry carrying the
|
||||
// refine.open command-bus payload.
|
||||
it('should return a single localised refine result', async () => {
|
||||
const results = await refineCommand.search('')
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.id).toBe('refine-current')
|
||||
expect(results[0]!.title).toBe('gotoAnything.actions.refineTitle')
|
||||
expect(results[0]!.data.command).toBe('refine.open')
|
||||
})
|
||||
})
|
||||
|
||||
describe('register() — `refine.open` command-bus handler', () => {
|
||||
beforeEach(() => {
|
||||
refineCommand.register?.({} as never)
|
||||
})
|
||||
afterEach(() => {
|
||||
refineCommand.unregister?.()
|
||||
})
|
||||
|
||||
it('should open the generator via the command bus too', async () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
|
||||
|
||||
await executeCommand('refine.open', {})
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
intent: 'refine',
|
||||
mode: 'workflow',
|
||||
currentAppId: 'app-1',
|
||||
currentAppMode: 'workflow',
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop firing after unregister', async () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
|
||||
refineCommand.unregister?.()
|
||||
|
||||
await executeCommand('refine.open', {})
|
||||
|
||||
expect(mockOpenGenerator).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,302 +1,100 @@
|
||||
import type { SlashCommandHandler } from '../types'
|
||||
import type { SlashCommand } from '../types'
|
||||
import { SlashCommandRegistry } from '../registry'
|
||||
import { createCommandContext } from './context'
|
||||
|
||||
function createHandler(overrides: Partial<SlashCommandHandler> = {}): SlashCommandHandler {
|
||||
return {
|
||||
name: 'test',
|
||||
description: 'Test command',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
register: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
const context = createCommandContext()
|
||||
const command = (overrides: Partial<SlashCommand> = {}): SlashCommand => ({
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
search: () => [],
|
||||
execute: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SlashCommandRegistry', () => {
|
||||
let registry: SlashCommandRegistry
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new SlashCommandRegistry()
|
||||
describe('command lookup and execution', () => {
|
||||
it('resolves names and aliases without duplicating the command directory', () => {
|
||||
const language = command({ name: 'language', aliases: ['lang'] })
|
||||
const registry = new SlashCommandRegistry([language])
|
||||
expect(registry.findCommand('lang')).toBe(language)
|
||||
expect(registry.search('/', context).map((result) => result.title)).toEqual(['/language'])
|
||||
})
|
||||
|
||||
it('publishes stable deduplicated snapshots when commands are registered or removed', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = registry.subscribe(listener)
|
||||
const empty = registry.getSnapshot()
|
||||
const command = createHandler({ name: 'language', aliases: ['lang'] })
|
||||
registry.register(command)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
const registered = registry.getSnapshot()
|
||||
expect(registered).toEqual([command])
|
||||
expect(registry.getSnapshot()).toBe(registered)
|
||||
expect(registered).not.toBe(empty)
|
||||
registry.unregister('language')
|
||||
expect(registry.getSnapshot()).toEqual([])
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
unsubscribe()
|
||||
registry.register(command)
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
it('rejects ambiguous command names and aliases', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SlashCommandRegistry([
|
||||
command({ name: 'language', aliases: ['lang'] }),
|
||||
command({ name: 'lang' }),
|
||||
]),
|
||||
).toThrow('Duplicate slash command: lang')
|
||||
})
|
||||
|
||||
describe('register & findCommand', () => {
|
||||
it('registers a handler and retrieves it by name', () => {
|
||||
const handler = createHandler({ name: 'docs' })
|
||||
registry.register(handler)
|
||||
|
||||
expect(registry.findCommand('docs')).toBe(handler)
|
||||
})
|
||||
|
||||
it('registers aliases so handler is found by any alias', () => {
|
||||
const handler = createHandler({ name: 'language', aliases: ['lang', 'l'] })
|
||||
registry.register(handler)
|
||||
|
||||
expect(registry.findCommand('language')).toBe(handler)
|
||||
expect(registry.findCommand('lang')).toBe(handler)
|
||||
expect(registry.findCommand('l')).toBe(handler)
|
||||
})
|
||||
|
||||
it('calls handler.register with provided deps', () => {
|
||||
const handler = createHandler({ name: 'theme' })
|
||||
const deps = { setTheme: vi.fn() }
|
||||
registry.register(handler, deps)
|
||||
|
||||
expect(handler.register).toHaveBeenCalledWith(deps)
|
||||
})
|
||||
|
||||
it('does not call handler.register when no deps provided', () => {
|
||||
const handler = createHandler({ name: 'docs' })
|
||||
registry.register(handler)
|
||||
|
||||
expect(handler.register).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns undefined for unknown command name', () => {
|
||||
expect(registry.findCommand('nonexistent')).toBeUndefined()
|
||||
})
|
||||
it('forwards arguments through exact names, aliases, and partial names', () => {
|
||||
const search = vi.fn(() => [])
|
||||
const registry = new SlashCommandRegistry([
|
||||
command({ name: 'language', aliases: ['lang'], search }),
|
||||
])
|
||||
for (const query of ['/language English', '/lang English', '/LANG English', '/lan English']) {
|
||||
registry.search(query, context)
|
||||
expect(search).toHaveBeenLastCalledWith('English', context)
|
||||
}
|
||||
})
|
||||
|
||||
describe('unregister', () => {
|
||||
it('removes handler by name', () => {
|
||||
const handler = createHandler({ name: 'docs' })
|
||||
registry.register(handler)
|
||||
registry.unregister('docs')
|
||||
|
||||
expect(registry.findCommand('docs')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes all aliases', () => {
|
||||
const handler = createHandler({ name: 'language', aliases: ['lang'] })
|
||||
registry.register(handler)
|
||||
registry.unregister('language')
|
||||
|
||||
expect(registry.findCommand('language')).toBeUndefined()
|
||||
expect(registry.findCommand('lang')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls handler.unregister', () => {
|
||||
const handler = createHandler({ name: 'docs' })
|
||||
registry.register(handler)
|
||||
registry.unregister('docs')
|
||||
|
||||
expect(handler.unregister).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op for unknown command', () => {
|
||||
expect(() => registry.unregister('unknown')).not.toThrow()
|
||||
})
|
||||
it('filters unavailable commands and rechecks availability before execution', async () => {
|
||||
const execute = vi.fn()
|
||||
const registry = new SlashCommandRegistry([
|
||||
command({
|
||||
name: 'refine',
|
||||
aliases: ['improve'],
|
||||
execute,
|
||||
isAvailable: (context) => context.currentApp !== null,
|
||||
}),
|
||||
])
|
||||
const studio = createCommandContext({ currentApp: { id: 'app', mode: 'workflow' } })
|
||||
expect(registry.search('/', studio)).toHaveLength(1)
|
||||
expect(registry.search('/', context)).toEqual([])
|
||||
expect(registry.search('/improve', context)).toEqual([])
|
||||
await registry.execute('refine', {}, context)
|
||||
expect(execute).not.toHaveBeenCalled()
|
||||
await registry.execute('improve', {}, studio)
|
||||
expect(execute).toHaveBeenCalledWith({}, studio)
|
||||
})
|
||||
|
||||
describe('getAllCommands', () => {
|
||||
it('returns deduplicated handlers', () => {
|
||||
const h1 = createHandler({ name: 'theme', aliases: ['t'] })
|
||||
const h2 = createHandler({ name: 'docs' })
|
||||
registry.register(h1)
|
||||
registry.register(h2)
|
||||
|
||||
const commands = registry.getAllCommands()
|
||||
expect(commands).toHaveLength(2)
|
||||
expect(commands).toContainEqual(expect.objectContaining({ name: 'theme' }))
|
||||
expect(commands).toContainEqual(expect.objectContaining({ name: 'docs' }))
|
||||
})
|
||||
|
||||
it('returns empty array when nothing registered', () => {
|
||||
expect(registry.getAllCommands()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAvailableCommands', () => {
|
||||
it('includes commands without isAvailable guard', () => {
|
||||
registry.register(createHandler({ name: 'docs' }))
|
||||
|
||||
expect(registry.getAvailableCommands()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('includes commands where isAvailable returns true', () => {
|
||||
registry.register(createHandler({ name: 'zen', isAvailable: () => true }))
|
||||
|
||||
expect(registry.getAvailableCommands()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('excludes commands where isAvailable returns false', () => {
|
||||
registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
|
||||
|
||||
expect(registry.getAvailableCommands()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('returns root commands for "/"', async () => {
|
||||
registry.register(createHandler({ name: 'theme', description: 'Change theme' }))
|
||||
registry.register(createHandler({ name: 'docs', description: 'Open docs' }))
|
||||
|
||||
const results = await registry.search('/')
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: expect.stringContaining('root-'),
|
||||
type: 'command',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns root commands for "/ "', async () => {
|
||||
registry.register(createHandler({ name: 'theme' }))
|
||||
|
||||
const results = await registry.search('/ ')
|
||||
expect(results).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('delegates to exact-match handler for "/theme dark"', async () => {
|
||||
const mockResults = [
|
||||
{ id: 'dark', title: 'Dark', description: '', type: 'command' as const, data: {} },
|
||||
]
|
||||
const handler = createHandler({
|
||||
name: 'theme',
|
||||
search: vi.fn().mockResolvedValue(mockResults),
|
||||
})
|
||||
registry.register(handler)
|
||||
|
||||
const results = await registry.search('/theme dark')
|
||||
|
||||
expect(handler.search).toHaveBeenCalledWith('dark', 'en')
|
||||
expect(results).toEqual(mockResults)
|
||||
})
|
||||
|
||||
it('delegates to exact-match handler for command without args', async () => {
|
||||
const handler = createHandler({ name: 'docs', search: vi.fn().mockResolvedValue([]) })
|
||||
registry.register(handler)
|
||||
|
||||
await registry.search('/docs')
|
||||
|
||||
expect(handler.search).toHaveBeenCalledWith('', 'en')
|
||||
})
|
||||
|
||||
it('uses partial match when no exact match found', async () => {
|
||||
const mockResults = [
|
||||
{ id: '1', title: 'T', description: '', type: 'command' as const, data: {} },
|
||||
]
|
||||
const handler = createHandler({
|
||||
name: 'theme',
|
||||
search: vi.fn().mockResolvedValue(mockResults),
|
||||
})
|
||||
registry.register(handler)
|
||||
|
||||
const results = await registry.search('/the')
|
||||
|
||||
expect(results).toEqual(mockResults)
|
||||
})
|
||||
|
||||
it('uses alias partial match', async () => {
|
||||
const mockResults = [
|
||||
{ id: '1', title: 'L', description: '', type: 'command' as const, data: {} },
|
||||
]
|
||||
const handler = createHandler({
|
||||
name: 'language',
|
||||
aliases: ['lang'],
|
||||
search: vi.fn().mockResolvedValue(mockResults),
|
||||
})
|
||||
registry.register(handler)
|
||||
|
||||
const results = await registry.search('/lan')
|
||||
|
||||
expect(results).toEqual(mockResults)
|
||||
})
|
||||
|
||||
it('falls back to fuzzy search when nothing matches', async () => {
|
||||
registry.register(createHandler({ name: 'theme', description: 'Set theme' }))
|
||||
|
||||
const results = await registry.search('/hem')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.title).toBe('/theme')
|
||||
})
|
||||
|
||||
it('fuzzy search also matches aliases', async () => {
|
||||
registry.register(
|
||||
createHandler({ name: 'language', aliases: ['lang'], description: 'Set language' }),
|
||||
)
|
||||
|
||||
const handler = registry.findCommand('language')
|
||||
await registry.search('/lan')
|
||||
expect(handler?.search).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty when handler.search throws', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const handler = createHandler({
|
||||
name: 'broken',
|
||||
search: vi.fn(() => {
|
||||
throw new Error('fail')
|
||||
it.each(['throw', 'reject'] as const)(
|
||||
'reports an execution %s and keeps other commands usable',
|
||||
async (failure) => {
|
||||
const error = new Error('Command failed')
|
||||
const context = createCommandContext()
|
||||
const succeeding = vi.fn()
|
||||
const registry = new SlashCommandRegistry([
|
||||
command({
|
||||
execute: () => {
|
||||
if (failure === 'throw') throw error
|
||||
return Promise.reject(error)
|
||||
},
|
||||
}),
|
||||
})
|
||||
registry.register(handler)
|
||||
command({ name: 'other', execute: succeeding }),
|
||||
])
|
||||
await expect(registry.execute('test', {}, context)).resolves.toBeUndefined()
|
||||
expect(context.onError).toHaveBeenCalledExactlyOnceWith(error)
|
||||
await registry.execute('other', {}, context)
|
||||
expect(succeeding).toHaveBeenCalledExactlyOnceWith({}, context)
|
||||
expect(context.onError).toHaveBeenCalledTimes(1)
|
||||
},
|
||||
)
|
||||
|
||||
const results = await registry.search('/broken')
|
||||
expect(results).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Command search failed'),
|
||||
expect.any(Error),
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('excludes unavailable commands from root listing', async () => {
|
||||
registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
|
||||
registry.register(createHandler({ name: 'docs' }))
|
||||
|
||||
const results = await registry.search('/')
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.title).toBe('/docs')
|
||||
})
|
||||
|
||||
it('skips unavailable handler in exact match', async () => {
|
||||
registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
|
||||
|
||||
const results = await registry.search('/zen')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('passes locale to handler search', async () => {
|
||||
const handler = createHandler({ name: 'theme', search: vi.fn().mockResolvedValue([]) })
|
||||
registry.register(handler)
|
||||
|
||||
await registry.search('/theme light', 'zh')
|
||||
|
||||
expect(handler.search).toHaveBeenCalledWith('light', 'zh')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCommandDependencies', () => {
|
||||
it('returns stored deps', () => {
|
||||
const deps = { setTheme: vi.fn() }
|
||||
registry.register(createHandler({ name: 'theme' }), deps)
|
||||
|
||||
expect(registry.getCommandDependencies('theme')).toBe(deps)
|
||||
})
|
||||
|
||||
it('returns undefined when no deps stored', () => {
|
||||
registry.register(createHandler({ name: 'docs' }))
|
||||
|
||||
expect(registry.getCommandDependencies('docs')).toBeUndefined()
|
||||
})
|
||||
it('contains command search failures and leaves other commands usable', () => {
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const registry = new SlashCommandRegistry([
|
||||
command({
|
||||
search: () => {
|
||||
throw new Error('Unavailable')
|
||||
},
|
||||
}),
|
||||
command({ name: 'other' }),
|
||||
])
|
||||
expect(registry.search('/test', context)).toEqual([])
|
||||
expect(registry.search('/', context)).toHaveLength(2)
|
||||
warning.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,202 +0,0 @@
|
||||
import type { SearchResult } from '../../types'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { slashAction } from '../slash'
|
||||
import { SlashCommandProvider } from '../slash-provider'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { createReactI18nextLanguageMock } = await import('@/test/i18n-mock')
|
||||
return createReactI18nextLanguageMock('ja')
|
||||
})
|
||||
|
||||
const {
|
||||
mockSetTheme,
|
||||
mockSetLocale,
|
||||
mockExecuteCommand,
|
||||
mockRegister,
|
||||
mockSearch,
|
||||
mockUnregister,
|
||||
featureFlag,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSetTheme: vi.fn(),
|
||||
mockSetLocale: vi.fn(),
|
||||
mockExecuteCommand: vi.fn(),
|
||||
mockRegister: vi.fn(),
|
||||
mockSearch: vi.fn(),
|
||||
mockUnregister: vi.fn(),
|
||||
// Mutable holder so each test can flip the feature-preview flag before render.
|
||||
featureFlag: { enabled: false },
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
get ENABLE_FEATURE_PREVIEW() {
|
||||
return featureFlag.enabled
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({
|
||||
setTheme: mockSetTheme,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/i18n/client', () => ({
|
||||
setLocaleOnClient: mockSetLocale,
|
||||
}))
|
||||
vi.mock('@/features/agent-v2/feature-flag', () => ({
|
||||
isAgentV2Enabled: () => true,
|
||||
}))
|
||||
|
||||
vi.mock('../command-bus', () => ({
|
||||
executeCommand: (...args: unknown[]) => mockExecuteCommand(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../registry', () => ({
|
||||
slashCommandRegistry: {
|
||||
register: (...args: unknown[]) => mockRegister(...args),
|
||||
search: (...args: unknown[]) => mockSearch(...args),
|
||||
unregister: (...args: unknown[]) => mockUnregister(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('slashAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should expose translated title and description', () => {
|
||||
expect(slashAction.title).toBe('gotoAnything.actions.slashTitle')
|
||||
expect(slashAction.description).toBe('gotoAnything.actions.slashDesc')
|
||||
})
|
||||
|
||||
it('should execute command results and ignore non-command results', () => {
|
||||
slashAction.action?.({
|
||||
id: 'cmd-1',
|
||||
title: 'Command',
|
||||
type: 'command',
|
||||
data: {
|
||||
command: 'navigation.docs',
|
||||
args: { path: '/docs' },
|
||||
},
|
||||
} as SearchResult)
|
||||
|
||||
slashAction.action?.({
|
||||
id: 'app-1',
|
||||
title: 'App',
|
||||
type: 'app',
|
||||
data: {} as never,
|
||||
} as SearchResult)
|
||||
|
||||
expect(mockExecuteCommand).toHaveBeenCalledTimes(1)
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith('navigation.docs', { path: '/docs' })
|
||||
})
|
||||
|
||||
it('should delegate search to the slash command registry with the active language', async () => {
|
||||
mockSearch.mockResolvedValue([
|
||||
{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } },
|
||||
])
|
||||
|
||||
expect(slashAction.source).toBe('local')
|
||||
if (slashAction.source !== 'local') throw new Error('Expected a local slash action')
|
||||
const results = await slashAction.search('/theme dark', 'dark')
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledWith('/theme dark', 'ja')
|
||||
expect(results).toEqual([
|
||||
{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SlashCommandProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Default: feature preview off, so /create and /refine are NOT registered.
|
||||
featureFlag.enabled = false
|
||||
})
|
||||
|
||||
it('should not register the /create and /refine preview commands when the feature flag is off', () => {
|
||||
const { unmount } = render(<SlashCommandProvider />)
|
||||
|
||||
expect(mockRegister.mock.calls.map((call) => call[0].name)).toEqual([
|
||||
'theme',
|
||||
'language',
|
||||
'docs',
|
||||
'discord',
|
||||
'models',
|
||||
'account',
|
||||
'go',
|
||||
])
|
||||
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), {
|
||||
setTheme: mockSetTheme,
|
||||
})
|
||||
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), {
|
||||
setLocale: mockSetLocale,
|
||||
})
|
||||
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'go' }), {
|
||||
agentsAvailable: true,
|
||||
skillsAvailable: true,
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
// Unregister is always called for the preview commands (a no-op when they
|
||||
// were never registered) so toggling the flag off mid-session stays clean.
|
||||
expect(mockUnregister.mock.calls.map((call) => call[0])).toEqual([
|
||||
'theme',
|
||||
'language',
|
||||
'docs',
|
||||
'discord',
|
||||
'models',
|
||||
'account',
|
||||
'go',
|
||||
'create',
|
||||
'refine',
|
||||
])
|
||||
})
|
||||
|
||||
it('should register the /create and /refine preview commands when the feature flag is on', () => {
|
||||
featureFlag.enabled = true
|
||||
|
||||
const { unmount } = render(<SlashCommandProvider />)
|
||||
|
||||
expect(mockRegister.mock.calls.map((call) => call[0].name)).toEqual([
|
||||
'theme',
|
||||
'language',
|
||||
'docs',
|
||||
'discord',
|
||||
'models',
|
||||
'account',
|
||||
'go',
|
||||
'create',
|
||||
'refine',
|
||||
])
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockUnregister.mock.calls.map((call) => call[0])).toEqual([
|
||||
'theme',
|
||||
'language',
|
||||
'docs',
|
||||
'discord',
|
||||
'models',
|
||||
'account',
|
||||
'go',
|
||||
'create',
|
||||
'refine',
|
||||
])
|
||||
})
|
||||
|
||||
it('should register the enterprise documentation home URL', () => {
|
||||
const { unmount } = render(<SlashCommandProvider />, {
|
||||
systemFeatures: { deployment_edition: 'ENTERPRISE' },
|
||||
})
|
||||
const docsRegistration = mockRegister.mock.calls.find((call) => call[0].name === 'docs')
|
||||
|
||||
expect(docsRegistration?.[1].getDocsHomeUrl()).toBe('https://enterprise-docs.dify.ai/en/')
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
import { registerCommands, unregisterCommands } from '../command-bus'
|
||||
import { themeCommand } from '../theme'
|
||||
|
||||
vi.mock('../command-bus')
|
||||
describe('themeCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(themeCommand.name).toBe('theme')
|
||||
expect(themeCommand.mode).toBe('submenu')
|
||||
expect(themeCommand.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('returns all theme options when query is empty', async () => {
|
||||
const results = await themeCommand.search('', 'en')
|
||||
|
||||
expect(results).toHaveLength(3)
|
||||
expect(results.map((r) => r.id)).toEqual(['system', 'light', 'dark'])
|
||||
})
|
||||
|
||||
it('returns all theme options with correct type', async () => {
|
||||
const results = await themeCommand.search('', 'en')
|
||||
|
||||
results.forEach((r) => {
|
||||
expect(r.type).toBe('command')
|
||||
expect(r.data).toEqual({
|
||||
command: 'theme.set',
|
||||
args: expect.objectContaining({ value: expect.any(String) }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('filters results by query matching id', async () => {
|
||||
const results = await themeCommand.search('dark', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.id).toBe('dark')
|
||||
})
|
||||
})
|
||||
|
||||
describe('register / unregister', () => {
|
||||
it('registers theme.set command with deps', () => {
|
||||
const deps = { setTheme: vi.fn() }
|
||||
themeCommand.register?.(deps)
|
||||
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'theme.set': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('unregisters theme.set command', () => {
|
||||
themeCommand.unregister?.()
|
||||
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['theme.set'])
|
||||
})
|
||||
|
||||
it('registered handler calls setTheme', async () => {
|
||||
const setTheme = vi.fn()
|
||||
vi.mocked(registerCommands).mockImplementation((map) => {
|
||||
map['theme.set']?.({ value: 'dark' })
|
||||
})
|
||||
|
||||
themeCommand.register?.({ setTheme })
|
||||
|
||||
expect(setTheme).toHaveBeenCalledWith('dark')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,32 +1,26 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Account command dependency types - no external dependencies needed
|
||||
type AccountDeps = Record<string, never>
|
||||
import type { SlashCommand } from './types'
|
||||
|
||||
/**
|
||||
* Account command - Navigates to account page
|
||||
*/
|
||||
export const accountCommand: SlashCommandHandler<AccountDeps> = {
|
||||
export const accountCommand: SlashCommand = {
|
||||
name: 'account',
|
||||
description: 'Navigate to account page',
|
||||
mode: 'direct',
|
||||
|
||||
// Direct execution function
|
||||
execute: () => {
|
||||
window.location.href = '/account'
|
||||
},
|
||||
execute: (_args, context) => context.navigate('/account'),
|
||||
|
||||
search(args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
search(args: string, context) {
|
||||
return [
|
||||
{
|
||||
id: 'account',
|
||||
title: i18n.t(($) => $['account.account'], { ns: 'accountSettings', lng: locale }),
|
||||
description: i18n.t(($) => $['gotoAnything.actions.accountDesc'], {
|
||||
title: context.t(($) => $['account.account'], {
|
||||
ns: 'accountSettings',
|
||||
lng: context.locale,
|
||||
}),
|
||||
description: context.t(($) => $['gotoAnything.actions.accountDesc'], {
|
||||
ns: 'app',
|
||||
lng: locale,
|
||||
lng: context.locale,
|
||||
}),
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
@@ -34,21 +28,8 @@ export const accountCommand: SlashCommandHandler<AccountDeps> = {
|
||||
<span aria-hidden className="i-ri-user-3-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'navigation.account', args: {} },
|
||||
data: { command: 'account', args: {} },
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
register(_deps: AccountDeps) {
|
||||
registerCommands({
|
||||
'navigation.account': async (_args) => {
|
||||
// Navigate to account page
|
||||
window.location.href = '/account'
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['navigation.account'])
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ENABLE_FEATURE_PREVIEW } from '@/config'
|
||||
import { accountCommand } from './account'
|
||||
import { createCommand } from './create'
|
||||
import { discordCommand } from './discord'
|
||||
import { docsCommand } from './docs'
|
||||
import { goCommand } from './go'
|
||||
import { languageCommand } from './language'
|
||||
import { modelsCommand } from './models'
|
||||
import { refineCommand } from './refine'
|
||||
import { SlashCommandRegistry } from './registry'
|
||||
import { themeCommand } from './theme'
|
||||
|
||||
export const slashCommandRegistry = new SlashCommandRegistry([
|
||||
themeCommand,
|
||||
languageCommand,
|
||||
docsCommand,
|
||||
discordCommand,
|
||||
modelsCommand,
|
||||
accountCommand,
|
||||
goCommand,
|
||||
...(ENABLE_FEATURE_PREVIEW ? [createCommand, refineCommand] : []),
|
||||
])
|
||||
@@ -1,25 +0,0 @@
|
||||
type CommandHandler = (args?: Record<string, any>) => void | Promise<void>
|
||||
|
||||
const handlers = new Map<string, CommandHandler>()
|
||||
|
||||
const registerCommand = (name: string, handler: CommandHandler) => {
|
||||
handlers.set(name, handler)
|
||||
}
|
||||
|
||||
const unregisterCommand = (name: string) => {
|
||||
handlers.delete(name)
|
||||
}
|
||||
|
||||
export const executeCommand = async (name: string, args?: Record<string, any>) => {
|
||||
const handler = handlers.get(name)
|
||||
if (!handler) return
|
||||
await handler(args)
|
||||
}
|
||||
|
||||
export const registerCommands = (map: Record<string, CommandHandler>) => {
|
||||
Object.entries(map).forEach(([name, handler]) => registerCommand(name, handler))
|
||||
}
|
||||
|
||||
export const unregisterCommands = (names: string[]) => {
|
||||
names.forEach(unregisterCommand)
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { SlashCommand } from './types'
|
||||
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
type CreateOption = {
|
||||
id: string
|
||||
@@ -66,16 +61,33 @@ const OPTIONS = [
|
||||
* through so the modal offers "Apply to current draft". Auto-mode always creates
|
||||
* a new app since the planner may pick a different type than the open Studio.
|
||||
*/
|
||||
export const createCommand: SlashCommandHandler = {
|
||||
export const createCommand: SlashCommand = {
|
||||
name: 'create',
|
||||
aliases: ['new', 'generate'],
|
||||
description: getI18n().t(($) => $['gotoAnything.actions.createCategoryDesc'], { ns: 'app' }),
|
||||
description: 'Create an app with AI',
|
||||
mode: 'submenu',
|
||||
execute(args, context) {
|
||||
const option = OPTIONS.find(
|
||||
(option) => option.mode === args.mode && option.auto === !!args.auto,
|
||||
)
|
||||
if (!option) return
|
||||
const initialInstruction = typeof args.instruction === 'string' ? args.instruction : ''
|
||||
const app = context.currentApp
|
||||
if (!option.auto && app?.mode === option.mode) {
|
||||
context.openGenerator({
|
||||
mode: option.mode,
|
||||
currentAppId: app.id,
|
||||
currentAppMode: app.mode,
|
||||
initialInstruction,
|
||||
})
|
||||
return
|
||||
}
|
||||
context.openGenerator({ mode: option.mode, autoMode: option.auto, initialInstruction })
|
||||
},
|
||||
|
||||
search(args: string, locale?: string) {
|
||||
const i18n = getI18n()
|
||||
search(args: string, context) {
|
||||
const tr = (key: (typeof OPTIONS)[number]['titleKey' | 'descKey']) =>
|
||||
i18n.t(($) => $[key], { ns: 'app', lng: locale })
|
||||
context.t(($) => $[key], { ns: 'app', lng: context.locale })
|
||||
|
||||
const renderIcon = (iconClassName: string) => (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
@@ -91,7 +103,7 @@ export const createCommand: SlashCommandHandler = {
|
||||
description: instruction || tr(opt.descKey),
|
||||
type: 'command' as const,
|
||||
icon: renderIcon(opt.iconClassName),
|
||||
data: { command: 'create.open', args: { mode: opt.mode, auto: !!opt.auto, instruction } },
|
||||
data: { command: 'create', args: { mode: opt.mode, auto: !!opt.auto, instruction } },
|
||||
})
|
||||
|
||||
const trimmed = args.trim()
|
||||
@@ -116,45 +128,4 @@ export const createCommand: SlashCommandHandler = {
|
||||
if (matched) return [toResult(matched, tokens.slice(1).join(' '))]
|
||||
return OPTIONS.map((opt) => toResult(opt, trimmed))
|
||||
},
|
||||
|
||||
register() {
|
||||
registerCommands({
|
||||
'create.open': async (args) => {
|
||||
const mode: WorkflowGeneratorMode = (args?.mode ?? 'workflow') as WorkflowGeneratorMode
|
||||
const autoMode = !!args?.auto
|
||||
const initialInstruction = typeof args?.instruction === 'string' ? args.instruction : ''
|
||||
|
||||
// If a graph-based Studio app is open and its mode matches the picked
|
||||
// mode, thread it through so the modal can offer "Apply to current
|
||||
// draft". A mode mismatch (or no app open) falls back to new-app only,
|
||||
// mirroring the precondition the modal uses for canApplyToCurrent.
|
||||
// Auto-mode always creates a new app — the planner may resolve a type
|
||||
// different from the open Studio, so applying to the current draft is
|
||||
// unsafe.
|
||||
const appDetail = useAppStore.getState().appDetail
|
||||
const currentAppMode: WorkflowGeneratorMode | null =
|
||||
appDetail?.mode === AppModeEnum.WORKFLOW
|
||||
? 'workflow'
|
||||
: appDetail?.mode === AppModeEnum.ADVANCED_CHAT
|
||||
? 'advanced-chat'
|
||||
: null
|
||||
|
||||
if (!autoMode && appDetail && currentAppMode === mode) {
|
||||
useWorkflowGeneratorStore.getState().openGenerator({
|
||||
mode,
|
||||
currentAppId: appDetail.id,
|
||||
currentAppMode,
|
||||
initialInstruction,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
useWorkflowGeneratorStore.getState().openGenerator({ mode, autoMode, initialInstruction })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['create.open'])
|
||||
},
|
||||
}
|
||||
@@ -1,49 +1,32 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
type DiscordDeps = Record<string, never>
|
||||
import type { SlashCommand } from './types'
|
||||
|
||||
const DISCORD_URL = 'https://discord.gg/5AEfbxcd9k'
|
||||
|
||||
const openDiscord = (url = DISCORD_URL) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
export const discordCommand: SlashCommandHandler<DiscordDeps> = {
|
||||
export const discordCommand: SlashCommand = {
|
||||
name: 'discord',
|
||||
description: 'Open Discord community',
|
||||
mode: 'direct',
|
||||
|
||||
execute: () => openDiscord(),
|
||||
execute: (_args, context) => context.openExternal(DISCORD_URL),
|
||||
|
||||
search(_args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
search(args: string, context) {
|
||||
return [
|
||||
{
|
||||
id: 'discord',
|
||||
title: 'Discord',
|
||||
description:
|
||||
i18n.t(($) => $['gotoAnything.actions.discordDesc'], { ns: 'app', lng: locale }) ||
|
||||
'Open Discord community',
|
||||
context.t(($) => $['gotoAnything.actions.discordDesc'], {
|
||||
ns: 'app',
|
||||
lng: context.locale,
|
||||
}) || 'Open Discord community',
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<span aria-hidden className="i-ri-discord-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'navigation.discord', args: { url: DISCORD_URL } },
|
||||
data: { command: 'discord', args: { url: DISCORD_URL } },
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
register(_deps: DiscordDeps) {
|
||||
registerCommands({
|
||||
'navigation.discord': async (args) => openDiscord(args?.url),
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['navigation.discord'])
|
||||
},
|
||||
}
|
||||
@@ -1,39 +1,22 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
type DocDeps = {
|
||||
getDocsHomeUrl: () => string
|
||||
}
|
||||
|
||||
let getDocsHomeUrl: (() => string) | undefined
|
||||
|
||||
const openDocsHome = () => {
|
||||
const url = getDocsHomeUrl?.()
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
import type { SlashCommand } from './types'
|
||||
|
||||
/**
|
||||
* Documentation command - Opens help documentation
|
||||
*/
|
||||
export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
export const docsCommand: SlashCommand = {
|
||||
name: 'docs',
|
||||
description: 'Open documentation',
|
||||
mode: 'direct',
|
||||
|
||||
// Direct execution function
|
||||
execute: () => {
|
||||
openDocsHome()
|
||||
},
|
||||
execute: (_args, context) => context.openExternal(context.getDocsHomeUrl()),
|
||||
|
||||
search(args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
search(args: string, context) {
|
||||
return [
|
||||
{
|
||||
id: 'doc',
|
||||
title: i18n.t(($) => $['userProfile.helpCenter'], { ns: 'common', lng: locale }),
|
||||
title: context.t(($) => $['userProfile.helpCenter'], { ns: 'common', lng: context.locale }),
|
||||
description:
|
||||
i18n.t(($) => $['gotoAnything.actions.docDesc'], { ns: 'app', lng: locale }) ||
|
||||
context.t(($) => $['gotoAnything.actions.docDesc'], { ns: 'app', lng: context.locale }) ||
|
||||
'Open help documentation',
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
@@ -41,22 +24,8 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
<span aria-hidden className="i-ri-book-open-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'navigation.doc', args: {} },
|
||||
data: { command: 'docs', args: {} },
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
register(deps: DocDeps) {
|
||||
getDocsHomeUrl = deps.getDocsHomeUrl
|
||||
registerCommands({
|
||||
'navigation.doc': async (_args) => {
|
||||
openDocsHome()
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
getDocsHomeUrl = undefined
|
||||
unregisterCommands(['navigation.doc'])
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
import type { SlashCommand } from './types'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'apps', label: 'Apps', path: '/apps', iconClassName: 'i-ri-apps-2-line' },
|
||||
@@ -24,31 +23,27 @@ const NAV_ITEMS = [
|
||||
{ id: 'account', label: 'Account', path: '/account', iconClassName: 'i-ri-user-line' },
|
||||
] as const
|
||||
|
||||
type GoDeps = {
|
||||
agentsAvailable: boolean
|
||||
skillsAvailable: boolean
|
||||
}
|
||||
|
||||
let availability: GoDeps = {
|
||||
agentsAvailable: false,
|
||||
skillsAvailable: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Go command - Navigate to a top-level section of the app
|
||||
*/
|
||||
export const goCommand: SlashCommandHandler<GoDeps> = {
|
||||
export const goCommand: SlashCommand = {
|
||||
name: 'go',
|
||||
aliases: ['navigate', 'nav'],
|
||||
description: 'Navigate to a section',
|
||||
mode: 'submenu',
|
||||
execute(args, context) {
|
||||
const item = NAV_ITEMS.find((item) => item.path === args.path)
|
||||
if (!item) return
|
||||
if ('availability' in item && !context[`${item.availability}Available`]) return
|
||||
context.navigate(item.path)
|
||||
},
|
||||
|
||||
search(args: string, _locale: string = 'en') {
|
||||
search(args: string, context) {
|
||||
const query = args.trim().toLowerCase()
|
||||
const items = NAV_ITEMS.filter((item) => {
|
||||
if ('availability' in item) {
|
||||
if (item.availability === 'agents' && !availability.agentsAvailable) return false
|
||||
if (item.availability === 'skills' && !availability.skillsAvailable) return false
|
||||
if (item.availability === 'agents' && !context.agentsAvailable) return false
|
||||
if (item.availability === 'skills' && !context.skillsAvailable) return false
|
||||
}
|
||||
|
||||
return !query || item.id.includes(query) || item.label.toLowerCase().includes(query)
|
||||
@@ -63,24 +58,7 @@ export const goCommand: SlashCommandHandler<GoDeps> = {
|
||||
<span aria-hidden className={`${item.iconClassName} size-4 text-text-tertiary`} />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'navigation.go', args: { path: item.path } },
|
||||
data: { command: 'go', args: { path: item.path } },
|
||||
}))
|
||||
},
|
||||
|
||||
register(deps: GoDeps) {
|
||||
availability = deps
|
||||
registerCommands({
|
||||
'navigation.go': async (args) => {
|
||||
if (args?.path) window.location.href = args.path
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
availability = {
|
||||
agentsAvailable: false,
|
||||
skillsAvailable: true,
|
||||
}
|
||||
unregisterCommands(['navigation.go'])
|
||||
},
|
||||
}
|
||||
@@ -1,56 +1,40 @@
|
||||
import type { CommandSearchResult } from '../types'
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import type { CommandContext, SlashCommand } from './types'
|
||||
import { languages } from '@/i18n/language'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Language dependency types
|
||||
type LanguageDeps = {
|
||||
setLocale?: (locale: string) => Promise<void>
|
||||
}
|
||||
|
||||
const buildLanguageCommands = (query: string): CommandSearchResult[] => {
|
||||
const buildLanguageCommands = (query: string, context: CommandContext): CommandSearchResult[] => {
|
||||
const q = query.toLowerCase()
|
||||
const list = languages.filter(
|
||||
(item) =>
|
||||
item.supported &&
|
||||
(!q || item.name.toLowerCase().includes(q) || String(item.value).toLowerCase().includes(q)),
|
||||
)
|
||||
const i18n = getI18n()
|
||||
return list.map((item) => ({
|
||||
id: `lang-${item.value}`,
|
||||
title: item.name,
|
||||
description: i18n.t(($) => $['gotoAnything.actions.languageChangeDesc'], { ns: 'app' }),
|
||||
description: context.t(($) => $['gotoAnything.actions.languageChangeDesc'], {
|
||||
ns: 'app',
|
||||
lng: context.locale,
|
||||
}),
|
||||
type: 'command' as const,
|
||||
data: { command: 'i18n.set', args: { locale: item.value } },
|
||||
data: { command: 'language', args: { locale: item.value } },
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Language command handler
|
||||
* Integrates UI building, search, and registration logic
|
||||
*/
|
||||
export const languageCommand: SlashCommandHandler<LanguageDeps> = {
|
||||
export const languageCommand: SlashCommand = {
|
||||
name: 'language',
|
||||
aliases: ['lang'],
|
||||
description: 'Switch between different languages',
|
||||
mode: 'submenu', // Explicitly set submenu mode
|
||||
|
||||
search(args: string, _locale: string = 'en') {
|
||||
// Return language options directly, regardless of parameters
|
||||
return buildLanguageCommands(args)
|
||||
mode: 'submenu',
|
||||
async execute(args, context) {
|
||||
const language = languages.find((item) => item.supported && item.value === args.locale)
|
||||
if (language) await context.setLocale(language.value)
|
||||
},
|
||||
|
||||
register(deps: LanguageDeps) {
|
||||
registerCommands({
|
||||
'i18n.set': async (args) => {
|
||||
const locale = args?.locale
|
||||
if (locale) await deps.setLocale?.(locale)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['i18n.set'])
|
||||
search(args: string, context) {
|
||||
return buildLanguageCommands(args, context)
|
||||
},
|
||||
}
|
||||
@@ -1,34 +1,25 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
type ModelsDeps = Record<string, never>
|
||||
import type { SlashCommand } from './types'
|
||||
|
||||
export const SYSTEM_MODELS_PATH = '/integrations/model-provider?dialog=system-models'
|
||||
|
||||
const openSystemModels = () => {
|
||||
window.location.href = SYSTEM_MODELS_PATH
|
||||
}
|
||||
|
||||
export const modelsCommand: SlashCommandHandler<ModelsDeps> = {
|
||||
export const modelsCommand: SlashCommand = {
|
||||
name: 'models',
|
||||
description: 'Configure default workspace models',
|
||||
mode: 'direct',
|
||||
|
||||
execute: openSystemModels,
|
||||
execute: (_args, context) => context.navigate(SYSTEM_MODELS_PATH),
|
||||
|
||||
search(_args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
search(args: string, context) {
|
||||
return [
|
||||
{
|
||||
id: 'models',
|
||||
title: i18n.t(($) => $['modelProvider.systemModelSettings'], {
|
||||
title: context.t(($) => $['modelProvider.systemModelSettings'], {
|
||||
ns: 'modelProvider',
|
||||
lng: locale,
|
||||
lng: context.locale,
|
||||
}),
|
||||
description: i18n.t(($) => $['modelProvider.systemModelSettingsDesc'], {
|
||||
description: context.t(($) => $['modelProvider.systemModelSettingsDesc'], {
|
||||
ns: 'modelProvider',
|
||||
lng: locale,
|
||||
lng: context.locale,
|
||||
}),
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
@@ -36,18 +27,8 @@ export const modelsCommand: SlashCommandHandler<ModelsDeps> = {
|
||||
<span aria-hidden className="i-ri-brain-2-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'navigation.models' },
|
||||
data: { command: 'models' },
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
register(_deps: ModelsDeps) {
|
||||
registerCommands({
|
||||
'navigation.models': async () => openSystemModels(),
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['navigation.models'])
|
||||
},
|
||||
}
|
||||
@@ -1,70 +1,37 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
import type { SlashCommand } from './types'
|
||||
|
||||
/**
|
||||
* Map the open app's mode to a generator mode, or null when the current app
|
||||
* isn't a graph-based Studio (Chat / Agent / Completion have no canvas to
|
||||
* refine). This is the single source of truth for both the availability gate
|
||||
* and the mode we open the generator with.
|
||||
*/
|
||||
const currentStudioMode = (): WorkflowGeneratorMode | null => {
|
||||
const appMode = useAppStore.getState().appDetail?.mode
|
||||
if (appMode === AppModeEnum.WORKFLOW) return 'workflow'
|
||||
if (appMode === AppModeEnum.ADVANCED_CHAT) return 'advanced-chat'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the generator in `refine` intent for the current Studio. The modal
|
||||
* fetches the current draft graph and sends it as context so the LLM amends
|
||||
* what's on the canvas instead of starting from scratch. No-op when there's
|
||||
* no graph-based app open (the command is gated by `isAvailable`, but the
|
||||
* guard keeps a stray command-bus call safe).
|
||||
*/
|
||||
const openRefineGenerator = () => {
|
||||
const appDetail = useAppStore.getState().appDetail
|
||||
const mode = currentStudioMode()
|
||||
if (!appDetail || !mode) return
|
||||
useWorkflowGeneratorStore.getState().openGenerator({
|
||||
intent: 'refine',
|
||||
mode,
|
||||
currentAppId: appDetail.id,
|
||||
currentAppMode: mode,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `/refine` command — refine the CURRENT Workflow / Chatflow draft graph from
|
||||
* a natural-language change description. Only available inside a graph-based
|
||||
* Studio; the mode is taken from the open app (no submenu to pick), and the
|
||||
* result always applies back to the current draft.
|
||||
*/
|
||||
export const refineCommand: SlashCommandHandler = {
|
||||
export const refineCommand: SlashCommand = {
|
||||
name: 'refine',
|
||||
aliases: ['improve'],
|
||||
description: getI18n().t(($) => $['gotoAnything.actions.refineCategoryDesc'], { ns: 'app' }),
|
||||
description: 'Refine the current app',
|
||||
mode: 'direct',
|
||||
|
||||
// Only surface inside a Workflow / Advanced-Chat Studio — elsewhere there's
|
||||
// no draft graph to refine.
|
||||
isAvailable: () => currentStudioMode() !== null,
|
||||
isAvailable: (context) => context.currentApp !== null,
|
||||
|
||||
execute: openRefineGenerator,
|
||||
execute(_args, context) {
|
||||
const app = context.currentApp
|
||||
if (!app) return
|
||||
context.openGenerator({
|
||||
intent: 'refine',
|
||||
mode: app.mode,
|
||||
currentAppId: app.id,
|
||||
currentAppMode: app.mode,
|
||||
})
|
||||
},
|
||||
|
||||
search(_args: string, locale?: string) {
|
||||
const i18n = getI18n()
|
||||
search(_args: string, context) {
|
||||
return [
|
||||
{
|
||||
id: 'refine-current',
|
||||
title: i18n.t(($) => $['gotoAnything.actions.refineTitle'], { ns: 'app', lng: locale }),
|
||||
description: i18n.t(($) => $['gotoAnything.actions.refineDesc'], {
|
||||
title: context.t(($) => $['gotoAnything.actions.refineTitle'], {
|
||||
ns: 'app',
|
||||
lng: locale,
|
||||
lng: context.locale,
|
||||
}),
|
||||
description: context.t(($) => $['gotoAnything.actions.refineDesc'], {
|
||||
ns: 'app',
|
||||
lng: context.locale,
|
||||
}),
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
@@ -72,18 +39,8 @@ export const refineCommand: SlashCommandHandler = {
|
||||
<span aria-hidden className="i-ri-sparkling-2-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'refine.open', args: {} },
|
||||
data: { command: 'refine', args: {} },
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
register() {
|
||||
registerCommands({
|
||||
'refine.open': async () => openRefineGenerator(),
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['refine.open'])
|
||||
},
|
||||
}
|
||||
@@ -1,256 +1,74 @@
|
||||
import type { CommandSearchResult } from '../types'
|
||||
import type { SlashCommand, SlashCommandHandler } from './types'
|
||||
import type { CommandContext, SlashCommand } from './types'
|
||||
|
||||
/**
|
||||
* Slash Command Registry System
|
||||
* Responsible for managing registration, lookup, and search of all slash commands
|
||||
*/
|
||||
export class SlashCommandRegistry {
|
||||
private commands = new Map<string, SlashCommand>()
|
||||
private snapshot: SlashCommand[] = []
|
||||
private listeners = new Set<() => void>()
|
||||
private readonly commands: readonly SlashCommand[]
|
||||
private readonly byName = new Map<string, SlashCommand>()
|
||||
|
||||
getSnapshot = () => this.snapshot
|
||||
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
private notify() {
|
||||
this.snapshot = this.getAllCommands()
|
||||
this.listeners.forEach((listener) => listener())
|
||||
}
|
||||
private commandDeps = new Map<string, unknown>()
|
||||
|
||||
/**
|
||||
* Register command handler
|
||||
*/
|
||||
register<TDeps>(handler: SlashCommandHandler<TDeps>, deps?: TDeps) {
|
||||
// Register main command name
|
||||
this.commands.set(handler.name, handler)
|
||||
|
||||
// Register aliases
|
||||
if (handler.aliases) {
|
||||
handler.aliases.forEach((alias) => {
|
||||
this.commands.set(alias, handler)
|
||||
})
|
||||
}
|
||||
|
||||
// Store dependencies and call registration method
|
||||
if (deps) {
|
||||
this.commandDeps.set(handler.name, deps)
|
||||
handler.register?.(deps)
|
||||
}
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister command
|
||||
*/
|
||||
unregister(name: string) {
|
||||
const handler = this.commands.get(name)
|
||||
if (handler) {
|
||||
// Call the command's unregister method
|
||||
handler.unregister?.()
|
||||
|
||||
// Remove dependencies
|
||||
this.commandDeps.delete(handler.name)
|
||||
|
||||
// Remove main command name
|
||||
this.commands.delete(handler.name)
|
||||
|
||||
// Remove all aliases
|
||||
if (handler.aliases) {
|
||||
handler.aliases.forEach((alias) => {
|
||||
this.commands.delete(alias)
|
||||
})
|
||||
constructor(commands: readonly SlashCommand[]) {
|
||||
this.commands = [...commands]
|
||||
for (const command of commands) {
|
||||
for (const name of [command.name, ...(command.aliases ?? [])]) {
|
||||
if (this.byName.has(name)) throw new Error(`Duplicate slash command: ${name}`)
|
||||
this.byName.set(name, command)
|
||||
}
|
||||
this.notify()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find command handler
|
||||
*/
|
||||
findCommand(commandName: string): SlashCommand | undefined {
|
||||
return this.commands.get(commandName)
|
||||
findCommand(name: string) {
|
||||
return this.byName.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart partial command matching
|
||||
* Prioritize alias matching, then match command name prefix
|
||||
*/
|
||||
private findBestPartialMatch(partialName: string): SlashCommand | undefined {
|
||||
const lowerPartial = partialName.toLowerCase()
|
||||
|
||||
// First check if any alias starts with this
|
||||
const aliasMatch = this.findHandlerByAliasPrefix(lowerPartial)
|
||||
if (aliasMatch && this.isCommandAvailable(aliasMatch)) return aliasMatch
|
||||
|
||||
// Then check if command name starts with this
|
||||
const nameMatch = this.findHandlerByNamePrefix(lowerPartial)
|
||||
return nameMatch && this.isCommandAvailable(nameMatch) ? nameMatch : undefined
|
||||
getAvailableCommands(context: CommandContext) {
|
||||
return this.commands.filter((command) => command.isAvailable?.(context) ?? true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find handler by alias prefix
|
||||
*/
|
||||
private findHandlerByAliasPrefix(prefix: string): SlashCommand | undefined {
|
||||
for (const handler of this.getAllCommands()) {
|
||||
if (handler.aliases?.some((alias) => alias.toLowerCase().startsWith(prefix))) return handler
|
||||
async execute(name: string, args: Record<string, unknown>, context: CommandContext) {
|
||||
const command = this.findCommand(name)
|
||||
if (!command || command.isAvailable?.(context) === false) return
|
||||
try {
|
||||
await command.execute(args, context)
|
||||
} catch (error) {
|
||||
context.onError(error)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Find handler by name prefix
|
||||
*/
|
||||
private findHandlerByNamePrefix(prefix: string): SlashCommand | undefined {
|
||||
return this.getAllCommands().find((handler) => handler.name.toLowerCase().startsWith(prefix))
|
||||
}
|
||||
search(query: string, context: CommandContext): CommandSearchResult[] {
|
||||
const input = query.trim().replace(/^\//, '').trim()
|
||||
const commands = this.getAvailableCommands(context)
|
||||
if (!input) return commands.map((command) => this.toResult(command))
|
||||
|
||||
/**
|
||||
* Get all registered commands (deduplicated)
|
||||
*/
|
||||
getAllCommands(): SlashCommand[] {
|
||||
const uniqueCommands = new Map<string, SlashCommand>()
|
||||
this.commands.forEach((handler) => {
|
||||
uniqueCommands.set(handler.name, handler)
|
||||
})
|
||||
return Array.from(uniqueCommands.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available commands in current context (deduplicated and filtered)
|
||||
* Commands without isAvailable method are considered always available
|
||||
*/
|
||||
getAvailableCommands(): SlashCommand[] {
|
||||
return this.getAllCommands().filter((handler) => this.isCommandAvailable(handler))
|
||||
}
|
||||
|
||||
/**
|
||||
* Search commands
|
||||
* @param query Full query (e.g., "/theme dark" or "/lang en")
|
||||
* @param locale Current language
|
||||
*/
|
||||
search(query: string, locale: string = 'en'): CommandSearchResult[] {
|
||||
const trimmed = query.trim()
|
||||
|
||||
// Handle root level search "/"
|
||||
if (trimmed === '/' || !trimmed.replace('/', '').trim()) return this.getRootCommands()
|
||||
|
||||
// Parse command and arguments
|
||||
const afterSlash = trimmed.substring(1).trim()
|
||||
const spaceIndex = afterSlash.indexOf(' ')
|
||||
const commandName = spaceIndex === -1 ? afterSlash : afterSlash.substring(0, spaceIndex)
|
||||
const args = spaceIndex === -1 ? '' : afterSlash.substring(spaceIndex + 1).trim()
|
||||
|
||||
// First try exact match
|
||||
let handler = this.findCommand(commandName)
|
||||
if (handler && this.isCommandAvailable(handler)) {
|
||||
const name = input.split(/\s/, 1)[0]!.toLowerCase()
|
||||
const args = input.slice(name.length).trim()
|
||||
const exact = this.findCommand(name)
|
||||
if (exact?.isAvailable?.(context) === false) return []
|
||||
const command =
|
||||
exact ??
|
||||
commands.find((candidate) => candidate.aliases?.some((alias) => alias.startsWith(name))) ??
|
||||
commands.find((candidate) => candidate.name.startsWith(name))
|
||||
if (command) {
|
||||
try {
|
||||
return handler.search(args, locale)
|
||||
return command.search(args, context)
|
||||
} catch (error) {
|
||||
console.warn(`Command search failed for ${commandName}:`, error)
|
||||
console.warn(`Command search failed for ${command.name}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match, try smart partial matching
|
||||
handler = this.findBestPartialMatch(commandName)
|
||||
if (handler && this.isCommandAvailable(handler)) {
|
||||
try {
|
||||
return handler.search(args, locale)
|
||||
} catch (error) {
|
||||
console.warn(`Command search failed for ${handler.name}:`, error)
|
||||
return []
|
||||
}
|
||||
return commands.flatMap((candidate) =>
|
||||
[candidate.name, ...(candidate.aliases ?? [])]
|
||||
.filter((name) => name.toLowerCase().includes(input.toLowerCase()))
|
||||
.map((name) => this.toResult(candidate, name)),
|
||||
)
|
||||
}
|
||||
|
||||
private toResult(command: SlashCommand, name = command.name): CommandSearchResult {
|
||||
return {
|
||||
id: `root-${name}`,
|
||||
title: `/${name}`,
|
||||
description: command.description,
|
||||
type: 'command',
|
||||
data: { command: command.name },
|
||||
}
|
||||
|
||||
// Finally perform fuzzy search
|
||||
return this.fuzzySearchCommands(afterSlash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get root level command list
|
||||
* Only shows commands that are available in current context
|
||||
*/
|
||||
private getRootCommands(): CommandSearchResult[] {
|
||||
return this.getAvailableCommands().map((handler) => ({
|
||||
id: `root-${handler.name}`,
|
||||
title: `/${handler.name}`,
|
||||
description: handler.description,
|
||||
type: 'command' as const,
|
||||
data: {
|
||||
command: `root.${handler.name}`,
|
||||
args: { name: handler.name },
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy search commands
|
||||
* Only shows commands that are available in current context
|
||||
*/
|
||||
private fuzzySearchCommands(query: string): CommandSearchResult[] {
|
||||
const lowercaseQuery = query.toLowerCase()
|
||||
const matches: CommandSearchResult[] = []
|
||||
|
||||
for (const handler of this.getAvailableCommands()) {
|
||||
// Check if command name matches
|
||||
if (handler.name.toLowerCase().includes(lowercaseQuery)) {
|
||||
matches.push({
|
||||
id: `fuzzy-${handler.name}`,
|
||||
title: `/${handler.name}`,
|
||||
description: handler.description,
|
||||
type: 'command' as const,
|
||||
data: {
|
||||
command: `root.${handler.name}`,
|
||||
args: { name: handler.name },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Check if aliases match
|
||||
if (handler.aliases) {
|
||||
handler.aliases.forEach((alias) => {
|
||||
if (alias.toLowerCase().includes(lowercaseQuery)) {
|
||||
matches.push({
|
||||
id: `fuzzy-${alias}`,
|
||||
title: `/${alias}`,
|
||||
description: `${handler.description} (alias for /${handler.name})`,
|
||||
type: 'command' as const,
|
||||
data: {
|
||||
command: `root.${handler.name}`,
|
||||
args: { name: handler.name },
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Get command dependencies
|
||||
*/
|
||||
getCommandDependencies(commandName: string): unknown {
|
||||
return this.commandDeps.get(commandName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a command is available in the current context.
|
||||
* Defaults to true when a handler does not implement the guard.
|
||||
*/
|
||||
private isCommandAvailable(handler: SlashCommand) {
|
||||
return handler.isAvailable?.() ?? true
|
||||
}
|
||||
}
|
||||
|
||||
// Global registry instance
|
||||
export const slashCommandRegistry = new SlashCommandRegistry()
|
||||
@@ -1,78 +0,0 @@
|
||||
'use client'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect } from 'react'
|
||||
import { ENABLE_FEATURE_PREVIEW } from '@/config'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { isCurrentWorkspaceDatasetOperatorAtom } from '@/context/workspace-state'
|
||||
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
||||
import { setLocaleOnClient } from '@/i18n/client'
|
||||
import { accountCommand } from './account'
|
||||
import { createCommand } from './create'
|
||||
import { discordCommand } from './discord'
|
||||
import { docsCommand } from './docs'
|
||||
import { goCommand } from './go'
|
||||
import { languageCommand } from './language'
|
||||
import { modelsCommand } from './models'
|
||||
import { refineCommand } from './refine'
|
||||
import { slashCommandRegistry } from './registry'
|
||||
import { themeCommand } from './theme'
|
||||
|
||||
type SlashCommandDeps = {
|
||||
agentsAvailable: boolean
|
||||
getDocsHomeUrl: () => string
|
||||
setTheme: (theme: string) => void
|
||||
setLocale: typeof setLocaleOnClient
|
||||
skillsAvailable: boolean
|
||||
}
|
||||
|
||||
const registerSlashCommands = (deps: SlashCommandDeps) => {
|
||||
slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme })
|
||||
slashCommandRegistry.register(languageCommand, {
|
||||
setLocale: deps.setLocale as (locale: string) => Promise<void>,
|
||||
})
|
||||
slashCommandRegistry.register(docsCommand, { getDocsHomeUrl: deps.getDocsHomeUrl })
|
||||
slashCommandRegistry.register(discordCommand, {})
|
||||
slashCommandRegistry.register(modelsCommand, {})
|
||||
slashCommandRegistry.register(accountCommand, {})
|
||||
slashCommandRegistry.register(goCommand, {
|
||||
agentsAvailable: deps.agentsAvailable,
|
||||
skillsAvailable: deps.skillsAvailable,
|
||||
})
|
||||
if (ENABLE_FEATURE_PREVIEW) {
|
||||
slashCommandRegistry.register(createCommand, {})
|
||||
slashCommandRegistry.register(refineCommand, {})
|
||||
}
|
||||
}
|
||||
|
||||
const unregisterSlashCommands = () => {
|
||||
slashCommandRegistry.unregister('theme')
|
||||
slashCommandRegistry.unregister('language')
|
||||
slashCommandRegistry.unregister('docs')
|
||||
slashCommandRegistry.unregister('discord')
|
||||
slashCommandRegistry.unregister('models')
|
||||
slashCommandRegistry.unregister('account')
|
||||
slashCommandRegistry.unregister('go')
|
||||
slashCommandRegistry.unregister('create')
|
||||
slashCommandRegistry.unregister('refine')
|
||||
}
|
||||
|
||||
export const SlashCommandProvider = () => {
|
||||
const theme = useTheme()
|
||||
const getDocsHomeUrl = useDocLink()
|
||||
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
|
||||
const agentsAvailable = isAgentV2Enabled()
|
||||
const skillsAvailable = !isCurrentWorkspaceDatasetOperator
|
||||
useEffect(() => {
|
||||
registerSlashCommands({
|
||||
agentsAvailable,
|
||||
getDocsHomeUrl,
|
||||
setTheme: theme.setTheme,
|
||||
setLocale: setLocaleOnClient,
|
||||
skillsAvailable,
|
||||
})
|
||||
return () => unregisterSlashCommands()
|
||||
}, [agentsAvailable, getDocsHomeUrl, skillsAvailable, theme.setTheme])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,28 +1,28 @@
|
||||
import type { ActionItem } from '../types'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { executeCommand } from './command-bus'
|
||||
import { slashCommandRegistry } from './registry'
|
||||
import type { CommandContext } from './types'
|
||||
import { slashCommandRegistry } from './catalog'
|
||||
|
||||
export const slashAction: ActionItem = {
|
||||
key: '/',
|
||||
shortcut: '/',
|
||||
get title() {
|
||||
const i18n = getI18n()
|
||||
return i18n.t(($) => $['gotoAnything.actions.slashTitle'], { ns: 'app' })
|
||||
},
|
||||
get description() {
|
||||
const i18n = getI18n()
|
||||
return i18n.t(($) => $['gotoAnything.actions.slashDesc'], { ns: 'app' })
|
||||
},
|
||||
source: 'local',
|
||||
action: (result) => {
|
||||
if (result.type !== 'command') return
|
||||
const { command, args } = result.data
|
||||
executeCommand(command, args)
|
||||
},
|
||||
search: (query, _searchTerm = '') => {
|
||||
const i18n = getI18n()
|
||||
// Delegate all search logic to the command registry system
|
||||
return slashCommandRegistry.search(query, i18n.language)
|
||||
},
|
||||
export function createSlashAction(context: CommandContext): ActionItem {
|
||||
return {
|
||||
key: '/',
|
||||
shortcut: '/',
|
||||
title: context.t(($) => $['gotoAnything.actions.slashTitle'], { ns: 'app' }),
|
||||
description: context.t(($) => $['gotoAnything.actions.slashDesc'], { ns: 'app' }),
|
||||
source: 'local',
|
||||
matches: (query) =>
|
||||
slashCommandRegistry
|
||||
.getAvailableCommands(context)
|
||||
.some(
|
||||
(command) =>
|
||||
command.mode !== 'direct' &&
|
||||
[command.name, ...(command.aliases ?? [])].some((name) =>
|
||||
query.startsWith(`/${name} `),
|
||||
),
|
||||
),
|
||||
action: (result) => {
|
||||
if (result.type === 'command')
|
||||
void slashCommandRegistry.execute(result.data.command, result.data.args ?? {}, context)
|
||||
},
|
||||
search: (query) => slashCommandRegistry.search(query, context),
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { CommandSearchResult } from '../types'
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Theme dependency types
|
||||
type ThemeDeps = {
|
||||
setTheme?: (value: 'light' | 'dark' | 'system') => void
|
||||
}
|
||||
import type { CommandContext, SlashCommand } from './types'
|
||||
|
||||
const THEME_ITEMS = [
|
||||
{
|
||||
@@ -29,55 +22,44 @@ const THEME_ITEMS = [
|
||||
},
|
||||
] as const
|
||||
|
||||
const buildThemeCommands = (query: string, locale?: string): CommandSearchResult[] => {
|
||||
const i18n = getI18n()
|
||||
const buildThemeCommands = (query: string, context: CommandContext): CommandSearchResult[] => {
|
||||
const q = query.toLowerCase()
|
||||
const list = THEME_ITEMS.filter(
|
||||
(item) =>
|
||||
!q ||
|
||||
i18n
|
||||
.t(($) => $[item.titleKey], { ns: 'app', lng: locale })
|
||||
context
|
||||
.t(($) => $[item.titleKey], { ns: 'app', lng: context.locale })
|
||||
.toLowerCase()
|
||||
.includes(q) ||
|
||||
item.id.includes(q),
|
||||
)
|
||||
return list.map((item) => ({
|
||||
id: item.id,
|
||||
title: i18n.t(($) => $[item.titleKey], { ns: 'app', lng: locale }),
|
||||
description: i18n.t(($) => $[item.descKey], { ns: 'app', lng: locale }),
|
||||
title: context.t(($) => $[item.titleKey], { ns: 'app', lng: context.locale }),
|
||||
description: context.t(($) => $[item.descKey], { ns: 'app', lng: context.locale }),
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<span aria-hidden className={`${item.iconClassName} size-4 text-text-tertiary`} />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'theme.set', args: { value: item.id } },
|
||||
data: { command: 'theme', args: { value: item.id } },
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme command handler
|
||||
* Integrates UI building, search, and registration logic
|
||||
*/
|
||||
export const themeCommand: SlashCommandHandler<ThemeDeps> = {
|
||||
export const themeCommand: SlashCommand = {
|
||||
name: 'theme',
|
||||
description: 'Switch between light and dark themes',
|
||||
mode: 'submenu', // Explicitly set submenu mode
|
||||
|
||||
search(args: string, locale: string = 'en') {
|
||||
// Return theme options directly, regardless of parameters
|
||||
return buildThemeCommands(args, locale)
|
||||
mode: 'submenu',
|
||||
execute(args, context) {
|
||||
const item = THEME_ITEMS.find((item) => item.id === args.value)
|
||||
if (item) context.setTheme(item.id)
|
||||
},
|
||||
|
||||
register(deps: ThemeDeps) {
|
||||
registerCommands({
|
||||
'theme.set': async (args) => {
|
||||
deps.setTheme?.(args?.value)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['theme.set'])
|
||||
search(args: string, context) {
|
||||
return buildThemeCommands(args, context)
|
||||
},
|
||||
}
|
||||
@@ -1,55 +1,28 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { CommandSearchResult } from '../types'
|
||||
import type { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
|
||||
|
||||
/**
|
||||
* Slash command handler interface
|
||||
* Each slash command should implement this interface
|
||||
*/
|
||||
export type SlashCommandHandler<TDeps = unknown> = {
|
||||
/** Command name (e.g., 'theme', 'language') */
|
||||
name: string
|
||||
|
||||
/** Command alias list (e.g., ['lang'] for language) */
|
||||
aliases?: string[]
|
||||
|
||||
/** Command description */
|
||||
description: string
|
||||
|
||||
/**
|
||||
* Command mode:
|
||||
* - 'direct': Execute immediately when selected (e.g., /docs, /discord)
|
||||
* - 'submenu': Show submenu options (e.g., /theme, /language)
|
||||
*/
|
||||
mode?: 'direct' | 'submenu'
|
||||
|
||||
/**
|
||||
* Check if command is available in current context
|
||||
* If not implemented, command is always available
|
||||
* Used to conditionally show/hide commands based on page, user state, etc.
|
||||
*/
|
||||
isAvailable?: () => boolean
|
||||
|
||||
/**
|
||||
* Direct execution function for 'direct' mode commands
|
||||
* Called when the command is selected and should execute immediately
|
||||
*/
|
||||
execute?: () => void | Promise<void>
|
||||
|
||||
/**
|
||||
* Search command results (for 'submenu' mode or showing options)
|
||||
* @param args Command arguments (part after removing command name)
|
||||
* @param locale Current language
|
||||
*/
|
||||
search: (args: string, locale?: string) => CommandSearchResult[]
|
||||
|
||||
/**
|
||||
* Called when registering command, passing external dependencies
|
||||
*/
|
||||
register?: (deps: TDeps) => void
|
||||
|
||||
/**
|
||||
* Called when unregistering command
|
||||
*/
|
||||
unregister?: () => void
|
||||
export type CommandContext = {
|
||||
t: TFunction<['app', 'common', 'modelProvider', 'accountSettings']>
|
||||
locale: string
|
||||
agentsAvailable: boolean
|
||||
skillsAvailable: boolean
|
||||
currentApp: { id: string; mode: 'workflow' | 'advanced-chat' } | null
|
||||
setTheme: (theme: string) => void
|
||||
setLocale: (locale: string) => Promise<void>
|
||||
getDocsHomeUrl: () => string
|
||||
onError: (error: unknown) => void
|
||||
navigate: (path: string) => void
|
||||
openExternal: (url: string) => void
|
||||
openGenerator: ReturnType<typeof useWorkflowGeneratorStore.getState>['openGenerator']
|
||||
}
|
||||
|
||||
export type SlashCommand = Omit<SlashCommandHandler, 'register'>
|
||||
export type SlashCommand = {
|
||||
name: string
|
||||
aliases?: readonly string[]
|
||||
description: string
|
||||
mode?: 'direct' | 'submenu'
|
||||
isAvailable?: (context: CommandContext) => boolean
|
||||
execute: (args: Record<string, unknown>, context: CommandContext) => void | Promise<void>
|
||||
search: (args: string, context: CommandContext) => CommandSearchResult[]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CommandContext } from './types'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { setLocaleOnClient } from '@/i18n/client'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
|
||||
const openExternal = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
const openGenerator: CommandContext['openGenerator'] = (options) =>
|
||||
useWorkflowGeneratorStore.getState().openGenerator(options)
|
||||
|
||||
export function useCommandContext(
|
||||
agentsAvailable: boolean,
|
||||
skillsAvailable: boolean,
|
||||
isWorkflowPage: boolean,
|
||||
): CommandContext {
|
||||
const { t, i18n } = useTranslation(['app', 'common', 'modelProvider', 'accountSettings'])
|
||||
const { setTheme } = useTheme()
|
||||
const { push } = useRouter()
|
||||
const getDocsHomeUrl = useDocLink()
|
||||
const appId = useAppStore((state) => state.appDetail?.id)
|
||||
const appMode = useAppStore((state) => state.appDetail?.mode)
|
||||
|
||||
return useMemo<CommandContext>(
|
||||
() => ({
|
||||
t,
|
||||
locale: i18n.language,
|
||||
agentsAvailable,
|
||||
skillsAvailable,
|
||||
currentApp:
|
||||
isWorkflowPage && appId && (appMode === 'workflow' || appMode === 'advanced-chat')
|
||||
? { id: appId, mode: appMode }
|
||||
: null,
|
||||
setTheme,
|
||||
setLocale: setLocaleOnClient,
|
||||
getDocsHomeUrl,
|
||||
navigate: push,
|
||||
onError: (error) => {
|
||||
console.error('Goto Anything command failed:', error)
|
||||
toast.error(t(($) => $['api.actionFailed'], { ns: 'common' }))
|
||||
},
|
||||
openExternal,
|
||||
openGenerator,
|
||||
}),
|
||||
[
|
||||
t,
|
||||
i18n.language,
|
||||
agentsAvailable,
|
||||
skillsAvailable,
|
||||
isWorkflowPage,
|
||||
appId,
|
||||
appMode,
|
||||
setTheme,
|
||||
getDocsHomeUrl,
|
||||
push,
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { ActionItem } from './types'
|
||||
import { agentAction } from './agent'
|
||||
import { appAction } from './app'
|
||||
import { slashCommandRegistry } from './commands/registry'
|
||||
import { slashAction } from './commands/slash'
|
||||
import { knowledgeAction } from './knowledge'
|
||||
import { pluginAction } from './plugin'
|
||||
import { ragPipelineNodesAction } from './rag-pipeline-nodes'
|
||||
@@ -10,7 +8,6 @@ import { skillAction } from './skill'
|
||||
import { workflowNodesAction } from './workflow-nodes'
|
||||
|
||||
const defaultActions = {
|
||||
slash: slashAction,
|
||||
app: appAction,
|
||||
knowledge: knowledgeAction,
|
||||
plugin: pluginAction,
|
||||
@@ -27,12 +24,14 @@ const defaultAvailability: ActionAvailability = {
|
||||
}
|
||||
|
||||
export function createActions(
|
||||
slash: ActionItem,
|
||||
isWorkflowPage: boolean,
|
||||
isRagPipelinePage: boolean,
|
||||
availability: ActionAvailability = defaultAvailability,
|
||||
) {
|
||||
const availableActions = {
|
||||
...defaultActions,
|
||||
slash,
|
||||
...(availability.skills ? { skill: skillAction } : {}),
|
||||
...(availability.agents ? { agent: agentAction } : {}),
|
||||
}
|
||||
@@ -52,15 +51,7 @@ export function getActionSearchTerm(query: string, action: ActionItem) {
|
||||
|
||||
export function matchAction(query: string, actions: Record<string, ActionItem>) {
|
||||
return Object.values(actions).find((action) => {
|
||||
if (action.key === '/') {
|
||||
return slashCommandRegistry.getAvailableCommands().some((command) => {
|
||||
if (command.mode === 'direct') return false
|
||||
|
||||
return [command.name, ...(command.aliases ?? [])].some((name) =>
|
||||
query.startsWith(`/${name} `),
|
||||
)
|
||||
})
|
||||
}
|
||||
if (action.matches) return action.matches(query)
|
||||
|
||||
return new RegExp(`^(${action.key}|${action.shortcut})\\s`).test(query)
|
||||
})
|
||||
|
||||
@@ -73,6 +73,7 @@ type ActionItemBase = {
|
||||
shortcut: string
|
||||
title: string | TypeWithI18N
|
||||
description: string
|
||||
matches?: (query: string) => boolean
|
||||
action?: (data: SearchResult) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { CommandContext } from './actions/commands/types'
|
||||
import type { ActionItem, CommandSearchResult, SearchResult } from './actions/types'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MAIN_NAV_ROUTES } from '@/app/components/main-nav/routes'
|
||||
import { slashCommandRegistry } from './actions/commands/registry'
|
||||
import { slashCommandRegistry } from './actions/commands/catalog'
|
||||
|
||||
export type CommandOption = {
|
||||
kind: 'command-option'
|
||||
@@ -55,14 +55,13 @@ function matches(query: string, values: (string | undefined)[]) {
|
||||
.every((term) => text.includes(term))
|
||||
}
|
||||
|
||||
export function useCommandOptions(actions: Record<string, ActionItem>, query: string) {
|
||||
const { t, i18n } = useTranslation(['app', 'common', 'skill', 'modelProvider', 'agentRoster'])
|
||||
const registeredCommands = useSyncExternalStore(
|
||||
slashCommandRegistry.subscribe,
|
||||
slashCommandRegistry.getSnapshot,
|
||||
slashCommandRegistry.getSnapshot,
|
||||
)
|
||||
const commands = registeredCommands.filter((command) => command.isAvailable?.() ?? true)
|
||||
export function useCommandOptions(
|
||||
actions: Record<string, ActionItem>,
|
||||
query: string,
|
||||
context: CommandContext,
|
||||
) {
|
||||
const { t } = useTranslation(['app', 'common', 'skill', 'modelProvider', 'agentRoster'])
|
||||
const commands = slashCommandRegistry.getAvailableCommands(context)
|
||||
const trimmed = query.trim()
|
||||
const filter = trimmed.replace(/^[@/]/, '')
|
||||
|
||||
@@ -115,7 +114,7 @@ export function useCommandOptions(actions: Record<string, ActionItem>, query: st
|
||||
if (trimmed.startsWith('/') || command.mode === 'direct') return []
|
||||
|
||||
return slashCommandRegistry
|
||||
.search(`/${command.name}`, i18n.language)
|
||||
.search(`/${command.name}`, context)
|
||||
.filter((result) => matches(filter, [result.title, result.description, result.id]))
|
||||
.map((result) => ({
|
||||
...option,
|
||||
|
||||
@@ -44,8 +44,9 @@ import InstallFromMarketplace from '../plugins/install-plugin/install-from-marke
|
||||
import { createActions, getActionSearchTerm, matchAction } from './actions'
|
||||
import { agentSearchQueryOptions } from './actions/agent'
|
||||
import { appSearchQueryOptions } from './actions/app'
|
||||
import { slashCommandRegistry } from './actions/commands/registry'
|
||||
import { SlashCommandProvider } from './actions/commands/slash-provider'
|
||||
import { slashCommandRegistry } from './actions/commands/catalog'
|
||||
import { createSlashAction } from './actions/commands/slash'
|
||||
import { useCommandContext } from './actions/commands/use-command-context'
|
||||
import { knowledgeSearchQueryOptions } from './actions/knowledge'
|
||||
import { pluginSearchQueryOptions } from './actions/plugin'
|
||||
import { skillSearchQueryOptions } from './actions/skill'
|
||||
@@ -161,15 +162,16 @@ export function GotoAnything() {
|
||||
const [activePlugin, setActivePlugin] = useState<Plugin>()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const searchHintId = useId()
|
||||
const commandContext = useCommandContext(agentsAvailable, skillsAvailable, isWorkflowPage)
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
createActions(isWorkflowPage, isRagPipelinePage, {
|
||||
createActions(createSlashAction(commandContext), isWorkflowPage, isRagPipelinePage, {
|
||||
agents: agentsAvailable,
|
||||
skills: skillsAvailable,
|
||||
}),
|
||||
[agentsAvailable, isWorkflowPage, isRagPipelinePage, skillsAvailable],
|
||||
[agentsAvailable, commandContext, isWorkflowPage, isRagPipelinePage, skillsAvailable],
|
||||
)
|
||||
const { commandOptions, scopeOptions } = useCommandOptions(actions, searchQuery)
|
||||
const { commandOptions, scopeOptions } = useCommandOptions(actions, searchQuery, commandContext)
|
||||
const trimmedSearchQuery = searchQuery.trim()
|
||||
const normalizedSearchQuery = searchQuery.trimStart().toLowerCase()
|
||||
const isCommandsMode = isCommandSelectionQuery(searchQuery, actions)
|
||||
@@ -325,9 +327,9 @@ export function GotoAnything() {
|
||||
function handleCommandSelect(commandKey: string) {
|
||||
if (commandKey.startsWith('/')) {
|
||||
const handler = slashCommandRegistry.findCommand(commandKey.slice(1))
|
||||
if (handler?.isAvailable?.() === false) return
|
||||
if (handler?.mode === 'direct' && handler.execute) {
|
||||
handler.execute()
|
||||
if (handler?.isAvailable?.(commandContext) === false) return
|
||||
if (handler?.mode === 'direct') {
|
||||
void slashCommandRegistry.execute(handler.name, {}, commandContext)
|
||||
gotoAnythingDialogHandle.close()
|
||||
return
|
||||
}
|
||||
@@ -431,7 +433,6 @@ export function GotoAnything() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlashCommandProvider />
|
||||
<Dialog
|
||||
handle={gotoAnythingDialogHandle}
|
||||
onOpenChangeComplete={handleDialogOpenChangeComplete}
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "فتح الوثائق",
|
||||
"gotoAnything.actions.goDesc": "الانتقال إلى صفحة",
|
||||
"gotoAnything.actions.languageChangeDesc": "تغيير اللغة",
|
||||
"gotoAnything.actions.refineCategoryDesc": "قم بتحسين سير العمل الحالي أو الرسم البياني لتدفق الدردشة",
|
||||
"gotoAnything.actions.refineDesc": "قم بوصف التغيير الذي سيتم تطبيقه على المسودة الحالية",
|
||||
"gotoAnything.actions.refineTitle": "تحسين الرسم البياني الحالي",
|
||||
"gotoAnything.actions.searchApplications": "بحث في التطبيقات",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Sənədləri açın",
|
||||
"gotoAnything.actions.goDesc": "Səhifəyə keç",
|
||||
"gotoAnything.actions.languageChangeDesc": "Dili dəyişin",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Cari iş axını və ya söhbət axını qrafını təkmilləşdir",
|
||||
"gotoAnything.actions.refineDesc": "Cari qaralamaya tətbiq ediləcək dəyişikliyi təsvir et",
|
||||
"gotoAnything.actions.refineTitle": "Cari qrafı təkmilləşdir",
|
||||
"gotoAnything.actions.searchApplications": "Tətbiqləri axtar",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Dokumentation öffnen",
|
||||
"gotoAnything.actions.goDesc": "Zu einer Seite wechseln",
|
||||
"gotoAnything.actions.languageChangeDesc": "Sprache ändern",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Verfeinern Sie den aktuellen Workflow oder das Chatflow-Diagramm",
|
||||
"gotoAnything.actions.refineDesc": "Beschreiben Sie eine Änderung, die auf den aktuellen Entwurf angewendet werden soll",
|
||||
"gotoAnything.actions.refineTitle": "Aktuelles Diagramm verfeinern",
|
||||
"gotoAnything.actions.searchApplications": "Anwendungen durchsuchen",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Open documentation",
|
||||
"gotoAnything.actions.goDesc": "Go to a page",
|
||||
"gotoAnything.actions.languageChangeDesc": "Change language",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Refine the current workflow or chatflow graph",
|
||||
"gotoAnything.actions.refineDesc": "Describe a change to apply to the current draft",
|
||||
"gotoAnything.actions.refineTitle": "Refine current graph",
|
||||
"gotoAnything.actions.searchApplications": "Search Applications",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Abrir documentación",
|
||||
"gotoAnything.actions.goDesc": "Ir a una página",
|
||||
"gotoAnything.actions.languageChangeDesc": "Cambiar idioma",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Refinar el flujo de trabajo actual o el gráfico de flujo de chat",
|
||||
"gotoAnything.actions.refineDesc": "Describir un cambio para aplicar al borrador actual.",
|
||||
"gotoAnything.actions.refineTitle": "Refinar el gráfico actual",
|
||||
"gotoAnything.actions.searchApplications": "Buscar aplicaciones",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "باز کردن مستندات",
|
||||
"gotoAnything.actions.goDesc": "رفتن به یک صفحه",
|
||||
"gotoAnything.actions.languageChangeDesc": "تغییر زبان",
|
||||
"gotoAnything.actions.refineCategoryDesc": "جریان کار یا نمودار جریان گفتگو را اصلاح کنید",
|
||||
"gotoAnything.actions.refineDesc": "تغییری را برای اعمال در پیشنویس فعلی توضیح دهید",
|
||||
"gotoAnything.actions.refineTitle": "نمودار فعلی را اصلاح کنید",
|
||||
"gotoAnything.actions.searchApplications": "جستجوی برنامه ها",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Ouvrir la documentation",
|
||||
"gotoAnything.actions.goDesc": "Accéder à une page",
|
||||
"gotoAnything.actions.languageChangeDesc": "Changer de langue",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Affiner le flux de travail ou le graphique de chatflow actuel",
|
||||
"gotoAnything.actions.refineDesc": "Décrire un changement à appliquer au brouillon actuel",
|
||||
"gotoAnything.actions.refineTitle": "Affiner le graphique actuel",
|
||||
"gotoAnything.actions.searchApplications": "Rechercher des applications",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "दस्तावेज़ खोलें",
|
||||
"gotoAnything.actions.goDesc": "किसी पेज पर जाएँ",
|
||||
"gotoAnything.actions.languageChangeDesc": "भाषा बदलें",
|
||||
"gotoAnything.actions.refineCategoryDesc": "वर्तमान वर्कफ़्लो या चैटफ़्लो ग्राफ़ को परिष्कृत करें",
|
||||
"gotoAnything.actions.refineDesc": "वर्मन मसौदे पर लागू हो रहा है",
|
||||
"gotoAnything.actions.refineTitle": "वर्तमान ग्राफ़ को परिष्कृत करें",
|
||||
"gotoAnything.actions.searchApplications": "एप्लिकेशन खोजें",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Buka dokumentasi",
|
||||
"gotoAnything.actions.goDesc": "Buka halaman",
|
||||
"gotoAnything.actions.languageChangeDesc": "Ganti bahasa",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Sempurnakan alur kerja atau grafik alur obrolan saat ini",
|
||||
"gotoAnything.actions.refineDesc": "Jelaskan perubahan yang akan diterapkan pada draf saat ini",
|
||||
"gotoAnything.actions.refineTitle": "Sempurnakan grafik saat ini",
|
||||
"gotoAnything.actions.searchApplications": "Cari Aplikasi",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Apri documentazione",
|
||||
"gotoAnything.actions.goDesc": "Vai a una pagina",
|
||||
"gotoAnything.actions.languageChangeDesc": "Cambia lingua",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Perfeziona il flusso di lavoro corrente o il grafico del flusso di chat",
|
||||
"gotoAnything.actions.refineDesc": "Descrivi una modifica da applicare alla bozza corrente",
|
||||
"gotoAnything.actions.refineTitle": "Perfeziona il grafico corrente",
|
||||
"gotoAnything.actions.searchApplications": "Cerca applicazioni",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "ドキュメントを開く",
|
||||
"gotoAnything.actions.goDesc": "ページへ移動",
|
||||
"gotoAnything.actions.languageChangeDesc": "言語を変更",
|
||||
"gotoAnything.actions.refineCategoryDesc": "現在のワークフローまたはチャットフローグラフを改良する",
|
||||
"gotoAnything.actions.refineDesc": "現在のドラフトに適用する変更について説明します",
|
||||
"gotoAnything.actions.refineTitle": "現在のグラフを絞り込む",
|
||||
"gotoAnything.actions.searchApplications": "アプリケーションを検索",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "문서 열기",
|
||||
"gotoAnything.actions.goDesc": "페이지로 이동",
|
||||
"gotoAnything.actions.languageChangeDesc": "언어 변경",
|
||||
"gotoAnything.actions.refineCategoryDesc": "현재 워크플로우 또는 챗플로우 그래프를 구체화합니다.",
|
||||
"gotoAnything.actions.refineDesc": "현재 초안에 적용할 변경 사항 설명",
|
||||
"gotoAnything.actions.refineTitle": "현재 그래프 세분화",
|
||||
"gotoAnything.actions.searchApplications": "응용 프로그램 검색",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "ເປີດເອກະສານ",
|
||||
"gotoAnything.actions.goDesc": "ໄປທີ່ໜ້າ",
|
||||
"gotoAnything.actions.languageChangeDesc": "ປ່ຽນພາສາ",
|
||||
"gotoAnything.actions.refineCategoryDesc": "ປັບປຸງກຣາຟ Workflow ຫຼື Chatflow ປະຈຸບັນ",
|
||||
"gotoAnything.actions.refineDesc": "ອະທິບາຍການປ່ຽນແປງທີ່ຈະນຳໃຊ້ກັບຮ່າງປະຈຸບັນ",
|
||||
"gotoAnything.actions.refineTitle": "ປັບປຸງກຣາຟປະຈຸບັນ",
|
||||
"gotoAnything.actions.searchApplications": "ຄົ້ນຫາແອັບພລິເຄຊັນ",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Documentatie openen",
|
||||
"gotoAnything.actions.goDesc": "Naar een pagina gaan",
|
||||
"gotoAnything.actions.languageChangeDesc": "Taal wijzigen",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Verfijn de huidige workflow of chatflowgrafiek",
|
||||
"gotoAnything.actions.refineDesc": "Beschrijf een wijziging die moet worden toegepast op het huidige concept",
|
||||
"gotoAnything.actions.refineTitle": "Verfijn de huidige grafiek",
|
||||
"gotoAnything.actions.searchApplications": "Search Applications",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Otwórz dokumentację",
|
||||
"gotoAnything.actions.goDesc": "Przejdź do strony",
|
||||
"gotoAnything.actions.languageChangeDesc": "Zmień język",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Udoskonal bieżący przepływ pracy lub wykres przepływu rozmów",
|
||||
"gotoAnything.actions.refineDesc": "Opisz zmianę, którą chcesz zastosować w bieżącej wersji roboczej",
|
||||
"gotoAnything.actions.refineTitle": "Udoskonal bieżący wykres",
|
||||
"gotoAnything.actions.searchApplications": "Szukaj aplikacji",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Abrir documentação",
|
||||
"gotoAnything.actions.goDesc": "Ir para uma página",
|
||||
"gotoAnything.actions.languageChangeDesc": "Alterar idioma",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Refinar o fluxo de trabalho atual ou gráfico de fluxo de chat",
|
||||
"gotoAnything.actions.refineDesc": "Descreva uma alteração a ser aplicada ao rascunho atual",
|
||||
"gotoAnything.actions.refineTitle": "Refinar o gráfico atual",
|
||||
"gotoAnything.actions.searchApplications": "Pesquisar aplicativos",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Deschide documentația",
|
||||
"gotoAnything.actions.goDesc": "Mergi la o pagină",
|
||||
"gotoAnything.actions.languageChangeDesc": "Schimbă limba",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Rafinați fluxul de lucru curent sau graficul fluxului de chat",
|
||||
"gotoAnything.actions.refineDesc": "Descrieți o modificare de aplicat schiței actuale",
|
||||
"gotoAnything.actions.refineTitle": "Rafinați graficul curent",
|
||||
"gotoAnything.actions.searchApplications": "Căutare aplicații",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Открыть документацию",
|
||||
"gotoAnything.actions.goDesc": "Перейти на страницу",
|
||||
"gotoAnything.actions.languageChangeDesc": "Сменить язык",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Уточните текущий рабочий процесс или график потока чата",
|
||||
"gotoAnything.actions.refineDesc": "Опишите изменение, которое будет применено к текущему проекту.",
|
||||
"gotoAnything.actions.refineTitle": "Уточнить текущий график",
|
||||
"gotoAnything.actions.searchApplications": "Поиск приложений",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Odpri dokumentacijo",
|
||||
"gotoAnything.actions.goDesc": "Pojdi na stran",
|
||||
"gotoAnything.actions.languageChangeDesc": "Spremeni jezik",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Izboljšajte trenutni tok dela ali graf toka klepeta",
|
||||
"gotoAnything.actions.refineDesc": "Opišite spremembo za trenutni osnutek",
|
||||
"gotoAnything.actions.refineTitle": "Izboljšajte trenutni graf",
|
||||
"gotoAnything.actions.searchApplications": "Iskanje aplikacij",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "เปิดเอกสาร",
|
||||
"gotoAnything.actions.goDesc": "ไปยังหน้า",
|
||||
"gotoAnything.actions.languageChangeDesc": "เปลี่ยนภาษา",
|
||||
"gotoAnything.actions.refineCategoryDesc": "ปรับแต่งเวิร์กโฟลว์ปัจจุบันหรือกราฟโฟลว์แชท",
|
||||
"gotoAnything.actions.refineDesc": "อธิบายการเปลี่ยนแปลงที่จะนำไปใช้กับร่างปัจจุบัน",
|
||||
"gotoAnything.actions.refineTitle": "ปรับแต่งกราฟปัจจุบัน",
|
||||
"gotoAnything.actions.searchApplications": "ค้นหาแอปพลิเคชัน",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Belgeleri aç",
|
||||
"gotoAnything.actions.goDesc": "Bir sayfaya git",
|
||||
"gotoAnything.actions.languageChangeDesc": "Dili değiştir",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Mevcut iş akışını veya sohbet akışı grafiğini hassaslaştırın",
|
||||
"gotoAnything.actions.refineDesc": "Mevcut taslağa uygulanacak bir değişikliği açıklayın",
|
||||
"gotoAnything.actions.refineTitle": "Mevcut grafiği hassaslaştır",
|
||||
"gotoAnything.actions.searchApplications": "Uygulamaları Ara",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Відкрити документацію",
|
||||
"gotoAnything.actions.goDesc": "Перейти на сторінку",
|
||||
"gotoAnything.actions.languageChangeDesc": "Змінити мову",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Уточніть поточний робочий процес або графік чату",
|
||||
"gotoAnything.actions.refineDesc": "Опишіть зміну, яку слід застосувати до поточної чернетки",
|
||||
"gotoAnything.actions.refineTitle": "Уточнити поточний графік",
|
||||
"gotoAnything.actions.searchApplications": "Пошук додатків",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "Mở tài liệu",
|
||||
"gotoAnything.actions.goDesc": "Đi đến trang",
|
||||
"gotoAnything.actions.languageChangeDesc": "Đổi ngôn ngữ",
|
||||
"gotoAnything.actions.refineCategoryDesc": "Tinh chỉnh quy trình làm việc hiện tại hoặc biểu đồ luồng trò chuyện",
|
||||
"gotoAnything.actions.refineDesc": "Mô tả một thay đổi để áp dụng cho dự thảo hiện tại",
|
||||
"gotoAnything.actions.refineTitle": "Tinh chỉnh biểu đồ hiện tại",
|
||||
"gotoAnything.actions.searchApplications": "Tìm kiếm ứng dụng",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "查看文档",
|
||||
"gotoAnything.actions.goDesc": "前往页面",
|
||||
"gotoAnything.actions.languageChangeDesc": "切换语言",
|
||||
"gotoAnything.actions.refineCategoryDesc": "优化当前的工作流或 Chatflow 图",
|
||||
"gotoAnything.actions.refineDesc": "描述要应用到当前草稿的修改",
|
||||
"gotoAnything.actions.refineTitle": "优化当前图",
|
||||
"gotoAnything.actions.searchApplications": "搜索应用程序",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"gotoAnything.actions.docDesc": "查看文件",
|
||||
"gotoAnything.actions.goDesc": "前往頁面",
|
||||
"gotoAnything.actions.languageChangeDesc": "切換語言",
|
||||
"gotoAnything.actions.refineCategoryDesc": "優化目前工作流程或聊天流程圖",
|
||||
"gotoAnything.actions.refineDesc": "描述適用於當前草案的更改",
|
||||
"gotoAnything.actions.refineTitle": "細化當前圖表",
|
||||
"gotoAnything.actions.searchApplications": "搜索應用程式",
|
||||
|
||||
Vendored
-1
@@ -552,7 +552,6 @@ export default interface Resources {
|
||||
'gotoAnything.actions.docDesc': 'Open documentation'
|
||||
'gotoAnything.actions.goDesc': 'Go to a page'
|
||||
'gotoAnything.actions.languageChangeDesc': 'Change language'
|
||||
'gotoAnything.actions.refineCategoryDesc': 'Refine the current workflow or chatflow graph'
|
||||
'gotoAnything.actions.refineDesc': 'Describe a change to apply to the current draft'
|
||||
'gotoAnything.actions.refineTitle': 'Refine current graph'
|
||||
'gotoAnything.actions.searchApplications': 'Search Applications'
|
||||
|
||||
@@ -259,14 +259,3 @@ export function createI18nextMock(translations: TranslationMap = {}) {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function createReactI18nextLanguageMock(language: string) {
|
||||
const mock = createReactI18nextMock()
|
||||
return {
|
||||
...mock,
|
||||
getI18n: () => ({
|
||||
...mock.getI18n(),
|
||||
language,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user