mirror of
https://github.com/langgenius/dify.git
synced 2026-09-21 13:20:52 +08:00
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:
co-authored by
Claude Opus 4.7
autofix-ci[bot]
Copilot Autofix powered by AI
parent
c8abb11bf0
commit
0bfbd2061e
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
* 15–18 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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": "搜索应用程序",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user