mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:08:07 +08:00
feat: cherry pick implementation (#31960)
This commit is contained in:
@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
59
web/app/components/goto-anything/actions/commands/banana.tsx
Normal file
59
web/app/components/goto-anything/actions/commands/banana.tsx
Normal 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'])
|
||||
},
|
||||
}
|
||||
@ -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 = () => {
|
||||
|
||||
59
web/app/components/goto-anything/actions/commands/vibe.tsx
Normal file
59
web/app/components/goto-anything/actions/commands/vibe.tsx
Normal 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'])
|
||||
},
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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' })
|
||||
})()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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#}}')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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'
|
||||
|
||||
@ -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()]
|
||||
}
|
||||
1571
web/app/components/workflow/hooks/use-workflow-vibe.tsx
Normal file
1571
web/app/components/workflow/hooks/use-workflow-vibe.tsx
Normal file
File diff suppressed because it is too large
Load Diff
333
web/app/components/workflow/panel/vibe-panel/index.spec.tsx
Normal file
333
web/app/components/workflow/panel/vibe-panel/index.spec.tsx
Normal 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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
332
web/app/components/workflow/panel/vibe-panel/index.tsx
Normal file
332
web/app/components/workflow/panel/vibe-panel/index.tsx
Normal 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
|
||||
@ -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),
|
||||
}))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user