feat: cherry pick implementation (#31960)

This commit is contained in:
qiuqiua
2026-02-05 10:00:47 +08:00
committed by crazywoola
parent 6fa943fe75
commit f4d6383019
37 changed files with 9729 additions and 1 deletions

View File

@ -0,0 +1,189 @@
import { isInWorkflowPage, VIBE_COMMAND_EVENT } from '@/app/components/workflow/constants'
import i18n from '@/i18n-config/i18next-config'
import { bananaCommand } from './banana'
import { registerCommands, unregisterCommands } from './command-bus'
vi.mock('@/i18n-config/i18next-config', () => ({
default: {
t: vi.fn((key: string, options?: Record<string, unknown>) => {
if (!options)
return key
return `${key}:${JSON.stringify(options)}`
}),
},
}))
vi.mock('@/app/components/workflow/constants', async () => {
const actual = await vi.importActual<typeof import('@/app/components/workflow/constants')>(
'@/app/components/workflow/constants',
)
return {
...actual,
isInWorkflowPage: vi.fn(),
}
})
vi.mock('./command-bus', () => ({
registerCommands: vi.fn(),
unregisterCommands: vi.fn(),
}))
const mockedIsInWorkflowPage = vi.mocked(isInWorkflowPage)
const mockedRegisterCommands = vi.mocked(registerCommands)
const mockedUnregisterCommands = vi.mocked(unregisterCommands)
const mockedT = vi.mocked(i18n.t)
type CommandArgs = { dsl?: string }
type CommandMap = Record<string, (args?: CommandArgs) => void | Promise<void>>
beforeEach(() => {
vi.clearAllMocks()
})
// Command availability, search, and registration behavior for banana command.
describe('bananaCommand', () => {
// Command metadata mirrors the static definition.
describe('metadata', () => {
it('should expose name, mode, and description', () => {
// Assert
expect(bananaCommand.name).toBe('banana')
expect(bananaCommand.mode).toBe('submenu')
expect(bananaCommand.description).toContain('app.gotoAnything.actions.vibeDesc')
})
})
// Availability mirrors workflow page detection.
describe('availability', () => {
it('should return true when on workflow page', () => {
// Arrange
mockedIsInWorkflowPage.mockReturnValue(true)
// Act
const available = bananaCommand.isAvailable?.()
// Assert
expect(available).toBe(true)
expect(mockedIsInWorkflowPage).toHaveBeenCalledTimes(1)
})
it('should return false when not on workflow page', () => {
// Arrange
mockedIsInWorkflowPage.mockReturnValue(false)
// Act
const available = bananaCommand.isAvailable?.()
// Assert
expect(available).toBe(false)
expect(mockedIsInWorkflowPage).toHaveBeenCalledTimes(1)
})
})
// Search results depend on provided arguments.
describe('search', () => {
it('should return hint description when args are empty', async () => {
// Arrange
mockedIsInWorkflowPage.mockReturnValue(true)
// Act
const result = await bananaCommand.search(' ')
// Assert
expect(result).toHaveLength(1)
const [item] = result
expect(item.description).toContain('app.gotoAnything.actions.vibeHint')
expect(item.data?.args?.dsl).toBe('')
expect(item.data?.command).toBe('workflow.vibe')
expect(mockedT).toHaveBeenCalledWith(
'app.gotoAnything.actions.vibeTitle',
expect.objectContaining({ lng: 'en' }),
)
expect(mockedT).toHaveBeenCalledWith(
'app.gotoAnything.actions.vibeHint',
expect.objectContaining({ prompt: expect.any(String), lng: 'en' }),
)
})
it('should return default description when args are provided', async () => {
// Arrange
mockedIsInWorkflowPage.mockReturnValue(true)
// Act
const result = await bananaCommand.search(' make a flow ', 'fr')
// Assert
expect(result).toHaveLength(1)
const [item] = result
expect(item.description).toContain('app.gotoAnything.actions.vibeDesc')
expect(item.data?.args?.dsl).toBe('make a flow')
expect(item.data?.command).toBe('workflow.vibe')
expect(mockedT).toHaveBeenCalledWith(
'app.gotoAnything.actions.vibeTitle',
expect.objectContaining({ lng: 'fr' }),
)
expect(mockedT).toHaveBeenCalledWith(
'app.gotoAnything.actions.vibeDesc',
expect.objectContaining({ lng: 'fr' }),
)
})
it('should fall back to Banana when title translation is empty', async () => {
// Arrange
mockedIsInWorkflowPage.mockReturnValue(true)
mockedT.mockImplementationOnce(() => '')
// Act
const result = await bananaCommand.search('make a plan')
// Assert
expect(result).toHaveLength(1)
expect(result[0]?.title).toBe('Banana')
})
})
// Command registration and event dispatching.
describe('registration', () => {
it('should register the workflow vibe command', () => {
// Act
expect(bananaCommand.register).toBeDefined()
bananaCommand.register?.({})
// Assert
expect(mockedRegisterCommands).toHaveBeenCalledTimes(1)
const commands = mockedRegisterCommands.mock.calls[0]?.[0] as CommandMap
expect(commands['workflow.vibe']).toEqual(expect.any(Function))
})
it('should dispatch vibe event when command handler runs', async () => {
// Arrange
const dispatchSpy = vi.spyOn(document, 'dispatchEvent')
expect(bananaCommand.register).toBeDefined()
bananaCommand.register?.({})
expect(mockedRegisterCommands).toHaveBeenCalledTimes(1)
const commands = mockedRegisterCommands.mock.calls[0]?.[0] as CommandMap
try {
// Act
await commands['workflow.vibe']?.({ dsl: 'hello' })
// Assert
expect(dispatchSpy).toHaveBeenCalledTimes(1)
const event = dispatchSpy.mock.calls[0][0] as CustomEvent
expect(event.type).toBe(VIBE_COMMAND_EVENT)
expect(event.detail).toEqual({ dsl: 'hello' })
}
finally {
dispatchSpy.mockRestore()
}
})
it('should unregister workflow vibe command', () => {
// Act
expect(bananaCommand.unregister).toBeDefined()
bananaCommand.unregister?.()
// Assert
expect(mockedUnregisterCommands).toHaveBeenCalledWith(['workflow.vibe'])
})
})
})

View File

@ -0,0 +1,59 @@
import type { SlashCommandHandler } from './types'
import { RiSparklingFill } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { isInWorkflowPage, VIBE_COMMAND_EVENT } from '@/app/components/workflow/constants'
import { registerCommands, unregisterCommands } from './command-bus'
type BananaDeps = Record<string, never>
const BANANA_PROMPT_EXAMPLE = 'Summarize a document, classify sentiment, then notify Slack'
const dispatchVibeCommand = (input?: string) => {
if (typeof document === 'undefined')
return
document.dispatchEvent(new CustomEvent(VIBE_COMMAND_EVENT, { detail: { dsl: input } }))
}
export const bananaCommand: SlashCommandHandler<BananaDeps> = {
name: 'banana',
description: getI18n().t('gotoAnything.actions.vibeDesc', { ns: 'app' }),
mode: 'submenu',
isAvailable: () => isInWorkflowPage(),
async search(args: string, locale: string = 'en') {
const trimmed = args.trim()
const hasInput = !!trimmed
return [{
id: 'banana-vibe',
title: getI18n().t('gotoAnything.actions.vibeTitle', { ns: 'app', lng: locale }) || 'Banana',
description: hasInput
? getI18n().t('gotoAnything.actions.vibeDesc', { ns: 'app', lng: locale })
: getI18n().t('gotoAnything.actions.vibeHint', { ns: 'app', lng: locale, prompt: BANANA_PROMPT_EXAMPLE }),
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">
<RiSparklingFill className="h-4 w-4 text-text-tertiary" />
</div>
),
data: {
command: 'workflow.vibe',
args: { dsl: trimmed },
},
}]
},
register(_deps: BananaDeps) {
registerCommands({
'workflow.vibe': async (args) => {
dispatchVibeCommand(args?.dsl)
},
})
},
unregister() {
unregisterCommands(['workflow.vibe'])
},
}

View File

@ -5,6 +5,7 @@ import { useEffect } from 'react'
import { getI18n } from 'react-i18next'
import { setLocaleOnClient } from '@/i18n-config'
import { accountCommand } from './account'
import { bananaCommand } from './banana'
import { executeCommand } from './command-bus'
import { communityCommand } from './community'
import { docsCommand } from './docs'
@ -43,6 +44,7 @@ export const registerSlashCommands = (deps: Record<string, any>) => {
slashCommandRegistry.register(communityCommand, {})
slashCommandRegistry.register(accountCommand, {})
slashCommandRegistry.register(zenCommand, {})
slashCommandRegistry.register(bananaCommand, {})
}
export const unregisterSlashCommands = () => {
@ -54,6 +56,7 @@ export const unregisterSlashCommands = () => {
slashCommandRegistry.unregister('community')
slashCommandRegistry.unregister('account')
slashCommandRegistry.unregister('zen')
slashCommandRegistry.unregister('banana')
}
export const SlashCommandProvider = () => {

View File

@ -0,0 +1,59 @@
import type { SlashCommandHandler } from './types'
import { RiSparklingFill } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { isInWorkflowPage, VIBE_COMMAND_EVENT } from '@/app/components/workflow/constants'
import { registerCommands, unregisterCommands } from './command-bus'
type VibeDeps = Record<string, never>
const VIBE_PROMPT_EXAMPLE = 'Summarize a document, classify sentiment, then notify Slack'
const dispatchVibeCommand = (input?: string) => {
if (typeof document === 'undefined')
return
document.dispatchEvent(new CustomEvent(VIBE_COMMAND_EVENT, { detail: { dsl: input } }))
}
export const vibeCommand: SlashCommandHandler<VibeDeps> = {
name: 'vibe',
description: getI18n().t('gotoAnything.actions.vibeDesc', { ns: 'app' }),
mode: 'submenu',
isAvailable: () => isInWorkflowPage(),
async search(args: string, locale: string = 'en') {
const trimmed = args.trim()
const hasInput = !!trimmed
return [{
id: 'vibe',
title: getI18n().t('gotoAnything.actions.vibeTitle', { ns: 'app', lng: locale }) || 'Vibe',
description: hasInput
? getI18n().t('gotoAnything.actions.vibeDesc', { ns: 'app', lng: locale })
: getI18n().t('gotoAnything.actions.vibeHint', { ns: 'app', lng: locale, prompt: VIBE_PROMPT_EXAMPLE }),
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">
<RiSparklingFill className="h-4 w-4 text-text-tertiary" />
</div>
),
data: {
command: 'workflow.vibe',
args: { dsl: trimmed },
},
}]
},
register(_deps: VibeDeps) {
registerCommands({
'workflow.vibe': async (args) => {
dispatchVibeCommand(args?.dsl)
},
})
},
unregister() {
unregisterCommands(['workflow.vibe'])
},
}

View File

@ -160,7 +160,7 @@
* - `@knowledge` / `@kb` - Search knowledge bases
* - `@plugin` - Search plugins
* - `@node` - Search workflow nodes (workflow pages only)
* - `/` - Execute slash commands (theme, language, etc.)
* - `/` - Execute slash commands (theme, language, banana, etc.)
*/
import type { ActionItem, SearchResult } from './types'

View File

@ -116,6 +116,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
'/docs': 'gotoAnything.actions.docDesc',
'/community': 'gotoAnything.actions.communityDesc',
'/zen': 'gotoAnything.actions.zenDesc',
'/banana': 'gotoAnything.actions.vibeDesc',
} as const
return t(slashKeyMap[item.key as keyof typeof slashKeyMap] || item.description, { ns: 'app' })
})()

View File

@ -9,6 +9,9 @@ export const NODE_WIDTH = 240
export const X_OFFSET = 60
export const NODE_WIDTH_X_OFFSET = NODE_WIDTH + X_OFFSET
export const Y_OFFSET = 39
export const VIBE_COMMAND_EVENT = 'workflow-vibe-command'
export const VIBE_REGENERATE_EVENT = 'workflow-vibe-regenerate'
export const VIBE_ACCEPT_EVENT = 'workflow-vibe-accept'
export const START_INITIAL_POSITION = { x: 80, y: 282 }
export const AUTO_LAYOUT_OFFSET = {
x: -42,

View File

@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { replaceVariableReferences } from '../use-workflow-vibe'
// Mock types needed for the test
type NodeData = {
title: string
[key: string]: any
}
describe('use-workflow-vibe', () => {
describe('replaceVariableReferences', () => {
it('should replace variable references in strings', () => {
const data = {
title: 'Test Node',
prompt: 'Hello {{#old_id.query#}}',
}
const nodeIdMap = new Map<string, any>()
nodeIdMap.set('old_id', { id: 'new_uuid', data: { type: 'llm' } })
const result = replaceVariableReferences(data, nodeIdMap) as NodeData
expect(result.prompt).toBe('Hello {{#new_uuid.query#}}')
})
it('should handle multiple references in one string', () => {
const data = {
title: 'Test Node',
text: '{{#node1.out#}} and {{#node2.out#}}',
}
const nodeIdMap = new Map<string, any>()
nodeIdMap.set('node1', { id: 'uuid1', data: { type: 'llm' } })
nodeIdMap.set('node2', { id: 'uuid2', data: { type: 'llm' } })
const result = replaceVariableReferences(data, nodeIdMap) as NodeData
expect(result.text).toBe('{{#uuid1.out#}} and {{#uuid2.out#}}')
})
it('should replace variable references in value_selector arrays', () => {
const data = {
title: 'End Node',
outputs: [
{
variable: 'result',
value_selector: ['old_id', 'text'],
},
],
}
const nodeIdMap = new Map<string, any>()
nodeIdMap.set('old_id', { id: 'new_uuid', data: { type: 'llm' } })
const result = replaceVariableReferences(data, nodeIdMap) as NodeData
expect(result.outputs[0].value_selector).toEqual(['new_uuid', 'text'])
})
it('should handle nested objects recursively', () => {
const data = {
config: {
model: {
prompt: '{{#old_id.text#}}',
},
},
}
const nodeIdMap = new Map<string, any>()
nodeIdMap.set('old_id', { id: 'new_uuid', data: { type: 'llm' } })
const result = replaceVariableReferences(data, nodeIdMap) as any
expect(result.config.model.prompt).toBe('{{#new_uuid.text#}}')
})
it('should ignoring missing node mappings', () => {
const data = {
text: '{{#missing_id.text#}}',
}
const nodeIdMap = new Map<string, any>()
// missing_id is not in map
const result = replaceVariableReferences(data, nodeIdMap) as NodeData
expect(result.text).toBe('{{#missing_id.text#}}')
})
})
})

View File

@ -24,3 +24,5 @@ export * from './use-workflow-run'
export * from './use-workflow-search'
export * from './use-workflow-start-run'
export * from './use-workflow-variables'
export * from './use-workflow-vibe'
export * from './use-workflow-vibe-config'

View File

@ -0,0 +1,99 @@
/**
* Vibe Workflow Generator Configuration
*
* This module centralizes configuration for the Vibe workflow generation feature,
* including node type aliases and field name corrections.
*
* Note: These definitions are mirrored in the backend at:
* api/core/workflow/generator/config/node_schemas.py
* When updating these values, also update the backend file.
*/
/**
* Node type aliases for inference from natural language.
* Maps common terms to canonical node type names.
*/
export const NODE_TYPE_ALIASES: Record<string, string> = {
// Start node aliases
'start': 'start',
'begin': 'start',
'input': 'start',
// End node aliases
'end': 'end',
'finish': 'end',
'output': 'end',
// LLM node aliases
'llm': 'llm',
'ai': 'llm',
'gpt': 'llm',
'model': 'llm',
'chat': 'llm',
// Code node aliases
'code': 'code',
'script': 'code',
'python': 'code',
'javascript': 'code',
// HTTP request node aliases
'http-request': 'http-request',
'http': 'http-request',
'request': 'http-request',
'api': 'http-request',
'fetch': 'http-request',
'webhook': 'http-request',
// Conditional node aliases
'if-else': 'if-else',
'condition': 'if-else',
'branch': 'if-else',
'switch': 'if-else',
// Loop node aliases
'iteration': 'iteration',
'loop': 'loop',
'foreach': 'iteration',
// Tool node alias
'tool': 'tool',
}
/**
* Field name corrections for LLM-generated node configs.
* Maps incorrect field names to correct ones for specific node types.
*/
export const FIELD_NAME_CORRECTIONS: Record<string, Record<string, string>> = {
'http-request': {
text: 'body', // LLM might use "text" instead of "body"
content: 'body',
response: 'body',
},
'code': {
text: 'result', // LLM might use "text" instead of "result"
output: 'result',
},
'llm': {
response: 'text',
answer: 'text',
},
}
/**
* Correct field names based on node type.
* LLM sometimes generates wrong field names (e.g., "text" instead of "body" for HTTP nodes).
*
* @param field - The field name to correct
* @param nodeType - The type of the node
* @returns The corrected field name, or the original if no correction needed
*/
export const correctFieldName = (field: string, nodeType: string): string => {
const corrections = FIELD_NAME_CORRECTIONS[nodeType]
if (corrections && corrections[field])
return corrections[field]
return field
}
/**
* Get the canonical node type from an alias.
*
* @param alias - The alias to look up
* @returns The canonical node type, or undefined if not found
*/
export const getCanonicalNodeType = (alias: string): string | undefined => {
return NODE_TYPE_ALIASES[alias.toLowerCase()]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,333 @@
/**
* VibePanel Component Tests
*
* Covers rendering states, user interactions, and edge cases for the vibe panel.
*/
import type { Shape as WorkflowState } from '@/app/components/workflow/store/workflow'
import type { Edge, Node } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast from '@/app/components/base/toast'
import { WorkflowContext } from '@/app/components/workflow/context'
import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider'
import { createHooksStore } from '@/app/components/workflow/hooks-store/store'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
import { VIBE_APPLY_EVENT, VIBE_COMMAND_EVENT } from '../../constants'
import VibePanel from './index'
// ============================================================================
// Mocks
// ============================================================================
const mockCopy = vi.hoisted(() => vi.fn())
const mockUseVibeFlowData = vi.hoisted(() => vi.fn())
vi.mock('copy-to-clipboard', () => ({
default: mockCopy,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ defaultModel: null }),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: ({ modelId, provider }: { modelId: string, provider: string }) => (
<div data-testid="model-parameter-modal" data-model-id={modelId} data-provider={provider} />
),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-vibe', () => ({
useVibeFlowData: () => mockUseVibeFlowData(),
}))
vi.mock('@/app/components/workflow/workflow-preview', () => ({
__esModule: true,
default: ({ nodes, edges }: { nodes: Node[], edges: Edge[] }) => (
<div data-testid="workflow-preview" data-nodes-count={nodes.length} data-edges-count={edges.length} />
),
}))
// ============================================================================
// Test Utilities
// ============================================================================
type FlowGraph = {
nodes: Node[]
edges: Edge[]
}
type VibeFlowData = {
versions: FlowGraph[]
currentVersionIndex: number
setCurrentVersionIndex: (index: number) => void
current?: FlowGraph
}
const createMockNode = (overrides: Partial<Node> = {}): Node => ({
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Start',
desc: '',
type: BlockEnum.Start,
},
...overrides,
})
const createMockEdge = (overrides: Partial<Edge> = {}): Edge => ({
id: 'edge-1',
source: 'node-1',
target: 'node-2',
data: {
sourceType: BlockEnum.Start,
targetType: BlockEnum.End,
},
...overrides,
})
const createFlowGraph = (overrides: Partial<FlowGraph> = {}): FlowGraph => ({
nodes: [],
edges: [],
...overrides,
})
const createVibeFlowData = (overrides: Partial<VibeFlowData> = {}): VibeFlowData => ({
versions: [],
currentVersionIndex: 0,
setCurrentVersionIndex: vi.fn(),
current: undefined,
...overrides,
})
const renderVibePanel = ({
workflowState,
vibeFlowData,
}: {
workflowState?: Partial<WorkflowState>
vibeFlowData?: VibeFlowData
} = {}) => {
if (vibeFlowData)
mockUseVibeFlowData.mockReturnValue(vibeFlowData)
const workflowStore = createWorkflowStore({})
const vibeFlowState = vibeFlowData
? {
vibeFlowVersions: vibeFlowData.versions,
vibeFlowCurrentIndex: vibeFlowData.currentVersionIndex,
currentVibeFlow: vibeFlowData.current,
}
: {}
workflowStore.setState({
showVibePanel: true,
isVibeGenerating: false,
vibePanelInstruction: '',
vibePanelMermaidCode: '',
...vibeFlowState,
...workflowState,
})
const hooksStore = createHooksStore({})
return {
workflowStore,
...render(
<WorkflowContext.Provider value={workflowStore}>
<HooksStoreContext.Provider value={hooksStore}>
<VibePanel />
</HooksStoreContext.Provider>
</WorkflowContext.Provider>,
),
}
}
const getCopyButton = () => {
const buttons = screen.getAllByRole('button')
const copyButton = buttons.find(button => button.textContent?.trim() === '' && button.querySelector('svg'))
if (!copyButton)
throw new Error('Copy button not found')
return copyButton
}
// ============================================================================
// Tests
// ============================================================================
describe('VibePanel', () => {
let toastNotifySpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
mockUseVibeFlowData.mockReturnValue(createVibeFlowData())
toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
afterEach(() => {
toastNotifySpy.mockRestore()
})
// --------------------------------------------------------------------------
// Rendering: default visibility and primary view states.
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render nothing when panel is hidden', () => {
renderVibePanel({ workflowState: { showVibePanel: false } })
expect(screen.queryByText(/app\.gotoAnything\.actions\.vibeTitle/i)).not.toBeInTheDocument()
})
it('should render placeholder when no preview data and not generating', () => {
renderVibePanel({
workflowState: { showVibePanel: true, isVibeGenerating: false },
vibeFlowData: createVibeFlowData({ current: undefined }),
})
expect(screen.getByText(/appDebug\.generate\.newNoDataLine1/i)).toBeInTheDocument()
})
it('should render loading state when generating', () => {
renderVibePanel({
workflowState: { showVibePanel: true, isVibeGenerating: true },
})
expect(screen.getByText(/workflow\.vibe\.generatingFlowchart/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'appDebug.generate.generate' })).toBeDisabled()
})
it('should render preview panel when nodes exist', () => {
const flowGraph = createFlowGraph({
nodes: [createMockNode()],
edges: [createMockEdge()],
})
renderVibePanel({
vibeFlowData: createVibeFlowData({
current: flowGraph,
versions: [flowGraph],
}),
})
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'workflow.vibe.apply' })).toBeInTheDocument()
expect(screen.getByText(/appDebug\.generate\.version/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Props: store-driven inputs that toggle behavior.
// --------------------------------------------------------------------------
describe('Props', () => {
it('should render modal content when showVibePanel is true', () => {
renderVibePanel({ workflowState: { showVibePanel: true } })
expect(screen.getByText(/app\.gotoAnything\.actions\.vibeTitle/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions: input edits and action triggers.
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should update instruction in store when typing', async () => {
const { workflowStore } = renderVibePanel()
const textarea = screen.getByPlaceholderText('workflow.vibe.missingInstruction')
fireEvent.change(textarea, { target: { value: 'Build a vibe flow' } })
await waitFor(() => {
expect(workflowStore.getState().vibePanelInstruction).toBe('Build a vibe flow')
})
})
it('should dispatch command event with instruction when generate clicked', async () => {
const user = userEvent.setup()
const { workflowStore } = renderVibePanel({
workflowState: { vibePanelInstruction: 'Generate a workflow' },
})
const handler = vi.fn()
document.addEventListener(VIBE_COMMAND_EVENT, handler)
await user.click(screen.getByRole('button', { name: 'appDebug.generate.generate' }))
expect(handler).toHaveBeenCalledTimes(1)
const event = handler.mock.calls[0][0] as CustomEvent<{ dsl?: string }>
expect(event.detail).toEqual({ dsl: workflowStore.getState().vibePanelInstruction })
document.removeEventListener(VIBE_COMMAND_EVENT, handler)
})
it('should close panel when dismiss clicked', async () => {
const user = userEvent.setup()
const { workflowStore } = renderVibePanel({
workflowState: {
vibePanelMermaidCode: 'graph TD',
isVibeGenerating: true,
},
})
await user.click(screen.getByRole('button', { name: 'appDebug.generate.dismiss' }))
const state = workflowStore.getState()
expect(state.showVibePanel).toBe(false)
expect(state.vibePanelMermaidCode).toBe('')
expect(state.isVibeGenerating).toBe(false)
})
it('should dispatch apply event and close panel when apply clicked', async () => {
const user = userEvent.setup()
const flowGraph = createFlowGraph({
nodes: [createMockNode()],
edges: [createMockEdge()],
})
const { workflowStore } = renderVibePanel({
workflowState: { vibePanelMermaidCode: 'graph TD' },
vibeFlowData: createVibeFlowData({
current: flowGraph,
versions: [flowGraph],
}),
})
const handler = vi.fn()
document.addEventListener(VIBE_APPLY_EVENT, handler)
await user.click(screen.getByRole('button', { name: 'workflow.vibe.apply' }))
expect(handler).toHaveBeenCalledTimes(1)
const state = workflowStore.getState()
expect(state.showVibePanel).toBe(false)
expect(state.vibePanelMermaidCode).toBe('')
expect(state.isVibeGenerating).toBe(false)
document.removeEventListener(VIBE_APPLY_EVENT, handler)
})
it('should copy mermaid and notify when copy clicked', async () => {
const user = userEvent.setup()
const flowGraph = createFlowGraph({
nodes: [createMockNode()],
edges: [createMockEdge()],
})
renderVibePanel({
workflowState: { vibePanelMermaidCode: 'graph TD' },
vibeFlowData: createVibeFlowData({
current: flowGraph,
versions: [flowGraph],
}),
})
await user.click(getCopyButton())
expect(mockCopy).toHaveBeenCalledWith('graph TD')
expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.actionMsg.copySuccessfully',
}))
})
})
})

View File

@ -0,0 +1,332 @@
'use client'
import type { FC } from 'react'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CompletionParams, Model } from '@/types/app'
import { RiClipboardLine } from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import ResPlaceholder from '@/app/components/app/configuration/config/automatic/res-placeholder'
import VersionSelector from '@/app/components/app/configuration/config/automatic/version-selector'
import Button from '@/app/components/base/button'
import { Generator } from '@/app/components/base/icons/src/vender/other'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { ModelModeType } from '@/types/app'
import { VIBE_APPLY_EVENT, VIBE_COMMAND_EVENT } from '../../constants'
import { useStore, useWorkflowStore } from '../../store'
import WorkflowPreview from '../../workflow-preview'
const CompletionParamsSchema = z.object({
max_tokens: z.number(),
temperature: z.number(),
top_p: z.number(),
echo: z.boolean(),
stop: z.array(z.string()),
presence_penalty: z.number(),
frequency_penalty: z.number(),
})
const ModelSchema = z.object({
provider: z.string(),
name: z.string(),
mode: z.nativeEnum(ModelModeType),
completion_params: CompletionParamsSchema,
})
const VibePanel: FC = () => {
const { t } = useTranslation()
const workflowStore = useWorkflowStore()
const showVibePanel = useStore(s => s.showVibePanel)
const setShowVibePanel = useStore(s => s.setShowVibePanel)
const isVibeGenerating = useStore(s => s.isVibeGenerating)
const setIsVibeGenerating = useStore(s => s.setIsVibeGenerating)
const vibePanelInstruction = useStore(s => s.vibePanelInstruction)
const vibePanelMermaidCode = useStore(s => s.vibePanelMermaidCode)
const setVibePanelMermaidCode = useStore(s => s.setVibePanelMermaidCode)
const currentFlowGraph = useStore(s => s.currentVibeFlow)
const versions = useStore(s => s.vibeFlowVersions)
const currentVersionIndex = useStore(s => s.vibeFlowCurrentIndex)
const vibePanelPreviewNodes = currentFlowGraph?.nodes || []
const vibePanelPreviewEdges = currentFlowGraph?.edges || []
const setVibePanelInstruction = useStore(s => s.setVibePanelInstruction)
const vibePanelIntent = useStore(s => s.vibePanelIntent)
const setVibePanelIntent = useStore(s => s.setVibePanelIntent)
const vibePanelMessage = useStore(s => s.vibePanelMessage)
const setVibePanelMessage = useStore(s => s.setVibePanelMessage)
const vibePanelSuggestions = useStore(s => s.vibePanelSuggestions)
const setVibePanelSuggestions = useStore(s => s.setVibePanelSuggestions)
const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
// Track user's explicit model selection (from localStorage)
const [userModel, setUserModel] = useState<Model | null>(() => {
try {
const stored = localStorage.getItem('auto-gen-model')
if (stored) {
const parsed = JSON.parse(stored)
const result = ModelSchema.safeParse(parsed)
if (result.success)
return result.data
// If validation fails, clear the invalid data
localStorage.removeItem('auto-gen-model')
}
}
catch {
// ignore parse errors
}
return null
})
// Derive the actual model from user selection or default
const model: Model = useMemo(() => {
if (userModel)
return userModel
if (defaultModel) {
return {
name: defaultModel.model,
provider: defaultModel.provider.provider,
mode: ModelModeType.chat,
completion_params: {} as CompletionParams,
}
}
return {
name: '',
provider: '',
mode: ModelModeType.chat,
completion_params: {} as CompletionParams,
}
}, [userModel, defaultModel])
const setModel = useCallback((newModel: Model) => {
setUserModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
}, [])
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
setModel({
...model,
provider: newValue.provider,
name: newValue.modelId,
mode: newValue.mode as ModelModeType,
})
}, [model, setModel])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
setModel({
...model,
completion_params: newParams as CompletionParams,
})
}, [model, setModel])
const handleInstructionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
workflowStore.setState(state => ({
...state,
vibePanelInstruction: e.target.value,
}))
}, [workflowStore])
const handleClose = useCallback(() => {
setShowVibePanel(false)
setVibePanelMermaidCode('')
setIsVibeGenerating(false)
setVibePanelIntent('')
setVibePanelMessage('')
setVibePanelSuggestions([])
}, [setShowVibePanel, setVibePanelMermaidCode, setIsVibeGenerating, setVibePanelIntent, setVibePanelMessage, setVibePanelSuggestions])
const handleGenerate = useCallback(() => {
const event = new CustomEvent(VIBE_COMMAND_EVENT, {
detail: { dsl: vibePanelInstruction },
})
document.dispatchEvent(event)
}, [vibePanelInstruction])
const handleAccept = useCallback(() => {
const event = new CustomEvent(VIBE_APPLY_EVENT)
document.dispatchEvent(event)
handleClose()
}, [handleClose])
const handleCopyMermaid = useCallback(() => {
copy(vibePanelMermaidCode)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}, [vibePanelMermaidCode, t])
const handleSuggestionClick = useCallback((suggestion: string) => {
setVibePanelInstruction(suggestion)
// Trigger generation with the suggestion
const event = new CustomEvent(VIBE_COMMAND_EVENT, {
detail: { dsl: suggestion },
})
document.dispatchEvent(event)
}, [setVibePanelInstruction])
const handleVersionChange = useCallback((index: number) => {
const { setVibeFlowCurrentIndex } = workflowStore.getState()
setVibeFlowCurrentIndex(index)
}, [workflowStore])
// Button label - always use "Generate" (refinement mode removed)
const generateButtonLabel = useMemo(() => {
return t('generate.generate', { ns: 'appDebug' })
}, [t])
if (!showVibePanel)
return null
const renderLoading = (
<div className="flex h-full w-full grow flex-col items-center justify-center space-y-3">
<Loading />
<div className="text-[13px] text-text-tertiary">{t('vibe.generatingFlowchart', { ns: 'workflow' })}</div>
</div>
)
const renderOffTopic = (
<div className="flex h-full w-0 grow flex-col items-center justify-center p-6">
<div className="flex max-w-[400px] flex-col items-center text-center">
<div className="text-sm font-medium text-text-secondary">
{t('vibe.offTopicTitle', { ns: 'workflow' })}
</div>
<div className="mt-1 text-xs text-text-tertiary">
{vibePanelMessage || t('vibe.offTopicDefault', { ns: 'workflow' })}
</div>
{vibePanelSuggestions.length > 0 && (
<div className="mt-6 w-full">
<div className="mb-2 text-xs text-text-quaternary">
{t('vibe.trySuggestion', { ns: 'workflow' })}
</div>
<div className="flex flex-col gap-2">
{vibePanelSuggestions.map(suggestion => (
<button
key={suggestion}
onClick={() => handleSuggestionClick(suggestion)}
className="w-full cursor-pointer rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2.5 text-left text-sm text-text-secondary transition-colors hover:border-divider-regular hover:bg-state-base-hover"
>
{suggestion}
</button>
))}
</div>
</div>
)}
</div>
</div>
)
return (
<Modal
isShow={showVibePanel}
onClose={handleClose}
className="min-w-[1140px] !p-0"
clickOutsideNotClose
>
<div className="flex h-[680px] flex-wrap">
<div className="h-full w-[300px] shrink-0 overflow-y-auto border-r border-divider-regular p-6">
<div className="mb-5">
<div className="text-lg font-bold leading-[28px] text-text-primary">{t('gotoAnything.actions.vibeTitle', { ns: 'app' })}</div>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('gotoAnything.actions.vibeDesc', { ns: 'app' })}</div>
</div>
<div>
<ModelParameterModal
popupClassName="!w-[520px]"
portalToFollowElemContentClassName="z-[1000]"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
modelId={model.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
<div className="mt-4">
<div className="system-sm-semibold-uppercase mb-1.5 text-text-secondary">{t('generate.instruction', { ns: 'appDebug' })}</div>
<Textarea
className="min-h-[240px] resize-none rounded-[10px] px-4 pt-3"
placeholder={t('vibe.missingInstruction', { ns: 'workflow' })}
value={vibePanelInstruction}
onChange={handleInstructionChange}
/>
</div>
<div className="mt-7 flex justify-end space-x-2">
<Button onClick={handleClose}>{t('generate.dismiss', { ns: 'appDebug' })}</Button>
<Button
className="flex space-x-1"
variant="primary"
onClick={handleGenerate}
disabled={isVibeGenerating}
>
<Generator className="h-4 w-4" />
<span className="system-xs-semibold">{generateButtonLabel}</span>
</Button>
</div>
</div>
{isVibeGenerating && (
<div className="h-full w-0 grow bg-background-default-subtle">
{renderLoading}
</div>
)}
{!isVibeGenerating && vibePanelIntent === 'off_topic' && renderOffTopic}
{!isVibeGenerating && vibePanelIntent !== 'off_topic' && (vibePanelPreviewNodes.length > 0 || vibePanelMermaidCode) && (
<div className="relative h-full w-0 grow bg-background-default-subtle p-6 pb-0">
<div className="flex h-full flex-col">
<div className="mb-3 flex shrink-0 items-center justify-between">
<div className="flex shrink-0 flex-col">
<div className="system-xl-semibold text-text-secondary">{t('vibe.panelTitle', { ns: 'workflow' })}</div>
<VersionSelector
versionLen={versions.length}
value={currentVersionIndex}
onChange={handleVersionChange}
contentClassName="z-[1200]"
/>
</div>
<div className="flex items-center space-x-2">
<Button
variant="secondary"
size="medium"
onClick={handleCopyMermaid}
className="px-2"
>
<RiClipboardLine className="h-4 w-4" />
</Button>
<Button
variant="primary"
size="medium"
onClick={handleAccept}
>
{t('vibe.apply', { ns: 'workflow' })}
</Button>
</div>
</div>
<div className="flex grow flex-col overflow-hidden pb-6">
<WorkflowPreview
key={currentVersionIndex}
fitView
fitViewOptions={{ padding: 0.2 }}
nodes={vibePanelPreviewNodes}
edges={vibePanelPreviewEdges}
className="rounded-lg border border-divider-subtle"
/>
</div>
</div>
</div>
)}
{!isVibeGenerating && vibePanelIntent !== 'off_topic' && vibePanelPreviewNodes.length === 0 && !vibePanelMermaidCode && <ResPlaceholder />}
</div>
</Modal>
)
}
export default VibePanel

View File

@ -11,6 +11,7 @@ import type { LayoutSliceShape } from './layout-slice'
import type { NodeSliceShape } from './node-slice'
import type { PanelSliceShape } from './panel-slice'
import type { ToolSliceShape } from './tool-slice'
import type { VibeWorkflowSliceShape } from './vibe-workflow-slice'
import type { VersionSliceShape } from './version-slice'
import type { WorkflowDraftSliceShape } from './workflow-draft-slice'
import type { WorkflowSliceShape } from './workflow-slice'
@ -33,6 +34,7 @@ import { createNodeSlice } from './node-slice'
import { createPanelSlice } from './panel-slice'
import { createToolSlice } from './tool-slice'
import { createVibeWorkflowSlice } from './vibe-workflow-slice'
import { createVersionSlice } from './version-slice'
import { createWorkflowDraftSlice } from './workflow-draft-slice'
import { createWorkflowSlice } from './workflow-slice'
@ -55,6 +57,7 @@ export type Shape
& WorkflowSliceShape
& InspectVarsSliceShape
& LayoutSliceShape
& VibeWorkflowSliceShape
& SliceFromInjection
export type InjectWorkflowStoreSliceFn = StateCreator<SliceFromInjection>
@ -80,6 +83,7 @@ export const createWorkflowStore = (params: CreateWorkflowStoreParams) => {
...createWorkflowSlice(...args),
...createInspectVarsSlice(...args),
...createLayoutSlice(...args),
...createVibeWorkflowSlice(...args),
...(injectWorkflowStoreSliceFn?.(...args) || {} as SliceFromInjection),
}))
}

View File

@ -0,0 +1,78 @@
import type { StateCreator } from 'zustand'
import type { Edge, Node } from '../../types'
export type FlowGraph = {
nodes: Node[]
edges: Edge[]
}
export type VibeWorkflowSliceShape = {
vibePanelMermaidCode: string
setVibePanelMermaidCode: (vibePanelMermaidCode: string) => void
isVibeGenerating: boolean
setIsVibeGenerating: (isVibeGenerating: boolean) => void
vibePanelInstruction: string
setVibePanelInstruction: (vibePanelInstruction: string) => void
vibeFlowVersions: FlowGraph[]
setVibeFlowVersions: (versions: FlowGraph[]) => void
vibeFlowCurrentIndex: number
setVibeFlowCurrentIndex: (index: number) => void
addVibeFlowVersion: (version: FlowGraph) => void
currentVibeFlow: FlowGraph | undefined
}
const getCurrentVibeFlow = (versions: FlowGraph[], currentIndex: number): FlowGraph | undefined => {
if (!versions || versions.length === 0)
return undefined
const index = currentIndex ?? 0
if (index < 0)
return undefined
return versions[index] || versions[versions.length - 1]
}
export const createVibeWorkflowSlice: StateCreator<VibeWorkflowSliceShape> = (set, get) => ({
vibePanelMermaidCode: '',
setVibePanelMermaidCode: vibePanelMermaidCode => set(() => ({ vibePanelMermaidCode })),
isVibeGenerating: false,
setIsVibeGenerating: isVibeGenerating => set(() => ({ isVibeGenerating })),
vibePanelInstruction: '',
setVibePanelInstruction: vibePanelInstruction => set(() => ({ vibePanelInstruction })),
vibeFlowVersions: [],
setVibeFlowVersions: versions => set((state) => {
const currentVibeFlow = getCurrentVibeFlow(versions, state.vibeFlowCurrentIndex)
return { vibeFlowVersions: versions, currentVibeFlow }
}),
vibeFlowCurrentIndex: 0,
setVibeFlowCurrentIndex: (index) => {
const state = get()
const versions = state.vibeFlowVersions || []
if (!versions || versions.length === 0) {
set({ vibeFlowCurrentIndex: 0, currentVibeFlow: undefined })
return
}
const normalizedIndex = Math.min(Math.max(index, 0), versions.length - 1)
const currentVibeFlow = getCurrentVibeFlow(versions, normalizedIndex)
set({ vibeFlowCurrentIndex: normalizedIndex, currentVibeFlow })
},
addVibeFlowVersion: (version) => {
// Prevent adding empty graphs
if (!version || !version.nodes || version.nodes.length === 0) {
set({ vibeFlowCurrentIndex: -1, currentVibeFlow: undefined })
return
}
set((state) => {
const newVersions = [...(state.vibeFlowVersions || []), version]
const newIndex = newVersions.length - 1
const currentVibeFlow = getCurrentVibeFlow(newVersions, newIndex)
return {
vibeFlowVersions: newVersions,
vibeFlowCurrentIndex: newIndex,
currentVibeFlow,
}
})
},
currentVibeFlow: undefined,
})