feat: workflow support get params from url and add set url config modal

This commit is contained in:
Joel
2026-04-16 16:56:06 +08:00
parent cf4d7afb9c
commit 6978767f56
17 changed files with 869 additions and 48 deletions

View File

@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
<ContentDialog
show={show}
onClose={onClose}
className="absolute top-2 bottom-2 left-2 flex w-[420px] flex-col rounded-2xl p-0!"
className="absolute top-2 bottom-2 left-2 flex w-[452px] max-w-[calc(100vw-1rem)] flex-col rounded-2xl p-0!"
>
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
<div className="flex items-center gap-3 self-stretch">

View File

@ -103,7 +103,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
{type === InputVarType.textInput && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
value={tempPayload.default || ''}
value={typeof tempPayload.default === 'string' ? tempPayload.default : ''}
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
@ -124,7 +124,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
type="number"
value={tempPayload.default || ''}
value={typeof tempPayload.default === 'number' || typeof tempPayload.default === 'string' ? tempPayload.default : ''}
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>

View File

@ -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', () => {
<AppCardOperations
t={t as never}
operations={operations}
launchConfigAction={{
label: 'operation.config',
disabled: false,
onClick: onLaunchConfig,
}}
/>,
)
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()
})

View File

@ -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,

View File

@ -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<Record<string, unknown>> } }> } } | 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(
<AppCard
appInfo={{
...appInfo,
mode: AppModeEnum.WORKFLOW,
}}
onChangeStatus={mockOnChangeStatus}
/>,
)
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(
<AppCard
appInfo={{
...appInfo,
mode: AppModeEnum.WORKFLOW,
}}
onChangeStatus={mockOnChangeStatus}
/>,
)
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(
<AppCard
appInfo={{
...appInfo,
mode: AppModeEnum.ADVANCED_CHAT,
} as AppDetailResponse}
onChangeStatus={mockOnChangeStatus}
/>,
)
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(
<AppCard
appInfo={{
...appInfo,
mode: AppModeEnum.ADVANCED_CHAT,
} as AppDetailResponse}
onChangeStatus={mockOnChangeStatus}
/>,
)
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(
<AppCard

View File

@ -1,14 +1,20 @@
/* eslint-disable react-refresh/only-export-components */
import type { TFunction } from 'i18next'
import type { ComponentType, ReactNode } from 'react'
import type { OverviewOperationKey } from './app-card-utils'
import type { ChangeEvent, ComponentType, FormEvent, ReactNode } from 'react'
import type {
OverviewOperationKey,
WorkflowHiddenStartVariable,
WorkflowLaunchInputValue,
} from './app-card-utils'
import type { ConfigParams } from './settings'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import ShareQRCode from '@/app/components/base/qrcode'
import Textarea from '@/app/components/base/textarea'
import {
AlertDialog,
AlertDialogActions,
@ -19,11 +25,25 @@ import {
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Button } from '@/app/components/base/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/app/components/base/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/app/components/base/ui/select'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import { InputVarType } from '@/app/components/workflow/types'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import AccessControl from '../app-access-control'
@ -50,6 +70,12 @@ type AppCardOperation = {
onClick: () => void
}
type LaunchConfigAction = {
label: string
disabled: boolean
onClick: () => void
}
const OPERATION_ICON_MAP: Record<OverviewOperationKey, OperationIcon> = {
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<string, WorkflowLaunchInputValue>
onOpenChange: (open: boolean) => void
onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void
onSubmit: (event: FormEvent<HTMLFormElement>) => 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 (
<Select
value={typeof fieldValue === 'string' ? fieldValue : ''}
onValueChange={value => onValueChange(variable.variable, value ?? '')}
>
<SelectTrigger className="w-full" aria-label={label}>
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
{(variable.options ?? []).map(option => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (variable.type === InputVarType.checkbox) {
return (
<label className="flex min-h-10 w-full cursor-pointer items-center gap-3 rounded-lg bg-components-input-bg-normal px-3 py-2">
<input
id={fieldId}
type="checkbox"
checked={Boolean(fieldValue)}
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(variable.variable, event.target.checked)}
className="h-4 w-4 rounded border-divider-subtle"
/>
<span className="system-sm-regular text-text-secondary">{label}</span>
</label>
)
}
if (
variable.type === InputVarType.paragraph
|| variable.type === InputVarType.json
|| variable.type === InputVarType.jsonObject
) {
return (
<Textarea
id={fieldId}
value={typeof fieldValue === 'string' ? fieldValue : ''}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onValueChange(variable.variable, event.target.value)}
placeholder={label}
maxLength={variable.max_length}
className="min-h-24"
/>
)
}
return (
<Input
id={fieldId}
type={variable.type === InputVarType.number ? 'number' : 'text'}
value={typeof fieldValue === 'string' ? fieldValue : ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(variable.variable, event.target.value)}
placeholder={label}
maxLength={variable.max_length}
/>
)
}
if (!hiddenVariables.length && !unsupportedVariables.length)
return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[560px]! max-w-[calc(100vw-2rem)]! p-0!">
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('overview.appInfo.workflowLaunchHiddenInputs.title', { ns: 'appOverview' })}
</DialogTitle>
<DialogDescription className="system-md-regular text-text-tertiary">
{t('overview.appInfo.workflowLaunchHiddenInputs.description', { ns: 'appOverview' })}
</DialogDescription>
</div>
<form onSubmit={onSubmit}>
<div className="space-y-4 px-6 pb-4">
{hiddenVariables.map(variable => (
<div key={variable.variable} className="space-y-1.5">
{variable.type !== InputVarType.checkbox && (
<label
htmlFor={`workflow-launch-hidden-input-${variable.variable}`}
className="block system-sm-medium text-text-secondary"
>
{typeof variable.label === 'string' ? variable.label : variable.variable}
</label>
)}
{renderField(variable)}
</div>
))}
</div>
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4">
<Button onClick={() => onOpenChange(false)}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button type="submit" variant="primary">
{t('overview.appInfo.launch', { ns: 'appOverview' })}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
export const createAppCardOperations = ({
operationKeys,
t,
@ -251,20 +409,15 @@ export const AppCardAccessControlSection = ({
export const AppCardOperations = ({
t,
operations,
launchConfigAction,
}: {
t: TFunction
operations: AppCardOperation[]
launchConfigAction?: LaunchConfigAction
}) => (
<>
{operations.map(({ key, label, Icon, disabled, onClick }) => (
<Button
className="mr-1 min-w-[88px]"
size="small"
variant="ghost"
key={key}
onClick={onClick}
disabled={disabled}
>
{operations.map(({ key, label, Icon, disabled, onClick }) => {
const buttonContent = (
<MaybeTooltip
content={t('overview.appInfo.preUseReminder', { ns: 'appOverview' }) ?? ''}
tooltipClassName="mt-[-8px]"
@ -275,8 +428,72 @@ export const AppCardOperations = ({
<div className={`${disabled ? 'text-components-button-ghost-text-disabled' : 'text-text-tertiary'} px-[3px] system-xs-medium`}>{label}</div>
</div>
</MaybeTooltip>
</Button>
))}
)
if (key === 'launch' && launchConfigAction) {
return (
<MaybeTooltip
key={key}
content={t('overview.appInfo.preUseReminder', { ns: 'appOverview' }) ?? ''}
tooltipClassName="mt-[-8px]"
show={disabled}
>
<Button
className="mr-1 border-0 px-0 py-0 shadow-none backdrop-blur-none hover:bg-components-button-secondary-bg"
size="small"
variant="secondary"
onClick={onClick}
disabled={disabled}
>
<div className="flex h-full min-w-[88px] items-center justify-center rounded-l-md px-2 hover:bg-components-button-secondary-bg-hover">
<div className="flex items-center justify-center gap-px">
<Icon className="h-3.5 w-3.5" />
<div className="px-[3px] system-xs-medium">{label}</div>
</div>
</div>
<div
aria-hidden="true"
className="h-4 w-px shrink-0 bg-divider-regular opacity-100"
/>
<div
className="flex h-full w-8 shrink-0 items-center justify-center rounded-r-md hover:bg-components-button-secondary-bg-hover"
onClick={(event) => {
event.stopPropagation()
launchConfigAction.onClick()
}}
aria-label={launchConfigAction.label}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={(event) => {
if (disabled)
return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
launchConfigAction.onClick()
}
}}
>
<RiEqualizer2Line className="h-3.5 w-3.5" />
</div>
</Button>
</MaybeTooltip>
)
}
return (
<Button
className="mr-1 min-w-[88px]"
size="small"
variant="ghost"
key={key}
onClick={onClick}
disabled={disabled}
>
{buttonContent}
</Button>
)
})}
</>
)

View File

@ -1,6 +1,7 @@
import type { InputVar } from '@/app/components/workflow/types'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/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'
@ -8,6 +9,11 @@ import { basePath } from '@/utils/var'
type OverviewCardType = 'api' | 'webapp'
export type OverviewOperationKey = 'launch' | 'embedded' | 'customize' | 'settings' | 'develop'
export type WorkflowLaunchInputValue = string | boolean
export type WorkflowHiddenStartVariable = Pick<
InputVar,
'default' | 'hide' | 'label' | 'max_length' | 'options' | 'required' | 'type' | 'variable'
>
type AppInfo = AppDetailResponse & Partial<AppSSO>
@ -16,6 +22,7 @@ type WorkflowLike = {
nodes?: Array<{
data?: {
type?: string
variables?: InputVar[]
}
}>
}
@ -42,10 +49,84 @@ const getCardAppMode = (mode: AppModeEnum) => {
return (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : mode
}
const SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES = new Set<InputVarType>([
InputVarType.textInput,
InputVarType.paragraph,
InputVarType.select,
InputVarType.number,
InputVarType.checkbox,
InputVarType.json,
InputVarType.jsonObject,
InputVarType.url,
])
const coerceWorkflowLaunchDefaultValue = (variable: WorkflowHiddenStartVariable): WorkflowLaunchInputValue => {
if (variable.type === InputVarType.checkbox) {
if (typeof variable.default === 'boolean')
return variable.default
return String(variable.default).toLowerCase() === 'true'
}
if (typeof variable.default === 'number')
return String(variable.default)
return String(variable.default ?? '')
}
export const hasWorkflowStartNode = (currentWorkflow: WorkflowLike) => {
return currentWorkflow?.graph?.nodes?.some(node => node.data?.type === BlockEnum.Start) ?? false
}
export const getWorkflowHiddenStartVariables = (currentWorkflow: WorkflowLike): WorkflowHiddenStartVariable[] => {
const startNode = currentWorkflow?.graph?.nodes?.find(node => node.data?.type === BlockEnum.Start)
return (startNode?.data?.variables ?? []).filter(variable => variable.hide === true)
}
export const getAppHiddenLaunchVariables = ({
appInfo,
currentWorkflow,
}: {
appInfo: AppInfo
currentWorkflow: WorkflowLike
}) => {
if ([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT].includes(appInfo.mode))
return getWorkflowHiddenStartVariables(currentWorkflow)
}
export const isWorkflowLaunchInputSupported = (variable: WorkflowHiddenStartVariable) => {
return SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES.has(variable.type)
}
export const createWorkflowLaunchInitialValues = (variables: WorkflowHiddenStartVariable[]) => {
return variables.reduce<Record<string, WorkflowLaunchInputValue>>((acc, variable) => {
acc[variable.variable] = coerceWorkflowLaunchDefaultValue(variable)
return acc
}, {})
}
export const buildWorkflowLaunchUrl = async ({
accessibleUrl,
variables,
values,
}: {
accessibleUrl: string
variables: WorkflowHiddenStartVariable[]
values: Record<string, WorkflowLaunchInputValue>
}) => {
const targetUrl = new URL(accessibleUrl, window.location.origin)
variables.forEach((variable) => {
const rawValue = values[variable.variable]
const serializedValue = variable.type === InputVarType.checkbox
? String(Boolean(rawValue))
: String(rawValue ?? '')
targetUrl.searchParams.set(variable.variable, serializedValue)
})
return targetUrl.toString()
}
export const getAppCardDisplayState = ({
appInfo,
cardType,

View File

@ -1,4 +1,5 @@
'use client'
import type { WorkflowLaunchInputValue } from './app-card-utils'
import type { ConfigParams } from './settings'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
@ -27,11 +28,16 @@ import {
AppCardOperations,
AppCardUrlSection,
createAppCardOperations,
WorkflowLaunchDialog,
} from './app-card-sections'
import {
buildWorkflowLaunchUrl,
createWorkflowLaunchInitialValues,
getAppCardDisplayState,
getAppCardOperationKeys,
getAppHiddenLaunchVariables,
isAppAccessConfigured,
isWorkflowLaunchInputSupported,
} from './app-card-utils'
export type IAppCardProps = {
@ -62,7 +68,8 @@ function AppCard({
const router = useRouter()
const pathname = usePathname()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '')
const shouldFetchWorkflow = appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT
const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '')
const docLink = useDocLink()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
@ -72,6 +79,8 @@ function AppCard({
const [genLoading, setGenLoading] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showAccessControl, setShowAccessControl] = useState(false)
const [showWorkflowLaunchDialog, setShowWorkflowLaunchDialog] = useState(false)
const [workflowLaunchValues, setWorkflowLaunchValues] = useState<Record<string, WorkflowLaunchInputValue>>({})
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { data: appAccessSubjects } = useAppWhiteListSubjects(
@ -97,6 +106,25 @@ function AppCard({
() => isAppAccessConfigured(appDetail, appAccessSubjects),
[appAccessSubjects, appDetail],
)
const hiddenLaunchVariables = useMemo(
() => getAppHiddenLaunchVariables({
appInfo,
currentWorkflow,
}) || [],
[appInfo, currentWorkflow],
)
const supportedWorkflowLaunchVariables = useMemo(
() => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported),
[hiddenLaunchVariables],
)
const unsupportedWorkflowLaunchVariables = useMemo(
() => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)),
[hiddenLaunchVariables],
)
const initialWorkflowLaunchValues = useMemo(
() => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables),
[supportedWorkflowLaunchVariables],
)
const onGenCode = async () => {
if (!onGenerateCode)
@ -138,6 +166,31 @@ function AppCard({
window.open(cardState.accessibleUrl, '_blank')
}, [cardState.accessibleUrl])
const handleOpenWorkflowLaunchDialog = useCallback(() => {
setWorkflowLaunchValues(initialWorkflowLaunchValues)
setShowWorkflowLaunchDialog(true)
}, [initialWorkflowLaunchValues])
const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => {
setWorkflowLaunchValues(prev => ({
...prev,
[variable]: value,
}))
}, [])
const handleWorkflowLaunchConfirm = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const targetUrl = await buildWorkflowLaunchUrl({
accessibleUrl: cardState.accessibleUrl,
variables: supportedWorkflowLaunchVariables,
values: workflowLaunchValues,
})
window.open(targetUrl, '_blank')
setShowWorkflowLaunchDialog(false)
}, [cardState.accessibleUrl, supportedWorkflowLaunchVariables, workflowLaunchValues])
const handleOpenCustomize = useCallback(() => {
setShowCustomizeModal(true)
}, [])
@ -279,7 +332,17 @@ function AppCard({
{!cardState.isMinimalState && (
<div className="flex items-center gap-1 self-stretch p-3">
{!isApp && <SecretKeyButton appId={appInfo.id} />}
<AppCardOperations t={t} operations={operations} />
<AppCardOperations
t={t}
operations={operations}
launchConfigAction={hiddenLaunchVariables.length > 0
? {
label: t('operation.config', { ns: 'common' }),
disabled: triggerModeDisabled || !cardState.runningStatus,
onClick: handleOpenWorkflowLaunchDialog,
}
: undefined}
/>
</div>
)}
</div>
@ -299,6 +362,16 @@ function AppCard({
onSaveSiteConfig={onSaveSiteConfig}
onConfirmAccessControl={handleAccessControlUpdate}
/>
<WorkflowLaunchDialog
t={t}
open={showWorkflowLaunchDialog}
hiddenVariables={supportedWorkflowLaunchVariables}
unsupportedVariables={unsupportedWorkflowLaunchVariables}
values={workflowLaunchValues}
onOpenChange={setShowWorkflowLaunchDialog}
onValueChange={handleWorkflowLaunchValueChange}
onSubmit={handleWorkflowLaunchConfirm}
/>
</div>
)
}

View File

@ -321,47 +321,86 @@ describe('chat utils - url params and answer helpers', () => {
expect(res).toEqual({ custom: '123', encoded: 'a b' })
})
it('getRawInputsFromUrlParams keeps encoded launch params as decoded plain values', async () => {
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}`)
const res = await getRawInputsFromUrlParams()
expect(res).toEqual({ custom: 'YWJjZA==' })
})
it('getRawUserVariablesFromUrlParams extracts only user. prefixed params', async () => {
setSearch('?custom=123&sys.param=456&user.param=789&user.encoded=a%20b')
const res = await getRawUserVariablesFromUrlParams()
expect(res).toEqual({ param: '789', encoded: 'a b' })
})
it('getRawUserVariablesFromUrlParams keeps encoded user values as decoded plain values', async () => {
setSearch(`?user.param=${encodeURIComponent('YWJjZA==')}`)
const res = await getRawUserVariablesFromUrlParams()
expect(res).toEqual({ param: 'YWJjZA==' })
})
it('getProcessedInputsFromUrlParams decompresses base64 inputs', async () => {
setSearch('?custom=123&sys.param=456&user.param=789')
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}&sys.param=456&user.param=789`)
const res = await getProcessedInputsFromUrlParams()
expect(res).toEqual({ custom: 'decompressed_text' })
})
it('getProcessedInputsFromUrlParams returns undefined for plain decoded values', async () => {
vi.stubGlobal('atob', () => {
throw new Error('invalid')
})
setSearch('?custom=a%20b')
const res = await getProcessedInputsFromUrlParams()
expect(res).toEqual({ custom: undefined })
})
it('getProcessedSystemVariablesFromUrlParams decompresses sys. prefixed params', async () => {
setSearch('?custom=123&sys.param=456&user.param=789')
setSearch(`?custom=123&sys.param=${encodeURIComponent('YWJjZA==')}&user.param=789`)
const res = await getProcessedSystemVariablesFromUrlParams()
expect(res).toEqual({ param: 'decompressed_text' })
})
it('getProcessedSystemVariablesFromUrlParams returns undefined for plain decoded values', async () => {
vi.stubGlobal('atob', () => {
throw new Error('invalid')
})
setSearch('?sys.param=a%20b')
const res = await getProcessedSystemVariablesFromUrlParams()
expect(res).toEqual({ param: undefined })
})
it('getProcessedSystemVariablesFromUrlParams parses redirect_url without query string', async () => {
setSearch(`?redirect_url=${encodeURIComponent('http://example.com')}&sys.param=456`)
setSearch(`?redirect_url=${encodeURIComponent('http://example.com')}&sys.param=${encodeURIComponent('YWJjZA==')}`)
const res = await getProcessedSystemVariablesFromUrlParams()
expect(res).toEqual({ param: 'decompressed_text' })
})
it('getProcessedSystemVariablesFromUrlParams parses redirect_url', async () => {
setSearch(`?redirect_url=${encodeURIComponent('http://example.com?sys.redirected=abc')}&sys.param=456`)
setSearch(`?redirect_url=${encodeURIComponent(`http://example.com?sys.redirected=${encodeURIComponent('YWJjZA==')}`)}&sys.param=${encodeURIComponent('YWJjZA==')}`)
const res = await getProcessedSystemVariablesFromUrlParams()
expect(res).toEqual({ param: 'decompressed_text', redirected: 'decompressed_text' })
})
it('getProcessedUserVariablesFromUrlParams decompresses user. prefixed params', async () => {
setSearch('?custom=123&sys.param=456&user.param=789')
setSearch(`?custom=123&sys.param=456&user.param=${encodeURIComponent('YWJjZA==')}`)
const res = await getProcessedUserVariablesFromUrlParams()
expect(res).toEqual({ param: 'decompressed_text' })
})
it('getProcessedUserVariablesFromUrlParams returns undefined for plain decoded values', async () => {
vi.stubGlobal('atob', () => {
throw new Error('invalid')
})
setSearch('?user.param=a%20b')
const res = await getProcessedUserVariablesFromUrlParams()
expect(res).toEqual({ param: undefined })
})
it('decodeBase64AndDecompress failure returns undefined softly', async () => {
vi.stubGlobal('atob', () => {
throw new Error('invalid')
})
setSearch('?custom=invalid_base64')
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}`)
const res = await getProcessedInputsFromUrlParams()
expect(res).toEqual({ custom: undefined })
})

View File

@ -54,11 +54,11 @@ vi.mock('@/context/web-app-context', () => ({
}))
const {
mockGetProcessedInputsFromUrlParams,
mockGetRawInputsFromUrlParams,
mockGetProcessedSystemVariablesFromUrlParams,
mockGetProcessedUserVariablesFromUrlParams,
} = vi.hoisted(() => ({
mockGetProcessedInputsFromUrlParams: vi.fn(),
mockGetRawInputsFromUrlParams: vi.fn(),
mockGetProcessedSystemVariablesFromUrlParams: vi.fn(),
mockGetProcessedUserVariablesFromUrlParams: vi.fn(),
}))
@ -67,7 +67,7 @@ vi.mock('../../utils', async () => {
const actual = await vi.importActual<typeof import('../../utils')>('../../utils')
return {
...actual,
getProcessedInputsFromUrlParams: mockGetProcessedInputsFromUrlParams,
getRawInputsFromUrlParams: mockGetRawInputsFromUrlParams,
getProcessedSystemVariablesFromUrlParams: mockGetProcessedSystemVariablesFromUrlParams,
getProcessedUserVariablesFromUrlParams: mockGetProcessedUserVariablesFromUrlParams,
}
@ -152,7 +152,7 @@ describe('useEmbeddedChatbot', () => {
beforeEach(() => {
vi.clearAllMocks()
// Re-establish default mock implementations after clearAllMocks
mockGetProcessedInputsFromUrlParams.mockResolvedValue({})
mockGetRawInputsFromUrlParams.mockResolvedValue({})
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({})
mockGetProcessedUserVariablesFromUrlParams.mockResolvedValue({})
localStorage.removeItem(CONVERSATION_ID_INFO)
@ -409,7 +409,7 @@ describe('useEmbeddedChatbot', () => {
}
it('should map various types properly with max_length truncation when defaults supplied via URL', async () => {
mockGetProcessedInputsFromUrlParams.mockResolvedValue({
mockGetRawInputsFromUrlParams.mockResolvedValue({
p1: 'toolongparagraph', // truncated to 5
n1: '99',
c1: true,
@ -422,7 +422,7 @@ describe('useEmbeddedChatbot', () => {
// Wait for the mock to be called
await waitFor(() => {
expect(mockGetProcessedInputsFromUrlParams).toHaveBeenCalled()
expect(mockGetRawInputsFromUrlParams).toHaveBeenCalled()
})
await waitFor(() => {
@ -623,7 +623,7 @@ describe('useEmbeddedChatbot', () => {
{ checkbox: { variable: 'c1', default: false } },
],
} as unknown as ChatConfig
mockGetProcessedInputsFromUrlParams.mockResolvedValue({
mockGetRawInputsFromUrlParams.mockResolvedValue({
n1: 'not-a-number',
c1: 'true',
})
@ -640,7 +640,7 @@ describe('useEmbeddedChatbot', () => {
{ select: { variable: 's1', options: ['A'], default: 'A' } },
],
} as unknown as ChatConfig
mockGetProcessedInputsFromUrlParams.mockResolvedValue({
mockGetRawInputsFromUrlParams.mockResolvedValue({
s1: 'INVALID',
})

View File

@ -19,11 +19,13 @@ async function getRawInputsFromUrlParams(): Promise<Record<string, any>> {
const urlParams = new URLSearchParams(window.location.search)
const inputs: Record<string, any> = {}
const entriesArray = Array.from(urlParams.entries())
entriesArray.forEach(([key, value]) => {
await Promise.all(entriesArray.map(async ([key, value]) => {
const prefixArray = ['sys.', 'user.']
if (!prefixArray.some(prefix => key.startsWith(prefix)))
inputs[key] = decodeURIComponent(value)
})
if (prefixArray.some(prefix => key.startsWith(prefix)))
return
inputs[key] = decodeURIComponent(value)
}))
return inputs
}
@ -81,10 +83,12 @@ async function getRawUserVariablesFromUrlParams(): Promise<Record<string, any>>
const urlParams = new URLSearchParams(window.location.search)
const userVariables: Record<string, any> = {}
const entriesArray = Array.from(urlParams.entries())
entriesArray.forEach(([key, value]) => {
if (key.startsWith('user.'))
userVariables[key.slice(5)] = decodeURIComponent(value)
})
await Promise.all(entriesArray.map(async ([key, value]) => {
if (!key.startsWith('user.'))
return
userVariables[key.slice(5)] = decodeURIComponent(value)
}))
return userVariables
}

View File

@ -5,6 +5,7 @@ import { useTextGenerationAppState } from '../use-text-generation-app-state'
const {
changeLanguageMock,
fetchSavedMessageMock,
getRawInputsFromUrlParamsMock,
notifyMock,
removeMessageMock,
saveMessageMock,
@ -13,6 +14,7 @@ const {
} = vi.hoisted(() => ({
changeLanguageMock: vi.fn(() => Promise.resolve()),
fetchSavedMessageMock: vi.fn(),
getRawInputsFromUrlParamsMock: vi.fn(),
notifyMock: vi.fn(),
removeMessageMock: vi.fn(),
saveMessageMock: vi.fn(),
@ -44,6 +46,10 @@ vi.mock('@/i18n-config/client', () => ({
changeLanguage: changeLanguageMock,
}))
vi.mock('@/app/components/base/chat/utils', () => ({
getRawInputsFromUrlParams: getRawInputsFromUrlParamsMock,
}))
vi.mock('@/service/share', async () => {
const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share')
return {
@ -181,6 +187,7 @@ describe('useTextGenerationAppState', () => {
})
removeMessageMock.mockResolvedValue(undefined)
saveMessageMock.mockResolvedValue(undefined)
getRawInputsFromUrlParamsMock.mockResolvedValue({})
})
it('should initialize app state and fetch saved messages for non-workflow web apps', async () => {
@ -301,4 +308,57 @@ describe('useTextGenerationAppState', () => {
enable: false,
}))
})
it('should apply workflow launch inputs from the url to hidden prompt variables', async () => {
mockWebAppState.appParams = {
...defaultAppParams,
user_input_form: [
{
'text-input': {
label: 'Visible',
variable: 'visible',
required: true,
max_length: 48,
default: 'Shown',
hide: false,
},
},
{
'text-input': {
label: 'Hidden Secret',
variable: 'secret',
required: true,
max_length: 48,
default: '',
hide: true,
},
},
],
}
getRawInputsFromUrlParamsMock.mockResolvedValue({
secret: 'prefilled-secret',
})
const { result } = renderHook(() => useTextGenerationAppState({
isInstalledApp: false,
isWorkflow: true,
}))
await waitFor(() => {
expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([
expect.objectContaining({
key: 'visible',
default: 'Shown',
}),
expect.objectContaining({
key: 'secret',
hide: true,
default: 'prefilled-secret',
}),
]))
})
expect(getRawInputsFromUrlParamsMock).toHaveBeenCalled()
expect(fetchSavedMessageMock).not.toHaveBeenCalled()
})
})

View File

@ -4,6 +4,7 @@ import type { SiteInfo } from '@/models/share'
import type { VisionSettings } from '@/types/app'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getRawInputsFromUrlParams } from '@/app/components/base/chat/utils'
import { toast } from '@/app/components/base/ui/toast'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWebAppStore } from '@/context/web-app-context'
@ -30,6 +31,44 @@ type ShareAppParams = {
image_file_size_limit?: number
}
}
const coerceWorkflowUrlDefault = (
promptVariable: NonNullable<PromptConfig['prompt_variables']>[number],
rawValue: unknown,
) => {
if (rawValue === undefined || rawValue === null)
return undefined
if (promptVariable.type === 'checkbox') {
if (typeof rawValue === 'boolean')
return rawValue
const normalized = String(rawValue).toLowerCase()
if (normalized === 'true')
return true
if (normalized === 'false')
return false
return undefined
}
if (promptVariable.type === 'number') {
const numericValue = Number(rawValue)
return Number.isNaN(numericValue) ? undefined : numericValue
}
if (typeof rawValue !== 'string')
return undefined
if (promptVariable.type === 'select')
return promptVariable.options?.includes(rawValue) ? rawValue : undefined
if (promptVariable.max_length)
return rawValue.slice(0, promptVariable.max_length)
return rawValue
}
export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTextGenerationAppStateOptions) => {
const { t } = useTranslation()
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
@ -83,6 +122,15 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex
setCustomConfig((custom_config || null) as TextGenerationCustomConfig | null)
await changeLanguage(site.default_language)
const { user_input_form, more_like_this, file_upload, text_to_speech } = appParams as unknown as ShareAppParams
const promptVariables = userInputsFormToPromptVariables(user_input_form)
if (isWorkflow && !isInstalledApp) {
const workflowUrlInputs = await getRawInputsFromUrlParams()
promptVariables.forEach((promptVariable) => {
const workflowDefault = coerceWorkflowUrlDefault(promptVariable, workflowUrlInputs[promptVariable.key])
if (workflowDefault !== undefined)
promptVariable.default = workflowDefault
})
}
if (cancelled)
return
setVisionConfig({
@ -93,7 +141,7 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex
} as VisionSettings)
setPromptConfig({
prompt_template: '',
prompt_variables: userInputsFormToPromptVariables(user_input_form),
prompt_variables: promptVariables,
} as PromptConfig)
setMoreLikeThisConfig(more_like_this)
setTextToSpeechConfig(text_to_speech)
@ -104,7 +152,7 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex
return () => {
cancelled = true
}
}, [appData, appParams, fetchSavedMessages, isWorkflow])
}, [appData, appParams, fetchSavedMessages, isInstalledApp, isWorkflow])
useDocumentTitle(siteInfo?.title || t('generation.title', { ns: 'share' }))
useAppFavicon({
enable: !isInstalledApp,

View File

@ -213,7 +213,7 @@ export type InputVar = {
}
variable: string
max_length?: number
default?: string | number
default?: string | number | boolean
required: boolean
hint?: string
options?: string[]

View File

@ -104,6 +104,8 @@
"overview.appInfo.settings.workflow.subTitle": "Workflow Details",
"overview.appInfo.settings.workflow.title": "Workflow",
"overview.appInfo.title": "Web App",
"overview.appInfo.workflowLaunchHiddenInputs.description": "Please complete the hidden fields before opening the web app.",
"overview.appInfo.workflowLaunchHiddenInputs.title": "Hidden fields",
"overview.disableTooltip.triggerMode": "The {{feature}} feature is not supported in Trigger Node mode.",
"overview.status.disable": "Disabled",
"overview.status.running": "In Service",

View File

@ -104,6 +104,8 @@
"overview.appInfo.settings.workflow.subTitle": "工作流详情",
"overview.appInfo.settings.workflow.title": "工作流",
"overview.appInfo.title": "Web App",
"overview.appInfo.workflowLaunchHiddenInputs.description": "打开 web app 前,请先填写隐藏字段。",
"overview.appInfo.workflowLaunchHiddenInputs.title": "隐藏字段",
"overview.disableTooltip.triggerMode": "触发节点模式下不支持{{feature}}功能。",
"overview.status.disable": "已停用",
"overview.status.running": "运行中",

View File

@ -52,7 +52,7 @@ export type PromptVariable = {
key: string
name: string
type: string // "string" | "number" | "select",
default?: string | number
default?: string | number | boolean
required?: boolean
options?: string[]
max_length?: number