From 6978767f565192f842b9dd63e92bbb367253769b Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 16 Apr 2026 16:56:06 +0800 Subject: [PATCH 01/36] feat: workflow support get params from url and add set url config modal --- .../app-info/app-info-detail-panel.tsx | 2 +- .../config-var/config-modal/form-fields.tsx | 4 +- .../__tests__/app-card-sections.spec.tsx | 8 + .../overview/__tests__/app-card-utils.spec.ts | 114 +++++++- .../app/overview/__tests__/app-card.spec.tsx | 179 ++++++++++++- .../app/overview/app-card-sections.tsx | 243 +++++++++++++++++- .../components/app/overview/app-card-utils.ts | 83 +++++- web/app/components/app/overview/app-card.tsx | 77 +++++- .../base/chat/__tests__/utils.spec.ts | 51 +++- .../embedded-chatbot/__tests__/hooks.spec.tsx | 16 +- web/app/components/base/chat/utils.ts | 20 +- .../use-text-generation-app-state.spec.ts | 60 +++++ .../hooks/use-text-generation-app-state.ts | 52 +++- web/app/components/workflow/types.ts | 2 +- web/i18n/en-US/app-overview.json | 2 + web/i18n/zh-Hans/app-overview.json | 2 + web/models/debug.ts | 2 +- 17 files changed, 869 insertions(+), 48 deletions(-) diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx index 624630b179..29b76bdada 100644 --- a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx @@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
diff --git a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx index fa318ae35d..360efc2ac2 100644 --- a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx @@ -103,7 +103,7 @@ const ConfigModalFormFields: FC = ({ {type === InputVarType.textInput && ( onPayloadChange('default')(e.target.value || undefined)} placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} /> @@ -124,7 +124,7 @@ const ConfigModalFormFields: FC = ({ onPayloadChange('default')(e.target.value || undefined)} placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} /> diff --git a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx index 9820e15ad8..4a1cddf4e4 100644 --- a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx @@ -52,6 +52,7 @@ describe('app-card-sections', () => { it('should render operation buttons and execute enabled actions', () => { const onLaunch = vi.fn() + const onLaunchConfig = vi.fn() const operations = createAppCardOperations({ operationKeys: ['launch', 'embedded'], t: t as never, @@ -68,12 +69,19 @@ describe('app-card-sections', () => { , ) fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i })) + fireEvent.click(screen.getByRole('button', { name: /operation\.config/i })) expect(onLaunch).toHaveBeenCalledTimes(1) + expect(onLaunchConfig).toHaveBeenCalledTimes(1) expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument() }) diff --git a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts index fbfcdaf955..9c38473447 100644 --- a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts +++ b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts @@ -1,9 +1,17 @@ import type { AppDetailResponse } from '@/models/app' -import { BlockEnum } from '@/app/components/workflow/types' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' -import { getAppCardDisplayState, getAppCardOperationKeys, hasWorkflowStartNode, isAppAccessConfigured } from '../app-card-utils' +import { + createWorkflowLaunchInitialValues, + getAppCardDisplayState, + getAppCardOperationKeys, + getAppHiddenLaunchVariables, + getWorkflowHiddenStartVariables, + hasWorkflowStartNode, + isAppAccessConfigured, +} from '../app-card-utils' describe('app-card-utils', () => { const baseAppInfo = { @@ -33,6 +41,108 @@ describe('app-card-utils', () => { })).toBe(false) }) + it('should return hidden workflow start variables and their initial launch values', () => { + const hiddenVariables = getWorkflowHiddenStartVariables({ + graph: { + nodes: [{ + data: { + type: BlockEnum.Start, + variables: [ + { + variable: 'visible', + label: 'Visible', + type: InputVarType.textInput, + hide: false, + required: false, + }, + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + default: 'prefilled', + required: false, + }, + { + variable: 'enabled', + label: 'Enabled', + type: InputVarType.checkbox, + hide: true, + default: true, + required: false, + }, + ], + }, + }], + }, + }) + + expect(hiddenVariables.map(variable => variable.variable)).toEqual(['secret', 'enabled']) + expect(createWorkflowLaunchInitialValues(hiddenVariables)).toEqual({ + secret: 'prefilled', + enabled: true, + }) + }) + + it('should return hidden advanced-chat launch variables from the workflow start node first', () => { + const hiddenVariables = getAppHiddenLaunchVariables({ + appInfo: { + ...baseAppInfo, + mode: AppModeEnum.ADVANCED_CHAT, + model_config: { + user_input_form: [ + { + 'text-input': { + label: 'Visible', + variable: 'visible', + required: true, + max_length: 48, + default: '', + hide: false, + }, + }, + { + checkbox: { + label: 'Hidden Toggle', + variable: 'hidden_toggle', + required: false, + default: true, + hide: true, + }, + }, + ], + }, + } as AppDetailResponse, + currentWorkflow: { + graph: { + nodes: [{ + data: { + type: BlockEnum.Start, + variables: [ + { + variable: 'start_secret', + label: 'Start Secret', + type: InputVarType.textInput, + hide: true, + default: 'from-start', + required: false, + }, + ], + }, + }], + }, + }, + }) + + expect(hiddenVariables).toEqual([ + expect.objectContaining({ + variable: 'start_secret', + type: InputVarType.textInput, + default: 'from-start', + }), + ]) + }) + it('should build the display state for a published web app', () => { const state = getAppCardDisplayState({ appInfo: baseAppInfo, diff --git a/web/app/components/app/overview/__tests__/app-card.spec.tsx b/web/app/components/app/overview/__tests__/app-card.spec.tsx index 0f67093e47..608097bcbc 100644 --- a/web/app/components/app/overview/__tests__/app-card.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card.spec.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import type { AppDetailResponse } from '@/models/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' @@ -12,7 +13,7 @@ const mockSetAppDetail = vi.fn() const mockOnChangeStatus = vi.fn() const mockOnGenerateCode = vi.fn() -let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string } }> } } | null = null +let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string, variables?: Array> } }> } } | null = null let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] } let mockAppDetail: AppDetailResponse | undefined @@ -169,6 +170,182 @@ describe('AppCard', () => { expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com${basePath}/chat/access-token`, '_blank') }) + it('should open the workflow web app directly when launch is clicked even with hidden inputs', () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByText('overview.appInfo.launch')) + + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/access-token`, + '_blank', + ) + expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument() + }) + + it('should collect hidden workflow inputs from the config action before launching the workflow web app', async () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.config' })) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Secret'), { + target: { value: 'top-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/access-token?secret=${encodeURIComponent('top-secret')}`, + '_blank', + ) + }) + }) + + it('should open the chat web app directly when launch is clicked even with hidden inputs', () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'chat_secret', + label: 'Chat Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByText('overview.appInfo.launch')) + + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/access-token`, + '_blank', + ) + expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument() + }) + + it('should collect hidden chatflow inputs from the config action before launching the chat web app', async () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'chat_secret', + label: 'Chat Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.config' })) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Chat Secret'), { + target: { value: 'chat-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/access-token?chat_secret=${encodeURIComponent('chat-secret')}`, + '_blank', + ) + }) + }) + it('should show the access-control not-set badge when specific access has no subjects', () => { render( void } +type LaunchConfigAction = { + label: string + disabled: boolean + onClick: () => void +} + const OPERATION_ICON_MAP: Record = { launch: RiExternalLinkLine, embedded: RiWindowLine, @@ -96,6 +122,138 @@ const MaybeTooltip = ({ ) } +export const WorkflowLaunchDialog = ({ + t, + open, + hiddenVariables, + unsupportedVariables, + values, + onOpenChange, + onValueChange, + onSubmit, +}: { + t: TFunction + open: boolean + hiddenVariables: WorkflowHiddenStartVariable[] + unsupportedVariables: WorkflowHiddenStartVariable[] + values: Record + onOpenChange: (open: boolean) => void + onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void + onSubmit: (event: FormEvent) => void +}) => { + const renderField = (variable: WorkflowHiddenStartVariable) => { + const fieldId = `workflow-launch-hidden-input-${variable.variable}` + const fieldValue = values[variable.variable] + const label = typeof variable.label === 'string' ? variable.label : variable.variable + + if (variable.type === InputVarType.select) { + return ( + + ) + } + + if (variable.type === InputVarType.checkbox) { + return ( + + ) + } + + if ( + variable.type === InputVarType.paragraph + || variable.type === InputVarType.json + || variable.type === InputVarType.jsonObject + ) { + return ( +