mirror of
https://github.com/langgenius/dify.git
synced 2026-04-21 03:07:39 +08:00
feat: workflow support get params from url and add set url config modal
This commit is contained in:
@ -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">
|
||||
|
||||
@ -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' })}
|
||||
/>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
|
||||
@ -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',
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[]
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "运行中",
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user