feat: enhance go to anything (#32130)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Crazywoola
2026-06-04 11:06:17 +00:00
committed by GitHub
co-authored by Claude Opus 4.7 autofix-ci[bot] Copilot Autofix powered by AI
parent c8abb11bf0
commit 0bfbd2061e
48 changed files with 8391 additions and 5 deletions
@@ -0,0 +1,65 @@
import { act, renderHook } from '@testing-library/react'
import useGenGraph from '../../../app/components/workflow/workflow-generator/use-gen-graph'
describe('useGenGraph', () => {
beforeEach(() => {
sessionStorage.clear()
})
const makeResponse = (label: string) => ({
graph: {
nodes: [{ id: label, type: 'custom', position: { x: 0, y: 0 }, data: { type: 'start', title: label } }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
message: label,
})
it('starts with an empty version list and undefined current', () => {
const { result } = renderHook(() => useGenGraph({ storageKey: 'k1' }))
expect(result.current.versions).toEqual([])
expect(result.current.current).toBeUndefined()
})
it('appends versions and tracks the latest one as current', () => {
const { result } = renderHook(() => useGenGraph({ storageKey: 'k2' }))
act(() => {
result.current.addVersion(makeResponse('v1') as never)
})
expect(result.current.versions).toHaveLength(1)
expect(result.current.current?.message).toBe('v1')
act(() => {
result.current.addVersion(makeResponse('v2') as never)
})
expect(result.current.versions).toHaveLength(2)
expect(result.current.current?.message).toBe('v2')
expect(result.current.currentVersionIndex).toBe(1)
})
it('allows switching back to an older version', () => {
const { result } = renderHook(() => useGenGraph({ storageKey: 'k3' }))
act(() => {
result.current.addVersion(makeResponse('a') as never)
result.current.addVersion(makeResponse('b') as never)
})
act(() => {
result.current.setCurrentVersionIndex(0)
})
expect(result.current.current?.message).toBe('a')
})
it('isolates state by storageKey', () => {
const { result: r1 } = renderHook(() => useGenGraph({ storageKey: 'mode-a' }))
const { result: r2 } = renderHook(() => useGenGraph({ storageKey: 'mode-b' }))
act(() => {
r1.current.addVersion(makeResponse('only-a') as never)
})
expect(r1.current.versions).toHaveLength(1)
expect(r2.current.versions).toHaveLength(0)
})
})
+2
View File
@@ -10,6 +10,7 @@ import Header from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import WorkflowGeneratorMount from '@/app/components/workflow/workflow-generator/mount'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
@@ -40,6 +41,7 @@ const Layout = async ({ children }: { children: ReactNode }) => {
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<WorkflowGeneratorMount />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
@@ -0,0 +1,178 @@
import { executeCommand } from '../command-bus'
import { createCommand } from '../create'
// Stub the icon imports — these are React components we don't render here.
vi.mock('@remixicon/react', () => ({
RiChat3Line: () => null,
RiNodeTree: () => null,
}))
// search() localises its labels via getI18n(); echo the key back so the
// filtering/payload assertions stay deterministic without a real i18n init.
vi.mock('react-i18next', () => ({
getI18n: () => ({ t: (key: string) => key }),
}))
// We spy on the store at module scope so the `create.open` handler that
// register() pushes into the command bus can be observed by the tests.
const mockOpenGenerator = vi.fn()
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
useWorkflowGeneratorStore: {
getState: () => ({ openGenerator: mockOpenGenerator }),
},
}))
// Controllable app-store state — the handler reads `appDetail` to decide
// whether to thread the current Studio app through to the generator. Mutated
// per-test; getState() reads it lazily so updates land after the mock factory.
const mockAppStore: { appDetail: { id: string, mode: string } | undefined } = {
appDetail: undefined,
}
vi.mock('@/app/components/app/store', () => ({
useStore: {
getState: () => ({ appDetail: mockAppStore.appDetail }),
},
}))
describe('/create slash command', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('handler metadata', () => {
// The slash registry relies on this metadata to route /create through the
// submenu UX rather than executing immediately.
it('should expose submenu mode with the expected name and aliases', () => {
expect(createCommand.mode).toBe('submenu')
expect(createCommand.name).toBe('create')
expect(createCommand.aliases).toEqual(['new', 'generate'])
})
})
describe('search()', () => {
// An empty arg list should surface every option; the submenu uses this to
// render its initial list when the user types just `/create`.
it('should surface both workflow and chatflow when args is empty', async () => {
const results = await createCommand.search('')
expect(results.map(r => r.id)).toEqual(['create-workflow', 'create-chatflow'])
})
// Typing a partial keyword should narrow the list and each result should
// carry the right command-bus payload so the navigation hook can fire it.
it('should filter by query and attach the right command payload', async () => {
const results = await createCommand.search('chat')
expect(results.map(r => r.id)).toEqual(['create-chatflow'])
expect(results[0]!.data.command).toBe('create.open')
expect(results[0]!.data.args).toEqual({ mode: 'advanced-chat' })
})
// A non-matching query returns an empty list rather than throwing, so the
// goto-anything dialog can render an empty-state without special-casing.
it('should return an empty list when the query matches nothing', async () => {
const results = await createCommand.search('zzz-no-match')
expect(results).toEqual([])
})
// Labels/descriptions must be localised through i18n (ns: 'app') rather
// than hardcoded English, so the palette renders in the user's language.
it('should source titles and descriptions from i18n keys', async () => {
const results = await createCommand.search('')
expect(results[0]!.title).toBe('gotoAnything.actions.createWorkflow')
expect(results[0]!.description).toBe('gotoAnything.actions.createWorkflowDesc')
expect(results[1]!.title).toBe('gotoAnything.actions.createChatflow')
expect(results[1]!.description).toBe('gotoAnything.actions.createChatflowDesc')
})
// The localised label is also searchable, not just the id — a token that
// appears only in the (mocked) title key still narrows the list, proving
// the filter consults the translated label.
it('should filter by the localised label, not just the id', async () => {
const results = await createCommand.search('createChatflow')
expect(results.map(r => r.id)).toEqual(['create-chatflow'])
})
})
describe('register() — `create.open` command-bus handler', () => {
// Register populates the global command bus; tests below rely on it so we
// run it once per case and clean up via the symmetric unregister(). Reset
// the app-store state so each case controls its own Studio context.
beforeEach(() => {
mockAppStore.appDetail = undefined
createCommand.register?.({} as never)
})
afterEach(() => {
createCommand.unregister?.()
})
// No Studio app open (e.g. /create from the apps list) — the modal opens
// for new-app creation only, with just the requested mode.
it('should open the generator with only the requested mode when no Studio app is open', async () => {
await executeCommand('create.open', { mode: 'workflow' })
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
})
// In-Studio create-and-apply: when a graph-based app is open and its mode
// matches the picked mode, the handler threads id + mode through so the
// modal can offer "Apply to current draft".
it('should thread the current app context when a matching Studio app is open', async () => {
mockAppStore.appDetail = { id: 'abc-123', mode: 'workflow' }
await executeCommand('create.open', { mode: 'workflow' })
expect(mockOpenGenerator).toHaveBeenCalledWith({
mode: 'workflow',
currentAppId: 'abc-123',
currentAppMode: 'workflow',
})
})
// Mode mismatch (Workflow Studio open, but the user picked Chatflow) must
// NOT capture currentAppId — applying a chatflow graph onto a workflow
// draft is the dead-end we explicitly avoid, so it stays new-app only.
it('should fall back to new-app only when the picked mode differs from the open app', async () => {
mockAppStore.appDetail = { id: 'abc-123', mode: 'workflow' }
await executeCommand('create.open', { mode: 'advanced-chat' })
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'advanced-chat' })
})
// Non-graph Studio apps (Chat / Agent / Completion) have no canvas to
// apply onto, so the handler ignores them and opens new-app only.
it('should ignore non-graph app modes and open new-app only', async () => {
mockAppStore.appDetail = { id: 'abc-123', mode: 'chat' }
await executeCommand('create.open', { mode: 'workflow' })
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
})
// Defensive fallback: if a caller forgets to pass a mode (or passes none),
// the handler must still open the generator with a safe default rather
// than crashing the goto-anything dialog.
it('should default to workflow mode when no args are passed', async () => {
await executeCommand('create.open')
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
})
})
describe('unregister()', () => {
// After unregister, the bus must drop the handler so a later execute call
// becomes a silent no-op (prevents stale references between mounts).
it('should remove the command-bus handler so it stops firing', async () => {
createCommand.register?.({} as never)
createCommand.unregister?.()
Object.defineProperty(window, 'location', {
writable: true,
value: { pathname: '/apps' },
})
await executeCommand('create.open', { mode: 'workflow' })
expect(mockOpenGenerator).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,151 @@
import { executeCommand } from '../command-bus'
import { refineCommand } from '../refine'
// Stub the icon import — it's a React component we don't render here.
vi.mock('@remixicon/react', () => ({
RiSparkling2Line: () => null,
}))
// search() localises its title/description via getI18n(); echo the key back
// so assertions stay deterministic without a real i18n init.
vi.mock('react-i18next', () => ({
getI18n: () => ({ t: (key: string) => key }),
}))
// Spy on the generator store so we can observe what /refine opens it with.
const mockOpenGenerator = vi.fn()
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
useWorkflowGeneratorStore: {
getState: () => ({ openGenerator: mockOpenGenerator }),
},
}))
// Controllable app-store state — /refine reads appDetail to gate availability
// and to pick the mode + id it refines. Mutated per-test; read lazily.
const mockAppStore: { appDetail: { id: string, mode: string } | undefined } = {
appDetail: undefined,
}
vi.mock('@/app/components/app/store', () => ({
useStore: {
getState: () => ({ appDetail: mockAppStore.appDetail }),
},
}))
describe('/refine slash command', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppStore.appDetail = undefined
})
describe('handler metadata', () => {
it('should be a direct command named refine with the expected alias', () => {
expect(refineCommand.mode).toBe('direct')
expect(refineCommand.name).toBe('refine')
expect(refineCommand.aliases).toEqual(['improve'])
})
})
describe('isAvailable()', () => {
// /refine only makes sense inside a graph-based Studio — elsewhere there's
// no draft graph to refine, so the command must hide itself.
it('should be unavailable when no app is open', () => {
expect(refineCommand.isAvailable?.()).toBe(false)
})
it('should be available in a Workflow Studio', () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
expect(refineCommand.isAvailable?.()).toBe(true)
})
it('should be available in an Advanced-Chat Studio', () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'advanced-chat' }
expect(refineCommand.isAvailable?.()).toBe(true)
})
it('should be unavailable for non-graph apps (chat / agent / completion)', () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'chat' }
expect(refineCommand.isAvailable?.()).toBe(false)
})
})
describe('execute()', () => {
// The core behaviour: open the generator in refine intent, threading the
// current app's id + mode so the modal fetches its draft as context.
it('should open the generator in refine intent for a Workflow Studio', () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
refineCommand.execute?.()
expect(mockOpenGenerator).toHaveBeenCalledWith({
intent: 'refine',
mode: 'workflow',
currentAppId: 'app-1',
currentAppMode: 'workflow',
})
})
it('should map advanced-chat apps to the advanced-chat generator mode', () => {
mockAppStore.appDetail = { id: 'app-2', mode: 'advanced-chat' }
refineCommand.execute?.()
expect(mockOpenGenerator).toHaveBeenCalledWith({
intent: 'refine',
mode: 'advanced-chat',
currentAppId: 'app-2',
currentAppMode: 'advanced-chat',
})
})
it('should be a no-op when no graph-based app is open', () => {
mockAppStore.appDetail = { id: 'app-3', mode: 'chat' }
refineCommand.execute?.()
expect(mockOpenGenerator).not.toHaveBeenCalled()
})
})
describe('search()', () => {
// The submenu/result list renders one localised entry carrying the
// refine.open command-bus payload.
it('should return a single localised refine result', async () => {
const results = await refineCommand.search('')
expect(results).toHaveLength(1)
expect(results[0]!.id).toBe('refine-current')
expect(results[0]!.title).toBe('gotoAnything.actions.refineTitle')
expect(results[0]!.data.command).toBe('refine.open')
})
})
describe('register() — `refine.open` command-bus handler', () => {
beforeEach(() => {
refineCommand.register?.({} as never)
})
afterEach(() => {
refineCommand.unregister?.()
})
it('should open the generator via the command bus too', async () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
await executeCommand('refine.open', {})
expect(mockOpenGenerator).toHaveBeenCalledWith({
intent: 'refine',
mode: 'workflow',
currentAppId: 'app-1',
currentAppMode: 'workflow',
})
})
it('should stop firing after unregister', async () => {
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
refineCommand.unregister?.()
await executeCommand('refine.open', {})
expect(mockOpenGenerator).not.toHaveBeenCalled()
})
})
})
@@ -106,6 +106,8 @@ describe('SlashCommandProvider', () => {
'account',
'zen',
'go',
'create',
'refine',
])
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme })
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale })
@@ -121,6 +123,8 @@ describe('SlashCommandProvider', () => {
'account',
'zen',
'go',
'create',
'refine',
])
})
})
@@ -0,0 +1,119 @@
import type { SlashCommandHandler } from './types'
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
import { RiChat3Line, RiNodeTree } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
import { AppModeEnum } from '@/types/app'
import { registerCommands, unregisterCommands } from './command-bus'
type CreateOption = {
id: string
/** i18n key (ns: 'app') for the option's display label. */
titleKey: string
/** i18n key (ns: 'app') for the option's one-line description. */
descKey: string
mode: WorkflowGeneratorMode
icon: React.ComponentType<{ className?: string }>
}
// `as const` keeps titleKey/descKey as literal types so the typed `i18n.t`
// accepts them as known keys; `satisfies` still validates the shape.
const OPTIONS = [
{
id: 'workflow',
titleKey: 'gotoAnything.actions.createWorkflow',
descKey: 'gotoAnything.actions.createWorkflowDesc',
mode: 'workflow',
icon: RiNodeTree,
},
{
id: 'chatflow',
titleKey: 'gotoAnything.actions.createChatflow',
descKey: 'gotoAnything.actions.createChatflowDesc',
mode: 'advanced-chat',
icon: RiChat3Line,
},
] as const satisfies readonly CreateOption[]
/**
* `/create` command — generate a Workflow or Chatflow app from a
* natural-language description.
*
* The user-picked mode is passed through to the generator modal explicitly
* rather than sniffed from the URL, which avoids the mode-mismatch dead-end
* the URL-sniffing approach used to produce.
*
* When triggered from inside a graph-based Studio (Workflow / Advanced-Chat)
* whose app mode matches the picked mode, it threads the current app (id +
* mode) through so the modal offers "Apply to current draft" — this is the
* in-Studio create-and-apply journey that replaced the old toolbar button.
* With no Studio app open, or when the picked mode differs from the open
* app's mode, it falls back to new-app creation only.
*/
export const createCommand: SlashCommandHandler = {
name: 'create',
aliases: ['new', 'generate'],
// Fallback only — the palette localises the root row via the slashKeyMap in
// command-selector.tsx (gotoAnything.actions.createCategoryDesc).
description: 'Create an AI-generated workflow or chatflow',
mode: 'submenu',
async search(args: string, locale?: string) {
const i18n = getI18n()
const tr = (key: (typeof OPTIONS)[number]['titleKey' | 'descKey']) =>
i18n.t(key, { ns: 'app', lng: locale })
const query = args.trim().toLowerCase()
const filtered = OPTIONS.filter(
opt => !query || opt.id.includes(query) || tr(opt.titleKey).toLowerCase().includes(query),
)
return filtered.map(opt => ({
id: `create-${opt.id}`,
title: tr(opt.titleKey),
description: tr(opt.descKey),
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">
<opt.icon className="size-4 text-text-tertiary" />
</div>
),
data: { command: 'create.open', args: { mode: opt.mode } },
}))
},
register() {
registerCommands({
'create.open': async (args) => {
const mode: WorkflowGeneratorMode = (args?.mode ?? 'workflow') as WorkflowGeneratorMode
// If a graph-based Studio app is open and its mode matches the picked
// mode, thread it through so the modal can offer "Apply to current
// draft". A mode mismatch (or no app open) falls back to new-app only,
// mirroring the precondition the modal uses for canApplyToCurrent.
const appDetail = useAppStore.getState().appDetail
const currentAppMode: WorkflowGeneratorMode | null
= appDetail?.mode === AppModeEnum.WORKFLOW
? 'workflow'
: appDetail?.mode === AppModeEnum.ADVANCED_CHAT
? 'advanced-chat'
: null
if (appDetail && currentAppMode === mode) {
useWorkflowGeneratorStore.getState().openGenerator({
mode,
currentAppId: appDetail.id,
currentAppMode,
})
return
}
useWorkflowGeneratorStore.getState().openGenerator({ mode })
},
})
},
unregister() {
unregisterCommands(['create.open'])
},
}
@@ -0,0 +1,91 @@
import type { SlashCommandHandler } from './types'
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
import { RiSparkling2Line } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
import { AppModeEnum } from '@/types/app'
import { registerCommands, unregisterCommands } from './command-bus'
/**
* Map the open app's mode to a generator mode, or null when the current app
* isn't a graph-based Studio (Chat / Agent / Completion have no canvas to
* refine). This is the single source of truth for both the availability gate
* and the mode we open the generator with.
*/
const currentStudioMode = (): WorkflowGeneratorMode | null => {
const appMode = useAppStore.getState().appDetail?.mode
if (appMode === AppModeEnum.WORKFLOW)
return 'workflow'
if (appMode === AppModeEnum.ADVANCED_CHAT)
return 'advanced-chat'
return null
}
/**
* Open the generator in `refine` intent for the current Studio. The modal
* fetches the current draft graph and sends it as context so the LLM amends
* what's on the canvas instead of starting from scratch. No-op when there's
* no graph-based app open (the command is gated by `isAvailable`, but the
* guard keeps a stray command-bus call safe).
*/
const openRefineGenerator = () => {
const appDetail = useAppStore.getState().appDetail
const mode = currentStudioMode()
if (!appDetail || !mode)
return
useWorkflowGeneratorStore.getState().openGenerator({
intent: 'refine',
mode,
currentAppId: appDetail.id,
currentAppMode: mode,
})
}
/**
* `/refine` command — refine the CURRENT Workflow / Chatflow draft graph from
* a natural-language change description. Only available inside a graph-based
* Studio; the mode is taken from the open app (no submenu to pick), and the
* result always applies back to the current draft.
*/
export const refineCommand: SlashCommandHandler = {
name: 'refine',
aliases: ['improve'],
// Fallback only — the palette localises the root row via the slashKeyMap in
// command-selector.tsx (gotoAnything.actions.refineCategoryDesc).
description: 'Refine the current workflow or chatflow graph',
mode: 'direct',
// Only surface inside a Workflow / Advanced-Chat Studio — elsewhere there's
// no draft graph to refine.
isAvailable: () => currentStudioMode() !== null,
execute: openRefineGenerator,
async search(_args: string, locale?: string) {
const i18n = getI18n()
return [{
id: 'refine-current',
title: i18n.t('gotoAnything.actions.refineTitle', { ns: 'app', lng: locale }),
description: i18n.t('gotoAnything.actions.refineDesc', { ns: 'app', lng: locale }),
type: 'command' as const,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<RiSparkling2Line className="size-4 text-text-tertiary" />
</div>
),
data: { command: 'refine.open', args: {} },
}]
},
register() {
registerCommands({
'refine.open': async () => openRefineGenerator(),
})
},
unregister() {
unregisterCommands(['refine.open'])
},
}
@@ -7,10 +7,12 @@ import { setLocaleOnClient } from '@/i18n-config'
import { accountCommand } from './account'
import { executeCommand } from './command-bus'
import { communityCommand } from './community'
import { createCommand } from './create'
import { docsCommand } from './docs'
import { forumCommand } from './forum'
import { goCommand } from './go'
import { languageCommand } from './language'
import { refineCommand } from './refine'
import { slashCommandRegistry } from './registry'
import { themeCommand } from './theme'
import { zenCommand } from './zen'
@@ -50,6 +52,8 @@ const registerSlashCommands = (deps: Record<string, any>) => {
slashCommandRegistry.register(accountCommand, {})
slashCommandRegistry.register(zenCommand, {})
slashCommandRegistry.register(goCommand, {})
slashCommandRegistry.register(createCommand, {})
slashCommandRegistry.register(refineCommand, {})
}
const unregisterSlashCommands = () => {
@@ -62,6 +66,8 @@ const unregisterSlashCommands = () => {
slashCommandRegistry.unregister('account')
slashCommandRegistry.unregister('zen')
slashCommandRegistry.unregister('go')
slashCommandRegistry.unregister('create')
slashCommandRegistry.unregister('refine')
}
export const SlashCommandProvider = () => {
@@ -109,6 +109,8 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
? (
(() => {
const slashKeyMap = {
'/create': 'gotoAnything.actions.createCategoryDesc',
'/refine': 'gotoAnything.actions.refineCategoryDesc',
'/theme': 'gotoAnything.actions.themeCategoryDesc',
'/language': 'gotoAnything.actions.languageChangeDesc',
'/account': 'gotoAnything.actions.accountDesc',
@@ -33,10 +33,10 @@ const FileUploadSetting: FC<Props> = ({
const { t } = useTranslation()
const {
allowed_file_upload_methods,
allowed_file_upload_methods = [],
max_length,
allowed_file_types,
allowed_file_extensions,
allowed_file_types = [],
allowed_file_extensions = [],
} = payload
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const {
@@ -0,0 +1,265 @@
import type { GeneratedGraph } from '../types'
import { AppModeEnum } from '@/types/app'
import { applyToCurrentApp, applyToNewApp, WorkflowApplyHashCollisionError, WorkflowApplyOrphanError } from '../apply'
// Stub the service calls so each test can assert what was POSTed without
// touching real fetch / next router state.
const mockCreateApp = vi.fn()
const mockSyncWorkflowDraft = vi.fn()
const mockFetchWorkflowDraft = vi.fn()
const mockDeleteApp = vi.fn()
vi.mock('@/service/apps', () => ({
createApp: (params: unknown) => mockCreateApp(params),
deleteApp: (appId: string) => mockDeleteApp(appId),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
syncWorkflowDraft: (params: unknown) => mockSyncWorkflowDraft(params),
}))
const makeGraph = (): GeneratedGraph => ({
nodes: [
{ id: 'node-1', type: 'custom', position: { x: 0, y: 0 }, data: { type: 'start', title: 'Start' } } as never,
],
edges: [],
viewport: { x: 0, y: 0, zoom: 0.7 },
})
describe('applyToNewApp', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateApp.mockResolvedValue({ id: 'new-app-1', mode: AppModeEnum.WORKFLOW })
mockSyncWorkflowDraft.mockResolvedValue({})
})
// The new-app path must create the app, then sync the generated graph to
// its draft and return the routing context the caller uses to navigate.
it('should create the app, sync the draft and return the new app id and mode', async () => {
const graph = makeGraph()
const result = await applyToNewApp({ mode: 'workflow', graph, instruction: 'Summarize a URL' })
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({
mode: AppModeEnum.WORKFLOW,
icon_type: 'emoji',
}))
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
url: 'apps/new-app-1/workflows/draft',
params: {
graph,
features: {},
environment_variables: [],
conversation_variables: [],
},
})
expect(result).toEqual({ appId: 'new-app-1', appMode: AppModeEnum.WORKFLOW })
})
// Mode → AppModeEnum must round-trip for chatflow; the type-level guarantee
// is verified at runtime so a regression here is caught before users hit it.
it('should map advanced-chat mode to AppModeEnum.ADVANCED_CHAT', async () => {
mockCreateApp.mockResolvedValueOnce({ id: 'cf-1', mode: AppModeEnum.ADVANCED_CHAT })
const result = await applyToNewApp({
mode: 'advanced-chat',
graph: makeGraph(),
instruction: 'A chat bot that answers questions',
})
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ mode: AppModeEnum.ADVANCED_CHAT }))
expect(result.appMode).toBe(AppModeEnum.ADVANCED_CHAT)
})
// The derived name keeps the user instruction recognisable in the apps list
// — strip trailing punctuation and never produce an empty string.
it('should derive a sensible app name from the instruction', async () => {
await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: ' Build a translator. ' })
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'Build a translator' }))
})
// Instruction-only-of-punctuation must still produce a usable, non-empty
// app name so create-app doesn't fail validation.
it('should fall back to "Generated Workflow" when the instruction is empty', async () => {
await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: ' ' })
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'Generated Workflow' }))
})
// When the planner picks a name + emoji, those win over the
// instruction-derived fallback so users see a real product name in the
// apps list (e.g. "URL Summarizer" + 📰 instead of "Summarize a URL" + 🤖).
it('should prefer planner-supplied app_name and icon over the fallbacks', async () => {
await applyToNewApp({
mode: 'workflow',
graph: makeGraph(),
instruction: 'Summarize a URL',
appName: 'URL Summarizer',
icon: '📰',
})
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({
name: 'URL Summarizer',
icon: '📰',
}))
})
// When the planner returns whitespace-only values (older prompts / model
// drift), the fallbacks must kick in so we never POST an empty string to
// createApp.
it('should fall back when planner-supplied app_name / icon are blank', async () => {
await applyToNewApp({
mode: 'workflow',
graph: makeGraph(),
instruction: 'Summarize a URL',
appName: ' ',
icon: '',
})
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({
name: 'Summarize a URL',
icon: '🤖',
}))
})
// Sync failure must roll back the createApp so the user isn't left with an
// empty app in their /apps list. deleteApp is called with the new app id,
// and the original sync error is re-thrown so the caller can toast it.
it('should delete the new app when syncWorkflowDraft fails', async () => {
mockCreateApp.mockResolvedValueOnce({ id: 'doomed', mode: AppModeEnum.WORKFLOW })
const syncErr = new Error('sync exploded')
mockSyncWorkflowDraft.mockRejectedValueOnce(syncErr)
mockDeleteApp.mockResolvedValueOnce(undefined)
await expect(applyToNewApp({
mode: 'workflow',
graph: makeGraph(),
instruction: 'x',
})).rejects.toBe(syncErr)
expect(mockDeleteApp).toHaveBeenCalledWith('doomed')
})
// The truly stuck path: sync fails AND the rollback delete also fails. We
// throw WorkflowApplyOrphanError so the caller can route to /apps where
// the orphan is at least discoverable for manual cleanup. The error
// carries the orphan app id so the toast can name it.
it('should throw WorkflowApplyOrphanError when both sync and rollback fail', async () => {
mockCreateApp.mockResolvedValueOnce({ id: 'orphan-7', mode: AppModeEnum.WORKFLOW })
mockSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync exploded'))
mockDeleteApp.mockRejectedValueOnce(new Error('delete also exploded'))
let caught: unknown
try {
await applyToNewApp({ mode: 'workflow', graph: makeGraph(), instruction: 'x' })
}
catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(WorkflowApplyOrphanError)
expect((caught as WorkflowApplyOrphanError).orphanAppId).toBe('orphan-7')
})
})
describe('applyToCurrentApp', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSyncWorkflowDraft.mockResolvedValue({})
})
// Happy path: the fetch yields an existing draft so the sync MUST include
// its hash. Without this, the backend rejects the write with
// WorkflowHashNotEqualError (the original bug behind the manual fix).
it('should fetch the current draft and forward its hash on sync', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'h-existing',
features: { file_upload: { enabled: true } },
environment_variables: [{ id: 'e1', name: 'API_KEY', value_type: 'secret', value: 'x' }],
conversation_variables: [{ id: 'c1', name: 'memory', value_type: 'string', value: '' }],
})
const graph = makeGraph()
await applyToCurrentApp({ appId: 'app-42', graph })
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('apps/app-42/workflows/draft')
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
url: 'apps/app-42/workflows/draft',
params: expect.objectContaining({
graph,
features: { file_upload: { enabled: true } },
hash: 'h-existing',
}),
})
// Existing env vars and conversation vars must be preserved verbatim.
const params = mockSyncWorkflowDraft.mock.calls[0]![0].params
expect(params.environment_variables).toHaveLength(1)
expect(params.conversation_variables).toHaveLength(1)
})
// First-apply path: a freshly created Workflow app has no draft yet, so the
// fetch resolves to undefined and we must sync without a hash field so the
// backend lazy-creates the draft instead of raising.
it('should sync without a hash when no draft yet exists', async () => {
mockFetchWorkflowDraft.mockResolvedValue(undefined)
await applyToCurrentApp({ appId: 'fresh-app', graph: makeGraph() })
expect(mockSyncWorkflowDraft).toHaveBeenCalledTimes(1)
const params = mockSyncWorkflowDraft.mock.calls[0]![0].params
expect(params).not.toHaveProperty('hash')
expect(params.features).toEqual({})
expect(params.environment_variables).toEqual([])
expect(params.conversation_variables).toEqual([])
})
// Resilience: a fetch failure (network blip, transient 5xx) must not block
// the apply — fall back to a hashless sync so the new draft can still land.
it('should fall back to a hashless sync when fetchWorkflowDraft throws', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('network down'))
await applyToCurrentApp({ appId: 'app-7', graph: makeGraph() })
expect(mockSyncWorkflowDraft).toHaveBeenCalledTimes(1)
const params = mockSyncWorkflowDraft.mock.calls[0]![0].params
expect(params).not.toHaveProperty('hash')
})
// A 409 from syncWorkflowDraft is the backend's signal that another tab
// edited the draft between our fetch and sync. The apply layer translates
// that Response into a typed error so the caller can show a Reload
// affordance instead of a generic "apply failed" toast.
it('should translate a 409 sync rejection into WorkflowApplyHashCollisionError', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'h1',
features: {},
environment_variables: [],
conversation_variables: [],
})
// ``base.ts`` rejects with the raw Response on non-401 — fake just the
// ``status`` field, which is what ``isHashCollisionResponse`` consults.
mockSyncWorkflowDraft.mockRejectedValueOnce({ status: 409, code: 'draft_workflow_not_sync' })
await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() }))
.rejects
.toBeInstanceOf(WorkflowApplyHashCollisionError)
})
// Non-409 errors (5xx, network) MUST NOT be misclassified as hash
// collisions — those still surface as the original rejection so the
// generic "apply failed" toast fires.
it('should NOT translate non-409 sync rejections', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'h1',
features: {},
environment_variables: [],
conversation_variables: [],
})
const original = { status: 500, code: 'internal_server_error' }
mockSyncWorkflowDraft.mockRejectedValueOnce(original)
await expect(applyToCurrentApp({ appId: 'app-9', graph: makeGraph() }))
.rejects
.toBe(original)
})
})
@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ExamplePrompts from '../example-prompts'
describe('ExamplePrompts', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
// Workflow mode surfaces a curated 4-prompt set; the count matters
// because the chip row's wrap behaviour was tuned for ≤ 4 entries.
it('should render the 4 workflow-mode prompts', () => {
render(<ExamplePrompts mode="workflow" onSelect={vi.fn()} />)
expect(screen.getAllByRole('button')).toHaveLength(4)
expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.translate/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.rag/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.classify/i })).toBeInTheDocument()
})
// Advanced-chat mode surfaces a different (3-prompt) set tailored to
// chatflow patterns. None of the workflow prompts should leak through.
it('should render the 3 chatflow-mode prompts when mode is advanced-chat', () => {
render(<ExamplePrompts mode="advanced-chat" onSelect={vi.fn()} />)
expect(screen.getAllByRole('button')).toHaveLength(3)
expect(screen.getByRole('button', { name: /workflowGenerator\.examples\.chatflow\.support/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })).not.toBeInTheDocument()
})
// The "Try one of these" label anchors the row visually; missing it
// would degrade the section to anonymous chips.
it('should render a section label above the chips', () => {
render(<ExamplePrompts mode="workflow" onSelect={vi.fn()} />)
expect(screen.getByText(/workflowGenerator\.examples\.label/i)).toBeInTheDocument()
})
})
describe('selection', () => {
// Clicking a chip is the whole point of the component — it must hand
// the chip text back to the parent verbatim so the parent can populate
// the instruction textarea.
it('should forward the clicked chip\'s text to onSelect', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(<ExamplePrompts mode="workflow" onSelect={onSelect} />)
const chip = screen.getByRole('button', { name: /workflowGenerator\.examples\.workflow\.summarize/i })
await user.click(chip)
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect.mock.calls[0]![0]).toMatch(/workflowGenerator\.examples\.workflow\.summarize/i)
})
})
})
@@ -0,0 +1,92 @@
import { act, render, screen } from '@testing-library/react'
import GenerationPhases from '../generation-phases'
describe('GenerationPhases', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// The first frame the user sees during generation must be the "planning"
// phase — never an empty container or a different phase — so the perceived
// latency starts dropping immediately.
it('should start on the planning phase', () => {
render(<GenerationPhases startedAt={1} />)
expect(screen.getByText(/phases\.planning/i)).toBeInTheDocument()
})
// After the planner timer elapses we move to "building". The component
// doesn't reset to "planning" if the parent stays mounted — the timer
// chain only steps forward.
it('should advance to the building phase after the planning timer', () => {
render(<GenerationPhases startedAt={1} />)
act(() => {
vi.advanceTimersByTime(3500)
})
expect(screen.getByText(/phases\.building/i)).toBeInTheDocument()
expect(screen.queryByText(/phases\.planning/i)).not.toBeInTheDocument()
})
// The validating phase is the last in the schedule; once we get there we
// stay there indefinitely so a slow LLM doesn't make the indicator loop
// backwards and confuse the user.
it('should land on validating and not loop back to planning even after long delays', () => {
render(<GenerationPhases startedAt={1} />)
// Advance through phases in two steps — React schedules the next
// ``setTimeout`` only after the prior effect re-runs with the new
// ``phaseIndex``, so a single combined advance leaves us mid-phase.
act(() => {
vi.advanceTimersByTime(3500)
})
act(() => {
vi.advanceTimersByTime(12000)
})
expect(screen.getByText(/phases\.validating/i)).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(60000)
})
// Still validating — no reset, no loop.
expect(screen.getByText(/phases\.validating/i)).toBeInTheDocument()
expect(screen.queryByText(/phases\.planning/i)).not.toBeInTheDocument()
})
// Unmount cleanup matters because the modal is destroyed when the user
// closes it mid-generation; lingering timers would keep firing setState on
// an unmounted tree.
it('should not leak a timer when unmounted before the next phase fires', () => {
const { unmount } = render(<GenerationPhases startedAt={1} />)
// Sanity: pending timer should exist.
expect(vi.getTimerCount()).toBeGreaterThan(0)
unmount()
expect(vi.getTimerCount()).toBe(0)
})
// A second Generate click bumps ``startedAt``. The component must reset to
// "Planning" so the indicator doesn't appear wedged on "Validating" from
// the previous attempt. Without this the user thinks the system is stuck.
it('should reset to the planning phase when startedAt changes', () => {
const { rerender } = render(<GenerationPhases startedAt={1} />)
// Drive the first attempt all the way to validating.
act(() => {
vi.advanceTimersByTime(3500)
})
act(() => {
vi.advanceTimersByTime(12000)
})
expect(screen.getByText(/phases\.validating/i)).toBeInTheDocument()
// New attempt starts → bump startedAt. The component should snap back
// to planning rather than staying on validating.
rerender(<GenerationPhases startedAt={2} />)
expect(screen.getByText(/phases\.planning/i)).toBeInTheDocument()
expect(screen.queryByText(/phases\.validating/i)).not.toBeInTheDocument()
})
})
@@ -0,0 +1,155 @@
import { act, renderHook } from '@testing-library/react'
import { useWorkflowGeneratorStore } from '../store'
// Reset zustand state between tests so they don't share opener context.
const resetStore = () => {
useWorkflowGeneratorStore.setState({
isOpen: false,
mode: 'workflow',
currentAppId: null,
currentAppMode: null,
})
}
describe('useWorkflowGeneratorStore', () => {
beforeEach(() => {
vi.clearAllMocks()
resetStore()
})
describe('initial state', () => {
// Default snapshot: the generator modal is closed, mode is "workflow", and
// there is no current-app context attached.
it('should start closed in workflow mode with no current app', () => {
const { result } = renderHook(() => useWorkflowGeneratorStore())
expect(result.current.isOpen).toBe(false)
expect(result.current.mode).toBe('workflow')
expect(result.current.currentAppId).toBeNull()
expect(result.current.currentAppMode).toBeNull()
})
})
describe('openGenerator', () => {
// Opening from a non-Studio surface (e.g. /apps page): only the requested
// mode is set; currentAppId stays null so the modal hides "Apply to current".
it('should open with the requested mode and no current app by default', () => {
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({ mode: 'advanced-chat' })
})
expect(result.current.isOpen).toBe(true)
expect(result.current.mode).toBe('advanced-chat')
expect(result.current.currentAppId).toBeNull()
expect(result.current.currentAppMode).toBeNull()
})
// Opening from inside Studio: caller passes currentAppId + currentAppMode
// so the modal can show "Apply to current draft".
it('should accept a current app id and mode when opened from Studio', () => {
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({
mode: 'workflow',
currentAppId: 'app-123',
currentAppMode: 'workflow',
})
})
expect(result.current.isOpen).toBe(true)
expect(result.current.mode).toBe('workflow')
expect(result.current.currentAppId).toBe('app-123')
expect(result.current.currentAppMode).toBe('workflow')
})
// Reopening with new parameters must overwrite the previous mode/context;
// stale state would let the modal apply to the wrong app.
it('should overwrite previous state on a subsequent open', () => {
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-1', currentAppMode: 'workflow' })
})
act(() => {
result.current.openGenerator({ mode: 'advanced-chat' })
})
expect(result.current.mode).toBe('advanced-chat')
expect(result.current.currentAppId).toBeNull()
expect(result.current.currentAppMode).toBeNull()
})
})
describe('closeGenerator', () => {
// Closing flips isOpen back to false but preserves mode / currentAppId so
// a subsequent reopen can decide whether to keep or replace them.
it('should close the modal without clearing the captured context', () => {
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-9', currentAppMode: 'workflow' })
})
act(() => {
result.current.closeGenerator()
})
expect(result.current.isOpen).toBe(false)
expect(result.current.mode).toBe('workflow')
expect(result.current.currentAppId).toBe('app-9')
expect(result.current.currentAppMode).toBe('workflow')
})
})
describe('history reset on /create open', () => {
// /create must always start on the empty placeholder — the previous
// session's versions belong to a different intent and confuse the user.
// Without this, opening /create twice in a row would re-show the prior
// generated graph. Studio-refine sessions (with currentAppId) keep
// their history so close+reopen of the toolbar Generate doesn't lose
// the versions the user was comparing.
it('should clear new-app sessionStorage keys when opened without a currentAppId', () => {
sessionStorage.setItem('workflow-gen-workflow-new-versions', JSON.stringify([{ ghost: true }]))
sessionStorage.setItem('workflow-gen-workflow-new-version-index', '3')
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({ mode: 'workflow' })
})
expect(sessionStorage.getItem('workflow-gen-workflow-new-versions')).toBeNull()
expect(sessionStorage.getItem('workflow-gen-workflow-new-version-index')).toBeNull()
})
// Only the mode being opened gets cleared — opening /create for
// workflow must not wipe a parallel advanced-chat /create session in
// another tab's sessionStorage path (we share the same sessionStorage
// namespace per tab, but only the corresponding mode key is wiped).
it('should leave the other mode\'s new-app history alone', () => {
sessionStorage.setItem('workflow-gen-advanced-chat-new-versions', JSON.stringify([{ keep: true }]))
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({ mode: 'workflow' })
})
expect(sessionStorage.getItem('workflow-gen-advanced-chat-new-versions')).not.toBeNull()
})
// Studio refine sessions (currentAppId present) must NOT clear their
// history — the user expects to find their previous versions when they
// reopen the toolbar Generate button.
it('should NOT clear history when opened with a currentAppId', () => {
sessionStorage.setItem('workflow-gen-workflow-app-42-versions', JSON.stringify([{ keep: true }]))
const { result } = renderHook(() => useWorkflowGeneratorStore())
act(() => {
result.current.openGenerator({ mode: 'workflow', currentAppId: 'app-42', currentAppMode: 'workflow' })
})
expect(sessionStorage.getItem('workflow-gen-workflow-app-42-versions')).not.toBeNull()
})
})
})
@@ -0,0 +1,195 @@
import type { GeneratedGraph, WorkflowGeneratorMode } from './types'
import { createApp, deleteApp } from '@/service/apps'
import { fetchWorkflowDraft, syncWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
const MODE_TO_APP_MODE: Record<WorkflowGeneratorMode, AppModeEnum> = {
'workflow': AppModeEnum.WORKFLOW,
'advanced-chat': AppModeEnum.ADVANCED_CHAT,
}
/**
* Thrown by ``applyToCurrentApp`` when the backend rejects the sync because
* the draft's ``unique_hash`` doesn't match — typically another tab edited
* the draft after we fetched it. The caller maps this to a dedicated
* "Workspace was edited elsewhere" toast with a Reload affordance instead
* of a generic "Apply failed".
*
* Backend surfaces this as HTTP 409 with ``error_code:
* "draft_workflow_not_sync"`` (see
* ``api/controllers/console/app/error.py::DraftWorkflowNotSync``).
*/
export class WorkflowApplyHashCollisionError extends Error {
constructor() {
super('Workflow draft was modified in another tab; reload required.')
this.name = 'WorkflowApplyHashCollisionError'
}
}
/**
* Thrown by ``applyToNewApp`` when the freshly-created app's draft sync
* failed AND we couldn't roll back by deleting the new app. The caller
* routes the user to ``/apps`` so the orphan is at least discoverable and
* surfaces a localised toast — without this an unrecoverable orphan would
* silently sit in the app list with no graph.
*/
export class WorkflowApplyOrphanError extends Error {
readonly orphanAppId: string
constructor(orphanAppId: string, cause?: unknown) {
// ES2022 Error supports ``cause`` natively via the options bag — far
// cleaner than reassigning a typed-cast property after construction.
super(`Failed to apply graph; new app ${orphanAppId} may be orphaned.`, { cause })
this.name = 'WorkflowApplyOrphanError'
this.orphanAppId = orphanAppId
}
}
const isHashCollisionResponse = (e: unknown): boolean => {
// The shared ``post()`` wrapper rejects with the raw ``Response`` for non-401
// failures (see ``service/base.ts::request`` catch branch). At this layer the
// only reliable signal is the HTTP status.
if (!e || typeof e !== 'object')
return false
return (e as { status?: number }).status === 409
}
// Derive a sane App name from the user's instruction: trim, cap at 40 chars,
// strip trailing punctuation.
const deriveAppName = (instruction: string): string => {
const trimmed = instruction.trim().slice(0, 40)
return trimmed.replace(/[.,!?;:。,!?;:]+$/, '').trim() || 'Generated Workflow'
}
type ApplyToNewAppParams = {
mode: WorkflowGeneratorMode
graph: GeneratedGraph
instruction: string
/**
* Planner-picked product-style name (e.g. "URL Summarizer"). When empty,
* we fall back to ``deriveAppName(instruction)`` so the apps list never
* shows an empty title.
*/
appName?: string
/**
* Planner-picked emoji (e.g. "📰"). When empty, we fall back to 🤖
* which is the historical default.
*/
icon?: string
}
/**
* Apply path A — create a brand-new Workflow / Chatflow app and write the
* generated graph into its draft. Returns the created app id so the caller
* can route to ``/app/{id}/workflow``.
*/
export const applyToNewApp = async ({
mode,
graph,
instruction,
appName,
icon,
}: ApplyToNewAppParams): Promise<{ appId: string, appMode: AppModeEnum }> => {
const appMode = MODE_TO_APP_MODE[mode]
const name = (appName ?? '').trim() || deriveAppName(instruction)
const appIcon = (icon ?? '').trim() || '🤖'
const app = await createApp({
name,
mode: appMode,
icon_type: 'emoji',
icon: appIcon,
icon_background: '#FFEAD5',
description: instruction.trim().slice(0, 200),
})
// Sync the generated graph into the brand-new app's draft. ``createApp``
// already succeeded so the app exists; if the sync fails (network blip,
// backend rejection of the graph) we MUST roll the createApp back so the
// user isn't left with a discoverable-but-empty app sitting at the top
// of their /apps list. ``deleteApp`` is best-effort — if that also fails
// (it usually won't, it's a simple DELETE) we surface ``WorkflowApplyOrphanError``
// so the caller can route to /apps where the orphan is still recoverable
// by hand.
try {
await syncWorkflowDraft({
url: `apps/${app.id}/workflows/draft`,
params: {
graph,
features: {},
environment_variables: [],
conversation_variables: [],
},
})
}
catch (syncErr) {
try {
await deleteApp(app.id)
}
catch (deleteErr) {
throw new WorkflowApplyOrphanError(app.id, deleteErr)
}
throw syncErr
}
return { appId: app.id, appMode }
}
type ApplyToCurrentAppParams = {
appId: string
graph: GeneratedGraph
}
/**
* Apply path B — overwrite the current Workflow Studio's draft graph.
*
* The backend's ``sync_draft_workflow`` rejects writes whose ``hash`` doesn't
* match the existing draft's ``unique_hash`` (WorkflowHashNotEqualError), so we
* must read the current draft first to grab its hash. We also preserve the
* existing ``features``, ``environment_variables`` and ``conversation_variables``
* — only nodes / edges / viewport (the ``graph`` field) get replaced by the
* generated graph.
*
* Caller is responsible for showing the overwrite confirmation dialog before
* invoking this.
*/
export const applyToCurrentApp = async ({
appId,
graph,
}: ApplyToCurrentAppParams): Promise<void> => {
const url = `apps/${appId}/workflows/draft`
// First sync may have no existing draft (workflow apps can exist before Studio
// has created/saved a draft). ``fetchWorkflowDraft`` rejects on non-2xx (e.g.
// 404), so we treat any fetch failure as "no existing draft" and sync without
// a hash.
let existing: Awaited<ReturnType<typeof fetchWorkflowDraft>> | null = null
try {
existing = await fetchWorkflowDraft(url)
}
catch {
existing = null
}
try {
await syncWorkflowDraft({
url,
params: {
graph,
features: existing?.features ?? {},
environment_variables: existing?.environment_variables ?? [],
conversation_variables: existing?.conversation_variables ?? [],
// Field is accepted by the backend but not typed in the Pick<> shape of
// ``syncWorkflowDraft``'s params — spread it in so it reaches the wire.
...(existing?.hash ? { hash: existing.hash } : {}),
} as Parameters<typeof syncWorkflowDraft>[0]['params'],
})
}
catch (e) {
// 409 → draft was edited in another tab between our fetch and sync.
// Translate the raw Response rejection into a typed error so the caller
// can show a Reload affordance instead of a generic "apply failed" toast.
if (isHashCollisionResponse(e))
throw new WorkflowApplyHashCollisionError()
throw e
}
}
@@ -0,0 +1,67 @@
'use client'
import type { WorkflowGeneratorMode } from './types'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
type Props = {
mode: WorkflowGeneratorMode
onSelect: (prompt: string) => void
}
/**
* "Try one of these" chips that sit below the instruction textarea.
*
* For brand-new users the blank instruction box is intimidating — they don't
* know what kinds of prompts the planner handles well. The chips give them
* a one-click way to populate a real prompt so they can see the modal end-
* to-end on their first attempt.
*
* The four prompts per mode are intentionally chosen to cover a spread of
* shapes:
* - workflow: summarization, translation, RAG, classification.
* - advanced-chat: support agent, tutor, triage.
*
* The strings live in i18n so they translate alongside the rest of the
* generator UI.
*/
const ExamplePrompts: React.FC<Props> = ({ mode, onSelect }) => {
const { t } = useTranslation('workflow')
const prompts = useMemo(() => {
if (mode === 'workflow') {
return [
t('workflowGenerator.examples.workflow.summarize'),
t('workflowGenerator.examples.workflow.translate'),
t('workflowGenerator.examples.workflow.rag'),
t('workflowGenerator.examples.workflow.classify'),
]
}
return [
t('workflowGenerator.examples.chatflow.support'),
t('workflowGenerator.examples.chatflow.tutor'),
t('workflowGenerator.examples.chatflow.triage'),
]
}, [mode, t])
return (
<div className="mt-3">
<div className="mb-1.5 system-xs-medium-uppercase text-text-tertiary">
{t('workflowGenerator.examples.label')}
</div>
<div className="flex flex-wrap gap-1.5">
{prompts.map(prompt => (
<button
key={prompt}
type="button"
className="cursor-pointer rounded-md border-[0.5px] border-divider-regular bg-components-button-secondary-bg px-2 py-1 system-xs-regular text-text-secondary hover:bg-components-button-secondary-bg-hover"
onClick={() => onSelect(prompt)}
>
{prompt}
</button>
))}
</div>
</div>
)
}
export default memo(ExamplePrompts)
@@ -0,0 +1,72 @@
'use client'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
/**
* Approximate stage durations (ms) for the slim planner→builder pipeline.
*
* The endpoint is single-shot — we don't get real per-phase events from the
* backend — but the user perception of "the system is doing things" is much
* better than a static spinner. The schedule below targets the typical
* 1518 s response time. If the real response lands earlier the modal
* unmounts this component; if it lands later we hold on the last phase
* indefinitely (rather than cycling back) so the user doesn't think we
* restarted.
*/
const PLANNING_MS = 3500
const BUILDING_MS = 12000
type Props = {
/**
* Per-attempt nonce — typically ``Date.now()`` of when Generate was
* clicked. The component resets ``phaseIndex`` whenever this changes so a
* second Generate click starts the indicator from "Planning…" instead of
* resuming wherever the previous attempt left off.
*/
startedAt: number
}
const GenerationPhases = ({ startedAt }: Props) => {
const { t } = useTranslation('workflow')
const [phaseIndex, setPhaseIndex] = useState(0)
// Reset the indicator whenever a new attempt starts. Without this, a
// failed first attempt followed by a quick retry would resume mid-phase
// (or stuck on "Validating…") which looks like the system is wedged.
// ``set-state-in-effect`` flags this pattern, but the reset is the
// intent — driven by an external prop change, not by render-time state.
useEffect(() => {
// eslint-disable-next-line react/set-state-in-effect
setPhaseIndex(0)
}, [startedAt])
useEffect(() => {
if (phaseIndex === 0) {
const timer = setTimeout(() => setPhaseIndex(1), PLANNING_MS)
return () => clearTimeout(timer)
}
if (phaseIndex === 1) {
const timer = setTimeout(() => setPhaseIndex(2), BUILDING_MS)
return () => clearTimeout(timer)
}
// phaseIndex === 2 — terminal phase, no further timer.
}, [phaseIndex])
const label = (() => {
if (phaseIndex === 0)
return t('workflowGenerator.phases.planning')
if (phaseIndex === 1)
return t('workflowGenerator.phases.building')
return t('workflowGenerator.phases.validating')
})()
return (
<div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3">
<Loading />
<div className="text-[13px] text-text-tertiary">{label}</div>
</div>
)
}
export default memo(GenerationPhases)
@@ -0,0 +1,592 @@
'use client'
import type { GeneratedGraph } from './types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CompletionParams, Model, ModelModeType } from '@/types/app'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import IdeaOutput from '@/app/components/app/configuration/config/automatic/idea-output'
import VersionSelector from '@/app/components/app/configuration/config/automatic/version-selector'
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 WorkflowPreview from '@/app/components/workflow/workflow-preview'
import { useAppContext } from '@/context/app-context'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { useRouter } from '@/next/navigation'
import { generateWorkflow } from '@/service/debug'
import { fetchWorkflowDraft } from '@/service/workflow'
import { getRedirectionPath } from '@/utils/app-redirection'
import { applyToCurrentApp, applyToNewApp, WorkflowApplyHashCollisionError, WorkflowApplyOrphanError } from './apply'
import ExamplePrompts from './example-prompts'
import GenerationPhases from './generation-phases'
import { useWorkflowGeneratorStore } from './store'
import useGenGraph from './use-gen-graph'
const STORAGE_MODEL_KEY = 'workflow-gen-model'
const FE_TIMEOUT_MS = 60_000
// Stable default used both as the SSR/empty-storage seed for the persisted
// model and as the merge base when patching a partial update. Module-level so
// the reference stays identical across renders (useLocalStorage uses it as the
// server value, which must not change identity each render).
const EMPTY_MODEL: Model = {
name: '',
provider: '',
mode: 'chat' as unknown as ModelModeType.chat,
completion_params: {} as CompletionParams,
}
const renderPlaceholder = (label: string) => (
<div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8">
<span className="i-custom-vender-other-generator size-8 text-text-quaternary" />
<div className="text-center text-[13px] leading-5 font-normal text-text-tertiary">
{label}
</div>
</div>
)
// AbortController throws a DOMException in modern browsers and a plain
// Error in older / non-DOM environments — accept both so we don't toast
// for an abort the user intentionally triggered.
const isAbortError = (e: unknown): boolean =>
(e instanceof DOMException || e instanceof Error) && e.name === 'AbortError'
type RecoveryDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
cancelLabel: string
confirmLabel: string
onConfirm: () => void
}
// Shared shell for "we hit a snag — here's a Reload / Confirm button"
// dialogs. The overwrite-confirm and hash-collision dialogs differ only in
// copy and confirm handler; this collapses 30 lines of duplicate JSX to
// one props bag and keeps the visual styling in lockstep across both.
const RecoveryDialog = ({ open, onOpenChange, title, description, cancelLabel, confirmLabel, onConfirm }: RecoveryDialogProps) => (
<AlertDialog open={open} onOpenChange={o => !o && onOpenChange(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{title}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{description}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{cancelLabel}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirm}>{confirmLabel}</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
const WorkflowGeneratorModal: React.FC = () => {
const { t } = useTranslation('workflow')
const router = useRouter()
const { isCurrentWorkspaceEditor } = useAppContext()
const isOpen = useWorkflowGeneratorStore(s => s.isOpen)
const mode = useWorkflowGeneratorStore(s => s.mode)
const intent = useWorkflowGeneratorStore(s => s.intent)
const currentAppId = useWorkflowGeneratorStore(s => s.currentAppId)
const currentAppMode = useWorkflowGeneratorStore(s => s.currentAppMode)
const closeGenerator = useWorkflowGeneratorStore(s => s.closeGenerator)
const isRefine = intent === 'refine' && !!currentAppId
// Persisted model selection. ``useLocalStorage`` is the storage boundary
// mandated for client-only preferences — the empty model is the SSR/seed
// value so ``model`` is always a concrete ``Model`` (never null) here.
const [model, setModel] = useLocalStorage<Model>(STORAGE_MODEL_KEY, EMPTY_MODEL)
const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
// Hydrate model from defaultModel once it loads (async). We deliberately set state
// from an effect here because defaultModel only resolves after the workspace's model
// catalogue fetch completes.
useEffect(() => {
if (defaultModel && !model.name) {
setModel(prev => ({
...(prev ?? EMPTY_MODEL),
name: defaultModel.model,
provider: defaultModel.provider.provider,
}))
}
}, [defaultModel, model.name, setModel])
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
setModel(prev => ({
...(prev ?? EMPTY_MODEL),
provider: newValue.provider,
name: newValue.modelId,
mode: newValue.mode as ModelModeType,
}))
}, [setModel])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
setModel(prev => ({
...(prev ?? EMPTY_MODEL),
completion_params: newParams as CompletionParams,
}))
}, [setModel])
const [instruction, setInstruction] = useState('')
const [ideaOutput, setIdeaOutput] = useState('')
const storageKey = `${mode}-${currentAppId ?? 'new'}`
const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenGraph({
storageKey,
})
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
const [isApplying, { setTrue: setApplyingTrue, setFalse: setApplyingFalse }] = useBoolean(false)
// Per-attempt nonce — bumped on each Generate click so ``GenerationPhases``
// can reset its internal phase timer instead of resuming wherever the
// previous attempt left off (which makes the UI look wedged).
const [startedAt, setStartedAt] = useState(0)
// Confirmation dialog for "Apply to current draft"
const [isShowConfirmOverwrite, { setTrue: showConfirmOverwrite, setFalse: hideConfirmOverwrite }] = useBoolean(false)
// Surfaced when the backend rejects the draft sync because another tab
// edited the workspace after we fetched it. Dedicated dialog instead of a
// toast because the user needs an explicit Reload action — without that,
// a generic "apply failed" toast leaves them stuck and confused.
const [isShowHashCollision, { setTrue: showHashCollision, setFalse: hideHashCollision }] = useBoolean(false)
// Holds the AbortController of the in-flight ``/workflow-generate`` request
// so we can cancel it on (a) modal close, (b) a second Generate click
// while loading, (c) the hard 60 s frontend timeout, or (d) the user
// pressing Cancel. Without this an in-flight request outlives the modal
// and can race a future Generate call.
const abortRef = useRef<AbortController | null>(null)
// Companion timer so the timeout doesn't keep running after the response
// lands. Cleared inside the same ``finally`` block that flips loading off.
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Mode the user generated against. If they switch app context mid-flight
// (e.g. open the same modal from a different Studio in another tab) we
// hide the "Apply to current" button so the wrong-mode graph never lands
// in the wrong Studio. Captured at Generate time, not Apply time.
const generatedModeRef = useRef<typeof mode | null>(null)
const clearTimers = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [])
const abortInFlight = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort()
abortRef.current = null
}
clearTimers()
}, [clearTimers])
// Cleanup on unmount — a modal unmount mid-generation must NOT leave the
// request running in the background (it would still resolve, mutate the
// store, and toast "applied" against a stale modal).
useEffect(() => {
return () => {
abortInFlight()
}
// The cleanup function reads refs only, so it's stable; we intentionally
// exclude ``abortInFlight`` from deps to avoid re-running this effect on
// every render.
// eslint-disable-next-line react/exhaustive-deps
}, [])
// Note: the modal is mounted lazily by ``mount.tsx`` which unmounts it when
// ``isOpen`` flips to false, so transient state (instruction / ideaOutput)
// resets implicitly on the next open. No reset effect needed.
const isValid = () => {
const trimmed = instruction.trim()
if (!trimmed) {
toast.error(t('workflowGenerator.instructionRequired'))
return false
}
if (!model.name) {
// No usable model resolved (provider catalogue empty or still
// loading). Without this guard the request would fly with an empty
// ``model_config.name`` and surface as a backend 400 — not actionable
// for the user. Tell them to pick a model.
toast.error(t('workflowGenerator.modelRequired'))
return false
}
return true
}
const onGenerate = async () => {
if (!isValid())
return
// Cancel any previous in-flight request (double-click guard). The
// previous promise will reject with AbortError which our catch swallows.
abortInFlight()
setStartedAt(Date.now())
generatedModeRef.current = mode
setLoadingTrue()
// Hard frontend timeout — aborts the request and surfaces a localised
// toast so the user sees something actionable instead of a perpetual
// spinner if the backend hangs.
timeoutRef.current = setTimeout(() => {
abortRef.current?.abort()
abortRef.current = null
toast.error(t('workflowGenerator.errors.timeout'))
}, FE_TIMEOUT_MS)
try {
// Refine mode: pull the current draft graph so the backend amends it
// instead of starting from scratch. The modal mounts outside the Studio's
// ReactFlow provider, so we read the persisted draft rather than the live
// canvas. A fetch failure (no draft saved yet) degrades gracefully to a
// from-scratch generation — better than blocking the user entirely.
let currentGraph: Awaited<ReturnType<typeof fetchWorkflowDraft>>['graph'] | undefined
if (isRefine && currentAppId) {
try {
const draft = await fetchWorkflowDraft(`apps/${currentAppId}/workflows/draft`)
if (draft?.graph?.nodes?.length)
currentGraph = draft.graph
}
catch {
currentGraph = undefined
}
}
const res = await generateWorkflow({
mode,
instruction,
ideal_output: ideaOutput,
model_config: model,
...(currentGraph ? { current_graph: currentGraph } : {}),
}, {
getAbortController: (c) => { abortRef.current = c },
})
const first = res.errors?.[0]
if (first) {
// Prefer the localised copy for the structured code; fall back to
// the backend's human-readable ``detail`` for codes we don't have
// a translation for yet.
const i18nKey = `workflowGenerator.errors.${first.code}`
const localised = t(i18nKey, { defaultValue: '' })
toast.error(localised || first.detail || res.error || t('workflowGenerator.generateFailed'))
return
}
if (res.error) {
toast.error(res.error)
return
}
addVersion(res)
}
catch (e: unknown) {
// Aborts are intentional (modal close, second click, timeout) — never
// toast for them. The timeout path already showed its own toast.
if (isAbortError(e))
return
const message = e instanceof Error ? e.message : ''
toast.error(message || t('workflowGenerator.generateFailed'))
}
finally {
setLoadingFalse()
clearTimers()
abortRef.current = null
}
}
const onCancelGeneration = useCallback(() => {
abortInFlight()
setLoadingFalse()
}, [abortInFlight, setLoadingFalse])
// "Apply to current" is valid only when the visible graph was generated
// for the app we'd be writing to. We require: a current app exists, its
// mode matches the current modal mode, AND the last Generate (if any)
// ran in this same mode — otherwise the user switched tabs mid-flight
// and we'd be writing a workflow graph into a chatflow draft (or vice
// versa). Falls back to "Create new app" only.
const generatedMode = generatedModeRef.current
const generatedModeMatches = generatedMode === null || generatedMode === mode
const canApplyToCurrent = !!currentAppId && currentAppMode === mode && generatedModeMatches
const handleApplyToNew = useCallback(async () => {
if (!current?.graph || isApplying)
return
setApplyingTrue()
try {
const { appId, appMode } = await applyToNewApp({
mode,
graph: current.graph as GeneratedGraph,
instruction,
appName: current.app_name,
icon: current.icon,
})
toast.success(t('workflowGenerator.applied'))
closeGenerator()
router.push(getRedirectionPath(isCurrentWorkspaceEditor, { id: appId, mode: appMode }))
}
catch (e: unknown) {
if (e instanceof WorkflowApplyOrphanError) {
// Sync failed AND we couldn't roll back. Route the user to /apps so
// the orphan is still discoverable — they can delete it by hand.
toast.error(t('workflowGenerator.errors.apply_failed_orphan'))
closeGenerator()
router.push('/apps')
return
}
const message = e instanceof Error ? e.message : ''
toast.error(message || t('workflowGenerator.applyFailed'))
}
finally {
setApplyingFalse()
}
}, [current, instruction, mode, router, isCurrentWorkspaceEditor, closeGenerator, t, isApplying, setApplyingTrue, setApplyingFalse])
const handleApplyToCurrentConfirmed = useCallback(async () => {
if (!current?.graph || !currentAppId || isApplying)
return
hideConfirmOverwrite()
setApplyingTrue()
try {
await applyToCurrentApp({ appId: currentAppId, graph: current.graph as GeneratedGraph })
toast.success(t('workflowGenerator.applied'))
closeGenerator()
// Hard reload the workflow page so the canvas picks up the new draft —
// ``router.refresh()`` only revalidates server-rendered route data, and
// the Studio canvas is hydrated client-side via react-query / zustand.
if (typeof window !== 'undefined')
window.location.reload()
}
catch (e: unknown) {
if (e instanceof WorkflowApplyHashCollisionError) {
// Another tab edited the draft after we fetched it. Show a
// dedicated dialog with a Reload affordance instead of a generic
// "apply failed" toast — the user needs to know what actually
// happened so they can pick up the other tab's edits before
// retrying.
showHashCollision()
return
}
const message = e instanceof Error ? e.message : ''
toast.error(message || t('workflowGenerator.applyFailed'))
}
finally {
setApplyingFalse()
}
}, [current, currentAppId, hideConfirmOverwrite, closeGenerator, t, isApplying, setApplyingTrue, setApplyingFalse, showHashCollision])
const modeLabel = mode === 'workflow' ? t('workflowGenerator.modes.workflow') : t('workflowGenerator.modes.chatflow')
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
// Cancel any in-flight request BEFORE closing the store — a
// request that resolves after the modal closes would still toast
// against the now-unmounted modal and pollute version history.
abortInFlight()
closeGenerator()
}
}}
>
<DialogContent className="h-[min(680px,calc(100dvh-2rem))] max-h-none! w-[1140px] max-w-none! min-w-[1140px] overflow-hidden! border-none p-0! text-left align-middle">
<div className="flex h-full min-h-0 flex-wrap">
{/* Left pane: instructions + ideal output + model selector */}
<div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6">
<div className="mb-5">
<div className="text-lg leading-[28px] font-bold text-text-primary">
{isRefine
? t('workflowGenerator.refineTitle', { mode: modeLabel })
: t('workflowGenerator.title', { mode: modeLabel })}
</div>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">
{isRefine ? t('workflowGenerator.refineDescription') : t('workflowGenerator.description')}
</div>
</div>
<div>
<ModelParameterModal
popupClassName="w-[520px]!"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
modelId={model.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
<div className="mt-4">
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
{t('workflowGenerator.instruction')}
</div>
<Textarea
className="h-[160px]"
placeholder={isRefine
? t('workflowGenerator.refineInstructionPlaceholder')
: t('workflowGenerator.instructionPlaceholder')}
value={instruction}
onValueChange={setInstruction}
/>
{/* Example prompts are create-from-scratch starters ("Summarize a
URL"); they don't fit a refine-the-current-graph task, so hide
them in refine mode. */}
{!isRefine && <ExamplePrompts mode={mode} onSelect={setInstruction} />}
<IdeaOutput
value={ideaOutput}
onChange={setIdeaOutput}
/>
<div className="mt-7 flex justify-end space-x-2">
<Button onClick={closeGenerator}>
{t('workflowGenerator.dismiss')}
</Button>
{isLoading
? (
// Cancel surfaces the abort affordance during the 60 s
// window where the user might want to bail (slow
// model, wrong instruction, etc.). Hidden when idle so
// the row stays focused on the primary action.
<Button
className="flex space-x-1"
variant="secondary"
onClick={onCancelGeneration}
>
<span className="text-xs font-semibold">{t('workflowGenerator.cancel')}</span>
</Button>
)
: (
<Button
className="flex space-x-1"
variant="primary"
onClick={onGenerate}
disabled={!model.name}
>
<span className="i-custom-vender-other-generator size-4" />
<span className="text-xs font-semibold">{t('workflowGenerator.generate')}</span>
</Button>
)}
</div>
</div>
</div>
{/* Right pane: preview + version selector + apply */}
{(!isLoading && current?.graph?.nodes?.length)
? (
<div className="flex h-full w-0 grow flex-col bg-background-default-subtle p-6">
<div className="mb-3 flex items-center justify-between">
<VersionSelector
versionLen={versions?.length || 0}
value={currentVersionIndex || 0}
onChange={setCurrentVersionIndex}
/>
<div className="flex items-center space-x-2">
{canApplyToCurrent
? (
// Studio button entry — overwrite the current draft
// is the only meaningful Apply action, so collapse
// the two buttons into one primary "Apply".
<Button
size="small"
variant="primary"
onClick={showConfirmOverwrite}
disabled={isApplying}
>
{t('workflowGenerator.studioApply')}
</Button>
)
: (
// cmd+k /create entry — no current-app context, so
// the only path is "Create new app".
<Button
size="small"
variant="primary"
onClick={handleApplyToNew}
disabled={isApplying}
>
{t('workflowGenerator.applyToNew')}
</Button>
)}
</div>
</div>
<div className="relative w-full grow overflow-hidden rounded-2xl border border-divider-subtle bg-background-default">
<WorkflowPreview
nodes={current.graph.nodes}
edges={current.graph.edges}
viewport={current.graph.viewport}
miniMapToRight
/>
</div>
{current.message && (
<div className="mt-2 system-xs-regular text-text-tertiary">
{current.message}
</div>
)}
</div>
)
: null}
{isLoading && <GenerationPhases startedAt={startedAt} />}
{!isLoading && !current?.graph?.nodes?.length && renderPlaceholder(t('workflowGenerator.placeholder'))}
</div>
<RecoveryDialog
open={isShowConfirmOverwrite}
onOpenChange={() => hideConfirmOverwrite()}
title={t('workflowGenerator.overwriteTitle')}
description={t('workflowGenerator.overwriteMessage')}
cancelLabel={t('operation.cancel', { ns: 'common' })}
confirmLabel={t('operation.confirm', { ns: 'common' })}
onConfirm={handleApplyToCurrentConfirmed}
/>
{/* Hash-collision recovery — surfaces when another tab edited the
draft between our fetch and sync. Reload picks up those edits;
Dismiss returns to the modal so the user can copy the generated
graph manually before re-fetching. */}
<RecoveryDialog
open={isShowHashCollision}
onOpenChange={() => hideHashCollision()}
title={t('workflowGenerator.errors.hash_collision_title')}
description={t('workflowGenerator.errors.hash_collision')}
cancelLabel={t('operation.cancel', { ns: 'common' })}
confirmLabel={t('workflowGenerator.reload')}
onConfirm={() => {
hideHashCollision()
if (typeof window !== 'undefined')
window.location.reload()
}}
/>
</DialogContent>
</Dialog>
)
}
export default React.memo(WorkflowGeneratorModal)
@@ -0,0 +1,22 @@
'use client'
import * as React from 'react'
import dynamic from '@/next/dynamic'
import { useWorkflowGeneratorStore } from './store'
// Lazy-load the modal so the bundle of the common layout stays light;
// the modal is only mounted on demand when cmd+k `/create` fires.
const WorkflowGeneratorModal = dynamic(() => import('./index'), { ssr: false })
/**
* Global mount point for the workflow generator modal. Place once in the
* common layout next to ``<GotoAnything />`` — the modal opens whenever the
* zustand store flips ``isOpen`` to true.
*/
const WorkflowGeneratorMount: React.FC = () => {
const isOpen = useWorkflowGeneratorStore(s => s.isOpen)
if (!isOpen)
return null
return <WorkflowGeneratorModal />
}
export default WorkflowGeneratorMount
@@ -0,0 +1,58 @@
'use client'
import type { WorkflowGeneratorIntent, WorkflowGeneratorMode } from './types'
import { create } from 'zustand'
type WorkflowGeneratorStore = {
isOpen: boolean
mode: WorkflowGeneratorMode
/** `create` = build a new app; `refine` = amend the current Studio draft. */
intent: WorkflowGeneratorIntent
currentAppId: string | null
currentAppMode: WorkflowGeneratorMode | null
openGenerator: (params: {
mode: WorkflowGeneratorMode
intent?: WorkflowGeneratorIntent
currentAppId?: string | null
currentAppMode?: WorkflowGeneratorMode | null
}) => void
closeGenerator: () => void
}
/**
* Wipe the session-storage entries ``useGenGraph`` keeps for the new-app
* (no ``currentAppId``) bucket. We do this every time ``/create`` opens so
* the panel starts on the empty placeholder instead of showing whatever
* graph the previous /create session produced — those generations belong
* to a different intent and confusingly leak across opens.
*
* Studio-refine sessions (``currentAppId`` set) keep their history so the
* user can close and reopen the generator from the same Studio without losing
* the versions they were comparing.
*/
const resetNewAppHistory = (mode: WorkflowGeneratorMode) => {
if (typeof window === 'undefined')
return
const storageKey = `${mode}-new`
try {
sessionStorage.removeItem(`workflow-gen-${storageKey}-versions`)
sessionStorage.removeItem(`workflow-gen-${storageKey}-version-index`)
}
catch {
// sessionStorage can throw in privacy-restricted contexts; the stale
// state will still flush on tab close, so swallowing here is fine.
}
}
export const useWorkflowGeneratorStore = create<WorkflowGeneratorStore>(set => ({
isOpen: false,
mode: 'workflow',
intent: 'create',
currentAppId: null,
currentAppMode: null,
openGenerator: ({ mode, intent = 'create', currentAppId = null, currentAppMode = null }) => {
if (!currentAppId)
resetNewAppHistory(mode)
set({ isOpen: true, mode, intent, currentAppId, currentAppMode })
},
closeGenerator: () => set({ isOpen: false }),
}))
@@ -0,0 +1,30 @@
import type { Viewport } from 'reactflow'
import type { Edge, Node } from '@/app/components/workflow/types'
export type WorkflowGeneratorMode = 'workflow' | 'advanced-chat'
/**
* `create` builds a brand-new app from scratch; `refine` feeds the current
* Studio draft graph to the generator as context so it amends what's already
* on the canvas. Only `refine` requires an open Studio (a `currentAppId`).
*/
export type WorkflowGeneratorIntent = 'create' | 'refine'
export type GeneratedGraph = {
nodes: Node[]
edges: Edge[]
viewport: Viewport
}
export type GenerateWorkflowResponse = {
graph: GeneratedGraph
message?: string
/**
* Planner-picked product-style name. Used by applyToNewApp; empty triggers
* a deriveAppName(instruction) fallback.
*/
app_name?: string
/** Planner-picked emoji icon for the new App. Empty triggers a 🤖 fallback. */
icon?: string
error?: string
}
@@ -0,0 +1,45 @@
import type { GenerateWorkflowResponse } from './types'
import { useSessionStorageState } from 'ahooks'
import { useCallback } from 'react'
const KEY_PREFIX = 'workflow-gen-'
type Params = {
storageKey: string
}
/**
* Session-storage-backed version history for generated workflows.
*
* Mirrors ``app/configuration/config/automatic/use-gen-data.ts`` so the
* cmd+k workflow generator's UX (left pane edit → Generate → right pane
* version selector) matches the existing Prompt Generator.
*/
const useGenGraph = ({ storageKey }: Params) => {
const [versions, setVersions] = useSessionStorageState<GenerateWorkflowResponse[]>(
`${KEY_PREFIX}${storageKey}-versions`,
{ defaultValue: [] },
)
const [currentVersionIndex, setCurrentVersionIndex] = useSessionStorageState<number>(
`${KEY_PREFIX}${storageKey}-version-index`,
{ defaultValue: 0 },
)
const current = versions?.[currentVersionIndex ?? 0]
const addVersion = useCallback((version: GenerateWorkflowResponse) => {
setCurrentVersionIndex(() => versions?.length || 0)
setVersions(prev => [...(prev ?? []), version])
}, [setVersions, setCurrentVersionIndex, versions?.length])
return {
versions,
addVersion,
currentVersionIndex,
setCurrentVersionIndex,
current,
}
}
export default useGenGraph
+9
View File
@@ -51,11 +51,20 @@
"exportFailed": "Export DSL failed.",
"gotoAnything.actions.accountDesc": "Navigate to account page",
"gotoAnything.actions.communityDesc": "Open Discord community",
"gotoAnything.actions.createCategoryDesc": "Create an AI-generated workflow or chatflow",
"gotoAnything.actions.createCategoryTitle": "Create",
"gotoAnything.actions.createChatflow": "Chatflow",
"gotoAnything.actions.createChatflowDesc": "Generate a chatflow (advanced chat) app from a description",
"gotoAnything.actions.createWorkflow": "Workflow",
"gotoAnything.actions.createWorkflowDesc": "Generate a workflow app from a description",
"gotoAnything.actions.docDesc": "Open help documentation",
"gotoAnything.actions.feedbackDesc": "Open community feedback discussions",
"gotoAnything.actions.languageCategoryDesc": "Switch interface language",
"gotoAnything.actions.languageCategoryTitle": "Language",
"gotoAnything.actions.languageChangeDesc": "Change UI language",
"gotoAnything.actions.refineCategoryDesc": "Refine the current workflow or chatflow graph",
"gotoAnything.actions.refineDesc": "Describe a change to apply to the current draft",
"gotoAnything.actions.refineTitle": "Refine current graph",
"gotoAnything.actions.runDesc": "Run quick commands (theme, language, ...)",
"gotoAnything.actions.runTitle": "Commands",
"gotoAnything.actions.searchApplications": "Search Applications",
+54 -1
View File
@@ -1226,5 +1226,58 @@
"versionHistory.nameThisVersion": "Name this version",
"versionHistory.releaseNotesPlaceholder": "Describe what changed",
"versionHistory.restorationTip": "After version restoration, the current draft will be overwritten.",
"versionHistory.title": "Versions"
"versionHistory.title": "Versions",
"workflowGenerator.applied": "Applied",
"workflowGenerator.applyFailed": "Failed to apply workflow",
"workflowGenerator.applyToCurrent": "Apply to current draft",
"workflowGenerator.applyToNew": "Create new app",
"workflowGenerator.cancel": "Cancel",
"workflowGenerator.description": "Describe what you want the workflow to do. Pick a model, write an instruction, and preview the generated graph before applying it to Studio.",
"workflowGenerator.dismiss": "Dismiss",
"workflowGenerator.errors.DANGLING_EDGE": "The generated workflow has an edge pointing at a node that doesn't exist. Try again or refine your instruction.",
"workflowGenerator.errors.EMPTY_INSTRUCTION": "Please write an instruction first.",
"workflowGenerator.errors.EMPTY_PLAN": "The model returned an empty plan. Try a more specific instruction.",
"workflowGenerator.errors.INVALID_CONTAINER": "The generated workflow has a malformed loop or iteration container. Try a simpler instruction or pick a more capable model.",
"workflowGenerator.errors.INVALID_JSON": "The model returned a response we couldn't parse. Try again or pick a more capable model.",
"workflowGenerator.errors.INVALID_SCHEMA": "The model returned a graph in an unexpected shape. Try again or pick a more capable model.",
"workflowGenerator.errors.MISSING_START": "The generated workflow is missing its start node. Try regenerating.",
"workflowGenerator.errors.MISSING_TERMINAL": "The generated workflow is missing an end or answer node. Try regenerating.",
"workflowGenerator.errors.MODEL_ERROR": "The model call failed. Check your provider quota and try again.",
"workflowGenerator.errors.UNKNOWN_NODE_REFERENCE": "A node in the generated workflow points at a node that doesn't exist. Try regenerating.",
"workflowGenerator.errors.UNKNOWN_TOOL": "The workflow references a tool that isn't installed for this workspace. Install it from the Tools page or refine your instruction.",
"workflowGenerator.errors.UNRESOLVED_REFERENCE": "A node in the generated workflow references a variable that isn't declared upstream. Try regenerating.",
"workflowGenerator.errors.apply_failed_orphan": "We couldn't finish creating the app. An empty draft may have been left in your apps list — please remove it manually.",
"workflowGenerator.errors.hash_collision": "The workflow draft was edited in another tab. Reload to pick up those changes, then try applying again.",
"workflowGenerator.errors.hash_collision_title": "Workspace was edited elsewhere",
"workflowGenerator.errors.timeout": "Generation took too long. The model might be slow or unavailable — try again.",
"workflowGenerator.examples.chatflow.support": "Customer-support bot backed by a knowledge base",
"workflowGenerator.examples.chatflow.triage": "Triage incoming questions and route to a specialist prompt",
"workflowGenerator.examples.chatflow.tutor": "Multi-language tutor that explains step by step",
"workflowGenerator.examples.label": "Try one of these",
"workflowGenerator.examples.workflow.classify": "Fetch GitHub issues and classify them",
"workflowGenerator.examples.workflow.rag": "Knowledge-base query, then format the answer as Markdown",
"workflowGenerator.examples.workflow.summarize": "Summarize a URL",
"workflowGenerator.examples.workflow.translate": "Translate text to multiple languages",
"workflowGenerator.generate": "Generate",
"workflowGenerator.generateFailed": "Failed to generate workflow",
"workflowGenerator.instruction": "Instructions",
"workflowGenerator.instructionPlaceholder": "Describe the workflow you want — what input, what processing, what output.",
"workflowGenerator.instructionRequired": "Please write an instruction first",
"workflowGenerator.loading": "Generating workflow…",
"workflowGenerator.modelRequired": "Please pick a model before generating.",
"workflowGenerator.modes.chatflow": "Chatflow",
"workflowGenerator.modes.workflow": "Workflow",
"workflowGenerator.overwriteMessage": "Applying this workflow will replace the current draft graph. This cannot be undone.",
"workflowGenerator.overwriteTitle": "Overwrite the current draft?",
"workflowGenerator.phases.building": "Building nodes…",
"workflowGenerator.phases.planning": "Planning the workflow…",
"workflowGenerator.phases.validating": "Validating the graph…",
"workflowGenerator.placeholder": "Write an instruction on the left, then click Generate to preview the workflow graph.",
"workflowGenerator.refineDescription": "Describe the change you want. The current draft is used as context; the generated graph replaces it when you apply.",
"workflowGenerator.refineInstructionPlaceholder": "Describe the change — e.g. add a translation step, switch to a tool, add error handling.",
"workflowGenerator.refineTitle": "Refine {{mode}}",
"workflowGenerator.reload": "Reload",
"workflowGenerator.studioApply": "Apply",
"workflowGenerator.studioButton": "Generate",
"workflowGenerator.title": "Generate {{mode}}"
}
+9
View File
@@ -51,11 +51,20 @@
"exportFailed": "导出 DSL 失败",
"gotoAnything.actions.accountDesc": "导航到账户页面",
"gotoAnything.actions.communityDesc": "打开 Discord 社区",
"gotoAnything.actions.createCategoryDesc": "创建由 AI 生成的工作流或 Chatflow",
"gotoAnything.actions.createCategoryTitle": "创建",
"gotoAnything.actions.createChatflow": "Chatflow",
"gotoAnything.actions.createChatflowDesc": "根据描述生成一个 Chatflow(高级聊天)应用",
"gotoAnything.actions.createWorkflow": "Workflow",
"gotoAnything.actions.createWorkflowDesc": "根据描述生成一个工作流应用",
"gotoAnything.actions.docDesc": "打开帮助文档",
"gotoAnything.actions.feedbackDesc": "打开社区反馈讨论",
"gotoAnything.actions.languageCategoryDesc": "切换界面语言",
"gotoAnything.actions.languageCategoryTitle": "语言",
"gotoAnything.actions.languageChangeDesc": "更改界面语言",
"gotoAnything.actions.refineCategoryDesc": "优化当前的工作流或 Chatflow 图",
"gotoAnything.actions.refineDesc": "描述要应用到当前草稿的修改",
"gotoAnything.actions.refineTitle": "优化当前图",
"gotoAnything.actions.runDesc": "快速执行命令(主题、语言等)",
"gotoAnything.actions.runTitle": "命令",
"gotoAnything.actions.searchApplications": "搜索应用程序",
+54 -1
View File
@@ -1226,5 +1226,58 @@
"versionHistory.nameThisVersion": "命名",
"versionHistory.releaseNotesPlaceholder": "请描述变更",
"versionHistory.restorationTip": "版本回滚后,当前草稿将被覆盖。",
"versionHistory.title": "版本"
"versionHistory.title": "版本",
"workflowGenerator.applied": "已应用",
"workflowGenerator.applyFailed": "应用工作流失败",
"workflowGenerator.applyToCurrent": "应用到当前草稿",
"workflowGenerator.applyToNew": "创建新应用",
"workflowGenerator.cancel": "取消",
"workflowGenerator.description": "描述你希望工作流完成的任务。选择模型、撰写指令,预览生成的图后再应用到 Studio。",
"workflowGenerator.dismiss": "关闭",
"workflowGenerator.errors.DANGLING_EDGE": "生成的工作流中有连线指向了不存在的节点,请重试或细化指令。",
"workflowGenerator.errors.EMPTY_INSTRUCTION": "请先填写指令。",
"workflowGenerator.errors.EMPTY_PLAN": "模型没有返回任何规划,尝试写一个更具体的指令。",
"workflowGenerator.errors.INVALID_CONTAINER": "生成的工作流中循环或迭代容器结构异常。尝试简化指令或更换更强的模型。",
"workflowGenerator.errors.INVALID_JSON": "模型返回的内容无法解析,请重试或更换更强的模型。",
"workflowGenerator.errors.INVALID_SCHEMA": "模型返回的图结构不符合预期,请重试或更换更强的模型。",
"workflowGenerator.errors.MISSING_START": "生成的工作流缺少起始节点,请重新生成。",
"workflowGenerator.errors.MISSING_TERMINAL": "生成的工作流缺少结束或回答节点,请重新生成。",
"workflowGenerator.errors.MODEL_ERROR": "模型调用失败。请检查供应商配额后重试。",
"workflowGenerator.errors.UNKNOWN_NODE_REFERENCE": "工作流中有节点引用了不存在的节点,请重新生成。",
"workflowGenerator.errors.UNKNOWN_TOOL": "工作流引用了当前工作空间未安装的工具。请先在「工具」页面安装该工具,或细化指令。",
"workflowGenerator.errors.UNRESOLVED_REFERENCE": "工作流中有节点引用了上游未声明的变量,请重新生成。",
"workflowGenerator.errors.apply_failed_orphan": "未能完成应用,应用列表中可能残留一个空草稿——请手动删除。",
"workflowGenerator.errors.hash_collision": "工作流草稿被另一个标签页修改过,请重新加载以同步那些改动后再尝试应用。",
"workflowGenerator.errors.hash_collision_title": "工作空间在别处被修改",
"workflowGenerator.errors.timeout": "生成耗时过长。模型可能较慢或不可用——请重试。",
"workflowGenerator.examples.chatflow.support": "基于知识库的客服机器人",
"workflowGenerator.examples.chatflow.triage": "分诊问题并路由到对应的专业 Prompt",
"workflowGenerator.examples.chatflow.tutor": "多语言导师,分步骤讲解",
"workflowGenerator.examples.label": "试试这些",
"workflowGenerator.examples.workflow.classify": "拉取 GitHub Issue 并分类",
"workflowGenerator.examples.workflow.rag": "查询知识库,然后以 Markdown 格式输出答案",
"workflowGenerator.examples.workflow.summarize": "总结一个网址",
"workflowGenerator.examples.workflow.translate": "把文本翻译成多种语言",
"workflowGenerator.generate": "生成",
"workflowGenerator.generateFailed": "生成工作流失败",
"workflowGenerator.instruction": "指令",
"workflowGenerator.instructionPlaceholder": "描述你想要的工作流——输入是什么、处理流程、输出形式。",
"workflowGenerator.instructionRequired": "请先填写指令",
"workflowGenerator.loading": "正在生成工作流……",
"workflowGenerator.modelRequired": "请先选择一个模型再生成。",
"workflowGenerator.modes.chatflow": "Chatflow",
"workflowGenerator.modes.workflow": "Workflow",
"workflowGenerator.overwriteMessage": "应用此工作流将覆盖当前草稿,操作不可撤销。",
"workflowGenerator.overwriteTitle": "覆盖当前草稿?",
"workflowGenerator.phases.building": "正在构建节点……",
"workflowGenerator.phases.planning": "正在规划工作流……",
"workflowGenerator.phases.validating": "正在校验图……",
"workflowGenerator.placeholder": "在左侧填写指令,点击「生成」预览工作流图。",
"workflowGenerator.refineDescription": "描述你想要的修改。当前草稿会作为上下文;应用时生成的图将替换它。",
"workflowGenerator.refineInstructionPlaceholder": "描述修改内容——例如添加翻译步骤、改用某个工具、增加错误处理。",
"workflowGenerator.refineTitle": "优化 {{mode}}",
"workflowGenerator.reload": "重新加载",
"workflowGenerator.studioApply": "应用",
"workflowGenerator.studioButton": "生成",
"workflowGenerator.title": "生成 {{mode}}"
}
+83
View File
@@ -0,0 +1,83 @@
// service/base is the dependency we're mocking in this test; the
// no-restricted-imports rule targets production imports, not test
// instrumentation — mirrors sibling service specs (annotation.spec.ts etc.).
// eslint-disable-next-line no-restricted-imports
import { post } from './base'
import { generateWorkflow } from './debug'
// Stub the shared `post` wrapper so tests verify only what `generateWorkflow`
// composes on top of it — URL, body, and the typed response surface.
vi.mock('./base', () => ({
post: vi.fn(),
get: vi.fn(),
ssePost: vi.fn(),
}))
describe('debug service — generateWorkflow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The new endpoint lives at /workflow-generate; the controller mirrors
// /rule-generate so the body must flow through unchanged.
it('should POST to /workflow-generate with the body verbatim', () => {
const body = {
mode: 'workflow' as const,
instruction: 'Summarize a URL',
ideal_output: 'A 3-sentence summary.',
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
}
generateWorkflow(body)
expect(post).toHaveBeenCalledWith('/workflow-generate', { body })
})
// The optional fields must still POST cleanly — `ideal_output` defaulting
// server-side requires the helper to forward the body as-is, not augment it.
it('should pass the body through even when ideal_output is omitted', () => {
const body = {
mode: 'advanced-chat' as const,
instruction: 'Friendly support bot',
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
}
generateWorkflow(body)
expect(post).toHaveBeenCalledWith('/workflow-generate', { body })
})
// When the caller threads a ``getAbortController`` callback (the modal's
// pattern for cancelling the in-flight request on close / double-click /
// 60 s timeout), it must reach ``post()`` as the third argument so the
// shared fetch wrapper wires it into the AbortController plumbing.
// Without this the modal cannot abort the request and a close-while-
// loading leaks the request beyond its UI surface.
it('should forward getAbortController to post when provided', () => {
const body = {
mode: 'workflow' as const,
instruction: 'Long-running generation',
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
}
const getAbortController = vi.fn()
generateWorkflow(body, { getAbortController })
expect(post).toHaveBeenCalledWith('/workflow-generate', { body }, { getAbortController })
})
// No options → no third argument. Keeps the call site clean and lets the
// shared wrapper apply its own defaults without a phantom empty object.
it('should NOT pass a third argument when no options are provided', () => {
const body = {
mode: 'workflow' as const,
instruction: 'Plain call',
model_config: { provider: 'openai', name: 'gpt-4o', mode: 'chat' },
}
generateWorkflow(body)
expect(post).toHaveBeenCalledWith('/workflow-generate', { body })
expect(vi.mocked(post).mock.calls[0]).toHaveLength(2)
})
})
+99
View File
@@ -1,4 +1,6 @@
import type { Viewport } from 'reactflow'
import type { IOnCompleted, IOnData, IOnError, IOnMessageReplace } from './base'
import type { Edge, Node } from '@/app/components/workflow/types'
import type { ChatPromptConfig, CompletionPromptConfig } from '@/models/debug'
import type { AppModeEnum, ModelModeType } from '@/types/app'
import { get, post, ssePost } from './base'
@@ -68,6 +70,103 @@ export const generateRule = (body: Record<string, any>) => {
})
}
/**
* One structured error from the workflow generator backend. ``code`` is a
* stable machine-readable identifier the frontend maps to localised copy
* via the ``workflowGenerator.errors.<code>`` i18n keys; ``detail`` is the
* raw English diagnostic; ``node_id`` is set when the error is tied to a
* specific node (the preview canvas can highlight it).
*
* Stable codes — adding a new one without updating the i18n map will fall
* back to ``detail`` and that's fine, but every value listed here MUST
* exist in both en-US and zh-Hans.
*/
// Not exported: knip flags unused exports and the modal looks codes up by
// string interpolation (``workflowGenerator.errors.${code}``) rather than
// importing the union. Kept here so the ``GenerateWorkflowResponse``
// definition below documents the contract in one place.
type GenerateWorkflowErrorCode
= | 'INVALID_JSON'
| 'INVALID_SCHEMA'
| 'EMPTY_INSTRUCTION'
| 'EMPTY_PLAN'
| 'UNKNOWN_NODE_REFERENCE'
| 'INVALID_CONTAINER'
| 'UNRESOLVED_REFERENCE'
| 'UNKNOWN_TOOL'
| 'MISSING_TERMINAL'
| 'MISSING_START'
| 'DANGLING_EDGE'
| 'MODEL_ERROR'
type GenerateWorkflowError = {
code: GenerateWorkflowErrorCode | string
detail: string
node_id?: string
}
export type GenerateWorkflowResponse = {
graph: {
nodes: Node[]
edges: Edge[]
viewport: Viewport
}
message?: string
/**
* Planner-picked product-style name (e.g. "URL Summarizer"). Empty when
* the planner omits it; the caller (applyToNewApp) supplies a fallback.
*/
app_name?: string
/**
* Planner-picked emoji that captures the workflow's purpose. Empty when
* the planner omits it; the caller supplies a 🤖 fallback.
*/
icon?: string
/** Human-readable concatenation of ``errors[].detail``. "" on success. */
error?: string
/** Structured errors with stable codes for FE-localised mapping. [] on success. */
errors?: GenerateWorkflowError[]
}
export type GenerateWorkflowBody = {
mode: 'workflow' | 'advanced-chat'
instruction: string
ideal_output?: string
model_config: { provider: string, name: string, mode: string, completion_params?: Record<string, unknown> }
/**
* Existing draft graph for the cmd+k `/refine` flow. When present the
* backend refines this graph instead of generating from scratch. Omitted
* for `/create`.
*/
current_graph?: {
nodes: Node[]
edges: Edge[]
viewport?: Viewport
}
}
export type GenerateWorkflowOptions = {
/**
* Callback receiving the ``AbortController`` for the in-flight request.
* The caller stores it and aborts on modal close / second submit / hard
* timeout. Pattern mirrors ``fetchSuggestedQuestions`` / ``fetchConversationMessages``
* which already thread this through ``base.ts``.
*/
getAbortController?: (controller: AbortController) => void
}
export const generateWorkflow = (body: GenerateWorkflowBody, options?: GenerateWorkflowOptions) => {
// Only pass the third argument when the caller actually supplied one —
// otherwise the shared ``post()`` wrapper sees ``undefined`` and that
// breaks tests asserting the 2-arg call shape, with no behaviour upside.
if (options?.getAbortController) {
return post<GenerateWorkflowResponse>('/workflow-generate', { body }, {
getAbortController: options.getAbortController,
})
}
return post<GenerateWorkflowResponse>('/workflow-generate', { body })
}
export const fetchPromptTemplate = ({
appMode,
mode,