refactor(web): consume generated app detail contracts (#42982)

This commit is contained in:
yyh
2026-09-28 04:35:06 +00:00
committed by GitHub
parent f3107fee3f
commit f95edc85f5
122 changed files with 2521 additions and 1732 deletions

View File

@@ -435,7 +435,7 @@
"count": 1
},
"typescript/no-explicit-any": {
"count": 11
"count": 4
}
},
"web/app/components/app/configuration/debug/types.ts": {
@@ -450,7 +450,7 @@
},
"web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts": {
"typescript/no-explicit-any": {
"count": 52
"count": 48
}
},
"web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx": {
@@ -4286,7 +4286,7 @@
},
"web/models/debug.ts": {
"typescript/no-explicit-any": {
"count": 4
"count": 3
}
},
"web/models/log.ts": {
@@ -4381,7 +4381,7 @@
"count": 1
},
"typescript/no-explicit-any": {
"count": 3
"count": 1
}
},
"web/service/base.spec.ts": {

View File

@@ -1,13 +1,14 @@
import type { App } from '@/types/app'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import { act, screen, waitFor } from '@testing-library/react'
import { useStore } from '@/app/components/app/store'
import { fetchAppDetailDirect } from '@/service/apps'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
import AppDetailLayout from '../layout-main'
const mockReplace = vi.fn()
const mockAppResponse = vi.fn()
let mockPathname = '/app/app-1/workflow'
let mockIsRbacEnabled = true
const mockConsoleState = vi.hoisted(() => ({
@@ -31,8 +32,12 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
vi.mock('@/next/navigation', () => mockNavigation)
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: vi.fn(),
vi.mock('@/service/base', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/base')>()),
request: async (url: string) => {
const response = await mockAppResponse(url)
return response instanceof Response ? response : Response.json(response)
},
}))
vi.mock('@/context/workspace-state', async () => {
@@ -46,20 +51,15 @@ vi.mock('@/context/permission-state', async () => {
const mockUsePathname = mockNavigation.usePathname
const mockUseRouter = mockNavigation.useRouter
const mockFetchAppDetailDirect = vi.mocked(fetchAppDetailDirect)
type AppDetailFixture = App & {
bound_agent_id?: string | null
}
const createAppDetail = (overrides: Partial<AppDetailFixture> = {}) =>
({
const createAppDetail = (overrides: Partial<AppDetailWithSite> = {}) =>
createAppDetailFixture({
id: 'app-1',
name: 'Demo App',
mode: AppModeEnum.WORKFLOW,
permission_keys: [AppACLPermission.ViewLayout, AppACLPermission.Monitor],
...overrides,
}) as AppDetailFixture
})
const waitForAppContent = async () => {
await waitFor(() => {
@@ -82,7 +82,7 @@ describe('AppDetailLayout', () => {
mockUseRouter.mockReturnValue({
replace: mockReplace,
})
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail())
mockAppResponse.mockResolvedValue(createAppDetail())
useStore.getState().setAppDetail()
})
@@ -98,7 +98,7 @@ describe('AppDetailLayout', () => {
['/app/app-1/access-config', 'navigation.settings.resourceAccess', AppModeEnum.WORKFLOW],
])('identifies the current detail page for %s', async (pathname, pageTitle, mode) => {
mockPathname = pathname
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({
mode,
permission_keys: Object.values(AppACLPermission),
@@ -134,10 +134,24 @@ describe('AppDetailLayout', () => {
await waitFor(() => {
expect(document.title).toBe('common.appMenus.promptEng · Renamed App - Dify')
})
expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1)
expect(mockAppResponse).toHaveBeenCalledTimes(1)
})
})
it('redirects a deleted app after the generated transport returns not found', async () => {
mockAppResponse.mockResolvedValue(Response.json({ message: 'App not found' }, { status: 404 }))
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/apps'))
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
expect(useStore.getState().appDetail).toBeUndefined()
})
it('should keep app detail data when navigating between pages in the same app', async () => {
const { rerender, unmount } = render(
<AppDetailLayout appId="app-1">
@@ -145,7 +159,8 @@ describe('AppDetailLayout', () => {
</AppDetailLayout>,
)
await waitForAppContent()
expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1)
expect(mockAppResponse).toHaveBeenCalledWith(expect.stringMatching(/\/apps\/app-1$/))
expect(mockAppResponse).toHaveBeenCalledTimes(1)
mockPathname = '/app/app-1/logs'
rerender(
@@ -155,7 +170,7 @@ describe('AppDetailLayout', () => {
)
await waitForAppContent()
expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1)
expect(mockAppResponse).toHaveBeenCalledTimes(1)
expect(useStore.getState().appDetail?.id).toBe('app-1')
unmount()
@@ -166,7 +181,7 @@ describe('AppDetailLayout', () => {
)
await waitForAppContent()
expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1)
expect(mockAppResponse).toHaveBeenCalledTimes(1)
expect(useStore.getState().appDetail?.id).toBe('app-1')
})
@@ -184,7 +199,7 @@ describe('AppDetailLayout', () => {
it('should redirect restricted app pages before exposing app detail content', async () => {
mockPathname = '/app/app-1/logs'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }),
)
@@ -203,7 +218,7 @@ describe('AppDetailLayout', () => {
it('should redirect logs pages when log and annotation access is missing', async () => {
mockPathname = '/app/app-1/logs'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.Monitor] }),
)
@@ -222,7 +237,7 @@ describe('AppDetailLayout', () => {
it('should allow users with log and annotation access to open logs directly', async () => {
mockPathname = '/app/app-1/logs'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.LogAndAnnotation] }),
)
@@ -240,7 +255,7 @@ describe('AppDetailLayout', () => {
it('should allow users with Access Point view permission to open the page directly', async () => {
mockPathname = '/app/app-1/access-point'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.AccessPointView] }),
)
@@ -258,7 +273,7 @@ describe('AppDetailLayout', () => {
it('should redirect access point pages when view permission is missing', async () => {
mockPathname = '/app/app-1/access-point'
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] }))
mockAppResponse.mockResolvedValue(createAppDetail({ permission_keys: [] }))
render(
<AppDetailLayout appId="app-1">
@@ -287,12 +302,12 @@ describe('AppDetailLayout', () => {
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/apps')
})
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
expect(mockAppResponse).not.toHaveBeenCalled()
})
it('should redirect deploy pages when app deploy ACL permission is missing', async () => {
mockPathname = '/app/app-1/deploy'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }),
)
@@ -311,7 +326,7 @@ describe('AppDetailLayout', () => {
it('should allow users with app deploy ACL permission to open deploy directly', async () => {
mockPathname = '/app/app-1/deploy'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.Deploy] }),
)
@@ -344,7 +359,7 @@ describe('AppDetailLayout', () => {
it('should redirect workflow pages when layout access is missing', async () => {
mockPathname = '/app/app-1/workflow'
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] }))
mockAppResponse.mockResolvedValue(createAppDetail({ permission_keys: [] }))
render(
<AppDetailLayout appId="app-1">
@@ -361,7 +376,7 @@ describe('AppDetailLayout', () => {
it('should redirect overview pages when monitor access is missing', async () => {
mockPathname = '/app/app-1/overview'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }),
)
@@ -381,7 +396,7 @@ describe('AppDetailLayout', () => {
it('should wait for workspace permission keys before redirecting restricted pages', async () => {
mockConsoleState.isLoadingWorkspacePermissionKeys = true
mockPathname = '/app/app-1/overview'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }),
)
@@ -392,7 +407,7 @@ describe('AppDetailLayout', () => {
)
await waitFor(() => {
expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1)
expect(mockAppResponse).toHaveBeenCalledTimes(1)
})
expect(mockReplace).not.toHaveBeenCalled()
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
@@ -411,7 +426,7 @@ describe('AppDetailLayout', () => {
it('should allow users with monitor access to open overview directly', async () => {
mockPathname = '/app/app-1/overview'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.Monitor] }),
)
@@ -429,7 +444,7 @@ describe('AppDetailLayout', () => {
it('should redirect access config pages when access config access is missing', async () => {
mockPathname = '/app/app-1/access-config'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }),
)
@@ -448,7 +463,7 @@ describe('AppDetailLayout', () => {
it('should allow users with access config access to open access config directly', async () => {
mockPathname = '/app/app-1/access-config'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({ permission_keys: [AppACLPermission.AccessConfig] }),
)
@@ -466,7 +481,7 @@ describe('AppDetailLayout', () => {
it('should redirect Agent app access config URLs to the Agent configure page', async () => {
mockPathname = '/app/app-1/access-config'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({
mode: AppModeEnum.AGENT,
bound_agent_id: 'agent-1',
@@ -507,13 +522,13 @@ describe('AppDetailLayout', () => {
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/agents/agent-1/configure')
})
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
expect(mockAppResponse).not.toHaveBeenCalled()
})
it('should redirect access config pages when RBAC is disabled', async () => {
mockIsRbacEnabled = false
mockPathname = '/app/app-1/access-config'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({
permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.AccessPointView],
}),
@@ -534,7 +549,7 @@ describe('AppDetailLayout', () => {
it('should redirect annotation pages when log and annotation access is missing', async () => {
mockPathname = '/app/app-1/annotations'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({
mode: AppModeEnum.CHAT,
permission_keys: [AppACLPermission.Monitor],
@@ -556,7 +571,7 @@ describe('AppDetailLayout', () => {
it('should allow users with log and annotation access to open annotations directly', async () => {
mockPathname = '/app/app-1/annotations'
mockFetchAppDetailDirect.mockResolvedValue(
mockAppResponse.mockResolvedValue(
createAppDetail({
mode: AppModeEnum.CHAT,
permission_keys: [AppACLPermission.LogAndAnnotation],

View File

@@ -1,6 +1,7 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import type { App } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
@@ -19,7 +20,7 @@ import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePathname, useRouter } from '@/next/navigation'
import { fetchAppDetailDirect } from '@/service/apps'
import { consoleClient } from '@/service/console'
import { AppModeEnum } from '@/types/app'
import { getRedirectionPath } from '@/utils/app-redirection'
import { getAppACLCapabilities } from '@/utils/permission'
@@ -73,7 +74,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
})),
)
const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false)
const [appDetailRes, setAppDetailRes] = useState<App | null>(null)
const [appDetailRes, setAppDetailRes] = useState<AppDetailWithSite | null>(null)
const routeAppDetail =
appDetail?.id === appId ? appDetail : appDetailRes?.id === appId ? appDetailRes : null
const pageTitle = appDetailPageTitle(pathname, t)
@@ -113,8 +114,9 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
void Promise.resolve().then(() => {
if (!ignore) setIsLoadingAppDetail(true)
})
fetchAppDetailDirect({ url: '/apps', id: appId })
.then((res: App) => {
consoleClient.apps.byAppId
.get({ params: { app_id: appId } })
.then((res: AppDetailWithSite) => {
if (ignore) return
setAppDetailRes(res)

View File

@@ -1,10 +1,12 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import { screen } from '@testing-library/react'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
import AppDetailSection from '../app-detail-section'
let mockAppMode = 'chat'
let mockAppMode: AppDetailWithSite['mode'] = 'chat'
let mockPathname = '/app/app-1/logs'
let mockAppPermissionKeys: string[] = []
let mockIsRbacEnabled = true
@@ -26,7 +28,7 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
appDetail: {
appDetail: createAppDetailFixture({
id: 'app-1',
name: 'Test App',
mode: mockAppMode,
@@ -34,7 +36,7 @@ vi.mock('@/app/components/app/store', () => ({
icon_type: 'emoji',
icon_background: '#fff',
permission_keys: mockAppPermissionKeys,
},
}),
}),
}))
@@ -200,7 +202,7 @@ describe('AppDetailSection', () => {
).not.toBeInTheDocument()
})
it.each(['workflow', 'advanced-chat'])(
it.each(['workflow', 'advanced-chat'] as const)(
'should render deploy navigation for a %s app with app deploy ACL regardless of the legacy workspace role',
(mode) => {
// Arrange
@@ -221,12 +223,12 @@ describe('AppDetailSection', () => {
it.each([
{
label: 'the app is not a workflow app',
mode: 'chat',
mode: AppModeEnum.CHAT,
permissionKeys: [AppACLPermission.Deploy],
},
{
label: 'app deploy ACL permission is missing',
mode: 'workflow',
mode: AppModeEnum.WORKFLOW,
permissionKeys: [AppACLPermission.Monitor],
},
])('should hide deploy navigation when $label', ({ mode, permissionKeys }) => {

View File

@@ -1,9 +1,10 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import AppDetailSection from '../../app-detail-section'
let currentApp = { id: 'app-1', name: 'First app', mode: 'chat' }
let currentApp = createAppDetailFixture({ name: 'First app' })
const mockConsoleState = vi.hoisted(() => ({
current: {
userProfile: { id: 'user-1' },
@@ -66,7 +67,7 @@ vi.mock('../app-info-modals', () => ({
describe('AppInfoView identity in the app detail sidebar', () => {
beforeEach(() => {
currentApp = { id: 'app-1', name: 'First app', mode: 'chat' }
currentApp = createAppDetailFixture({ name: 'First app' })
})
it('keeps transient state for the same app and clears it when the app changes', async () => {
@@ -80,12 +81,12 @@ describe('AppInfoView identity in the app detail sidebar', () => {
expect(screen.getByText('First app: edit; secrets: 1')).toBeInTheDocument()
screen.getByRole('button', { name: 'Open First app' }).focus()
currentApp = { id: 'app-1', name: 'Renamed app', mode: 'chat' }
currentApp = createAppDetailFixture({ name: 'Renamed app' })
view.rerender(<AppDetailSection />)
expect(screen.getByText('Renamed app: edit; secrets: 1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Open Renamed app' })).toHaveFocus()
currentApp = { id: 'app-2', name: 'Second app', mode: 'chat' }
currentApp = createAppDetailFixture({ id: 'app-2', name: 'Second app' })
view.rerender(<AppDetailSection />)
expect(screen.getByRole('button', { name: 'Open Second app' })).toBeInTheDocument()
expect(screen.getByText('Second app: closed; secrets: 0')).toBeInTheDocument()

View File

@@ -1,7 +1,8 @@
import type { App } from '@/types/app'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import AppInfoModals from '../app-info-modals'
@@ -107,8 +108,8 @@ vi.mock('@/app/components/app/export-confirm-modal', () => ({
),
}))
const createAppDetail = (overrides: Partial<App> = {}): App =>
({
const createAppDetail = (overrides: Partial<AppDetailWithSite> = {}) =>
createAppDetailFixture({
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
@@ -120,7 +121,7 @@ const createAppDetail = (overrides: Partial<App> = {}): App =>
use_icon_as_answer_icon: false,
max_active_requests: null,
...overrides,
}) as App
})
const defaultProps = {
appDetail: createAppDetail(),

View File

@@ -1,9 +1,10 @@
import type { App } from '@/types/app'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { createAccountProfileQueryWrapper } from '@/test/console/account-profile'
import { render as renderWithConsoleState } from '@/test/console/render'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
import AppInfoTrigger from '../app-info-trigger'
@@ -54,8 +55,8 @@ const defaultAppPermissionKeys = [
AppACLPermission.Delete,
]
const createAppDetail = (overrides: Partial<App> = {}): App =>
({
const createAppDetail = (overrides: Partial<AppDetailWithSite> = {}) =>
createAppDetailFixture({
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
@@ -68,7 +69,7 @@ const createAppDetail = (overrides: Partial<App> = {}): App =>
permission_keys: defaultAppPermissionKeys,
maintainer: 'user-1',
...overrides,
}) as App
})
const createProps = (overrides: Partial<React.ComponentProps<typeof AppInfoTrigger>> = {}) => ({
appDetail: createAppDetail(),

View File

@@ -1,5 +1,9 @@
import { act, renderHook } from '@testing-library/react'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import { act, waitFor } from '@testing-library/react'
import { useStore } from '@/app/components/app/store'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderHookWithConsoleQuery } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { useAppInfoActions } from '../use-app-info-actions'
@@ -17,8 +21,6 @@ const toastMocks = vi.hoisted(() => {
}
})
const mockReplace = vi.fn()
const mockInvalidateQueries = vi.fn()
const mockSetAppDetail = vi.fn()
const mockUpdateAppInfo = vi.fn()
const mockCopyApp = vi.fn()
const mockExportAppDsl = vi.fn()
@@ -32,29 +34,33 @@ const mockMarkAppDeletionSucceeded = vi.fn()
const mockMarkAppDeletionFailed = vi.fn()
const mockGetSocket = vi.fn()
const mockOnAppMetaUpdate = vi.fn()
const mockSetQueryData = vi.fn()
let mockAppDetail: Record<string, unknown> | undefined = {
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
let mockAppDetail: AppDetailWithSite | undefined
const appDetailQueryKey = consoleQuery.apps.byAppId.get.queryKey({
input: { params: { app_id: 'app-1' } },
})
const appListQueryKeys = [
consoleQuery.apps.get.key(),
consoleQuery.apps.starred.get.key(),
consoleQuery.apps.recent.get.key(),
]
const renderActions = () => {
useStore.getState().setAppDetail(mockAppDetail)
const queryClient = createConsoleQueryClient()
if (mockAppDetail) queryClient.setQueryData(appDetailQueryKey, mockAppDetail)
for (const queryKey of appListQueryKeys) queryClient.setQueryData(queryKey, { data: [] })
return renderHookWithConsoleQuery(() => useAppInfoActions(), {
queryClient,
systemFeatures: { rbac_enabled: true },
})
}
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ replace: mockReplace }),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
appDetail: mockAppDetail,
setAppDetail: mockSetAppDetail,
}),
}))
vi.mock('@/app/components/app/use-export-app-dsl', () => ({
useExportAppDsl: () => ({
exportAppDsl: mockExportAppDsl,
@@ -78,27 +84,20 @@ vi.mock('@/app/notifications', () => ({
}),
}))
vi.mock('@tanstack/react-query', () => ({
queryOptions: <TOptions>(options: TOptions) => options,
useSuspenseQuery: () => ({
data: { rbac_enabled: true },
}),
useMutation: ({ mutationKey }: { mutationKey: unknown }) => ({
mutateAsync:
JSON.stringify(mutationKey) ===
JSON.stringify(consoleQuery.apps.byAppId.copy.post.mutationOptions().mutationKey)
? mockCopyApp
: mockDeleteApp,
}),
useQueryClient: () => ({
invalidateQueries: mockInvalidateQueries,
setQueryData: mockSetQueryData,
}),
}))
vi.mock('@/service/apps', () => ({
updateAppInfo: (...args: unknown[]) => mockUpdateAppInfo(...args),
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
vi.mock('@/service/base', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/base')>()),
request: async (url: string, _init: RequestInit, { request }: { request: Request }) => {
if (request.method === 'GET') return Response.json(await mockFetchAppDetail(url))
if (request.method === 'PUT')
return Response.json(await mockUpdateAppInfo(url, await request.json()))
if (request.method === 'POST' && new URL(url).pathname.endsWith('/copy'))
return Response.json(await mockCopyApp(url, await request.json()))
if (request.method === 'DELETE') {
await mockDeleteApp(url)
return new Response(null, { status: 204 })
}
throw new Error(`Unexpected request: ${request.method} ${url}`)
},
}))
vi.mock('@/service/app-deletion', () => ({
@@ -132,19 +131,19 @@ describe('useAppInfoActions', () => {
mockExportWorkflowAppDsl.mockResolvedValue({ status: 'downloaded' })
mockOnAppMetaUpdate.mockReturnValue(() => {})
mockGetSocket.mockReturnValue(null)
mockAppDetail = {
mockAppDetail = createAppDetailFixture({
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
}
})
})
describe('Initial state', () => {
it('should return initial state correctly', () => {
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
expect(result.current.appDetail).toEqual(mockAppDetail)
expect(result.current.activeModal).toBeNull()
expect(result.current.secretEnvList).toEqual([])
@@ -153,7 +152,7 @@ describe('useAppInfoActions', () => {
describe('Modal management', () => {
it('should open modal', () => {
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
act(() => {
result.current.openModal('edit')
@@ -163,7 +162,7 @@ describe('useAppInfoActions', () => {
})
it('should close modal', () => {
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
act(() => {
result.current.openModal('delete')
@@ -179,10 +178,14 @@ describe('useAppInfoActions', () => {
describe('onEdit', () => {
it('should update app info and close modal on success', async () => {
const updatedApp = { ...mockAppDetail, name: 'Updated', max_active_requests: null }
const updatedApp = createAppDetailFixture({
...mockAppDetail,
name: 'Updated',
max_active_requests: null,
})
mockUpdateAppInfo.mockResolvedValue(updatedApp)
const { result } = renderHook(() => useAppInfoActions())
const { result, queryClient } = renderActions()
await act(async () => {
await result.current.onEdit({
@@ -195,37 +198,28 @@ describe('useAppInfoActions', () => {
})
})
expect(mockUpdateAppInfo).toHaveBeenCalled()
expect(mockSetQueryData).toHaveBeenCalledWith(
consoleQuery.apps.byAppId.get.queryKey({
input: { params: { app_id: 'app-1' } },
}),
expect.any(Function),
)
const updateCachedApp = mockSetQueryData.mock.calls[0]![1]
expect(updateCachedApp({ id: 'app-1', name: 'Old name', max_active_requests: 5 })).toEqual(
expect.objectContaining({ id: 'app-1', name: 'Updated', max_active_requests: null }),
)
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.apps.get.key(),
expect(mockUpdateAppInfo).toHaveBeenCalledWith(expect.stringContaining('/apps/app-1'), {
name: 'Updated',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
description: '',
use_icon_as_answer_icon: false,
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.apps.starred.get.key(),
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.apps.recent.get.key(),
})
expect(mockSetAppDetail).toHaveBeenCalledWith(updatedApp)
expect(queryClient.getQueryData(appDetailQueryKey)).toEqual(updatedApp)
for (const queryKey of appListQueryKeys)
expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(true)
expect(useStore.getState().appDetail).toEqual(updatedApp)
expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.editDone' })
})
it('should emit app_meta_update after successful edit when collaboration socket exists', async () => {
const updatedApp = { ...mockAppDetail, name: 'Updated' }
const updatedApp = createAppDetailFixture({ ...mockAppDetail, name: 'Updated' })
const socket = { emit: vi.fn() }
mockUpdateAppInfo.mockResolvedValue(updatedApp)
mockGetSocket.mockReturnValue(socket)
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.onEdit({
@@ -237,7 +231,7 @@ describe('useAppInfoActions', () => {
use_icon_as_answer_icon: false,
})
})
await new Promise((resolve) => setTimeout(resolve, 0))
await waitFor(() => expect(socket.emit).toHaveBeenCalled())
expect(mockGetSocket).toHaveBeenCalledWith('app-1')
expect(socket.emit).toHaveBeenCalledWith(
@@ -251,7 +245,7 @@ describe('useAppInfoActions', () => {
it('should notify error on edit failure', async () => {
mockUpdateAppInfo.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.onEdit({
@@ -270,7 +264,7 @@ describe('useAppInfoActions', () => {
it('should not call updateAppInfo when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.onEdit({
@@ -291,7 +285,7 @@ describe('useAppInfoActions', () => {
it.each(['completed', 'pending'] as const)(
'should redirect only when the copy is completed (%s)',
async (status) => {
const newApp = { id: 'app-2', name: 'Copy', mode: 'chat' }
const newApp = createAppDetailFixture({ id: 'app-2', name: 'Copy' })
mockCopyApp.mockResolvedValue(
status === 'completed'
? newApp
@@ -302,7 +296,7 @@ describe('useAppInfoActions', () => {
},
)
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.onCopy({
@@ -313,9 +307,11 @@ describe('useAppInfoActions', () => {
})
})
expect(mockCopyApp).toHaveBeenCalledWith({
params: { app_id: 'app-1' },
body: { name: 'Copy', icon_type: 'emoji', icon: '🤖', icon_background: '#fff' },
expect(mockCopyApp).toHaveBeenCalledWith(expect.stringContaining('/apps/app-1/copy'), {
name: 'Copy',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
})
if (status === 'completed') {
expect(toastMocks.call).toHaveBeenCalledWith({
@@ -336,7 +332,7 @@ describe('useAppInfoActions', () => {
it('should notify error on copy failure', async () => {
mockCopyApp.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.onCopy({
@@ -358,7 +354,7 @@ describe('useAppInfoActions', () => {
it('should not call copyApp when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.onCopy({
@@ -375,7 +371,7 @@ describe('useAppInfoActions', () => {
describe('onExport', () => {
it('should export the app DSL', async () => {
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.onExport(false)
@@ -391,7 +387,7 @@ describe('useAppInfoActions', () => {
it('preserves the export failure result for the confirmation dialog', async () => {
mockExportAppDsl.mockResolvedValue({ status: 'failed' })
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await expect(result.current.onExport(true)).resolves.toBe(false)
})
@@ -401,7 +397,7 @@ describe('useAppInfoActions', () => {
it('should not export when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.onExport()
@@ -413,7 +409,7 @@ describe('useAppInfoActions', () => {
describe('exportCheck', () => {
it('should call onExport directly for non-workflow modes', async () => {
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.exportCheck()
@@ -423,9 +419,9 @@ describe('useAppInfoActions', () => {
})
it('should open export warning modal for workflow mode', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
mockAppDetail = createAppDetailFixture({ ...mockAppDetail, mode: AppModeEnum.WORKFLOW })
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.exportCheck()
@@ -435,9 +431,9 @@ describe('useAppInfoActions', () => {
})
it('should open export warning modal for advanced_chat mode', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.ADVANCED_CHAT }
mockAppDetail = createAppDetailFixture({ ...mockAppDetail, mode: AppModeEnum.ADVANCED_CHAT })
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.exportCheck()
@@ -451,7 +447,7 @@ describe('useAppInfoActions', () => {
it('should not do anything when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.exportCheck()
@@ -463,11 +459,11 @@ describe('useAppInfoActions', () => {
describe('handleConfirmExport', () => {
it('keeps the warning open after failure so the user can retry', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
mockAppDetail = createAppDetailFixture({ ...mockAppDetail, mode: AppModeEnum.WORKFLOW })
mockExportWorkflowAppDsl
.mockResolvedValueOnce({ status: 'failed' })
.mockResolvedValueOnce({ status: 'downloaded' })
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.exportCheck()
})
@@ -482,8 +478,8 @@ describe('useAppInfoActions', () => {
})
it('should export directly when no secret env variables', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
const { result } = renderHook(() => useAppInfoActions())
mockAppDetail = createAppDetailFixture({ ...mockAppDetail, mode: AppModeEnum.WORKFLOW })
const { result } = renderActions()
await act(async () => {
await result.current.handleConfirmExport()
@@ -496,14 +492,14 @@ describe('useAppInfoActions', () => {
})
it('should set secret env list when secret variables exist', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
mockAppDetail = createAppDetailFixture({ ...mockAppDetail, mode: AppModeEnum.WORKFLOW })
const secretVars = [{ value_type: 'secret', name: 'API_KEY', value: 'secret' }]
mockExportWorkflowAppDsl.mockResolvedValue({
status: 'confirmation-required',
secretEnvList: secretVars,
})
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.handleConfirmExport()
@@ -517,7 +513,7 @@ describe('useAppInfoActions', () => {
it('should not do anything when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.handleConfirmExport()
@@ -531,25 +527,25 @@ describe('useAppInfoActions', () => {
it('should delete app and redirect on success', async () => {
mockDeleteApp.mockResolvedValue({})
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.onConfirmDelete()
})
expect(mockDeleteApp).toHaveBeenCalledWith({ params: { app_id: 'app-1' } })
expect(mockDeleteApp).toHaveBeenCalledWith(expect.stringContaining('/apps/app-1'))
expect(mockMarkAppDeletionStarted).toHaveBeenCalledWith('app-1')
expect(mockMarkAppDeletionSucceeded).toHaveBeenCalledWith('app-1')
expect(mockMarkAppDeletionFailed).not.toHaveBeenCalled()
expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.appDeleted' })
expect(mockReplace).toHaveBeenCalledWith('/apps')
expect(mockSetAppDetail).toHaveBeenCalledWith()
expect(useStore.getState().appDetail).toBeUndefined()
})
it('should not delete when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.onConfirmDelete()
@@ -561,7 +557,7 @@ describe('useAppInfoActions', () => {
it('should notify error on delete failure', async () => {
mockDeleteApp.mockRejectedValue({ message: 'cannot delete' })
const { result } = renderHook(() => useAppInfoActions())
const { result } = renderActions()
await act(async () => {
await result.current.onConfirmDelete()
@@ -579,7 +575,7 @@ describe('useAppInfoActions', () => {
describe('collaboration app meta updates', () => {
it('should refresh app detail when receiving app_meta_update', async () => {
const updated = { ...mockAppDetail, name: 'Remote Updated' }
const updated = createAppDetailFixture({ ...mockAppDetail, name: 'Remote Updated' })
const unsubscribe = vi.fn()
let onUpdate: (() => Promise<void>) | undefined
@@ -589,25 +585,16 @@ describe('useAppInfoActions', () => {
})
mockFetchAppDetail.mockResolvedValue(updated)
const { unmount } = renderHook(() => useAppInfoActions())
await new Promise((resolve) => setTimeout(resolve, 0))
const { unmount, queryClient } = renderActions()
await waitFor(() => expect(onUpdate).toBeDefined())
await act(async () => {
await onUpdate?.()
})
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetQueryData).toHaveBeenCalledWith(
consoleQuery.apps.byAppId.get.queryKey({
input: { params: { app_id: 'app-1' } },
}),
expect.any(Function),
)
const updateCachedApp = mockSetQueryData.mock.calls[0]![1]
expect(updateCachedApp({ id: 'app-1', name: 'Old name' })).toEqual(
expect.objectContaining({ id: 'app-1', name: 'Remote Updated' }),
)
expect(mockSetAppDetail).toHaveBeenCalledWith(updated)
expect(mockFetchAppDetail).toHaveBeenCalledWith(expect.stringContaining('/apps/app-1'))
expect(queryClient.getQueryData(appDetailQueryKey)).toEqual(updated)
expect(useStore.getState().appDetail).toEqual(updated)
unmount()
expect(unsubscribe).toHaveBeenCalled()

View File

@@ -1,8 +1,10 @@
import type { EnvironmentVariableItemResponse } from '@dify/contracts/api/console/apps/types.gen'
import type {
AppDetailWithSite,
EnvironmentVariableItemResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type { AppInfoModalType } from './use-app-info-actions'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/types/app'
import {
AlertDialog,
AlertDialogActions,
@@ -35,7 +37,7 @@ const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-ds
})
type AppInfoModalsProps = {
appDetail: App
appDetail: AppDetailWithSite
activeModal: AppInfoModalType
closeModal: () => void
secretEnvList: EnvironmentVariableItemResponse[]

View File

@@ -1,6 +1,6 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { Operation } from './app-operations'
import type { AppInfoModalType } from './use-app-info-actions'
import type { App } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
@@ -15,7 +15,7 @@ import { getAppModeLabel } from './app-mode-labels'
import AppOperations from './app-operations'
type AppInfoTriggerProps = {
appDetail: App
appDetail: AppDetailWithSite
expand: boolean
openModal: (modal: Exclude<AppInfoModalType, null>) => void
isExporting: boolean
@@ -125,7 +125,7 @@ const AppInfoTrigger = ({
<AppIcon
size="medium"
iconType={appDetail.icon_type}
icon={appDetail.icon}
icon={appDetail.icon ?? undefined}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>

View File

@@ -4,7 +4,6 @@ import type {
} from '@dify/contracts/api/console/apps/types.gen'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/types/app'
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -18,8 +17,7 @@ import {
markAppDeletionStarted,
markAppDeletionSucceeded,
} from '@/service/app-deletion'
import { fetchAppDetail, updateAppInfo } from '@/service/apps'
import { consoleQuery } from '@/service/console'
import { consoleClient, consoleQuery } from '@/service/console'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
@@ -33,7 +31,7 @@ export type AppInfoModalType =
| null
type AppMetadata = Pick<
App,
AppDetailWithSite,
| 'description'
| 'icon'
| 'icon_background'
@@ -123,7 +121,7 @@ export function useAppInfoActions() {
unsubscribe = collaborationManager.onAppMetaUpdate(async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appDetail.id })
const res = await consoleClient.apps.byAppId.get({ params: { app_id: appDetail.id } })
if (disposed) return
queryClient.setQueryData(
consoleQuery.apps.byAppId.get.queryKey({
@@ -160,15 +158,17 @@ export function useAppInfoActions() {
}) => {
if (!appDetail) return
try {
const app = await updateAppInfo({
appID: appDetail.id,
name,
icon_type,
icon,
icon_background,
description,
use_icon_as_answer_icon,
max_active_requests,
const app = await consoleClient.apps.byAppId.put({
params: { app_id: appDetail.id },
body: {
name,
icon_type,
icon,
icon_background,
description,
use_icon_as_answer_icon,
max_active_requests,
},
})
closeModal()
toast(

View File

@@ -1,11 +1,12 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { PublishedWorkflow } from '../shared/utils'
import type { App } from '@/types/app'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '@/app/components/workflow/types'
import { toast } from '@/app/notifications'
import { render } from '@/test/console/render'
import { createAppDetailFixture, createAppModelConfigFixture } from '@/test/fixtures/app'
import { createTestQueryClient } from '@/test/query-client'
import { AppModeEnum } from '@/types/app'
import { MCPAccessPointCard } from '../built-in-access-points/mcp-card'
@@ -61,11 +62,11 @@ vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({
},
}))
const appInfo = {
const appInfo = createAppDetailFixture({
api_base_url: 'https://api.example.test/v1',
id: 'app-1',
mode: AppModeEnum.CHAT,
model_config: {
model_config: createAppModelConfigFixture({
updated_at: 1_710_000_000,
user_input_form: [
{
@@ -76,14 +77,14 @@ const appInfo = {
},
},
],
},
} as App
}),
})
const workflowAppInfo = {
const workflowAppInfo = createAppDetailFixture({
...appInfo,
mode: AppModeEnum.WORKFLOW,
model_config: null,
} as unknown as App
})
const publishedWorkflow = {
graph: {
@@ -109,7 +110,7 @@ function createDeferredPromise<T>() {
return { promise, reject, resolve }
}
function renderCard(cardAppInfo: App = appInfo, workflow?: PublishedWorkflow) {
function renderCard(cardAppInfo: AppDetailWithSite = appInfo, workflow?: PublishedWorkflow) {
const queryClient = createTestQueryClient()
return render(

View File

@@ -1,10 +1,11 @@
import type { App } from '@/types/app'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { toast } from '@/app/notifications'
import { render } from '@/test/console/render'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { createTestQueryClient } from '@/test/query-client'
import { AppModeEnum } from '@/types/app'
import { ServiceApiAccessPointCard } from '../built-in-access-points/service-api-card'
@@ -53,21 +54,24 @@ vi.mock('../shared/api-secret-key-button', () => ({
},
}))
function createAppInfo(mode: AppModeEnum, overrides: Partial<App> = {}): App {
return {
function createAppInfo(
mode: AppModeEnum,
overrides: Partial<AppDetailWithSite> = {},
): AppDetailWithSite {
return createAppDetailFixture({
api_base_url: 'https://api.example.test/v1',
enable_api: true,
id: 'app-1',
mode,
...overrides,
} as App
})
}
function renderCard(
mode: AppModeEnum,
availability: 'available' | 'loading' | 'unavailable' = 'available',
canManage = true,
overrides: Partial<App> = {},
overrides: Partial<AppDetailWithSite> = {},
) {
useAppStore.setState({ appDetail: createAppInfo(mode, overrides) })
const queryClient = createTestQueryClient()

View File

@@ -1,10 +1,10 @@
import type { AppTrigger } from '@/service/use-tools'
import type { App } from '@/types/app'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from '@/app/notifications'
import { render } from '@/test/console/render'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { createTestQueryClient } from '@/test/query-client'
import { AppModeEnum } from '@/types/app'
import { TriggerAccessPointCard } from '../built-in-access-points/trigger-card'
@@ -69,10 +69,10 @@ vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => null,
}))
const appInfo = {
const appInfo = createAppDetailFixture({
id: 'app-1',
mode: AppModeEnum.WORKFLOW,
} as App
})
function createTrigger(id: string, status: AppTrigger['status']): AppTrigger {
return {

View File

@@ -1,14 +1,18 @@
import type { ConfigParams } from '@/app/components/app/overview/settings'
import type {
AppSiteResponse,
AppSiteUpdatePayload,
} from '@dify/contracts/api/console/apps/types.gen'
import { act, renderHook, waitFor } from '@testing-library/react'
import { createQueryClientWrapper } from '@/test/console/query-client'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { createTestQueryClient } from '@/test/query-client'
import { useAccessPointActions } from '../shared/use-access-point-actions'
const mocks = vi.hoisted(() => ({
fetchAppDetail: vi.fn().mockResolvedValue({ id: 'app-1' }),
fetchAppDetail: vi.fn(),
setAppDetail: vi.fn(),
toast: vi.fn(),
updateAppSiteConfig: vi.fn().mockResolvedValue({}),
updateAppSiteConfig: vi.fn(),
}))
vi.mock('@/app/notifications', () => ({ toast: mocks.toast }))
@@ -18,12 +22,10 @@ vi.mock('@/app/components/app/store', () => ({
selector({ setAppDetail: mocks.setAppDetail }),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: mocks.fetchAppDetail,
updateAppSiteConfig: mocks.updateAppSiteConfig,
}))
vi.mock('@/service/console', () => ({
consoleClient: {
apps: { byAppId: { get: mocks.fetchAppDetail, site: { post: mocks.updateAppSiteConfig } } },
},
consoleQuery: {
apps: {
get: { key: () => ['apps'] },
@@ -56,7 +58,7 @@ const siteConfig = {
show_workflow_steps: false,
title: 'App',
use_icon_as_answer_icon: false,
} satisfies ConfigParams
} satisfies AppSiteUpdatePayload
function renderActions(appId = 'app-1', canManageAccessPoint = true) {
const queryClient = createTestQueryClient()
@@ -70,6 +72,16 @@ function renderActions(appId = 'app-1', canManageAccessPoint = true) {
describe('useAccessPointActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.fetchAppDetail.mockResolvedValue(createAppDetailFixture())
mocks.updateAppSiteConfig.mockResolvedValue({
app_id: 'app-1',
customize_token_strategy: 'not_allow',
default_language: 'en-US',
prompt_public: false,
show_workflow_steps: false,
title: 'Updated site',
use_icon_as_answer_icon: false,
} satisfies AppSiteResponse)
})
it('refreshes after a successful access point result', async () => {
@@ -78,8 +90,8 @@ describe('useAccessPointActions', () => {
act(() => result.current.handleResult(null))
await waitFor(() => {
expect(mocks.fetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mocks.setAppDetail).toHaveBeenCalledWith({ id: 'app-1' })
expect(mocks.fetchAppDetail).toHaveBeenCalledWith({ params: { app_id: 'app-1' } })
expect(mocks.setAppDetail).toHaveBeenCalledWith(createAppDetailFixture())
})
expect(mocks.toast).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully', {
type: 'success',
@@ -97,7 +109,7 @@ describe('useAccessPointActions', () => {
})
})
it('invalidates app detail and lists after saving legacy site configuration', async () => {
it('invalidates app detail and lists after saving site configuration', async () => {
const { queryClient, result } = renderActions()
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
@@ -106,13 +118,14 @@ describe('useAccessPointActions', () => {
})
expect(mocks.updateAppSiteConfig).toHaveBeenCalledWith({
url: '/apps/app-1/site',
params: { app_id: 'app-1' },
body: siteConfig,
})
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['app-detail', 'app-1'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'starred'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'recent'] })
await waitFor(() => expect(mocks.setAppDetail).toHaveBeenCalledWith(createAppDetailFixture()))
})
it('keeps site configuration behind Access Point management permission', async () => {

View File

@@ -1,6 +1,6 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { PublishedWorkflow } from '../shared/utils'
import type { InputVar, Node } from '@/app/components/workflow/types'
import type { App } from '@/types/app'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -9,6 +9,7 @@ import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { toast } from '@/app/notifications'
import { AccessMode } from '@/models/access-control'
import { render } from '@/test/console/render'
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
import { createTestQueryClient } from '@/test/query-client'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
@@ -110,8 +111,8 @@ vi.mock('@/app/components/app/overview/embedded', () => ({
) : null,
}))
function createAppInfo(mode: AppModeEnum): App {
return {
function createAppInfo(mode: AppModeEnum): AppDetailWithSite {
return createAppDetailFixture({
access_mode: AccessMode.PUBLIC,
api_base_url: 'https://api.example.test/v1',
enable_site: true,
@@ -121,11 +122,11 @@ function createAppInfo(mode: AppModeEnum): App {
icon_url: null,
id: 'app-1',
mode,
site: {
site: createAppSiteFixture({
access_token: 'site-code',
app_base_url: 'https://site.example.test',
},
} as App
}),
})
}
function renderCard(
@@ -134,17 +135,21 @@ function renderCard(
workflow?: PublishedWorkflow,
{
accessMode = AccessMode.PUBLIC,
appOverrides = {},
canManageAccessPoint = true,
onRefreshApp = vi.fn().mockResolvedValue(undefined),
showAccessControl = true,
}: {
accessMode?: AccessMode
accessMode?: AccessMode | null
appOverrides?: Partial<AppDetailWithSite>
canManageAccessPoint?: boolean
onRefreshApp?: () => Promise<void>
showAccessControl?: boolean
} = {},
) {
useAppStore.setState({ appDetail: { ...createAppInfo(mode), access_mode: accessMode } })
useAppStore.setState({
appDetail: { ...createAppInfo(mode), ...appOverrides, access_mode: accessMode },
})
const queryClient = createTestQueryClient()
queryClient.setQueryData(['system-features'], {
webapp_auth: { enabled: showAccessControl },
@@ -259,6 +264,34 @@ describe('WebAppAccessPointCard', () => {
vi.restoreAllMocks()
})
it('disables site actions when the app has no site', () => {
renderCard(AppModeEnum.CHAT, 'available', undefined, { appOverrides: { site: null } })
expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeDisabled()
expect(
screen.getByRole('button', { name: /studio\.accessPoint\.embedIntoSite/ }),
).toBeDisabled()
expect(
screen.queryByRole('link', { name: /studio\.accessPoint\.open/ }),
).not.toBeInTheDocument()
expect(screen.queryByText('https://site.example.test/chat/')).not.toBeInTheDocument()
})
it('does not present an unknown access mode as public or allow launching', () => {
renderCard(AppModeEnum.CHAT, 'available', undefined, { accessMode: null })
expect(
screen.queryByRole('button', { name: /accessControlDialog\.accessItems\.anyone/ }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('link', { name: /studio\.accessPoint\.open/ }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /studio\.accessPoint\.open/ })).toHaveAttribute(
'aria-disabled',
'true',
)
})
it('shows the current access mode without a redundant section label', () => {
renderCard(AppModeEnum.CHAT)

View File

@@ -1,7 +1,7 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { PublishedWorkflow } from '../shared/utils'
import type { App } from '@/types/app'
import {
AlertDialog,
AlertDialogActions,
@@ -31,7 +31,7 @@ import { useAccessPointStatusLabel } from '../shared/use-access-point-status-lab
import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils'
type MCPAccessPointCardProps = {
appInfo: App
appInfo: AppDetailWithSite
canManageAccessPoint: boolean
highlighted?: boolean
triggerModeDisabled: boolean

View File

@@ -1,7 +1,7 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { AccessPointAvailability } from '@/app/components/base/access-point/status'
import type { App } from '@/types/app'
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
@@ -12,7 +12,7 @@ import { ServiceApiCardView } from '../shared/service-api-card-view'
import { getBuiltInAccessUrls } from '../shared/utils'
type ServiceApiAccessPointCardProps = {
appInfo: App
appInfo: AppDetailWithSite
availability: AccessPointAvailability
canManage: boolean
highlighted?: boolean

View File

@@ -1,8 +1,8 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { AppTrigger } from '@/service/use-tools'
import type { App } from '@/types/app'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { useMutation } from '@tanstack/react-query'
@@ -131,7 +131,7 @@ function TriggerAccessPointItem({
}
type TriggerAccessPointCardProps = {
appInfo: App
appInfo: AppDetailWithSite
availability: 'available' | 'loading' | 'unavailable'
canManageAccessPoint: boolean
highlighted?: boolean

View File

@@ -1,10 +1,12 @@
'use client'
import type {
AppDetailWithSite,
AppSiteUpdatePayload,
} from '@dify/contracts/api/console/apps/types.gen'
import type { SelectorParam } from 'i18next'
import type { PublishedWorkflow } from '../shared/utils'
import type { ConfigParams } from '@/app/components/app/overview/settings'
import type { AccessPointAvailability } from '@/app/components/base/access-point/status'
import type { App } from '@/types/app'
import {
AlertDialog,
AlertDialogActions,
@@ -58,14 +60,14 @@ const ACCESS_MODE_LABEL_MAP: Record<AccessMode, SelectorParam<'app'>> = {
}
type WebAppAccessPointCardProps = {
appInfo: App
appInfo: AppDetailWithSite
availability: AccessPointAvailability
canDeploy: boolean
canManageAccessPoint: boolean
highlighted?: boolean
showAccessControl: boolean
onRefreshApp: () => Promise<void>
onSaveSiteConfig: (params: ConfigParams) => Promise<void>
onSaveSiteConfig: (params: AppSiteUpdatePayload) => Promise<void>
workflow: PublishedWorkflow
}
@@ -127,19 +129,19 @@ export function WebAppAccessPointCard({
},
}),
)
const site = appInfo.site
const siteAvailability = site ? availability : 'unavailable'
const { webApp: webAppUrl } = getBuiltInAccessUrls(appInfo)
const pendingEnabled = toggleSiteMutation.variables?.body.enable_site
const optimisticEnabled =
toggleSiteMutation.isPending && pendingEnabled !== undefined
? pendingEnabled
: appInfo.enable_site
const running = availability === 'available' && optimisticEnabled
const actionsAvailable = running && !toggleSiteMutation.isPending
const running = siteAvailability === 'available' && optimisticEnabled
const actionsAvailable = running && Boolean(webAppUrl) && !toggleSiteMutation.isPending
const supportsEmbedded =
appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW
const hiddenLaunchVariables = getHiddenStartInputs(workflow)
const accessIcon = ACCESS_MODE_ICON_MAP[appInfo.access_mode]
const accessLabel = ACCESS_MODE_LABEL_MAP[appInfo.access_mode]
const { data: accessSubjects } = useAppWhiteListSubjects(
appInfo.id,
showAccessControl &&
@@ -156,8 +158,8 @@ export function WebAppAccessPointCard({
})
const noAccessPermission =
showAccessControl &&
appInfo.access_mode !== AccessMode.EXTERNAL_MEMBERS &&
!userCanAccessApp?.result
(appInfo.access_mode === null ||
(appInfo.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result))
const handleRegenerate = () => {
if (!canManageAccessPoint || resetSiteAccessToken.isPending) return
@@ -178,7 +180,7 @@ export function WebAppAccessPointCard({
})
}
const status = getAccessPointStatus(availability, running)
const status = getAccessPointStatus(siteAvailability, running)
const statusLabel = useAccessPointStatusLabel(status)
return (
@@ -192,7 +194,7 @@ export function WebAppAccessPointCard({
<AppIcon
size="large"
iconType={appInfo.icon_type}
icon={appInfo.icon}
icon={appInfo.icon ?? undefined}
background={appInfo.icon_background}
imageUrl={appInfo.icon_url}
/>
@@ -202,7 +204,7 @@ export function WebAppAccessPointCard({
highlighted={highlighted}
switchDisabled={!canManageAccessPoint}
switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })}
onEnabledChange={availability === 'available' ? handleEnabledChange : undefined}
onEnabledChange={siteAvailability === 'available' ? handleEnabledChange : undefined}
actions={
<>
{hiddenLaunchVariables.length > 0 && (
@@ -241,7 +243,7 @@ export function WebAppAccessPointCard({
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={availability !== 'available' || !canManageAccessPoint}
disabled={siteAvailability !== 'available' || !canManageAccessPoint}
onClick={() => setShowSettings(true)}
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
@@ -254,8 +256,8 @@ export function WebAppAccessPointCard({
label={t(($) => $['agentDetail.access.webApp.accessUrl'], { ns: 'agentV2' })}
value={webAppUrl}
enabled={running}
loading={availability === 'loading'}
unavailable={availability === 'unavailable'}
loading={siteAvailability === 'loading'}
unavailable={siteAvailability === 'unavailable'}
unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], {
ns: 'deployments',
})}
@@ -270,39 +272,41 @@ export function WebAppAccessPointCard({
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
ns: 'appOverview',
})}
regenerateDisabled={!canManageAccessPoint}
regenerateDisabled={!site || !canManageAccessPoint}
regenerating={resetSiteAccessToken.isPending}
onRegenerate={() => setShowRegenerate(true)}
/>
{showAccessControl &&
(availability === 'available' ? (
(siteAvailability === 'available' && appInfo.access_mode !== null ? (
<WebAppAccessControlEntry
accessConfigured={accessConfigured}
accessIcon={accessIcon}
accessLabel={t(accessLabel, { ns: 'app' })}
accessIcon={ACCESS_MODE_ICON_MAP[appInfo.access_mode]}
accessLabel={t(ACCESS_MODE_LABEL_MAP[appInfo.access_mode], { ns: 'app' })}
disabled={!canManageAccessPoint}
onClick={() => setShowAccess(true)}
/>
) : (
<WebAppAccessControlEntrySkeleton loading={availability === 'loading'} />
<WebAppAccessControlEntrySkeleton loading={siteAvailability === 'loading'} />
))}
</AccessPointCard>
<SettingsModal
isChat={appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW}
canDeploy={canDeploy}
appInfo={appInfo}
isShow={showSettings}
onClose={() => setShowSettings(false)}
onSave={onSaveSiteConfig}
/>
{supportsEmbedded && (
{site && (
<SettingsModal
isChat={appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW}
canDeploy={canDeploy}
appInfo={{ id: appInfo.id, mode: appInfo.mode, site }}
isShow={showSettings}
onClose={() => setShowSettings(false)}
onSave={onSaveSiteConfig}
/>
)}
{supportsEmbedded && site?.access_token && (
<EmbeddedModal
siteInfo={appInfo.site}
siteInfo={site}
isShow={showEmbedded}
onClose={() => setShowEmbedded(false)}
appBaseUrl={appInfo.site?.app_base_url}
accessToken={appInfo.site?.access_token}
appBaseUrl={site.app_base_url}
accessToken={site.access_token}
hiddenInputs={hiddenLaunchVariables}
/>
)}

View File

@@ -173,7 +173,7 @@ export function EnvironmentWebAppCard({
<AppIcon
size="large"
iconType={appInfo.icon_type}
icon={appInfo.icon}
icon={appInfo.icon ?? undefined}
background={appInfo.icon_background}
imageUrl={appInfo.icon_url}
/>
@@ -203,7 +203,7 @@ export function EnvironmentWebAppCard({
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!appInfo || !siteQuery.isSuccess || !canManageAccessPoint}
disabled={!appInfo?.site || !siteQuery.isSuccess || !canManageAccessPoint}
onClick={() => setShowSettings(true)}
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
@@ -247,11 +247,11 @@ export function EnvironmentWebAppCard({
))}
</AccessPointCard>
{appInfo && (
{appInfo?.site && (
<SettingsModal
isChat={false}
canDeploy
appInfo={appInfo}
appInfo={{ id: appInfo.id, mode: appInfo.mode, site: appInfo.site }}
isShow={showSettings}
onClose={() => setShowSettings(false)}
onSave={actions.saveSiteConfig}

View File

@@ -1,8 +1,8 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { ComponentProps } from 'react'
import type { AccessPointStatus } from '@/app/components/base/access-point/status'
import type { AppModeEnum } from '@/types/app'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
@@ -17,7 +17,7 @@ import { getAppApiReferencePath } from './utils'
type ServiceApiCardViewProps = {
apiKeyButtonProps: ComponentProps<typeof ApiSecretKeyButton>
apiUrl: string
appMode?: AppModeEnum
appMode?: AppDetailWithSite['mode']
available: boolean
status: AccessPointStatus
switchDisabled: boolean

View File

@@ -1,15 +1,13 @@
'use client'
import type { ConfigParams } from '@/app/components/app/overview/settings'
import type { App } from '@/types/app'
import type { AppSiteUpdatePayload } from '@dify/contracts/api/console/apps/types.gen'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { toast } from '@/app/notifications'
import { fetchAppDetail, updateAppSiteConfig } from '@/service/apps'
import { consoleQuery } from '@/service/console'
import { consoleClient, consoleQuery } from '@/service/console'
import { asyncRunSafe } from '@/utils'
export function useAccessPointActions(appId: string, canManageAccessPoint: boolean) {
@@ -18,8 +16,8 @@ export function useAccessPointActions(appId: string, canManageAccessPoint: boole
const setAppDetail = useAppStore((state) => state.setAppDetail)
const refreshAppDetail = useCallback(async () => {
try {
const appDetail = await fetchAppDetail({ url: '/apps', id: appId })
setAppDetail({ ...appDetail })
const appDetail = await consoleClient.apps.byAppId.get({ params: { app_id: appId } })
setAppDetail(appDetail)
} catch (error) {
console.error('Failed to refresh app detail:', error)
}
@@ -41,13 +39,13 @@ export function useAccessPointActions(appId: string, canManageAccessPoint: boole
[refreshAppDetail, t],
)
const saveSiteConfig = useCallback(
async (params: ConfigParams) => {
async (params: AppSiteUpdatePayload) => {
if (!canManageAccessPoint) return
const [error] = await asyncRunSafe<App>(
updateAppSiteConfig({
url: `/apps/${appId}/site`,
const [error] = await asyncRunSafe(
consoleClient.apps.byAppId.site.post({
params: { app_id: appId },
body: params,
}) as Promise<App>,
}),
)
if (!error) {
void queryClient.invalidateQueries({

View File

@@ -1,6 +1,8 @@
import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen'
import type {
AppDetailWithSite,
WorkflowResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type { InputVar, Node } from '@/app/components/workflow/types'
import type { App } from '@/types/app'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
@@ -8,11 +10,9 @@ import { basePath } from '@/utils/var'
export type PublishedWorkflow = WorkflowResponse | null | undefined
type AppRouteMode = Exclude<AppModeEnum, 'agent'>
const EMPTY_WORKFLOW_NODES: Node[] = []
const APP_API_REFERENCE_PATHS: Record<AppRouteMode, DocPathWithoutLang> = {
const APP_API_REFERENCE_PATHS: Partial<Record<AppDetailWithSite['mode'], DocPathWithoutLang>> = {
'advanced-chat': '/api-reference/guides/chatflow',
'agent-chat': '/api-reference/guides/chat',
chat: '/api-reference/guides/chat',
@@ -20,13 +20,11 @@ const APP_API_REFERENCE_PATHS: Record<AppRouteMode, DocPathWithoutLang> = {
workflow: '/api-reference/guides/workflow',
}
export function getAppApiReferencePath(appMode: AppModeEnum) {
if (appMode === 'agent') return undefined
export function getAppApiReferencePath(appMode: AppDetailWithSite['mode']) {
return APP_API_REFERENCE_PATHS[appMode]
}
export function getPublishedWorkflowState(appInfo: App, workflow: PublishedWorkflow) {
export function getPublishedWorkflowState(appInfo: AppDetailWithSite, workflow: PublishedWorkflow) {
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const nodes = getPublishedWorkflowNodes(workflow)
const hasStartNode = nodes.some((node) => node.data.type === BlockEnum.Start)
@@ -46,7 +44,7 @@ export function getPublishedWorkflowNodes(workflow: PublishedWorkflow) {
: EMPTY_WORKFLOW_NODES
}
export function getBuiltInAccessUrls(appInfo: App) {
export function getBuiltInAccessUrls(appInfo: AppDetailWithSite) {
const appMode =
appInfo.mode === AppModeEnum.COMPLETION || appInfo.mode === AppModeEnum.WORKFLOW
? appInfo.mode
@@ -54,9 +52,9 @@ export function getBuiltInAccessUrls(appInfo: App) {
return {
api: appInfo.api_base_url ?? '',
webApp: `${appInfo.site?.app_base_url ?? ''}${basePath}/${appMode}/${
appInfo.site?.access_token ?? ''
}`,
webApp: appInfo.site?.access_token
? `${appInfo.site.app_base_url}${basePath}/${appMode}/${appInfo.site.access_token}`
: '',
}
}
@@ -70,6 +68,6 @@ export function getHiddenStartInputs(workflow: PublishedWorkflow) {
)
}
export function isAdvancedApp(appInfo: App) {
export function isAdvancedApp(appInfo: AppDetailWithSite) {
return appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT
}

View File

@@ -1,7 +1,6 @@
import type { ReactElement } from 'react'
import type { Mock } from 'vite-plus/test'
import type { AnnotationItem } from '../type'
import type { App } from '@/types/app'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { toast } from '@/app/notifications'
@@ -17,6 +16,7 @@ import {
updateAnnotationStatus,
} from '@/service/annotation'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import Annotation from '../index'
import { AnnotationEnableStatus, JobStatus } from '../type'
@@ -179,10 +179,10 @@ const queryAnnotationJobStatusMock = queryAnnotationJobStatus as Mock
const updateAnnotationScoreMock = updateAnnotationScore as Mock
const updateAnnotationStatusMock = updateAnnotationStatus as Mock
const appDetail = {
const appDetail = createAppDetailFixture({
id: 'app-id',
mode: AppModeEnum.CHAT,
} as App
})
const createAnnotation = (overrides: Partial<AnnotationItem> = {}): AnnotationItem => ({
id: overrides.id ?? 'annotation-1',

View File

@@ -1,9 +1,10 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import type { QueryParam } from './filter'
import type { AnnotationItem, AnnotationItemBasic } from './type'
import type { AnnotationReplyConfig } from '@/models/debug'
import type { App } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Pagination } from '@langgenius/dify-ui/pagination'
@@ -45,7 +46,7 @@ import { AnnotationEnableStatus, JobStatus } from './type'
import ViewAnnotationModal from './view-annotation-modal'
type Props = Readonly<{
appDetail: App
appDetail: AppDetailWithSite
}>
const Annotation: FC<Props> = (props) => {

View File

@@ -1,10 +1,10 @@
import type { ReactElement } from 'react'
import type { App } from '@/types/app'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from '@/app/notifications'
import { AccessMode } from '@/models/access-control'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import AccessControl from '../index'
let mockWebappAuth = {
@@ -86,10 +86,10 @@ describe('AccessControl', () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
const app = {
const app = createAppDetailFixture({
id: 'app-id-1',
access_mode: AccessMode.PUBLIC,
} as App
})
render(<AccessControl app={app} onClose={vi.fn()} onConfirm={onConfirm} />)
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
@@ -187,7 +187,7 @@ describe('AccessControl', () => {
render(
<AccessControl
app={{ id: 'app-id-4', access_mode: AccessMode.PUBLIC } as App}
app={createAppDetailFixture({ id: 'app-id-4', access_mode: AccessMode.PUBLIC })}
onClose={vi.fn()}
/>,
)
@@ -198,7 +198,7 @@ describe('AccessControl', () => {
it('should preserve an unfinished selection when the parent rerenders', async () => {
const user = userEvent.setup()
const app = { id: 'app-id-5', access_mode: AccessMode.PUBLIC } as App
const app = createAppDetailFixture({ id: 'app-id-5', access_mode: AccessMode.PUBLIC })
const { rerender } = render(<AccessControl app={app} onClose={vi.fn()} />)
const organization = screen.getByRole('radio', {
@@ -222,7 +222,10 @@ describe('AccessControl', () => {
render(
<AccessControl
app={{ id: 'app-id-6', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } as App}
app={createAppDetailFixture({
id: 'app-id-6',
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
})}
onClose={vi.fn()}
/>,
)

View File

@@ -537,11 +537,6 @@ describe('app-publisher sections', () => {
appDetail={{
id: 'workflow-app',
mode: AppModeEnum.WORKFLOW,
icon: '⚙️',
icon_type: 'emoji',
icon_background: '#fff',
name: 'Workflow App',
description: 'Workflow description',
}}
appURL="https://example.com/app"
canViewAccessPoint
@@ -604,7 +599,6 @@ describe('app-publisher sections', () => {
appDetail={{
id: 'workflow-app',
mode: AppModeEnum.WORKFLOW,
name: 'Workflow App',
}}
appURL="https://example.com/app"
canViewAccessPoint

View File

@@ -1,3 +1,4 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { AppPublisherProps } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
@@ -10,18 +11,7 @@ type PublisherActionsSectionProps = Pick<
AppPublisherProps,
'hasHumanInputNode' | 'hasTriggerNode' | 'publishedAt' | 'toolPublished' | 'workflowToolAvailable'
> & {
appDetail:
| {
id?: string
icon?: string
icon_type?: string | null
icon_background?: string | null
description?: string
mode?: AppModeEnum
name?: string
}
| null
| undefined
appDetail: Partial<Pick<AppDetailWithSite, 'id' | 'mode'>> | null | undefined
appURL: string
canViewAccessPoint: boolean
disabledFunctionButton: boolean

View File

@@ -141,7 +141,7 @@ export function PublisherContent({
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
const appURL = getPublisherAppUrl({
appBaseUrl: appBaseURL,
accessToken,
accessToken: accessToken ?? '',
mode: appDetail?.mode,
})
const shouldLoadUserCanAccessApp = Boolean(

View File

@@ -1,3 +1,4 @@
import type { AppMode } from '@dify/contracts/api/console/apps/types.gen'
import type { QueryClient } from '@tanstack/react-query'
import type {
AppPublisherProps,
@@ -24,7 +25,7 @@ type UsePublishControllerParams = Pick<
'onPublish' | 'onRestore' | 'publishedAt'
> & {
appId?: string
appMode?: AppModeEnum
appMode?: AppMode
appName?: string
supportsMultiEnvironment: boolean
onClose: () => void

View File

@@ -1,5 +1,4 @@
import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { AppModeEnum } from '@/types/app'
import type { AppMode, WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/notifications'
@@ -18,7 +17,7 @@ export function useVersionInfo({
onClosePublisher,
}: {
appId?: string
appMode?: AppModeEnum
appMode?: AppMode
publishedWorkflow?: WorkflowResponse | null
onClosePublisher: () => void
}) {

View File

@@ -1,3 +1,4 @@
import type { AppMode } from '@dify/contracts/api/console/apps/types.gen'
import type { AppPublisherPublishParams } from '../types'
import type { WorkflowToolOutputVariable } from '@/app/components/tools/types'
import type { InputVar } from '@/app/components/workflow/types'
@@ -10,11 +11,11 @@ import { AppModeEnum } from '@/types/app'
type UseWorkflowToolParams = {
appDescription?: string
appIcon?: string
appIcon?: string | null
appIconBackground?: string | null
appIconType?: string | null
appId?: string
appMode?: AppModeEnum
appMode?: AppMode
appName?: string
appPublished: boolean
hasHumanInputNode: boolean

View File

@@ -1,8 +1,9 @@
import type { AppMode } from '@dify/contracts/api/console/apps/types.gen'
import type { TFunction } from 'i18next'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
export const getPublisherAppMode = (mode?: AppModeEnum) => {
export const getPublisherAppMode = (mode?: AppMode) => {
if (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) return AppModeEnum.CHAT
return mode
@@ -15,7 +16,7 @@ export const getPublisherAppUrl = ({
}: {
appBaseUrl: string
accessToken: string
mode?: AppModeEnum
mode?: AppMode
}) => `${appBaseUrl}${basePath}/${getPublisherAppMode(mode)}/${accessToken}`
export const getDisabledFunctionTooltip = ({

View File

@@ -197,13 +197,6 @@ const createContextValue = (): ComponentProps<typeof ConfigContext.Provider>['va
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
dataSets: [],
agentConfig: {
enabled: false,

View File

@@ -35,13 +35,6 @@ const createModelConfig = (overrides: Partial<ModelConfig> = {}): ModelConfig =>
sensitive_word_avoidance: { enabled: false },
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
dataSets: [],
agentConfig: {
enabled: false,

View File

@@ -1,5 +1,4 @@
import type { InputVar } from '@/app/components/workflow/types'
import type { App } from '@/types/app'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { toast } from '@/app/components/app/configuration/toast'
@@ -7,6 +6,7 @@ import { useStore } from '@/app/components/app/store'
import { InputVarType } from '@/app/components/workflow/types'
import DebugConfigurationContext from '@/context/debug-configuration'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import ConfigModal from '../index'
@@ -126,9 +126,7 @@ describe('ConfigModal logic', () => {
vi.clearAllMocks()
latestFormProps = null
useStore.setState({
appDetail: {
mode: AppModeEnum.CHAT,
} as App,
appDetail: createAppDetailFixture({ mode: AppModeEnum.CHAT }),
})
})

View File

@@ -1,5 +1,4 @@
import type { InputVar } from '@/app/components/workflow/types'
import type { App } from '@/types/app'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
@@ -8,6 +7,7 @@ import { useStore } from '@/app/components/app/store'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { commonQueryKeys } from '@/service/use-common'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum, TransferMethod } from '@/types/app'
import ConfigModal from '../index'
@@ -56,9 +56,7 @@ describe('ConfigModal', () => {
beforeEach(() => {
vi.clearAllMocks()
useStore.setState({
appDetail: {
mode: AppModeEnum.CHAT,
} as App,
appDetail: createAppDetailFixture({ mode: AppModeEnum.CHAT }),
})
})

View File

@@ -107,13 +107,6 @@ const createModelConfig = (overrides: Partial<ModelConfig> = {}): ModelConfig =>
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: null,
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
dataSets: [],
agentConfig: {
enabled: false,

View File

@@ -186,13 +186,6 @@ const createModelConfig = (tools: AgentTool[]): ModelConfig => ({
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
dataSets: [],
agentConfig: {
...DEFAULT_AGENT_SETTING,

View File

@@ -88,6 +88,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
draft.agentConfig.tools.forEach((tool) => {
if (
'provider_id' in tool &&
typeof tool.provider_id === 'string' &&
tool.isDeleted &&
installedPluginNames.includes(tool.provider_id)
) {

View File

@@ -124,13 +124,6 @@ describe('configuration debug hooks', () => {
prompt_variables: [{ key: 'name', name: 'Name', type: 'string', required: true }],
},
more_like_this: { enabled: true },
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
},
appId: 'app-1',
promptMode: 'simple',

View File

@@ -398,13 +398,6 @@ const createContextValue = (overrides: Partial<DebugContextValue> = {}): DebugCo
retriever_resource: null,
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
dataSets: [],
agentConfig: {
enabled: false,
@@ -776,6 +769,49 @@ describe('Debug', () => {
expect(screen.queryByTestId('cannot-query-dataset')).not.toBeInTheDocument()
})
it('reports an invalid chat prompt before starting a completion request', async () => {
const user = userEvent.setup()
const { notify } = renderDebug({
contextValue: {
mode: AppModeEnum.COMPLETION,
isAdvancedMode: true,
chatPromptConfig: { prompt: [{ text: 'Missing role' }] },
},
})
await user.click(screen.getByTestId('panel-send'))
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'common.api.actionFailed' })
expect(mockState.mockSendCompletionMessage).not.toHaveBeenCalled()
expect(screen.getByText('appDebug.noResult')).toBeInTheDocument()
})
it('reports an invalid upload count and allows retry after correcting the configuration', async () => {
const user = userEvent.setup()
const file = { ...mockState.mockFeaturesState.file, enabled: true, number_limits: 1.5 }
mockState.mockFeaturesState.file = file
const { notify } = renderDebug({ contextValue: { mode: AppModeEnum.COMPLETION } })
await user.click(screen.getByRole('button', { name: 'Send' }))
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'common.api.actionFailed' })
expect(mockState.mockSendCompletionMessage).not.toHaveBeenCalled()
expect(screen.getByText('appDebug.noResult')).toBeInTheDocument()
file.number_limits = 2
await user.click(screen.getByRole('button', { name: 'Send' }))
expect(mockState.mockSendCompletionMessage).toHaveBeenCalledExactlyOnceWith(
'app-id',
expect.objectContaining({
model_config: expect.objectContaining({
file_upload: expect.objectContaining({ number_limits: 2 }),
}),
}),
expect.any(Object),
)
})
it('should send completion request and render completion result', async () => {
mockState.mockText2speechDefaultModel = { provider: 'openai' }
mockState.mockFeaturesState = {

View File

@@ -156,13 +156,6 @@ const createModelConfig = (promptVariables: PromptVariableWithMeta[] = []): Mode
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
dataSets: [],
agentConfig: DEFAULT_AGENT_SETTING,
})

View File

@@ -90,7 +90,6 @@ const createDefaultMocks = () => {
prompt_template: 'Hello {{name}}',
prompt_variables: [{ key: 'name', name: 'Name', type: 'string', is_context_var: false }],
},
system_parameters: {},
},
appId: 'app-123',
inputs: { name: 'World' },
@@ -252,7 +251,6 @@ describe('TextGenerationItem', () => {
prompt_template: 'My Template',
prompt_variables: [],
},
system_parameters: {},
},
appId: 'app-123',
inputs: {},
@@ -282,7 +280,6 @@ describe('TextGenerationItem', () => {
prompt_template: 'Should not be used',
prompt_variables: [],
},
system_parameters: {},
},
appId: 'app-123',
inputs: {},
@@ -314,7 +311,6 @@ describe('TextGenerationItem', () => {
{ key: 'query', name: 'Query', type: 'string', is_context_var: false },
],
},
system_parameters: {},
},
appId: 'app-123',
inputs: {},
@@ -342,7 +338,6 @@ describe('TextGenerationItem', () => {
isAdvancedMode: false,
modelConfig: {
configs: { prompt_template: '', prompt_variables: [] },
system_parameters: {},
},
appId: 'app-123',
inputs: {},
@@ -673,7 +668,6 @@ describe('TextGenerationItem', () => {
isAdvancedMode: false,
modelConfig: {
configs: { prompt_template: '', prompt_variables: [] },
system_parameters: {},
},
appId: 'app-123',
inputs: {},
@@ -702,7 +696,6 @@ describe('TextGenerationItem', () => {
{ key: 'var1', name: 'Var1', type: 'string', is_context_var: false },
],
},
system_parameters: {},
},
appId: 'app-123',
inputs: {},
@@ -736,7 +729,6 @@ describe('TextGenerationItem', () => {
isAdvancedMode: false,
modelConfig: {
configs: { prompt_template: '', prompt_variables: promptVariables },
system_parameters: {},
},
appId: 'app-123',
inputs: {},

View File

@@ -82,7 +82,6 @@ const TextGenerationItem: FC<TextGenerationItemProps> = ({ modelAndParameter })
datasets: [...postDatasets],
} as any,
},
system_parameters: modelConfig.system_parameters,
}
const { completion, handleSend, isResponding, messageId } = useTextGeneration()

View File

@@ -51,13 +51,6 @@ function createMockModelConfig(overrides: Partial<ModelConfig> = {}): ModelConfi
retriever_resource: null,
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
dataSets: [],
agentConfig: {
enabled: false,

View File

@@ -110,7 +110,6 @@ export const useConfigFromDebugContext = () => {
number_limits: visionConfig.number_limits,
},
annotation_reply: annotationConfig,
system_parameters: modelConfig.system_parameters,
supportAnnotation: true,
appId,

View File

@@ -1,10 +1,17 @@
'use client'
import type { AppModelConfigPayload } from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import type { DebugWithSingleModelRefType } from './debug-with-single-model'
import type { ModelAndParameter } from './types'
import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import type { Inputs } from '@/models/debug'
import type { ModelConfig as BackendModelConfig, VisionFile, VisionSettings } from '@/types/app'
import type { VisionFile, VisionSettings } from '@/types/app'
import {
zAppChatPromptPayload,
zAppDatasetConfigPayload,
zAppFileUploadPayload,
zAppUserInputFormPayload,
} from '@dify/contracts/api/console/apps/zod.gen'
import { Button } from '@langgenius/dify-ui/button'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { IconButton } from '@langgenius/dify-ui/icon-button'
@@ -230,41 +237,53 @@ const Debug: FC<IDebug> = ({
}))
const contextVar = modelConfig.configs.prompt_variables.find((item) => item.is_context_var)?.key
const postModelConfig: BackendModelConfig = {
pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
prompt_type: promptMode,
chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG),
completion_prompt_config: isAdvancedMode
? completionPromptConfig
: cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG),
user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
dataset_query_variable: contextVar || '',
dataset_configs: {
...datasetConfigs,
datasets: {
datasets: [...postDatasets],
} as any,
},
agent_mode: {
enabled: false,
tools: [],
},
model: {
provider: modelConfig.provider,
name: modelConfig.model_id,
mode: modelConfig.mode,
completion_params: completionParams as any,
},
more_like_this: features.moreLikeThis as any,
sensitive_word_avoidance: features.moderation as any,
text_to_speech: features.text2speech as any,
file_upload: features.file as any,
opening_statement: introduction,
suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
speech_to_text: speechToTextConfig,
retriever_resource: citationConfig,
system_parameters: modelConfig.system_parameters,
external_data_tools: externalDataToolsConfig,
let postModelConfig: AppModelConfigPayload
try {
const chatPrompt = zAppChatPromptPayload.parse(
isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG),
)
const fileUpload = { ...features.file }
delete fileUpload.fileUploadConfig
postModelConfig = {
pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
prompt_type: promptMode,
chat_prompt_config: chatPrompt,
completion_prompt_config: isAdvancedMode
? completionPromptConfig
: cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG),
user_input_form: zAppUserInputFormPayload
.array()
.parse(promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables)),
dataset_query_variable: contextVar || '',
dataset_configs: zAppDatasetConfigPayload.parse({
...datasetConfigs,
datasets: {
datasets: postDatasets,
},
}),
agent_mode: {
enabled: false,
tools: [],
},
model: {
provider: modelConfig.provider,
name: modelConfig.model_id,
mode: modelConfig.mode,
completion_params: completionParams,
},
more_like_this: features.moreLikeThis,
sensitive_word_avoidance: features.moderation,
text_to_speech: features.text2speech,
file_upload: zAppFileUploadPayload.parse(fileUpload),
opening_statement: introduction,
suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
speech_to_text: speechToTextConfig,
retriever_resource: citationConfig,
external_data_tools: externalDataToolsConfig,
}
} catch {
toast.error(t(($) => $['api.actionFailed'], { ns: 'common' }))
return false
}
const data: Record<string, any> = {
@@ -272,7 +291,7 @@ const Debug: FC<IDebug> = ({
model_config: postModelConfig,
}
if ((features.file as any).enabled && completionFiles && completionFiles?.length > 0) {
if (features.file?.enabled && completionFiles && completionFiles?.length > 0) {
data.files = completionFiles.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {

View File

@@ -1,6 +1,8 @@
import type { TFunction } from 'i18next'
import type { VisionSettings } from '@/types/app'
import { zAppModelConfigPayload } from '@dify/contracts/api/console/apps/zod.gen'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { createAppDetailFixture, createAppModelConfigFixture } from '@/test/fixtures/app'
import { withSelectorKey } from '@/test/i18n-mock'
import {
AgentStrategy,
@@ -10,6 +12,7 @@ import {
RETRIEVE_TYPE,
TransferMethod,
} from '@/types/app'
import { buildConfigurationFeaturesData } from '../../utils'
import {
buildConfigurationDatasetConfigs,
createDatasetSelectHandler,
@@ -38,8 +41,10 @@ const baseVisionConfig: VisionSettings = {
transfer_methods: [TransferMethod.remote_url],
}
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
vi.mock('@/service/console', () => ({
consoleClient: {
apps: { byAppId: { get: (...args: unknown[]) => mockFetchAppDetailDirect(...args) } },
},
}))
vi.mock('@/service/datasets', () => ({
@@ -86,13 +91,127 @@ describe('useConfiguration utils', () => {
})
})
it('should preserve persisted settings when loading and publishing without edits', async () => {
const chatPrompt = {
prompt: [{ role: 'tool', text: 'Tool result', name: 'lookup' }],
vendor: { version: 1 },
}
const completionPrompt = {
prompt: { text: 'Complete this', prefix: 'custom' },
conversation_histories_role: {
user_prefix: 'User',
assistant_prefix: 'Assistant',
separator: '\n',
},
}
const agentMode = {
enabled: true,
strategy: 'react' as const,
prompt: { first_prompt: 'Plan', next_iteration: 'Continue' },
extension: { flags: [true, 'custom'] },
tools: [
{ google_search: { enabled: true } },
{
enabled: true,
provider_id: 'custom-tool',
provider_type: 'api' as const,
provider_name: 'custom-tool',
tool_name: 'lookup',
tool_parameters: { nested: { count: 2 } },
extension: { protocol: 'custom' },
},
],
}
const externalTools = [
{
enabled: true as const,
type: 'custom',
variable: 'lookup',
config: { options: { retries: 2, flags: [true, 'fast'] } },
},
]
mockFetchCollectionList.mockResolvedValue([])
mockFetchAppDetailDirect.mockResolvedValue(
createAppDetailFixture({
mode: AppModeEnum.AGENT_CHAT,
model_config: createAppModelConfigFixture({
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4o',
mode: 'chat',
completion_params: { temperature: 0.7 },
},
prompt_type: 'advanced',
chat_prompt_config: chatPrompt,
completion_prompt_config: completionPrompt,
agent_mode: agentMode,
external_data_tools: externalTools,
file_upload: {
enabled: true,
allowed_file_upload_methods: ['datasource_file', 'tool_file'],
image: { enabled: true, transfer_methods: ['datasource_file', 'tool_file'] },
},
dataset_configs: {
retrieval_model: 'multiple',
metadata_filtering_conditions: {
logical_operator: 'and',
conditions: [
{ id: 'tags', name: 'tags', comparison_operator: 'in', value: ['a', 'b'] },
],
},
},
}),
}),
)
const loaded = await loadConfigurationState({ appId: 'app-1' })
const published = loaded.publishedConfig
const body = buildPublishBody({
chatPromptConfig: published.chatPromptConfig,
completionParams: published.completionParams,
completionPromptConfig: published.completionPromptConfig,
dataSets: [],
datasetConfigs: published.datasetConfigs,
externalDataToolsConfig: published.externalDataToolsConfig,
features: buildConfigurationFeaturesData(published.modelConfig, undefined),
isAdvancedMode: true,
isFunctionCall: false,
modelConfig: published.modelConfig,
modelId: published.modelConfig.model_id,
modelProvider: published.modelConfig.provider,
promptMode: published.promptMode,
promptVariables: published.modelConfig.configs.prompt_variables,
promptTemplate: published.modelConfig.configs.prompt_template,
resolvedModelModeType: published.modelConfig.mode,
})
expect(zAppModelConfigPayload.parse(body)).toEqual(body)
expect(body.completion_prompt_config?.prompt).not.toHaveProperty('role')
expect(body.chat_prompt_config).toEqual(chatPrompt)
expect(body.completion_prompt_config).toEqual(completionPrompt)
expect(body.agent_mode).toEqual(
expect.objectContaining({ prompt: agentMode.prompt, extension: agentMode.extension }),
)
expect(body.agent_mode.tools).toEqual(
expect.arrayContaining(agentMode.tools.map((tool) => expect.objectContaining(tool))),
)
expect(body.external_data_tools).toEqual(externalTools)
expect(loaded.externalDataToolsConfig).toEqual(published.externalDataToolsConfig)
expect(body.file_upload.allowed_file_upload_methods).toEqual(['datasource_file', 'tool_file'])
expect(body.file_upload.image?.transfer_methods).toEqual(['datasource_file', 'tool_file'])
expect(body.dataset_configs.metadata_filtering_conditions?.conditions?.[0]?.value).toEqual([
'a',
'b',
])
expect(body).not.toHaveProperty('system_parameters')
})
it('should build the published config with external tools and agent metadata', () => {
const publishedConfig = buildPublishedConfig({
backendModelConfig: {
backendModelConfig: createAppModelConfigFixture({
pre_prompt: 'hello {{name}}',
user_input_form: [
{
text_input: {
'text-input': {
variable: 'name',
label: 'Name',
required: true,
@@ -106,10 +225,10 @@ describe('useConfiguration utils', () => {
sensitive_word_avoidance: { enabled: false },
speech_to_text: { enabled: false },
text_to_speech: { enabled: false, voice: '', language: '' },
file_upload: null,
file_upload: {},
suggested_questions_after_answer: { enabled: false },
retriever_resource: { enabled: false },
annotation_reply: null,
annotation_reply: { enabled: false },
external_data_tools: [
{
enabled: true,
@@ -118,16 +237,11 @@ describe('useConfiguration utils', () => {
label: 'Search',
type: 'search',
variable: 'search',
config: {},
},
],
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
dataset_configs: {
retrieval_model: 'multiple',
datasets: { datasets: [] },
},
model: {
@@ -137,6 +251,7 @@ describe('useConfiguration utils', () => {
completion_params: { temperature: 0.7 },
},
agent_mode: {
enabled: false,
strategy: AgentStrategy.react,
tools: [
{
@@ -145,10 +260,11 @@ describe('useConfiguration utils', () => {
provider_name: 'builtin/search',
provider_type: 'builtin',
tool_name: 'search',
tool_parameters: {},
},
],
},
} as any,
}),
collectionList: [
{
id: 'tool-1',
@@ -160,7 +276,7 @@ describe('useConfiguration utils', () => {
retrieval_model: RETRIEVE_TYPE.multiWay,
top_k: 4,
} as any,
deletedTools: [{ provider_id: 'tool-1', tool_name: 'search' }],
deletedTools: [{ type: 'tool', provider_id: 'tool-1', tool_name: 'search' }],
mode: AppModeEnum.AGENT_CHAT,
nextDataSets: [{ id: 'dataset-1' }] as any,
})
@@ -186,13 +302,14 @@ describe('useConfiguration utils', () => {
isDeleted: true,
notAuthor: true,
tool_name: 'search',
tool_parameters: {},
}),
)
})
it('should normalize an empty chat prompt config for completion apps', () => {
const publishedConfig = buildPublishedConfig({
backendModelConfig: {
backendModelConfig: createAppModelConfigFixture({
chat_prompt_config: {},
completion_prompt_config: {
prompt: { text: 'completion' },
@@ -202,6 +319,7 @@ describe('useConfiguration utils', () => {
},
},
dataset_configs: {
retrieval_model: 'multiple',
datasets: { datasets: [] },
},
external_data_tools: [],
@@ -212,7 +330,7 @@ describe('useConfiguration utils', () => {
completion_params: {},
},
user_input_form: [],
} as any,
}),
collectionList: [],
datasetConfigs: {
datasets: { datasets: [] },
@@ -228,12 +346,13 @@ describe('useConfiguration utils', () => {
it('should normalize an empty completion prompt config for chat apps', () => {
const publishedConfig = buildPublishedConfig({
backendModelConfig: {
backendModelConfig: createAppModelConfigFixture({
chat_prompt_config: {
prompt: [{ role: 'system', text: 'chat' }],
},
completion_prompt_config: {},
dataset_configs: {
retrieval_model: 'multiple',
datasets: { datasets: [] },
},
external_data_tools: [],
@@ -244,7 +363,7 @@ describe('useConfiguration utils', () => {
completion_params: {},
},
user_input_form: [],
} as any,
}),
collectionList: [],
datasetConfigs: {
datasets: { datasets: [] },
@@ -262,15 +381,16 @@ describe('useConfiguration utils', () => {
it('should build dataset configs with reranking defaults', () => {
const datasetConfigs = buildConfigurationDatasetConfigs({
backendModelConfig: {
backendModelConfig: createAppModelConfigFixture({
dataset_configs: {
retrieval_model: 'multiple',
datasets: { datasets: [] },
reranking_model: {
reranking_model_name: 'rerank-1',
reranking_provider_name: 'langgenius/cohere/cohere',
},
},
} as any,
}),
currentRerankModel: 'rerank-1',
currentRerankProvider: 'langgenius/cohere/cohere',
nextDataSets: [],
@@ -334,13 +454,6 @@ describe('useConfiguration utils', () => {
strategy: AgentStrategy.react,
tools: [],
},
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
} as any,
modelId: 'gpt-4o',
modelProvider: 'langgenius/openai/openai',
@@ -384,7 +497,7 @@ describe('useConfiguration utils', () => {
mockFetchAppDetailDirect.mockResolvedValue({
deleted_tools: [],
mode: AppModeEnum.CHAT,
model_config: {
model_config: createAppModelConfigFixture({
prompt_type: 'advanced',
chat_prompt_config: {
prompt: [{ role: 'system', text: 'hi' }],
@@ -397,8 +510,9 @@ describe('useConfiguration utils', () => {
},
},
dataset_configs: {
retrieval_model: 'multiple',
datasets: {
datasets: [{ id: 'dataset-1' }],
datasets: [{ dataset: { id: 'dataset-1', enabled: true } }],
},
},
model: {
@@ -415,6 +529,8 @@ describe('useConfiguration utils', () => {
retriever_resource: { enabled: true },
annotation_reply: {
enabled: true,
id: 'annotation-1',
score_threshold: 0.9,
embedding_model: {
embedding_provider_name: 'langgenius/openai/openai',
embedding_model_name: 'text-embedding-3-small',
@@ -424,14 +540,7 @@ describe('useConfiguration utils', () => {
external_data_tools: [],
user_input_form: [],
pre_prompt: '',
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
},
}),
})
mockFetchDatasets.mockResolvedValue({
data: [{ id: 'dataset-1', name: 'Dataset One' }],
@@ -443,7 +552,7 @@ describe('useConfiguration utils', () => {
})
expect(mockFetchCollectionList).toHaveBeenCalledTimes(1)
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ params: { app_id: 'app-1' } })
expect(mockFetchDatasets).toHaveBeenCalledWith({
params: {
ids: ['dataset-1'],
@@ -473,14 +582,16 @@ describe('useConfiguration utils', () => {
mockFetchAppDetailDirect.mockResolvedValue({
deleted_tools: [],
mode: AppModeEnum.AGENT_CHAT,
model_config: {
model_config: createAppModelConfigFixture({
prompt_type: 'simple',
chat_prompt_config: { prompt: [] },
completion_prompt_config: undefined,
dataset_configs: {
retrieval_model: 'multiple',
datasets: { datasets: [] },
},
agent_mode: {
enabled: false,
tools: [
{
dataset: {
@@ -496,30 +607,17 @@ describe('useConfiguration utils', () => {
mode: ModelModeType.chat,
completion_params: {},
},
annotation_reply: {
enabled: false,
embedding_model: {
embedding_provider_name: 'langgenius/openai/openai',
embedding_model_name: 'text-embedding-3-small',
},
},
annotation_reply: { enabled: false },
more_like_this: undefined,
speech_to_text: undefined,
text_to_speech: undefined,
retriever_resource: undefined,
suggested_questions: undefined,
suggested_questions_after_answer: undefined,
external_data_tools: undefined,
external_data_tools: [],
user_input_form: [],
pre_prompt: '',
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
},
}),
})
const state = await loadConfigurationState({ appId: 'app-2' })
@@ -540,16 +638,17 @@ describe('useConfiguration utils', () => {
expect(state.chatPromptConfig).toEqual(expect.any(Object))
})
it('should keep annotation config undefined when app detail does not include annotation settings', async () => {
it('should initialize the disabled annotation draft from the response', async () => {
mockFetchCollectionList.mockResolvedValue([])
mockFetchAppDetailDirect.mockResolvedValue({
deleted_tools: [],
mode: AppModeEnum.CHAT,
model_config: {
model_config: createAppModelConfigFixture({
prompt_type: 'simple',
chat_prompt_config: { prompt: [] },
completion_prompt_config: undefined,
dataset_configs: {
retrieval_model: 'multiple',
datasets: { datasets: [] },
},
model: {
@@ -564,22 +663,15 @@ describe('useConfiguration utils', () => {
retriever_resource: undefined,
suggested_questions: undefined,
suggested_questions_after_answer: undefined,
external_data_tools: undefined,
external_data_tools: [],
user_input_form: [],
pre_prompt: '',
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
},
}),
})
const state = await loadConfigurationState({ appId: 'app-3' })
expect(state.annotationConfig).toBeUndefined()
expect(state.annotationConfig).toEqual(expect.objectContaining({ enabled: false, id: '' }))
})
it('should hydrate selected datasets and open the rerank modal when selection changes', () => {
@@ -737,6 +829,57 @@ describe('useConfiguration utils', () => {
expect(setRerankSettingModalOpen).toHaveBeenCalledWith(true)
})
it.each(['chat prompt', 'model parameters'])(
'reports invalid %s without publishing or replacing the saved configuration',
async (invalidField) => {
const backendModelConfig = createAppModelConfigFixture({
model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
})
const config = buildPublishedConfig({
backendModelConfig,
collectionList: [],
datasetConfigs: buildConfigurationDatasetConfigs({ backendModelConfig, nextDataSets: [] }),
mode: AppModeEnum.CHAT,
nextDataSets: [],
})
const setPublishedConfig = vi.fn()
const updateModelConfig = vi.fn()
const onPublish = createPublishHandler({
appId: 'app-1',
chatPromptConfig:
invalidField === 'chat prompt'
? { prompt: [{ text: 'Missing role' }] }
: config.chatPromptConfig,
completionParamsState:
invalidField === 'model parameters'
? { temperature: Number.NaN }
: config.completionParams,
completionPromptConfig: config.completionPromptConfig,
contextVarEmpty: false,
dataSets: [],
datasetConfigs: config.datasetConfigs,
externalDataToolsConfig: [],
hasSetBlockStatus: { history: true, query: true },
isAdvancedMode: true,
isFunctionCall: false,
mode: AppModeEnum.CHAT,
modelConfig: config.modelConfig,
promptEmpty: false,
promptMode: 'advanced',
resolvedModelModeType: ModelModeType.chat,
setCanReturnToSimpleMode: vi.fn(),
setPublishedConfig,
t,
})
await expect(onPublish(updateModelConfig)).rejects.toThrow()
expect(mockToastError).toHaveBeenCalledWith('api.actionFailed')
expect(updateModelConfig).not.toHaveBeenCalled()
expect(setPublishedConfig).not.toHaveBeenCalled()
expect(mockToastSuccess).not.toHaveBeenCalled()
},
)
it('should validate and publish configuration changes', async () => {
const setPublishedConfig = vi.fn()
const setCanReturnToSimpleMode = vi.fn()
@@ -782,13 +925,6 @@ describe('useConfiguration utils', () => {
},
model_id: 'gpt-4o',
provider: 'langgenius/openai/openai',
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
} as any,
promptEmpty: false,
promptMode: 'advanced' as any,
@@ -833,7 +969,7 @@ describe('useConfiguration utils', () => {
strategy: AgentStrategy.functionCall,
}),
}),
url: '/apps/app-1/model-config',
params: { app_id: 'app-1' },
}),
)
expect(setPublishedConfig).toHaveBeenCalledWith(
@@ -891,13 +1027,6 @@ describe('useConfiguration utils', () => {
},
model_id: 'gpt-4o',
provider: 'langgenius/openai/openai',
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
} as any,
promptEmpty: false,
promptMode: 'advanced' as any,

View File

@@ -1,12 +1,13 @@
import { act, waitFor } from '@testing-library/react'
import { act, screen, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import CommonLayoutError from '@/app/(commonLayout)/error'
import ErrorBoundary from '@/app/components/base/error-boundary'
import { DETAIL_SIDEBAR_COOKIE_NAME } from '@/app/components/detail-sidebar/cookie'
import { updateAppModelConfig } from '@/service/apps'
import { consoleQuery } from '@/service/console'
import { consoleClient, consoleQuery } from '@/service/console'
import { seedAccountProfileQuery } from '@/test/console/account-profile'
import { createQueryClientWrapper } from '@/test/console/query-client'
import { renderHook as renderHookWithConsoleState } from '@/test/console/render'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { createAppDetailFixture, createAppModelConfigFixture } from '@/test/fixtures/app'
import { createTestQueryClient } from '@/test/query-client'
import { AppModeEnum, ModelModeType } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
@@ -28,6 +29,7 @@ const mockSetShowAppConfigureFeaturesModal = vi.fn()
const mockHandleMultipleModelConfigsChange = vi.fn()
const mockFetchCollectionList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockUpdateModelConfig = vi.hoisted(() => vi.fn())
const mockFetchDatasets = vi.fn()
const mockFetchAndMergeValidCompletionParams = vi.fn()
const mockFormattingChangedDispatcher = vi.fn()
@@ -90,9 +92,9 @@ vi.mock('@/app/components/app/store', () => ({
selector({
appDetail: {
id: 'app-1',
model_config: {
model_config: createAppModelConfigFixture({
updated_at: 1710000000,
},
}),
mode: AppModeEnum.CHAT,
permission_keys: mockAppPermissionKeys,
},
@@ -174,10 +176,37 @@ vi.mock('@/service/tools', () => ({
fetchCollectionList: (...args: unknown[]) => mockFetchCollectionList(...args),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
updateAppModelConfig: vi.fn(),
}))
vi.mock('@/service/console', async () => {
const actual = await vi.importActual<typeof import('@/service/console')>('@/service/console')
return {
...actual,
consoleQuery: {
...actual.consoleQuery,
account: actual.consoleQuery.account,
apps: {
byAppId: {
get: actual.consoleQuery.apps.byAppId.get,
modelConfig: {
post: {
mutationOptions: (options: Record<string, unknown>) => ({
...options,
mutationFn: (input: unknown) => mockUpdateModelConfig(input),
}),
},
},
},
},
},
consoleClient: {
apps: {
byAppId: {
get: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
modelConfig: { post: mockUpdateModelConfig },
},
},
},
}
})
vi.mock('@/service/datasets', () => ({
fetchDatasets: (...args: unknown[]) => mockFetchDatasets(...args),
@@ -203,11 +232,11 @@ describe('useConfiguration', () => {
params: { temperature: 0.3 },
removedDetails: {},
})
vi.mocked(updateAppModelConfig).mockResolvedValue(undefined as never)
vi.mocked(consoleClient.apps.byAppId.modelConfig.post).mockResolvedValue(undefined as never)
mockFetchAppDetailDirect.mockResolvedValue({
deleted_tools: [],
mode: AppModeEnum.CHAT,
model_config: {
model_config: createAppModelConfigFixture({
prompt_type: 'advanced',
chat_prompt_config: {
prompt: [{ role: 'system', text: 'hi' }],
@@ -220,6 +249,7 @@ describe('useConfiguration', () => {
},
},
dataset_configs: {
retrieval_model: 'multiple',
datasets: {
datasets: [],
},
@@ -238,20 +268,66 @@ describe('useConfiguration', () => {
speech_to_text: { enabled: false },
text_to_speech: { enabled: false, voice: '', language: '' },
retriever_resource: { enabled: true },
annotation_reply: null,
annotation_reply: { enabled: false },
sensitive_word_avoidance: { enabled: false },
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
},
}),
})
})
it('should leave loading through the error boundary when the app has no model configuration', async () => {
mockFetchAppDetailDirect.mockResolvedValueOnce(createAppDetailFixture({ model_config: null }))
const queryClient = createTestQueryClient()
seedAccountProfileQuery(queryClient, { id: 'user-1' })
const QueryWrapper = createQueryClientWrapper(queryClient)
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
renderHookWithConsoleState(() => useConfiguration(), {
wrapper: ({ children }) => (
<QueryWrapper>
<ErrorBoundary fallback={(error) => <div role="alert">{error.message}</div>}>
{children}
</ErrorBoundary>
</QueryWrapper>
),
})
expect(await screen.findByRole('alert')).toHaveTextContent(
'App app-1 has no model configuration',
)
} finally {
report.mockRestore()
}
})
it('should retain the legacy unauthorized response for the redirect loading fallback', async () => {
const response = new Response(null, { status: 401 })
mockFetchCollectionList.mockRejectedValueOnce(response)
const queryClient = createTestQueryClient()
seedAccountProfileQuery(queryClient, { id: 'user-1' })
const QueryWrapper = createQueryClientWrapper(queryClient)
const onError = vi.fn()
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
renderHookWithConsoleState(() => useConfiguration(), {
wrapper: ({ children }) => (
<QueryWrapper>
<ErrorBoundary
onError={onError}
fallback={(error) => <CommonLayoutError error={error} retry={vi.fn()} />}
>
{children}
</ErrorBoundary>
</QueryWrapper>
),
})
await waitFor(() => expect(onError).toHaveBeenCalledWith(response, expect.anything()))
expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
} finally {
report.mockRestore()
}
})
it('should load configuration state and expose the derived view model', async () => {
const { result } = renderHook(() => useConfiguration())
@@ -308,9 +384,9 @@ describe('useConfiguration', () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(updateAppModelConfig).toHaveBeenCalledWith(
expect(consoleClient.apps.byAppId.modelConfig.post).toHaveBeenCalledWith(
expect.objectContaining({
url: '/apps/app-1/model-config',
params: { app_id: 'app-1' },
}),
)
expect(queryClient.getQueryState(detailQueryKey)?.isInvalidated).toBe(true)
@@ -503,7 +579,7 @@ describe('useConfiguration', () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(updateAppModelConfig).not.toHaveBeenCalled()
expect(consoleClient.apps.byAppId.modelConfig.post).not.toHaveBeenCalled()
})
it('should allow test and run while keeping configuration readonly when only app test/run permission exists', async () => {
@@ -524,7 +600,7 @@ describe('useConfiguration', () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(updateAppModelConfig).not.toHaveBeenCalled()
expect(consoleClient.apps.byAppId.modelConfig.post).not.toHaveBeenCalled()
})
it('should keep configuration editable but block publishing when only app edit permission exists', async () => {
@@ -545,7 +621,7 @@ describe('useConfiguration', () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(updateAppModelConfig).not.toHaveBeenCalled()
expect(consoleClient.apps.byAppId.modelConfig.post).not.toHaveBeenCalled()
})
it('should allow publishing with app release permission even when configuration is readonly', async () => {
@@ -566,9 +642,9 @@ describe('useConfiguration', () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(updateAppModelConfig).toHaveBeenCalledWith(
expect(consoleClient.apps.byAppId.modelConfig.post).toHaveBeenCalledWith(
expect.objectContaining({
url: '/apps/app-1/model-config',
params: { app_id: 'app-1' },
}),
)
})
@@ -578,7 +654,7 @@ describe('useConfiguration', () => {
mockFetchAppDetailDirect.mockResolvedValueOnce({
deleted_tools: [],
mode: AppModeEnum.CHAT,
model_config: {
model_config: createAppModelConfigFixture({
prompt_type: 'simple',
chat_prompt_config: { prompt: [] },
completion_prompt_config: {
@@ -588,7 +664,10 @@ describe('useConfiguration', () => {
user_prefix: 'user',
},
},
dataset_configs: { datasets: { datasets: [] } },
dataset_configs: {
retrieval_model: 'multiple',
datasets: { datasets: [] },
},
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4o',
@@ -622,14 +701,7 @@ describe('useConfiguration', () => {
transfer_methods: ['local_file'],
},
},
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
},
}),
})
const { result } = renderHook(() => useConfiguration())
@@ -695,7 +767,7 @@ describe('useConfiguration', () => {
mockFetchAppDetailDirect.mockResolvedValueOnce({
deleted_tools: [],
mode: AppModeEnum.CHAT,
model_config: {
model_config: createAppModelConfigFixture({
prompt_type: 'simple',
chat_prompt_config: { prompt: [] },
completion_prompt_config: {
@@ -706,8 +778,9 @@ describe('useConfiguration', () => {
},
},
dataset_configs: {
retrieval_model: 'multiple',
datasets: {
datasets: [{ id: 'dataset-1' }],
datasets: [{ dataset: { id: 'dataset-1', enabled: true } }],
},
},
dataset_query_variable: 'context',
@@ -719,7 +792,7 @@ describe('useConfiguration', () => {
},
user_input_form: [
{
text_input: {
'text-input': {
variable: 'context',
label: 'Context',
required: false,
@@ -733,18 +806,11 @@ describe('useConfiguration', () => {
speech_to_text: { enabled: false },
text_to_speech: { enabled: false, voice: '', language: '' },
retriever_resource: { enabled: false },
annotation_reply: null,
annotation_reply: { enabled: false },
sensitive_word_avoidance: { enabled: false },
external_data_tools: [],
file_upload: null,
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
},
file_upload: {},
}),
})
const { result } = renderHook(() => useConfiguration())

View File

@@ -1,12 +1,14 @@
import type { AppModelConfigResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { DataSet } from '@/models/datasets'
import type { DatasetConfigs } from '@/models/debug'
import type { ModelConfig as BackendModelConfig } from '@/types/app'
import { produce } from 'immer'
import { useMemo } from 'react'
import {
getMultipleRetrievalConfig,
getSelectedDatasetsMode,
} from '@/app/components/workflow/nodes/knowledge-retrieval/utils'
import { DATASET_DEFAULT } from '@/config'
import { WeightedScoreEnum } from '@/models/datasets'
import { RETRIEVE_TYPE } from '@/types/app'
import { correctModelProvider } from '@/utils'
@@ -16,7 +18,7 @@ export function buildConfigurationDatasetConfigs({
currentRerankProvider,
nextDataSets,
}: {
backendModelConfig: BackendModelConfig
backendModelConfig: AppModelConfigResponse
currentRerankModel?: string
currentRerankProvider?: string
nextDataSets: DataSet[]
@@ -24,10 +26,23 @@ export function buildConfigurationDatasetConfigs({
const retrievalConfig = getMultipleRetrievalConfig(
{
...backendModelConfig.dataset_configs,
reranking_model: backendModelConfig.dataset_configs.reranking_model && {
provider: backendModelConfig.dataset_configs.reranking_model.reranking_provider_name,
model: backendModelConfig.dataset_configs.reranking_model.reranking_model_name,
},
top_k: backendModelConfig.dataset_configs.top_k ?? DATASET_DEFAULT.top_k,
score_threshold: backendModelConfig.dataset_configs.score_threshold,
weights: backendModelConfig.dataset_configs.weights
? {
...backendModelConfig.dataset_configs.weights,
weight_type:
backendModelConfig.dataset_configs.weights.weight_type ??
WeightedScoreEnum.Customized,
}
: undefined,
reranking_model:
(backendModelConfig.dataset_configs.reranking_model && {
provider:
backendModelConfig.dataset_configs.reranking_model.reranking_provider_name ?? '',
model: backendModelConfig.dataset_configs.reranking_model.reranking_model_name ?? '',
}) ||
undefined,
},
nextDataSets,
nextDataSets,
@@ -37,22 +52,42 @@ export function buildConfigurationDatasetConfigs({
},
)
const nextDatasetConfigs = {
...backendModelConfig.dataset_configs,
const config = backendModelConfig.dataset_configs
return {
...config,
...retrievalConfig,
...(retrievalConfig.reranking_model
retrieval_model: config.retrieval_model ?? RETRIEVE_TYPE.multiWay,
score_threshold_enabled: config.score_threshold_enabled ?? false,
datasets: {
datasets: nextDataSets.map(({ id }) => ({ enabled: true, id })),
},
reranking_model: {
reranking_model_name: retrievalConfig.reranking_model?.model ?? '',
reranking_provider_name: correctModelProvider(
retrievalConfig.reranking_model?.provider ?? '',
),
},
metadata_filtering_conditions: config.metadata_filtering_conditions
? {
reranking_model: {
reranking_model_name: retrievalConfig.reranking_model.model,
reranking_provider_name: correctModelProvider(retrievalConfig.reranking_model.provider),
},
logical_operator: config.metadata_filtering_conditions.logical_operator ?? 'and',
conditions: (config.metadata_filtering_conditions.conditions ?? []).map(
(condition, index) => ({
...condition,
id: condition.id ?? `${condition.name}-${index}`,
}),
),
}
: {}),
} as DatasetConfigs
nextDatasetConfigs.retrieval_model = nextDatasetConfigs.retrieval_model ?? RETRIEVE_TYPE.multiWay
return nextDatasetConfigs
: undefined,
metadata_model_config: config.metadata_model_config
? {
...config.metadata_model_config,
provider: config.metadata_model_config.provider ?? '',
name: config.metadata_model_config.name ?? '',
mode: config.metadata_model_config.mode ?? '',
completion_params: config.metadata_model_config.completion_params ?? {},
}
: undefined,
}
}
type DatasetSelectHandlerOptions = {

View File

@@ -1,30 +1,14 @@
import type { DataSet } from '@/models/datasets'
import type { AnnotationReplyConfig } from '@/models/debug'
import type { AppModeEnum, ModelConfig as BackendModelConfig } from '@/types/app'
import { zAppLegacyDatasetToolResponse } from '@dify/contracts/api/console/apps/zod.gen'
import { PromptMode } from '@/models/debug'
import { fetchAppDetailDirect } from '@/service/apps'
import { consoleClient } from '@/service/console'
import { fetchDatasets } from '@/service/datasets'
import { fetchCollectionList } from '@/service/tools'
import { correctModelProvider } from '@/utils'
import { AppModeEnum } from '@/types/app'
import { withCollectionIconBasePath } from '../../utils'
import { buildConfigurationDatasetConfigs } from './dataset'
import { normalizeChatPromptConfig, normalizeCompletionPromptConfig } from './prompt-config'
import { buildPublishedConfig } from './published-config'
function normalizeAnnotationConfig(annotationReply?: BackendModelConfig['annotation_reply']) {
if (!annotationReply) return undefined
if (!annotationReply.enabled) return annotationReply as AnnotationReplyConfig
return {
...annotationReply,
embedding_model: {
...annotationReply.embedding_model,
embedding_provider_name: correctModelProvider(
annotationReply.embedding_model.embedding_provider_name,
),
},
} as AnnotationReplyConfig
}
import { buildAnnotationDraft, buildPublishedConfig } from './published-config'
export async function loadConfigurationState({
appId,
@@ -38,24 +22,29 @@ export async function loadConfigurationState({
currentRerankProvider?: string
}) {
const collectionList = withCollectionIconBasePath(await fetchCollectionList(), basePath)
const response = await fetchAppDetailDirect({ url: '/apps', id: appId })
const backendModelConfig = response.model_config as BackendModelConfig
const response = await consoleClient.apps.byAppId.get({ params: { app_id: appId } })
const mode = response.mode
if (
mode !== AppModeEnum.CHAT &&
mode !== AppModeEnum.AGENT_CHAT &&
mode !== AppModeEnum.COMPLETION
)
throw new Error(`App mode ${mode} does not use model configuration`)
const backendModelConfig = response.model_config
if (!backendModelConfig) throw new Error(`App ${appId} has no model configuration`)
const nextPromptMode =
backendModelConfig.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple
let nextDataSets: DataSet[] = []
const agentModeTools = (backendModelConfig.agent_mode?.tools ?? []) as Array<{
dataset?: { enabled: boolean; id: string }
}>
const agentDatasets = (backendModelConfig.agent_mode.tools ?? []).flatMap((tool) => {
const result = zAppLegacyDatasetToolResponse.safeParse(tool)
return result.success ? [result.data.dataset] : []
})
const configuredDatasets = agentDatasets.some((dataset) => dataset.enabled)
? agentDatasets
: (backendModelConfig.dataset_configs.datasets?.datasets ?? []).map((item) => item.dataset)
const datasetIds = configuredDatasets.flatMap(({ id }) => (id ? [id] : []))
if (agentModeTools.find((tool) => tool.dataset?.enabled))
nextDataSets = agentModeTools as unknown as DataSet[]
else if (backendModelConfig.dataset_configs.datasets?.datasets?.length)
nextDataSets = backendModelConfig.dataset_configs.datasets.datasets as unknown as DataSet[]
if (nextDataSets.length) {
const datasetIds = (nextDataSets as Array<DataSet & { dataset?: { id: string } }>)
.map((item) => item.dataset?.id || item.id)
.filter((id): id is string => Boolean(id))
if (datasetIds.length) {
const { data } = await fetchDatasets({
url: '/datasets',
params: {
@@ -73,8 +62,17 @@ export async function loadConfigurationState({
nextDataSets,
})
const publishedConfig = buildPublishedConfig({
backendModelConfig,
collectionList,
datasetConfigs,
deletedTools: response.deleted_tools,
mode,
nextDataSets,
})
return {
annotationConfig: normalizeAnnotationConfig(backendModelConfig.annotation_reply),
annotationConfig: buildAnnotationDraft(backendModelConfig.annotation_reply),
backendModelConfig,
canReturnToSimpleMode: nextPromptMode !== PromptMode.advanced,
collectionList,
@@ -82,19 +80,12 @@ export async function loadConfigurationState({
backendModelConfig.completion_prompt_config,
),
datasetConfigs,
externalDataToolsConfig: backendModelConfig.external_data_tools ?? [],
mode: response.mode as AppModeEnum,
externalDataToolsConfig: publishedConfig.externalDataToolsConfig,
mode,
moreLikeThisConfig: backendModelConfig.more_like_this || { enabled: false },
nextDataSets,
promptMode: nextPromptMode,
publishedConfig: buildPublishedConfig({
backendModelConfig,
collectionList,
datasetConfigs,
deletedTools: response.deleted_tools,
mode: response.mode as AppModeEnum,
nextDataSets,
}),
publishedConfig,
response,
speechToTextConfig: backendModelConfig.speech_to_text || { enabled: false },
suggestedQuestions: backendModelConfig.suggested_questions || [],

View File

@@ -1,15 +1,36 @@
import type { ModelConfig as BackendModelConfig } from '@/types/app'
import type {
AppChatPromptPayload,
AppCompletionPromptPayload,
} from '@dify/contracts/api/console/apps/types.gen'
import type { ChatPromptConfig, CompletionPromptConfig } from '@/models/debug'
import { clone } from 'es-toolkit/object'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
export const normalizeChatPromptConfig = (
chatPromptConfig: BackendModelConfig['chat_prompt_config'],
): NonNullable<BackendModelConfig['chat_prompt_config']> =>
chatPromptConfig?.prompt?.length ? chatPromptConfig : clone(DEFAULT_CHAT_PROMPT_CONFIG)
chatPromptConfig?: AppChatPromptPayload | null,
): ChatPromptConfig =>
chatPromptConfig?.prompt?.length
? {
...chatPromptConfig,
prompt: chatPromptConfig.prompt.map((prompt) => ({
...prompt,
role: prompt.role ?? undefined,
})),
}
: clone(DEFAULT_CHAT_PROMPT_CONFIG)
export const normalizeCompletionPromptConfig = (
completionPromptConfig: BackendModelConfig['completion_prompt_config'],
): NonNullable<BackendModelConfig['completion_prompt_config']> =>
completionPromptConfig?: AppCompletionPromptPayload | null,
): CompletionPromptConfig =>
completionPromptConfig?.prompt && completionPromptConfig.conversation_histories_role
? completionPromptConfig
? {
...completionPromptConfig,
prompt: { ...completionPromptConfig.prompt },
conversation_histories_role: {
...completionPromptConfig.conversation_histories_role,
user_prefix: completionPromptConfig.conversation_histories_role.user_prefix ?? '',
assistant_prefix:
completionPromptConfig.conversation_histories_role.assistant_prefix ?? '',
},
}
: clone(DEFAULT_COMPLETION_PROMPT_CONFIG)

View File

@@ -1,10 +1,21 @@
import type { AppModelConfigPayload } from '@dify/contracts/api/console/apps/types.gen'
import type { TFunction } from 'i18next'
import type { ConfigurationPublishConfig } from './types'
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { DataSet } from '@/models/datasets'
import type { DatasetConfigs, ModelConfig, PromptVariable } from '@/models/debug'
import type { ModelConfig as BackendModelConfig } from '@/types/app'
import type { ConsoleClient } from '@/service/console'
import {
zAppAgentModePayload,
zAppChatPromptPayload,
zAppDatasetConfigPayload,
zAppExternalDataToolPayload,
zAppFileUploadPayload,
zAppModelConfigPayload,
zAppSuggestedQuestionsPayload,
zAppUserInputFormPayload,
} from '@dify/contracts/api/console/apps/zod.gen'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
import { toast } from '@/app/components/app/configuration/toast'
@@ -33,24 +44,24 @@ export function buildPublishBody({
promptTemplate,
resolvedModelModeType,
}: {
chatPromptConfig: BackendModelConfig['chat_prompt_config']
chatPromptConfig: ModelConfig['chat_prompt_config']
completionParams: FormValue
completionPromptConfig: BackendModelConfig['completion_prompt_config']
completionPromptConfig: ModelConfig['completion_prompt_config']
contextVar?: string
dataSets: DataSet[]
datasetConfigs: DatasetConfigs
externalDataToolsConfig: BackendModelConfig['external_data_tools']
externalDataToolsConfig: ModelConfig['external_data_tools']
features?: FeaturesData
isAdvancedMode: boolean
isFunctionCall: boolean
modelConfig: ModelConfig
modelId: string
modelProvider: string
promptMode: BackendModelConfig['prompt_type']
promptMode: PromptMode
promptVariables: PromptVariable[]
promptTemplate: string
resolvedModelModeType: BackendModelConfig['model']['mode']
}): BackendModelConfig {
resolvedModelModeType: ModelModeType
}) {
const postDatasets = dataSets.map(({ id }) => ({
dataset: {
enabled: true,
@@ -63,42 +74,47 @@ export function buildPublishBody({
return {
pre_prompt: !isAdvancedMode ? promptTemplate : '',
prompt_type: promptMode,
chat_prompt_config: isAdvancedMode ? chatPromptConfig : clone(DEFAULT_CHAT_PROMPT_CONFIG),
chat_prompt_config: isAdvancedMode
? zAppChatPromptPayload.nullish().parse(chatPromptConfig)
: clone(DEFAULT_CHAT_PROMPT_CONFIG),
completion_prompt_config: isAdvancedMode
? completionPromptConfig
: clone(DEFAULT_COMPLETION_PROMPT_CONFIG),
user_input_form: promptVariablesToUserInputsForm(promptVariables),
user_input_form: zAppUserInputFormPayload
.array()
.parse(promptVariablesToUserInputsForm(promptVariables)),
dataset_query_variable: contextVar || '',
more_like_this: features?.moreLikeThis as never,
more_like_this: features?.moreLikeThis,
opening_statement: features?.opening?.enabled ? features.opening?.opening_statement || '' : '',
suggested_questions: features?.opening?.enabled
? features.opening?.suggested_questions || []
: [],
sensitive_word_avoidance: features?.moderation as never,
speech_to_text: features?.speech2text as never,
text_to_speech: features?.text2speech as never,
file_upload: fileUpload as never,
suggested_questions_after_answer: features?.suggested as never,
retriever_resource: features?.citation as never,
agent_mode: {
sensitive_word_avoidance: features?.moderation,
speech_to_text: features?.speech2text,
text_to_speech: features?.text2speech,
file_upload: zAppFileUploadPayload.parse(fileUpload),
suggested_questions_after_answer: features?.suggested
? zAppSuggestedQuestionsPayload.parse(features.suggested)
: undefined,
retriever_resource: features?.citation,
agent_mode: zAppAgentModePayload.parse({
...modelConfig.agentConfig,
strategy: isFunctionCall ? AgentStrategy.functionCall : AgentStrategy.react,
},
external_data_tools: externalDataToolsConfig,
}),
external_data_tools: externalDataToolsConfig
? zAppExternalDataToolPayload.array().parse(externalDataToolsConfig)
: externalDataToolsConfig,
model: {
provider: modelProvider,
name: modelId,
mode: resolvedModelModeType,
completion_params: completionParams as BackendModelConfig['model']['completion_params'],
completion_params: completionParams,
},
dataset_configs: {
dataset_configs: zAppDatasetConfigPayload.parse({
...datasetConfigs,
datasets: {
datasets: [...postDatasets],
} as never,
},
system_parameters: modelConfig.system_parameters,
}
datasets: { datasets: postDatasets },
}),
} satisfies AppModelConfigPayload
}
export const createPublishHandler =
@@ -125,28 +141,30 @@ export const createPublishHandler =
t,
}: {
appId: string
chatPromptConfig: BackendModelConfig['chat_prompt_config']
chatPromptConfig: ModelConfig['chat_prompt_config']
completionParamsState: FormValue
completionPromptConfig: BackendModelConfig['completion_prompt_config']
completionPromptConfig: ModelConfig['completion_prompt_config']
contextVar?: string
contextVarEmpty: boolean
dataSets: DataSet[]
datasetConfigs: DatasetConfigs
externalDataToolsConfig: BackendModelConfig['external_data_tools']
externalDataToolsConfig: ModelConfig['external_data_tools']
hasSetBlockStatus: { history: boolean; query: boolean }
isAdvancedMode: boolean
isFunctionCall: boolean
mode: AppModeEnum
modelConfig: ModelConfig
promptEmpty: boolean
promptMode: BackendModelConfig['prompt_type']
promptMode: PromptMode
resolvedModelModeType: ModelModeType
setCanReturnToSimpleMode: (value: boolean) => void
setPublishedConfig: (config: ConfigurationPublishConfig) => void
t: TFunction<['appDebug', 'common']>
}) =>
async (
updateAppModelConfig: (params: { url: string; body: BackendModelConfig }) => Promise<unknown>,
updateAppModelConfig: (
params: Parameters<ConsoleClient['apps']['byAppId']['modelConfig']['post']>[0],
) => Promise<unknown>,
modelAndParameter?: { model: string; provider: string; parameters: FormValue },
features?: FeaturesData,
) => {
@@ -179,27 +197,38 @@ export const createPublishHandler =
return
}
const body = buildPublishBody({
chatPromptConfig,
completionParams: modelAndParameter?.parameters || completionParamsState,
completionPromptConfig,
contextVar,
dataSets,
datasetConfigs,
externalDataToolsConfig,
features,
isAdvancedMode,
isFunctionCall,
modelConfig,
modelId,
modelProvider: modelAndParameter?.provider || modelConfig.provider,
promptMode,
promptTemplate,
promptVariables,
resolvedModelModeType,
})
let body: ReturnType<typeof buildPublishBody>
let payload: ReturnType<typeof zAppModelConfigPayload.parse>
try {
body = buildPublishBody({
chatPromptConfig,
completionParams: modelAndParameter?.parameters || completionParamsState,
completionPromptConfig,
contextVar,
dataSets,
datasetConfigs,
externalDataToolsConfig,
features,
isAdvancedMode,
isFunctionCall,
modelConfig,
modelId,
modelProvider: modelAndParameter?.provider || modelConfig.provider,
promptMode,
promptTemplate,
promptVariables,
resolvedModelModeType,
})
payload = zAppModelConfigPayload.parse(body)
} catch (error) {
toast.error(t(($) => $['api.actionFailed'], { ns: 'common' }))
throw error
}
await updateAppModelConfig({ url: `/apps/${appId}/model-config`, body })
await updateAppModelConfig({
params: { app_id: appId },
body: payload,
})
const nextModelConfig = produce(modelConfig, (draft: ModelConfig) => {
draft.provider = body.model.provider
draft.model_id = body.model.name
@@ -212,20 +241,32 @@ export const createPublishHandler =
)
draft.opening_statement = body.opening_statement
draft.more_like_this = body.more_like_this
? { ...body.more_like_this, enabled: body.more_like_this.enabled ?? false }
: null
draft.suggested_questions = body.suggested_questions ?? []
draft.suggested_questions_after_answer = body.suggested_questions_after_answer
draft.suggested_questions_after_answer = features?.suggested
? { ...features.suggested, enabled: features.suggested.enabled ?? false }
: null
draft.speech_to_text = body.speech_to_text
? { ...body.speech_to_text, enabled: body.speech_to_text.enabled ?? false }
: null
draft.text_to_speech = body.text_to_speech
draft.file_upload = body.file_upload ?? null
? { ...body.text_to_speech, enabled: body.text_to_speech.enabled ?? false }
: null
draft.file_upload = features?.file ? { ...features.file, fileUploadConfig: undefined } : null
draft.retriever_resource = body.retriever_resource
? { ...body.retriever_resource, enabled: body.retriever_resource.enabled ?? false }
: null
draft.sensitive_word_avoidance = body.sensitive_word_avoidance
draft.external_data_tools = body.external_data_tools
draft.system_parameters = body.system_parameters
const publishedAgentConfig = body.agent_mode as ModelConfig['agentConfig']
? {
...body.sensitive_word_avoidance,
enabled: body.sensitive_word_avoidance.enabled ?? false,
}
: null
draft.external_data_tools = externalDataToolsConfig
draft.agentConfig = {
...draft.agentConfig,
...publishedAgentConfig,
max_iteration: publishedAgentConfig.max_iteration || draft.agentConfig.max_iteration,
strategy: isFunctionCall ? AgentStrategy.functionCall : AgentStrategy.react,
}
draft.dataSets = dataSets
})
@@ -239,9 +280,9 @@ export const createPublishHandler =
completionPromptConfig: normalizeCompletionPromptConfig(body.completion_prompt_config),
datasetConfigs: {
...datasetConfigs,
datasets: body.dataset_configs.datasets,
datasets: { datasets: dataSets.map(({ id }) => ({ enabled: true, id })) },
},
externalDataToolsConfig: body.external_data_tools ?? [],
externalDataToolsConfig: externalDataToolsConfig ?? [],
})
toast.success(t(($) => $['api.success'], { ns: 'common' }))
setCanReturnToSimpleMode(false)

View File

@@ -1,9 +1,16 @@
import type {
AppModelConfigResponse,
DeletedTool,
} from '@dify/contracts/api/console/apps/types.gen'
import type { ConfigurationPublishConfig } from './types'
import type { Collection } from '@/app/components/tools/types'
import type { DataSet } from '@/models/datasets'
import type { DatasetConfigs, ModelConfig } from '@/models/debug'
import type { ModelConfig as BackendModelConfig, UserInputFormItem } from '@/types/app'
import { DEFAULT_AGENT_SETTING } from '@/config'
import type { AnnotationReplyConfig, DatasetConfigs, ModelConfig } from '@/models/debug'
import {
zAppExternalDataToolPayload,
zAppProviderAgentToolResponse,
} from '@dify/contracts/api/console/apps/zod.gen'
import { ANNOTATION_DEFAULT, DEFAULT_AGENT_SETTING } from '@/config'
import { PromptMode } from '@/models/debug'
import { AgentStrategy, AppModeEnum } from '@/types/app'
import { correctModelProvider, correctToolProvider } from '@/utils'
@@ -11,21 +18,26 @@ import { userInputsFormToPromptVariables } from '@/utils/model-config'
import { matchesProviderReference } from '@/utils/provider-reference'
import { normalizeChatPromptConfig, normalizeCompletionPromptConfig } from './prompt-config'
type BackendAgentTool = ModelConfig['agentConfig']['tools'][number] & {
dataset?: {
enabled: boolean
id: string
export function buildAnnotationDraft(
annotation: AppModelConfigResponse['annotation_reply'],
): AnnotationReplyConfig {
if (!annotation.enabled) {
return {
id: '',
enabled: false,
score_threshold: ANNOTATION_DEFAULT.score_threshold,
embedding_model: { embedding_provider_name: '', embedding_model_name: '' },
}
}
return {
...annotation,
embedding_model: {
...annotation.embedding_model,
embedding_provider_name: correctModelProvider(
annotation.embedding_model.embedding_provider_name,
),
},
}
provider_id: string
provider_name: string
provider_type: string
tool_name: string
}
type DeletedTool = {
id?: string
provider_id?: string
tool_name: string
}
function buildPublishedModelConfig({
@@ -35,40 +47,38 @@ function buildPublishedModelConfig({
mode,
nextDataSets,
}: {
backendModelConfig: BackendModelConfig
backendModelConfig: AppModelConfigResponse
collectionList: Collection[]
deletedTools?: DeletedTool[]
mode: AppModeEnum
nextDataSets: DataSet[]
}): ModelConfig {
const model = backendModelConfig.model
const agentModeTools = (backendModelConfig.agent_mode?.tools ?? []) as BackendAgentTool[]
const externalDataTools = backendModelConfig.external_data_tools.map((tool) => {
const { config } = zAppExternalDataToolPayload.parse({ config: tool.config })
return { ...tool, config: config ?? undefined }
})
const image = backendModelConfig.file_upload.image
return {
provider: correctModelProvider(model.provider),
model_id: model.name,
mode: model.mode,
provider: correctModelProvider(model.provider ?? ''),
model_id: model.name ?? '',
mode: model.mode ?? '',
configs: {
prompt_template: backendModelConfig.pre_prompt || '',
prompt_template: backendModelConfig.pre_prompt ?? '',
prompt_variables: userInputsFormToPromptVariables(
[
...backendModelConfig.user_input_form,
...(backendModelConfig.external_data_tools?.length
? backendModelConfig.external_data_tools.map((item) => ({
external_data_tool: {
variable: item.variable as string,
label: item.label as string,
enabled: !!item.enabled,
type: item.type as string,
config: item.config,
required: true,
icon: item.icon,
icon_background: item.icon_background,
},
}))
: []),
] as unknown as UserInputFormItem[],
backendModelConfig.dataset_query_variable,
...externalDataTools.map((item) => ({
external_data_tool: {
...item,
label: item.label ?? '',
variable: item.variable ?? '',
required: true,
},
})),
],
backendModelConfig.dataset_query_variable ?? undefined,
),
},
prompt_type: backendModelConfig.prompt_type,
@@ -76,57 +86,73 @@ function buildPublishedModelConfig({
completion_prompt_config: normalizeCompletionPromptConfig(
backendModelConfig.completion_prompt_config,
),
more_like_this: backendModelConfig.more_like_this ?? { enabled: false },
more_like_this: backendModelConfig.more_like_this,
opening_statement: backendModelConfig.opening_statement,
suggested_questions: backendModelConfig.suggested_questions ?? [],
suggested_questions: backendModelConfig.suggested_questions,
sensitive_word_avoidance: backendModelConfig.sensitive_word_avoidance,
speech_to_text: backendModelConfig.speech_to_text,
text_to_speech: backendModelConfig.text_to_speech,
file_upload: backendModelConfig.file_upload ?? null,
suggested_questions_after_answer: backendModelConfig.suggested_questions_after_answer ?? {
enabled: false,
file_upload: {
...backendModelConfig.file_upload,
image: image
? {
...image,
detail: image.detail ?? undefined,
}
: undefined,
},
suggested_questions_after_answer: backendModelConfig.suggested_questions_after_answer,
retriever_resource: backendModelConfig.retriever_resource,
annotation_reply: backendModelConfig.annotation_reply ?? null,
external_data_tools: backendModelConfig.external_data_tools ?? [],
system_parameters: backendModelConfig.system_parameters,
annotation_reply: buildAnnotationDraft(backendModelConfig.annotation_reply),
external_data_tools: externalDataTools,
dataSets: nextDataSets,
agentConfig:
mode === AppModeEnum.AGENT_CHAT
? {
max_iteration: DEFAULT_AGENT_SETTING.max_iteration,
...backendModelConfig.agent_mode,
enabled: true,
tools: agentModeTools
max_iteration:
backendModelConfig.agent_mode.max_iteration ?? DEFAULT_AGENT_SETTING.max_iteration,
tools: (backendModelConfig.agent_mode.tools ?? [])
.filter((tool) => !tool.dataset)
.map((tool) => {
const toolInCollectionList = collectionList.find((collection) =>
matchesProviderReference(collection, tool.provider_id),
const providerTool = zAppProviderAgentToolResponse.safeParse(tool)
if (!providerTool.success) return tool
const current = providerTool.data
const collection = collectionList.find((item) =>
matchesProviderReference(item, current.provider_id),
)
return {
...tool,
...current,
provider_name: current.provider_name ?? current.provider_id,
tool_label: current.tool_label ?? current.tool_name,
isDeleted:
deletedTools?.some(
(deletedTool) =>
(deletedTool.provider_id || deletedTool.id) === tool.provider_id &&
deletedTool.tool_name === tool.tool_name,
(deleted) =>
deleted.provider_id === current.provider_id &&
deleted.tool_name === current.tool_name,
) ?? false,
notAuthor: toolInCollectionList?.is_team_authorization === false,
...(tool.provider_type === 'builtin'
notAuthor: collection?.is_team_authorization === false,
...(current.provider_type === 'builtin'
? {
provider_id: correctToolProvider(
tool.provider_name,
!!toolInCollectionList,
current.provider_name ?? current.provider_id,
!!collection,
),
provider_name: correctToolProvider(
tool.provider_name,
!!toolInCollectionList,
current.provider_name ?? current.provider_id,
!!collection,
),
}
: {}),
}
}) as ModelConfig['agentConfig']['tools'],
strategy: backendModelConfig.agent_mode?.strategy ?? AgentStrategy.react,
}),
strategy:
backendModelConfig.agent_mode.strategy === 'function_call' ||
backendModelConfig.agent_mode.strategy === 'function-calling'
? AgentStrategy.functionCall
: AgentStrategy.react,
}
: DEFAULT_AGENT_SETTING,
}
@@ -140,22 +166,23 @@ export function buildPublishedConfig({
mode,
nextDataSets,
}: {
backendModelConfig: BackendModelConfig
backendModelConfig: AppModelConfigResponse
collectionList: Collection[]
datasetConfigs: DatasetConfigs
deletedTools?: DeletedTool[]
mode: AppModeEnum
nextDataSets: DataSet[]
}): ConfigurationPublishConfig {
const modelConfig = buildPublishedModelConfig({
backendModelConfig,
collectionList,
deletedTools,
mode,
nextDataSets,
})
return {
modelConfig: buildPublishedModelConfig({
backendModelConfig,
collectionList,
deletedTools,
mode,
nextDataSets,
}),
completionParams: backendModelConfig.model.completion_params,
modelConfig,
completionParams: backendModelConfig.model.completion_params ?? {},
promptMode:
backendModelConfig.prompt_type === PromptMode.advanced
? PromptMode.advanced
@@ -165,6 +192,6 @@ export function buildPublishedConfig({
backendModelConfig.completion_prompt_config,
),
datasetConfigs,
externalDataToolsConfig: backendModelConfig.external_data_tools ?? [],
externalDataToolsConfig: modelConfig.external_data_tools ?? [],
}
}

View File

@@ -1,13 +1,12 @@
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { DatasetConfigs, ModelConfig, PromptMode } from '@/models/debug'
import type { ModelConfig as BackendModelConfig } from '@/types/app'
export type ConfigurationPublishConfig = {
modelConfig: ModelConfig
completionParams: FormValue
promptMode: PromptMode
chatPromptConfig: NonNullable<BackendModelConfig['chat_prompt_config']>
completionPromptConfig: NonNullable<BackendModelConfig['completion_prompt_config']>
chatPromptConfig: NonNullable<ModelConfig['chat_prompt_config']>
completionPromptConfig: NonNullable<ModelConfig['completion_prompt_config']>
datasetConfigs: DatasetConfigs
externalDataToolsConfig: NonNullable<BackendModelConfig['external_data_tools']>
externalDataToolsConfig: NonNullable<ModelConfig['external_data_tools']>
}

View File

@@ -7,7 +7,6 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { usePathname } from '@/next/navigation'
import { updateAppModelConfig } from '@/service/apps'
import { consoleQuery } from '@/service/console'
import { getAppACLCapabilities } from '@/utils/permission'
@@ -40,16 +39,16 @@ export function useConfigurationAppContext() {
}),
[appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys],
)
const { mutateAsync: updateModelConfig } = useMutation({
mutationFn: (params: Parameters<typeof updateAppModelConfig>[0]) =>
updateAppModelConfig(params),
onSuccess: (_data, _variables, _onMutateResult, context) =>
context.client.invalidateQueries({
queryKey: consoleQuery.apps.byAppId.get.queryKey({
input: { params: { app_id: appId } },
const { mutateAsync: updateModelConfig } = useMutation(
consoleQuery.apps.byAppId.modelConfig.post.mutationOptions({
onSuccess: (_data, variables, _onMutateResult, context) =>
context.client.invalidateQueries({
queryKey: consoleQuery.apps.byAppId.get.queryKey({
input: { params: { app_id: variables.params.app_id } },
}),
}),
}),
})
}),
)
return {
appACLCapabilities,

View File

@@ -3,7 +3,7 @@ import type { ConfigurationPublishConfig } from './types'
import type { Collection } from '@/app/components/tools/types'
import type { AnnotationReplyConfig } from '@/models/debug'
import type { AppModeEnum } from '@/types/app'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { basePath } from '@/utils/var'
import { loadConfigurationState } from './load'
@@ -22,11 +22,13 @@ export function useConfigurationLoader({
appId,
...operations
}: ConfigurationLoaderOperations & { appId: string }) {
const [loadFailure, setLoadFailure] = useState<{ error: unknown } | null>(null)
const operationsRef = useRef(operations)
operationsRef.current = operations
useEffect(() => {
const current = operationsRef.current
let cancelled = false
void (async () => {
const configurationState = await loadConfigurationState({
appId,
@@ -35,6 +37,8 @@ export function useConfigurationLoader({
currentRerankProvider: current.currentRerankProvider,
})
if (cancelled) return
current.setCollectionList(configurationState.collectionList)
current.setMode(configurationState.mode)
current.syncToPublishedConfig(configurationState.publishedConfig)
@@ -43,6 +47,13 @@ export function useConfigurationLoader({
current.setPublishedConfig(configurationState.publishedConfig)
current.setHasFetchedDetail(true)
})()
})().catch((error: unknown) => {
if (!cancelled) setLoadFailure({ error })
})
return () => {
cancelled = true
}
}, [appId])
if (loadFailure) throw loadFailure.error
}

View File

@@ -5,35 +5,38 @@ import type { AppPublisherPublishParams } from '@/app/components/app/app-publish
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { DataSet } from '@/models/datasets'
import type { DatasetConfigs, ModelConfig } from '@/models/debug'
import type { AppModeEnum, ModelConfig as BackendModelConfig, ModelModeType } from '@/types/app'
import type { DatasetConfigs, ModelConfig, PromptMode } from '@/models/debug'
import type { ConsoleClient } from '@/service/console'
import type { AppModeEnum, ModelModeType } from '@/types/app'
import { useCallback } from 'react'
import { createPublishHandler } from './publish'
type UseConfigurationPublishParams = {
appId: string
canReleaseAndVersion: boolean
chatPromptConfig: BackendModelConfig['chat_prompt_config']
chatPromptConfig: ModelConfig['chat_prompt_config']
completionParams: FormValue
completionPromptConfig: BackendModelConfig['completion_prompt_config']
completionPromptConfig: ModelConfig['completion_prompt_config']
contextVar?: string
contextVarEmpty: boolean
dataSets: DataSet[]
datasetConfigs: DatasetConfigs
externalDataToolsConfig: BackendModelConfig['external_data_tools']
externalDataToolsConfig: ModelConfig['external_data_tools']
hasSetBlockStatus: { history: boolean; query: boolean }
isAdvancedMode: boolean
isFunctionCall: boolean
mode: AppModeEnum
modelConfig: ModelConfig
promptEmpty: boolean
promptMode: BackendModelConfig['prompt_type']
promptMode: PromptMode
resolvedModelModeType: ModelModeType
setCanReturnToSimpleMode: (value: boolean) => void
setPublishedConfig: Dispatch<SetStateAction<ConfigurationPublishConfig | null>>
syncToPublishedConfig: (config: ConfigurationPublishConfig) => void
t: TFunction<['appDebug', 'common']>
updateModelConfig: (params: { url: string; body: BackendModelConfig }) => Promise<unknown>
updateModelConfig: (
params: Parameters<ConsoleClient['apps']['byAppId']['modelConfig']['post']>[0],
) => Promise<unknown>
}
export function useConfigurationPublish({

View File

@@ -33,13 +33,6 @@ function createInitialModelConfig(): ModelConfig {
retriever_resource: null,
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
dataSets: [],
agentConfig: DEFAULT_AGENT_SETTING,
}

View File

@@ -34,6 +34,14 @@ const codeBasedExtensionList: { data: CodeBasedExtensionItem[] } = {
options: [],
label: i18n('API Key', '接口密钥'),
},
{
variable: 'optional_token',
required: false,
type: 'text',
placeholder: '',
options: [],
label: i18n('Optional token'),
},
],
},
],
@@ -59,7 +67,7 @@ describe('external-data-tool-modal-utils', () => {
])
expect(getProviderDefaultConfig('api', providers)).toBeUndefined()
expect(getProviderDefaultConfig('code-tool', providers)).toEqual({
expect(getProviderDefaultConfig('code-tool', providers)).toStrictEqual({
api_key: 'default-key',
})
})

View File

@@ -1,3 +1,4 @@
import type { AppConfigJsonValue } from '@dify/contracts/api/console/apps/types.gen'
import type { TFunction } from 'i18next'
import type { CodeBasedExtensionItem, ExternalDataTool } from '@/models/common'
import { LanguagesSupported } from '@/i18n/language'
@@ -51,7 +52,7 @@ export const getProviderDefaultConfig = (type: string, providers: Provider[]) =>
return undefined
return currentProvider.form_schema.reduce<Record<string, string>>((prev, next) => {
prev[next.variable] = next.default
if (next.default !== undefined) prev[next.variable] = next.default
return prev
}, {})
}
@@ -62,13 +63,15 @@ export const formatExternalDataTool = (
isEdit: boolean,
) => {
const { type, config } = originData
const params: Record<string, string | undefined> = {}
const params: Record<string, AppConfigJsonValue> = {}
if (type === 'api') params.api_based_extension_id = config?.api_based_extension_id
if (type === 'api' && config?.api_based_extension_id !== undefined)
params.api_based_extension_id = config.api_based_extension_id
if (!systemTypes.includes(type as (typeof systemTypes)[number]) && currentProvider?.form_schema) {
currentProvider.form_schema.forEach((form) => {
params[form.variable] = config?.[form.variable]
const value = config?.[form.variable]
if (value !== undefined) params[form.variable] = value
})
}

View File

@@ -1,11 +1,12 @@
import type { AppDetail } from '@dify/contracts/api/console/apps/types.gen'
import type { ReactElement } from 'react'
import type { App } from '@/types/app'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { useRouter } from '@/next/navigation'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { mockEmojiData } from '@/test/emoji-picker'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { trackCreateApp } from '@/utils/create-app-tracking'
@@ -141,12 +142,11 @@ describe('CreateAppModal', () => {
})
it('creates an app, notifies success, and fires callbacks', async () => {
const mockApp: Partial<App> = {
id: 'app-1',
const mockApp = createAppDetailFixture({
mode: AppModeEnum.ADVANCED_CHAT,
maintainer: 'user-1',
}
mockCreateApp.mockResolvedValue(mockApp as App)
})
mockCreateApp.mockResolvedValue(mockApp)
const { onClose } = renderModal()
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
@@ -181,13 +181,12 @@ describe('CreateAppModal', () => {
})
it('waits for create_app tracking before redirecting after blank app creation', async () => {
const mockApp: Partial<App> = {
id: 'app-1',
const mockApp = createAppDetailFixture({
mode: AppModeEnum.ADVANCED_CHAT,
maintainer: 'user-1',
}
})
let resolveTracking: (() => void) | undefined
mockCreateApp.mockResolvedValue(mockApp as App)
mockCreateApp.mockResolvedValue(mockApp)
mockTrackCreateApp.mockReturnValue(
new Promise<void>((resolve) => {
resolveTracking = resolve
@@ -251,7 +250,9 @@ describe('CreateAppModal', () => {
})
it('creates a beginner chat app with the keyboard shortcut and selected icon style', async () => {
mockCreateApp.mockResolvedValue({ id: 'chat-app', mode: AppModeEnum.CHAT } as App)
mockCreateApp.mockResolvedValue(
createAppDetailFixture({ id: 'chat-app', mode: AppModeEnum.CHAT }),
)
renderModal()
fireEvent.click(screen.getByText('app.newApp.forBeginners'))
@@ -324,7 +325,9 @@ describe('CreateAppModal', () => {
})
it('should switch between app types before creating a completion app', async () => {
mockCreateApp.mockResolvedValue({ id: 'completion-app', mode: AppModeEnum.COMPLETION } as App)
mockCreateApp.mockResolvedValue(
createAppDetailFixture({ id: 'completion-app', mode: AppModeEnum.COMPLETION }),
)
renderModal()
fireEvent.click(screen.getByText('app.types.workflow'))
@@ -349,11 +352,11 @@ describe('CreateAppModal', () => {
})
it('should ignore duplicate create clicks while a request is in flight', async () => {
let resolveCreate: ((value: App) => void) | undefined
let resolveCreate: ((value: AppDetail) => void) | undefined
mockCreateApp.mockImplementation(
() =>
new Promise((resolve) => {
resolveCreate = resolve as (value: App) => void
new Promise<AppDetail>((resolve) => {
resolveCreate = resolve
}),
)
renderModal()
@@ -373,7 +376,7 @@ describe('CreateAppModal', () => {
expect(mockCreateApp).toHaveBeenCalledTimes(1)
resolveCreate?.({ id: 'slow-app', mode: AppModeEnum.ADVANCED_CHAT } as App)
resolveCreate?.(createAppDetailFixture({ id: 'slow-app', mode: AppModeEnum.ADVANCED_CHAT }))
await waitFor(() => {
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
})

View File

@@ -641,18 +641,8 @@ function seedWorkflowDeploymentConfigurationQueries(queryClient: QueryClient) {
})
}
const mockBuiltInEnvironment = vi.hoisted(() => ({
appDetail: {
enable_api: false,
enable_site: true,
id: 'app-1',
maintainer: 'user-2',
mode: 'workflow',
},
mcpServerDetail: {
status: 'active',
},
publishedWorkflow: {
const mockBuiltInEnvironment = vi.hoisted(() => {
const publishedWorkflow: WorkflowResponse = {
conversation_variables: [],
created_at: 1_710_000_100,
created_by: {
@@ -676,10 +666,23 @@ const mockBuiltInEnvironment = vi.hoisted(() => ({
email: 'bob@example.com',
id: 'user-3',
name: 'Bob',
} as { email: string; id: string; name: string } | null,
},
version: '2026-07-30.1',
},
}))
}
return {
appDetail: {
enable_api: false,
enable_site: true,
id: 'app-1',
maintainer: 'user-2',
mode: 'workflow',
},
mcpServerDetail: {
status: 'active',
},
publishedWorkflow,
}
})
function render(
ui: ReactElement,
@@ -869,6 +872,11 @@ describe('AppDeploy', () => {
mockBuiltInEnvironment.publishedWorkflow.graph.nodes = [
{ data: { type: 'start' }, id: 'start' },
]
mockBuiltInEnvironment.publishedWorkflow.created_by = {
email: 'alice@example.com',
id: 'user-2',
name: 'Alice',
}
mockBuiltInEnvironment.publishedWorkflow.updated_by = {
email: 'bob@example.com',
id: 'user-3',
@@ -1107,6 +1115,18 @@ describe('AppDeploy', () => {
expect(builtInEnvironment.getByText('Updated at 03-09 16:03 by Alice')).toBeInTheDocument()
})
it('shows a fallback when the workflow publisher and updater are unavailable', () => {
mockBuiltInEnvironment.publishedWorkflow.created_by = null
mockBuiltInEnvironment.publishedWorkflow.updated_by = null
render(<AppDeploy />)
const builtInEnvironment = within(
screen.getByRole('region', { name: 'deployments.studio.builtInTitle' }),
)
expect(builtInEnvironment.getByText('Updated at 03-09 16:03 by --')).toBeInTheDocument()
})
it('shows loading while the app detail is unavailable', () => {
appDetailAvailable = false

View File

@@ -40,7 +40,7 @@ export const BuiltInEnvironmentCard = memo(
trigger: Boolean(publishedWorkflow && hasTriggerNode),
webApp: serviceModeAvailable && Boolean(appDetail?.enable_site),
}
const publishedBy = publishedWorkflow?.created_by?.name ?? appDetail?.author_name ?? '--'
const publishedBy = publishedWorkflow?.created_by?.name ?? '--'
const updatedBy = publishedWorkflow?.updated_by?.name ?? publishedBy
const publishedVersion: WorkflowVersion | undefined = publishedWorkflow
? {

View File

@@ -89,6 +89,30 @@ describe('DuplicateAppModal', () => {
mockAppQuota.limit = 1
})
it.each([
{ icon_type: null, icon: null, icon_background: null },
{ icon_type: 'link' as const, icon: 'https://example.com/icon.png', icon_background: null },
])(
'preserves the source icon when duplicating without a new selection: %j',
async (iconProps) => {
const user = userEvent.setup()
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(
<DuplicateAppModal
appName="Copy"
{...iconProps}
show
onConfirm={onConfirm}
onHide={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: /(?:^|\.)duplicate(?=$|:)/ }))
expect(onConfirm).toHaveBeenCalledWith({ name: 'Copy', ...iconProps })
},
)
it('should render a named dialog', () => {
render(
<DuplicateAppModal

View File

@@ -1,5 +1,6 @@
'use client'
import type { AppIconType } from '@/types/app'
import type { AppDetailWithSite, CopyAppPayload } from '@dify/contracts/api/console/apps/types.gen'
import type { IconPickerDefaultValue, IconPickerValue } from '@/app/components/base/icon-picker'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
@@ -19,17 +20,12 @@ import { consoleQuery } from '@/service/console'
export type DuplicateAppModalProps = {
appName: string
icon_type: AppIconType | null
icon: string
icon_type: AppDetailWithSite['icon_type']
icon: AppDetailWithSite['icon']
icon_background?: string | null
icon_url?: string | null
show: boolean
onConfirm: (info: {
name: string
icon_type: AppIconType
icon: string
icon_background?: string | null
}) => Promise<void>
onConfirm: (info: CopyAppPayload) => Promise<void>
onHide: () => void
}
@@ -48,11 +44,22 @@ const DuplicateAppModal = ({
const [name, setName] = React.useState(appName)
const [showIconPicker, setShowIconPicker] = useState(false)
const [appIcon, setAppIcon] = useState(
icon_type === 'image'
? { type: 'image' as const, url: icon_url ?? '', fileId: icon }
: { type: 'emoji' as const, icon, background: icon_background },
)
const [selectedIcon, setSelectedIcon] = useState<IconPickerValue | null>(null)
const pickerDefaultValue: IconPickerDefaultValue | undefined =
selectedIcon ??
(icon_type === 'image' && icon
? { type: 'image', url: icon_url ?? '', fileId: icon }
: icon_type === 'emoji' && icon
? { type: 'emoji', icon, background: icon_background }
: undefined)
const currentIcon = selectedIcon
? {
icon_type: selectedIcon.type,
icon: selectedIcon.type === 'emoji' ? selectedIcon.icon : selectedIcon.fileId,
icon_background: selectedIcon.type === 'emoji' ? selectedIcon.background : undefined,
icon_url: selectedIcon.type === 'image' ? selectedIcon.url : undefined,
}
: { icon_type, icon, icon_background, icon_url }
const deploymentEdition = useAtomValue(deploymentEditionAtom)
const { data: appQuota } = useQuery(
@@ -78,9 +85,9 @@ const DuplicateAppModal = ({
}
onConfirm({
name,
icon_type: appIcon.type,
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
icon_type: currentIcon.icon_type,
icon: currentIcon.icon,
icon_background: currentIcon.icon_background,
})
onHide()
}
@@ -127,10 +134,12 @@ const DuplicateAppModal = ({
>
<AppIcon
size="large"
iconType={appIcon.type}
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'image' ? undefined : appIcon.background}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
iconType={currentIcon.icon_type === 'link' ? 'image' : currentIcon.icon_type}
icon={currentIcon.icon ?? undefined}
background={currentIcon.icon_background}
imageUrl={
currentIcon.icon_type === 'link' ? currentIcon.icon : currentIcon.icon_url
}
/>
</button>
<Input
@@ -162,11 +171,9 @@ const DuplicateAppModal = ({
</Dialog>
<IconPickerDialog
open={showIconPicker}
defaultValue={appIcon}
defaultValue={pickerDefaultValue}
onOpenChange={setShowIconPicker}
onConfirm={(payload) => {
setAppIcon(payload)
}}
onConfirm={setSelectedIcon}
/>
</>
)

View File

@@ -1,57 +1,37 @@
import type { App, AppIconType } from '@/types/app'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import { render, screen } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import LogAnnotation from '../index'
vi.mock('@/app/components/app/annotation', () => ({
default: ({ appDetail }: { appDetail: App }) => (
default: ({ appDetail }: { appDetail: AppDetailWithSite }) => (
<section aria-label="Annotation log">{appDetail.id}</section>
),
}))
vi.mock('@/app/components/app/log', () => ({
default: ({ appDetail }: { appDetail: App }) => (
default: ({ appDetail }: { appDetail: AppDetailWithSite }) => (
<section aria-label="App log">{appDetail.id}</section>
),
}))
vi.mock('@/app/components/app/workflow-log', () => ({
default: ({ appDetail }: { appDetail: App }) => (
default: ({ appDetail }: { appDetail: AppDetailWithSite }) => (
<section aria-label="Workflow log">{appDetail.id}</section>
),
}))
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'app-123',
name: 'Test App',
description: 'Test app description',
author_name: 'Test Author',
icon_type: 'emoji' as AppIconType,
icon: ':icon:',
icon_background: '#FFEAD5',
icon_url: null,
use_icon_as_answer_icon: false,
mode: AppModeEnum.CHAT,
enable_site: true,
enable_api: true,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
created_at: Date.now(),
updated_at: Date.now(),
site: {
access_token: 'token',
app_base_url: 'https://example.com',
} as App['site'],
api_base_url: 'https://api.example.com',
tags: [],
access_mode: 'public_access' as App['access_mode'],
...overrides,
})
const createMockApp = (overrides: Partial<AppDetailWithSite> = {}): AppDetailWithSite =>
createAppDetailFixture({
id: 'app-123',
name: 'Test App',
mode: 'chat',
site: createAppSiteFixture({ access_token: 'token', app_base_url: 'https://example.com' }),
...overrides,
})
describe('LogAnnotation', () => {
beforeEach(() => {

View File

@@ -1,6 +1,7 @@
import type { App } from '@/types/app'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import { screen } from '@testing-library/react'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import EmptyElement from '../empty-element'
@@ -29,7 +30,8 @@ vi.mock('react-i18next', async () => {
})
vi.mock('@/utils/app-redirection', () => ({
getRedirectionPath: (isTest: boolean, _app: App) => (isTest ? '/test-path' : '/prod-path'),
getRedirectionPath: (isTest: boolean, _app: AppDetailWithSite) =>
isTest ? '/test-path' : '/prod-path',
}))
vi.mock('@/utils/var', () => ({
@@ -38,7 +40,7 @@ vi.mock('@/utils/var', () => ({
describe('EmptyElement', () => {
const createMockAppDetail = (mode: AppModeEnum) =>
({
createAppDetailFixture({
id: 'test-app-id',
name: 'Test App',
description: 'Test description',
@@ -49,11 +51,11 @@ describe('EmptyElement', () => {
enable_site: true,
enable_api: true,
created_at: Date.now(),
site: {
site: createAppSiteFixture({
access_token: 'test-token',
app_base_url: 'https://app.example.com',
},
}) as unknown as App
}),
})
describe('Rendering', () => {
it('should render empty element with title', () => {
@@ -113,6 +115,17 @@ describe('EmptyElement', () => {
})
})
it.each([null, createAppSiteFixture({ access_token: null })])(
'keeps the editor link without offering an unavailable site link',
(site) => {
render(<EmptyElement appDetail={createAppDetailFixture({ site })} />)
const links = screen.getAllByRole('link')
expect(links).toHaveLength(1)
expect(links[0]).toHaveAttribute('href', '/test-path')
},
)
describe('Links', () => {
it('should render share link with correct attributes', () => {
const appDetail = createMockAppDetail(AppModeEnum.CHAT)

View File

@@ -1,6 +1,7 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { FC, SVGProps } from 'react'
import type { App } from '@/types/app'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import * as React from 'react'
@@ -34,7 +35,7 @@ const ThreeDotsIcon = ({ className }: SVGProps<SVGElement>) => {
)
}
const EmptyElement: FC<{ appDetail: App }> = ({ appDetail }) => {
const EmptyElement: FC<{ appDetail: AppDetailWithSite }> = ({ appDetail }) => {
const { t } = useTranslation(['appLog'])
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
@@ -44,7 +45,7 @@ const EmptyElement: FC<{ appDetail: App }> = ({ appDetail }) => {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const getWebAppType = (appType: AppModeEnum) => {
const getWebAppType = (appType: AppDetailWithSite['mode']) => {
if (appType !== AppModeEnum.COMPLETION && appType !== AppModeEnum.WORKFLOW)
return AppModeEnum.CHAT
return appType
@@ -62,13 +63,15 @@ const EmptyElement: FC<{ appDetail: App }> = ({ appDetail }) => {
i18nKey={($) => $['table.empty.element.content']}
ns="appLog"
components={{
shareLink: (
shareLink: appDetail.site?.access_token ? (
<Link
href={`${appDetail.site.app_base_url}${basePath}/${getWebAppType(appDetail.mode)}/${appDetail.site.access_token}`}
className="text-util-colors-blue-blue-600"
target="_blank"
rel="noopener noreferrer"
/>
) : (
<span />
),
testLink: (
<Link

View File

@@ -1,6 +1,7 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import type { App } from '@/types/app'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
@@ -26,7 +27,7 @@ import List from './list'
import { RetentionUpgradeNotice } from './retention-upgrade-notice'
type ILogsProps = {
appDetail: App
appDetail: AppDetailWithSite
}
export type QueryParam = {

View File

@@ -1,4 +1,6 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import type { ChatItemInTree } from '../../base/chat/types'
import type {
@@ -15,7 +17,6 @@ import type {
CompletionConversationsResponse,
LogAnnotation,
} from '@/models/log'
import type { App } from '@/types/app'
import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
import { cn } from '@langgenius/dify-ui/cn'
import {
@@ -89,7 +90,7 @@ dayjs.extend(timezone)
type IConversationList = {
logs?: ChatConversationsResponse | CompletionConversationsResponse
appDetail: App
appDetail: AppDetailWithSite
onRefresh: () => void
}
@@ -158,7 +159,7 @@ const statusTdRender = (statusCount: StatusCount) => {
}
type IDetailPanel = {
appDetail: App
appDetail: AppDetailWithSite
detail: any
onClose: () => void
onFeedback: FeedbackFunc
@@ -645,7 +646,7 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) {
}
type ConversationDetailProps = {
appDetail: App
appDetail: AppDetailWithSite
conversationId?: string
onClose: () => void
}

View File

@@ -1,4 +1,5 @@
'use client'
import type { AppMode } from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@@ -20,7 +21,7 @@ type IShareLinkProps = {
onClose: () => void
api_base_url: string
appId: string
mode?: AppModeEnum
mode?: AppMode
sourceCodeRepository?: 'webapp-conversation' | 'webapp-text-generator'
}

View File

@@ -1,10 +1,10 @@
import type { AppDetailSiteResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { MutableRefObject } from 'react'
import type {
EmbeddedWebAppRoute,
WorkflowHiddenStartVariable,
WorkflowLaunchInputValue,
} from '../app-card-utils'
import type { SiteInfo } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { IconButton } from '@langgenius/dify-ui/icon-button'
@@ -29,7 +29,7 @@ import WorkflowHiddenInputFields from '../workflow-hidden-input-fields'
import style from './style.module.css'
type Props = Readonly<{
siteInfo?: SiteInfo
siteInfo?: Partial<Pick<AppDetailSiteResponse, 'chat_color_theme' | 'chat_color_theme_inverted'>>
isShow: boolean
onClose: () => void
accessToken?: string

View File

@@ -7,6 +7,7 @@ import {
createConsoleQueryClient,
renderWithConsoleQuery as renderWithoutPricing,
} from '@/test/console/query-data'
import { createAppSiteFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import SettingsModal from '../index'
@@ -132,6 +133,29 @@ describe('SettingsModal', () => {
vi.useRealTimers()
})
it('edits a site with nullable optional fields as empty form values', async () => {
renderSettingsModal({ id: 'app-null-fields', mode: 'chat', site: createAppSiteFixture() })
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue('')
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() =>
expect(mockOnSave).toHaveBeenCalledWith(
expect.objectContaining({
title: 'App',
description: '',
chat_color_theme: '',
privacy_policy: '',
input_placeholder: '',
icon: null,
icon_type: null,
icon_background: null,
}),
),
)
expect(mockOnClose).toHaveBeenCalledOnce()
})
it('should render the modal with all settings exposed by default', async () => {
renderSettingsModal()
expect(screen.getByText('appOverview.overview.appInfo.settings.title')).toBeInTheDocument()

View File

@@ -1,8 +1,13 @@
'use client'
import type {
AppDetailSiteResponse,
AppDetailWithSite,
AppSiteUpdatePayload,
} from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import type { IconPickerValue } from '@/app/components/base/icon-picker'
import type { AppIconType, Language, SiteConfig } from '@/types/app'
import type { Language } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
@@ -56,11 +61,11 @@ type ISettingsModalProps = {
isShow: boolean
defaultValue?: string
onClose: () => void
onSave?: (params: ConfigParams) => Promise<void>
onSave?: (params: AppSiteUpdatePayload) => Promise<void>
}
type SettingsSiteInfo = Pick<
SiteConfig,
AppDetailSiteResponse,
| 'title'
| 'description'
| 'default_language'
@@ -80,6 +85,7 @@ type SettingsSiteInfo = Pick<
type SettingsAppIconSelection =
| IconPickerValue
| null
| {
type: 'link'
icon: string
@@ -88,30 +94,12 @@ type SettingsAppIconSelection =
export type SettingsAppInfo = {
id: string
mode: AppModeEnum
mode: AppDetailWithSite['mode']
site: SettingsSiteInfo
}
export type ConfigParams = {
title: string
description: string
default_language: string
chat_color_theme: string
chat_color_theme_inverted: boolean
prompt_public: boolean
copyright?: string
privacy_policy: string
custom_disclaimer: string
input_placeholder?: string
icon_type: AppIconType
icon: string
icon_background?: string
show_workflow_steps: boolean
use_icon_as_answer_icon: boolean
}
const INPUT_PLACEHOLDER_MAX_LENGTH = 64
const INPUT_PLACEHOLDER_SUPPORTED_MODES: ReadonlyArray<AppModeEnum> = [
const INPUT_PLACEHOLDER_SUPPORTED_MODES: ReadonlyArray<AppDetailWithSite['mode']> = [
AppModeEnum.CHAT,
AppModeEnum.AGENT_CHAT,
AppModeEnum.ADVANCED_CHAT,
@@ -141,12 +129,12 @@ const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => {
return {
title,
desc: description,
chatColorTheme: chat_color_theme,
desc: description ?? '',
chatColorTheme: chat_color_theme ?? '',
chatColorThemeInverted: chat_color_theme_inverted,
copyright,
copyright: copyright ?? '',
copyrightSwitchValue: !!copyright,
privacyPolicy: privacy_policy,
privacyPolicy: privacy_policy ?? '',
customDisclaimer: custom_disclaimer,
inputPlaceholder: input_placeholder ?? '',
show_workflow_steps,
@@ -157,11 +145,14 @@ const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => {
const createAppIcon = (appInfo: ISettingsModalProps['appInfo']): SettingsAppIconSelection => {
const { icon_type, icon, icon_background, icon_url } = appInfo.site
if (icon_type === 'image') return { type: 'image', url: icon_url!, fileId: icon }
if (icon_type === 'image' && icon) return { type: 'image', url: icon_url ?? '', fileId: icon }
if (icon_type === 'link') return { type: 'link', icon, url: icon }
if (icon_type === 'link' && icon) return { type: 'link', icon, url: icon }
return { type: 'emoji', icon, background: icon_background! }
if (icon_type === 'emoji' && icon && icon_background)
return { type: 'emoji', icon, background: icon_background }
return null
}
const getSettingsResetKey = (appInfo: ISettingsModalProps['appInfo']) =>
@@ -339,9 +330,17 @@ const SettingsModal: FC<ISettingsModalProps> = ({
: !INPUT_PLACEHOLDER_SUPPORTED_MODES.includes(appInfo.mode)
? ''
: (inputInfo.inputPlaceholder ?? '').slice(0, INPUT_PLACEHOLDER_MAX_LENGTH),
icon_type: appIcon.type,
icon: appIcon.type === 'image' ? appIcon.fileId : appIcon.icon,
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
icon_type: appIcon?.type ?? appInfo.site.icon_type,
icon: appIcon
? appIcon.type === 'image'
? appIcon.fileId
: appIcon.icon
: appInfo.site.icon,
icon_background: appIcon
? appIcon.type === 'emoji'
? appIcon.background
: undefined
: appInfo.site.icon_background,
show_workflow_steps: inputInfo.show_workflow_steps,
use_icon_as_answer_icon: inputInfo.use_icon_as_answer_icon,
}
@@ -447,10 +446,28 @@ const SettingsModal: FC<ISettingsModalProps> = ({
setShowIconPicker(true)
}}
className="mt-2 cursor-pointer"
iconType={appIcon.type === 'link' ? 'image' : appIcon.type}
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'emoji' ? appIcon.background : undefined}
imageUrl={appIcon.type === 'emoji' ? undefined : appIcon.url}
iconType={
appIcon
? appIcon.type === 'link'
? 'image'
: appIcon.type
: appInfo.site.icon_type
}
icon={
appIcon
? appIcon.type === 'image'
? appIcon.fileId
: appIcon.icon
: (appInfo.site.icon ?? undefined)
}
background={
appIcon?.type === 'emoji'
? appIcon.background
: appInfo.site.icon_background
}
imageUrl={
appIcon && appIcon.type !== 'emoji' ? appIcon.url : appInfo.site.icon_url
}
/>
</div>
{/* description */}
@@ -805,7 +822,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</Dialog>
<IconPickerDialog
open={showIconPicker}
defaultValue={appIcon.type === 'link' ? undefined : appIcon}
defaultValue={appIcon?.type === 'link' ? undefined : (appIcon ?? undefined)}
onOpenChange={setShowIconPicker}
onConfirm={setAppIcon}
/>

View File

@@ -1,9 +1,9 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { App } from '@/types/app'
import { create } from 'zustand'
type State = {
appDetail?: App
appDetail?: AppDetailWithSite
currentLogItem?: IChatItem
currentLogModalActiveTab: string
showPromptLogModal: boolean
@@ -13,7 +13,7 @@ type State = {
}
type Action = {
setAppDetail: (appDetail?: App) => void
setAppDetail: (appDetail?: AppDetailWithSite) => void
setCurrentLogItem: (item?: IChatItem) => void
setCurrentLogModalActiveTab: (tab: string) => void
setShowPromptLogModal: (showPromptLogModal: boolean) => void

View File

@@ -1,3 +1,4 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
/**
* DetailPanel Component Tests
*
@@ -7,11 +8,10 @@
* - Close button
* - Run component with detail/tracing URLs
*/
import type { App, AppIconType, AppModeEnum } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
import DetailPanel from '../detail'
// ============================================================================
@@ -58,35 +58,14 @@ vi.mock('ahooks', () => ({
// Test Data Factories
// ============================================================================
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'test-app-id',
name: 'Test App',
description: 'Test app description',
author_name: 'Test Author',
icon_type: 'emoji' as AppIconType,
icon: '🚀',
icon_background: '#FFEAD5',
icon_url: null,
use_icon_as_answer_icon: false,
mode: 'workflow' as AppModeEnum,
enable_site: true,
enable_api: true,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
created_at: Date.now(),
updated_at: Date.now(),
site: {
access_token: 'token',
app_base_url: 'https://example.com',
} as App['site'],
api_base_url: 'https://api.example.com',
tags: [],
access_mode: 'public_access' as App['access_mode'],
...overrides,
})
const createMockApp = (overrides: Partial<AppDetailWithSite> = {}): AppDetailWithSite =>
createAppDetailFixture({
id: 'test-app-id',
name: 'Test App',
mode: 'workflow',
site: createAppSiteFixture({ access_token: 'token', app_base_url: 'https://example.com' }),
...overrides,
})
// ============================================================================
// Tests

View File

@@ -1,3 +1,4 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { UseQueryResult } from '@tanstack/react-query'
/**
* Logs Container Component Tests
@@ -18,7 +19,7 @@ import type { MockedFunction } from 'vite-plus/test'
import type { CloudSandboxPlanState } from '../../log/cloud-sandbox-retention'
import type { ILogsProps } from '../index'
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
import type { App, AppIconType, AppModeEnum } from '@/types/app'
import type { AppModeEnum } from '@/types/app'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
@@ -27,6 +28,7 @@ import { WorkflowRunTriggeredFrom } from '@/models/log'
import * as useLogModule from '@/service/use-log'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { TIME_PERIOD_MAPPING } from '../filter'
import Logs from '../index'
@@ -169,35 +171,14 @@ const createMockQueryResult = <T,>(
// Test Data Factories
// ============================================================================
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'test-app-id',
name: 'Test App',
description: 'Test app description',
author_name: 'Test Author',
icon_type: 'emoji' as AppIconType,
icon: '🚀',
icon_background: '#FFEAD5',
icon_url: null,
use_icon_as_answer_icon: false,
mode: 'workflow' as AppModeEnum,
enable_site: true,
enable_api: true,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
created_at: Date.now(),
updated_at: Date.now(),
site: {
access_token: 'token',
app_base_url: 'https://example.com',
} as App['site'],
api_base_url: 'https://api.example.com',
tags: [],
access_mode: 'public_access' as App['access_mode'],
...overrides,
})
const createMockApp = (overrides: Partial<AppDetailWithSite> = {}): AppDetailWithSite =>
createAppDetailFixture({
id: 'test-app-id',
name: 'Test App',
mode: 'workflow',
site: createAppSiteFixture({ access_token: 'token', app_base_url: 'https://example.com' }),
...overrides,
})
const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): WorkflowRunDetail => ({
id: 'run-1',

View File

@@ -1,3 +1,5 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
/**
* WorkflowAppLogList Component Tests
*
@@ -8,14 +10,13 @@
* - Drawer with run details
* - Loading states
*/
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
import type { App, AppIconType, AppModeEnum } from '@/types/app'
import type { AppModeEnum } from '@/types/app'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
import WorkflowAppLogList from '../list'
// ============================================================================
@@ -90,35 +91,14 @@ vi.mock('ahooks', () => ({
// Test Data Factories
// ============================================================================
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'test-app-id',
name: 'Test App',
description: 'Test app description',
author_name: 'Test Author',
icon_type: 'emoji' as AppIconType,
icon: '🚀',
icon_background: '#FFEAD5',
icon_url: null,
use_icon_as_answer_icon: false,
mode: 'workflow' as AppModeEnum,
enable_site: true,
enable_api: true,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
created_at: Date.now(),
updated_at: Date.now(),
site: {
access_token: 'token',
app_base_url: 'https://example.com',
} as App['site'],
api_base_url: 'https://api.example.com',
tags: [],
access_mode: 'public_access' as App['access_mode'],
...overrides,
})
const createMockApp = (overrides: Partial<AppDetailWithSite> = {}): AppDetailWithSite =>
createAppDetailFixture({
id: 'test-app-id',
name: 'Test App',
mode: 'workflow',
site: createAppSiteFixture({ access_token: 'token', app_base_url: 'https://example.com' }),
...overrides,
})
const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): WorkflowRunDetail => ({
id: 'run-1',

View File

@@ -1,6 +1,7 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import type { App } from '@/types/app'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
@@ -32,7 +33,7 @@ dayjs.extend(utc)
dayjs.extend(timezone)
export type ILogsProps = {
appDetail: App
appDetail: AppDetailWithSite
}
export type QueryParam = {

View File

@@ -1,11 +1,12 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import type {
WorkflowAppLogDetail,
WorkflowLogsResponse,
WorkflowRunTriggeredFrom,
} from '@/models/log'
import type { App } from '@/types/app'
import { ArrowDownIcon } from '@heroicons/react/24/outline'
import { cn } from '@langgenius/dify-ui/cn'
import {
@@ -30,7 +31,7 @@ import TriggerByDisplay from './trigger-by-display'
type ILogs = {
logs?: WorkflowLogsResponse
appDetail?: App
appDetail?: AppDetailWithSite
onRefresh: () => void
}

View File

@@ -1,6 +1,7 @@
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { WorkflowRunningStatus } from '@/app/components/workflow/types'
import type { AgentConfig } from '@/models/debug'
import type { ModelConfig } from '@/types/app'
import type { HumanInputFilledFormData, HumanInputFormData, NodeTracing } from '@/types/workflow'
@@ -12,13 +13,14 @@ export type EnableType = {
enabled: boolean
}
export type ChatConfig = Omit<ModelConfig, 'model'> & {
export type ChatConfig = Omit<ModelConfig, 'model' | 'system_parameters' | 'agent_mode'> & {
agent_mode: Pick<AgentConfig, 'enabled' | 'tools'> & { strategy?: AgentConfig['strategy'] }
supportAnnotation?: boolean
appId?: string
questionEditEnable?: boolean
supportFeedback?: boolean
supportCitationHitInfo?: boolean
system_parameters: {
system_parameters?: {
audio_file_size_limit: number
file_size_limit: number
image_file_size_limit: number

View File

@@ -1,6 +1,7 @@
import type { AppModelSelectionResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { SuggestedQuestionsAfterAnswer } from '@/app/components/base/features/types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CompletionParams, Model, ModelModeType } from '@/types/app'
import type { CompletionParams, ModelModeType } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Field, FieldItem } from '@langgenius/dify-ui/field'
@@ -35,7 +36,7 @@ const DEFAULT_COMPLETION_PARAMS: CompletionParams = {
const DEFAULT_FOLLOW_UP_PROMPT = `Please predict the three most likely follow-up questions a user would ask, keep each question under 20 characters, use the same language as the assistant's latest response, and output a JSON array like ["question1", "question2", "question3"].`
const CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH = 1000
const getInitialModel = (model?: Model): Model => ({
const getInitialModel = (model?: AppModelSelectionResponse) => ({
provider: model?.provider || '',
name: model?.name || '',
mode: model?.mode || ModelModeTypeEnum.chat,
@@ -53,7 +54,7 @@ type PromptMode = (typeof PROMPT_MODE)[keyof typeof PROMPT_MODE]
const FollowUpSettingModal = ({ data, onSave, onCancel }: FollowUpSettingModalProps) => {
const { t } = useTranslation(['appDebug', 'common'])
const [model, setModel] = useState<Model>(() => getInitialModel(data.model))
const [model, setModel] = useState(() => getInitialModel(data.model))
const [prompt, setPrompt] = useState(data.prompt || '')
const [promptMode, setPromptMode] = useState<PromptMode>(
data.prompt ? PROMPT_MODE.custom : PROMPT_MODE.default,
@@ -61,7 +62,7 @@ const FollowUpSettingModal = ({ data, onSave, onCancel }: FollowUpSettingModalPr
const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(
ModelTypeEnum.textGeneration,
)
const selectedModel = useMemo<Model>(() => {
const selectedModel = useMemo(() => {
if (model.provider && model.name) return model
if (!defaultModel) return model
@@ -89,7 +90,7 @@ const FollowUpSettingModal = ({ data, onSave, onCancel }: FollowUpSettingModalPr
(newParams: FormValue) => {
setModel({
...selectedModel,
completion_params: newParams as CompletionParams,
completion_params: newParams,
})
},
[selectedModel],

View File

@@ -0,0 +1,191 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { ModerationConfig } from '@/models/debug'
import type { ConsoleClient } from '@/service/console'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import ModerationSettingModal from '../moderation-setting-modal'
const { getExtensions, createExtension } = vi.hoisted(() => ({
getExtensions: vi.fn<ConsoleClient['apiBasedExtension']['get']>(),
createExtension: vi.fn<ConsoleClient['apiBasedExtension']['post']>(),
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { createConsoleQuery } = await import('@/service/console/query-policies')
const consoleClient = new Proxy(actual.consoleClient, {
get(target, property, receiver) {
if (property === 'apiBasedExtension') {
return {
get: getExtensions,
post: createExtension,
byId: target.apiBasedExtension.byId,
}
}
return Reflect.get(target, property, receiver)
},
})
return { ...actual, consoleClient, consoleQuery: createConsoleQuery(consoleClient) }
})
vi.mock('@/service/use-common', () => ({
useCodeBasedExtensions: () => ({ data: { data: [] } }),
useModelProviderDetails: () => ({ data: { data: [] }, isPending: false }),
}))
vi.mock('nuqs', async (importOriginal) => ({
...(await importOriginal<typeof import('nuqs')>()),
useQueryState: () => [null, vi.fn()],
}))
const existingExtension: ApiBasedExtensionResponse = {
id: 'existing-extension',
name: 'Existing API',
api_endpoint: 'https://existing.example.test',
api_key: 'existing-secret',
}
const newExtension: ApiBasedExtensionResponse = {
id: 'new-extension',
name: 'New API',
api_endpoint: 'https://new.example.test',
api_key: 'new-secret',
}
const moderation: ModerationConfig = {
enabled: true,
type: 'api',
config: {
api_based_extension_id: existingExtension.id,
inputs_config: { enabled: true },
outputs_config: { enabled: false },
},
}
const childDialogName = 'common.apiBasedExtension.modal.title'
const outputLabel = 'appDebug.feature.moderation.modal.content.output'
const renderModal = async () => {
const onSave = vi.fn()
const onCancel = vi.fn()
await act(async () => {
renderWithConsoleQuery(
<ModerationSettingModal data={moderation} onSave={onSave} onCancel={onCancel} />,
)
})
return { onSave, onCancel }
}
const openExtensionForm = async (
user: ReturnType<typeof userEvent.setup>,
apiKey = 'new-secret',
) => {
await user.click(await screen.findByRole('button', { name: /Existing API/ }))
await user.click(await screen.findByRole('button', { name: 'common.operation.add' }))
const dialog = await screen.findByRole('dialog', { name: childDialogName })
await user.type(
within(dialog).getByRole('textbox', { name: 'common.apiBasedExtension.modal.name.title' }),
newExtension.name,
)
await user.type(
within(dialog).getByRole('textbox', {
name: 'common.apiBasedExtension.modal.apiEndpoint.title',
}),
newExtension.api_endpoint,
)
await user.type(
within(dialog).getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiKey.title' }),
apiKey,
)
return dialog
}
beforeEach(() => {
getExtensions.mockResolvedValue([existingExtension])
createExtension.mockReset()
})
it.each(['success', 'failure'] as const)(
'preserves the moderation draft while a nested extension submission is pending and after %s',
async (outcome) => {
const user = userEvent.setup()
let resolveCreation!: (response: ApiBasedExtensionResponse) => void
let rejectCreation!: (error: Error) => void
const creation = new Promise<ApiBasedExtensionResponse>((resolve, reject) => {
resolveCreation = resolve
rejectCreation = reject
})
createExtension.mockReturnValue(creation)
const { onSave, onCancel } = await renderModal()
await user.click(screen.getByRole('switch', { name: outputLabel }))
const dialog = await openExtensionForm(user)
const childSave = within(dialog).getByRole('button', { name: 'common.operation.save' })
await user.click(childSave)
await waitFor(() => expect(createExtension).toHaveBeenCalledTimes(1))
expect(createExtension.mock.calls[0]?.[0]).toEqual({
body: {
name: newExtension.name,
api_endpoint: newExtension.api_endpoint,
api_key: newExtension.api_key,
},
})
expect(childSave).toBeDisabled()
expect(onSave).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
if (outcome === 'success') {
await act(async () => resolveCreation(newExtension))
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: childDialogName })).not.toBeInTheDocument(),
)
} else {
await act(async () => rejectCreation(new Error('Creation failed')))
await waitFor(() => expect(childSave).toBeEnabled())
expect(screen.getByRole('dialog', { name: childDialogName })).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' }))
}
expect(onSave).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: /Existing API/ })).toBeInTheDocument()
expect(screen.getByRole('switch', { name: outputLabel })).toBeChecked()
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledExactlyOnceWith({
...moderation,
config: {
...moderation.config,
outputs_config: { enabled: true, preset_response: '' },
},
})
},
)
it('keeps nested extension validation and resubmission from submitting the moderation draft', async () => {
const user = userEvent.setup()
createExtension.mockResolvedValue(newExtension)
const { onSave, onCancel } = await renderModal()
const dialog = await openExtensionForm(user, 'a')
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
expect(
await within(dialog).findByText('common.apiBasedExtension.modal.apiKey.lengthError'),
).toBeInTheDocument()
expect(createExtension).not.toHaveBeenCalled()
expect(onSave).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
const apiKey = within(dialog).getByRole('textbox', {
name: 'common.apiBasedExtension.modal.apiKey.title',
})
await user.clear(apiKey)
await user.type(apiKey, newExtension.api_key)
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: childDialogName })).not.toBeInTheDocument(),
)
expect(createExtension).toHaveBeenCalledTimes(1)
expect(onSave).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: /Existing API/ })).toBeInTheDocument()
})

View File

@@ -1,9 +1,17 @@
import type { AppConfigJsonValue } from '@dify/contracts/api/console/apps/types.gen'
import type { TFunction } from 'i18next'
import type { ModerationConfig } from '@/models/debug'
import { act, fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as i18n from 'react-i18next'
import { buildConfigurationDatasetConfigs } from '@/app/components/app/configuration/hooks/configuration-lifecycle/dataset'
import { createPublishHandler } from '@/app/components/app/configuration/hooks/configuration-lifecycle/publish'
import { buildPublishedConfig } from '@/app/components/app/configuration/hooks/configuration-lifecycle/published-config'
import { createFeaturesStore } from '@/app/components/base/features/store'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { createAppModelConfigFixture } from '@/test/fixtures/app'
import { withSelectorKey } from '@/test/i18n-mock'
import { AppModeEnum, ModelModeType } from '@/types/app'
import ModerationSettingModal from '../moderation-setting-modal'
const mockNotify = vi.fn()
@@ -975,6 +983,144 @@ describe('ModerationSettingModal', () => {
expect(screen.getByText(/apiBasedExtension\.link/))!.toBeInTheDocument()
})
it.each<{
scenario: string
initialConfig: NonNullable<ModerationConfig['config']>
clear: boolean
expectedValue?: AppConfigJsonValue
displayedValue?: string
}>([
{ scenario: 'without editing an absent optional value', initialConfig: {}, clear: false },
{
scenario: 'after explicitly clearing an optional value',
initialConfig: { optional_token: 'old-token' },
clear: true,
expectedValue: '',
},
{
scenario: 'without editing a null extension value',
initialConfig: { optional_token: null },
clear: false,
expectedValue: null,
},
{
scenario: 'without editing a false extension value',
initialConfig: { optional_token: false },
clear: false,
expectedValue: false,
},
{
scenario: 'without editing a zero extension value',
initialConfig: { optional_token: 0 },
clear: false,
expectedValue: 0,
displayedValue: '0',
},
{
scenario: 'without editing a numeric extension value',
initialConfig: { optional_token: 42 },
clear: false,
expectedValue: 42,
displayedValue: '42',
},
])(
'publishes a saved custom moderation configuration $scenario',
async ({ initialConfig, clear, expectedValue, displayedValue }) => {
const user = userEvent.setup()
mockCodeBasedExtensions = {
data: {
data: [
{
name: 'custom-ext',
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
form_schema: [
{
variable: 'optional_token',
label: { 'en-US': 'Optional token', 'zh-Hans': '可选令牌' },
type: 'text-input',
required: false,
default: '',
placeholder: 'Optional token',
options: [],
},
],
},
],
},
}
const featuresStore = createFeaturesStore()
await renderModal(
<ModerationSettingModal
data={{
enabled: true,
type: 'custom-ext',
config: {
...initialConfig,
inputs_config: { enabled: true, preset_response: 'Blocked' },
outputs_config: { enabled: false },
},
}}
onCancel={vi.fn()}
onSave={(moderation) => {
const { features, setFeatures } = featuresStore.getState()
setFeatures({ ...features, moderation })
}}
/>,
)
const optionalToken = screen.getByPlaceholderText('Optional token')
if (clear) await user.clear(optionalToken)
else expect(optionalToken).toHaveValue(displayedValue ?? '')
if (clear) await user.keyboard('{Enter}')
else await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
const backendModelConfig = createAppModelConfigFixture({
model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
})
const config = buildPublishedConfig({
backendModelConfig,
collectionList: [],
datasetConfigs: buildConfigurationDatasetConfigs({ backendModelConfig, nextDataSets: [] }),
mode: AppModeEnum.CHAT,
nextDataSets: [],
})
const publish = createPublishHandler({
appId: 'app-1',
chatPromptConfig: config.chatPromptConfig,
completionParamsState: config.completionParams,
completionPromptConfig: config.completionPromptConfig,
contextVarEmpty: false,
dataSets: [],
datasetConfigs: config.datasetConfigs,
externalDataToolsConfig: [],
hasSetBlockStatus: { history: true, query: true },
isAdvancedMode: false,
isFunctionCall: false,
mode: AppModeEnum.CHAT,
modelConfig: config.modelConfig,
promptEmpty: false,
promptMode: 'simple',
resolvedModelModeType: ModelModeType.chat,
setCanReturnToSimpleMode: vi.fn(),
setPublishedConfig: vi.fn(),
t: withSelectorKey((key: string) => key) as TFunction<['appDebug', 'common']>,
})
const updateModelConfig = vi.fn<Parameters<typeof publish>[0]>().mockResolvedValue(undefined)
await publish(updateModelConfig, undefined, featuresStore.getState().features)
expect(updateModelConfig).toHaveBeenCalledTimes(1)
const moderation = updateModelConfig.mock.calls[0]?.[0].body.sensitive_word_avoidance
expect(moderation).toMatchObject({
enabled: true,
type: 'custom-ext',
config: { inputs_config: { enabled: true, preset_response: 'Blocked' } },
})
if (expectedValue !== undefined)
expect(moderation?.config).toHaveProperty('optional_token', expectedValue)
else expect(moderation?.config).not.toHaveProperty('optional_token')
},
)
it('should fallback missing inputs_config to disabled in formatted save data', async () => {
await renderModal(
<ModerationSettingModal

View File

@@ -21,12 +21,15 @@ const FormGeneration: FC<FormGenerationProps> = ({ forms, value, onChange }) =>
const locale = useLocale()
const handleFormChange = (type: string, v: string) => {
onChange({ ...value, [type]: v })
onChange({ [type]: v })
}
return (
<>
{forms.map((form, index) => {
{forms.map((form) => {
const fieldValue = value?.[form.variable]
const inputValue =
typeof fieldValue === 'string' || typeof fieldValue === 'number' ? fieldValue : ''
const selectOptions =
form.type === 'select'
? form.options.map((option) => ({
@@ -38,13 +41,14 @@ const FormGeneration: FC<FormGenerationProps> = ({ forms, value, onChange }) =>
selectOptions.find((option) => option.value === value?.[form.variable]) ?? null
return (
<div key={index} className="py-2">
<div key={form.variable} className="py-2">
<div className="flex h-9 items-center text-sm font-medium text-text-primary">
{locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
</div>
{form.type === 'text-input' && (
<input
value={value?.[form.variable] || ''}
aria-label={locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
value={inputValue}
className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden"
placeholder={form.placeholder}
onChange={(e) => handleFormChange(form.variable, e.target.value)}
@@ -55,7 +59,7 @@ const FormGeneration: FC<FormGenerationProps> = ({ forms, value, onChange }) =>
<Textarea
aria-label={locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
className="resize-none"
value={value?.[form.variable] || ''}
value={inputValue}
placeholder={form.placeholder}
onValueChange={(value) => handleFormChange(form.variable, value)}
/>

View File

@@ -127,7 +127,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ data, onCance
if (systemTypes.findIndex((t) => t === type) < 0 && currProvider?.form_schema) {
config = currProvider?.form_schema.reduce(
(prev, next) => {
prev[next.variable] = next.default
if (next.default !== undefined) prev[next.variable] = next.default
return prev
},
{} as Record<string, string>,
@@ -157,10 +157,11 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ data, onCance
}))
}
const handleDataContentChange = (contentType: string, contentConfig: ModerationContentConfig) => {
const previousContentConfig = localeDataRef.current.config?.[contentType] as
| ModerationContentConfig
| undefined
const handleDataContentChange = (
contentType: 'inputs_config' | 'outputs_config',
contentConfig: ModerationContentConfig,
) => {
const previousContentConfig = localeDataRef.current.config?.[contentType]
const shouldRender = previousContentConfig?.enabled !== contentConfig.enabled
updateLocaleData(
@@ -198,15 +199,17 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ data, onCance
const formatData = (originData: ModerationConfig) => {
const { enabled, type, config } = originData
const { inputs_config, outputs_config } = config!
const params: Record<string, string | undefined> = {}
const params: NonNullable<ModerationConfig['config']> = {}
if (type === 'keywords') params.keywords = config?.keywords
if (type === 'keywords' && config?.keywords !== undefined) params.keywords = config.keywords
if (type === 'api') params.api_based_extension_id = config?.api_based_extension_id
if (type === 'api' && config?.api_based_extension_id !== undefined)
params.api_based_extension_id = config.api_based_extension_id
if (systemTypes.findIndex((t) => t === type) < 0 && currentProvider?.form_schema) {
currentProvider.form_schema.forEach((form) => {
params[form.variable] = config?.[form.variable]
const value = config?.[form.variable]
if (value !== undefined) params[form.variable] = value
})
}
@@ -303,193 +306,205 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ data, onCance
return (
<Dialog open>
<DialogContent className="mt-14! w-150! max-w-none! overflow-hidden border-[0.5px]! border-components-panel-border! p-0! text-left align-middle">
<div className="flex items-start gap-2 px-6 pt-6 pr-14 pb-3">
<div className="title-2xl-semi-bold text-text-primary">
{t(($) => $['feature.moderation.modal.title'], { ns: 'appDebug' })}
</div>
<button
type="button"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="absolute top-5 right-5 flex size-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={onCancel}
>
<span className="i-ri-close-line size-4.5" aria-hidden="true" />
</button>
</div>
<div className="flex flex-col gap-4 px-6 py-3">
<div className="flex flex-col gap-1">
<div className="system-sm-medium text-text-secondary">
{t(($) => $['feature.moderation.modal.provider.title'], { ns: 'appDebug' })}
<form
onSubmit={(event) => {
// Portalled child forms still bubble submit events through the React tree.
if (event.target !== event.currentTarget) return
event.preventDefault()
handleSave()
}}
>
<div className="flex items-start gap-2 px-6 pt-6 pr-14 pb-3">
<div className="title-2xl-semi-bold text-text-primary">
{t(($) => $['feature.moderation.modal.title'], { ns: 'appDebug' })}
</div>
<div className="grid grid-cols-3 gap-2">
{providers.map((provider) => (
<button
type="button"
key={provider.key}
className={cn(
'flex min-h-17 flex-col items-start justify-center gap-1.5 rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg px-3 py-2 text-left text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
localeData.type !== provider.key &&
'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
localeData.type === provider.key &&
'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs',
localeData.type === 'openai_moderation' &&
provider.key === 'openai_moderation' &&
!isOpenAIProviderConfigured &&
'text-text-disabled',
)}
onClick={() => handleDataTypeChange(provider.key)}
>
<div className="flex size-8 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-background-default-dodge">
<ProviderIcon type={provider.key} />
</div>
<span className="w-full truncate system-xs-regular">{provider.name}</span>
</button>
))}
</div>
{!isLoading &&
!isOpenAIProviderConfigured &&
localeData.type === 'openai_moderation' && (
<div className="mt-2 flex items-center rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 py-2">
<span className="mr-1 i-custom-vender-line-general-info-circle h-4 w-4 text-[#F79009]" />
<div className="flex items-center text-xs font-medium text-gray-700">
{t(($) => $['feature.moderation.modal.openaiNotConfig.before'], {
ns: 'appDebug',
})}
<button
type="button"
className="cursor-pointer text-primary-600"
onClick={handleOpenSettingsModal}
>
&nbsp;
{t(($) => $['settings.provider'], { ns: 'navigation' })}
&nbsp;
</button>
{t(($) => $['feature.moderation.modal.openaiNotConfig.after'], {
ns: 'appDebug',
})}
</div>
</div>
)}
<button
type="button"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="absolute top-5 right-5 flex size-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={onCancel}
>
<span className="i-ri-close-line size-4.5" aria-hidden="true" />
</button>
</div>
{localeData.type === 'keywords' && (
<div className="flex flex-col gap-4 px-6 py-3">
<div className="flex flex-col gap-1">
<div className="system-sm-medium text-text-secondary">
{t(($) => $['feature.moderation.modal.provider.keywords'], { ns: 'appDebug' })}
{t(($) => $['feature.moderation.modal.provider.title'], { ns: 'appDebug' })}
</div>
<div className="system-xs-regular text-text-tertiary">
{t(($) => $['feature.moderation.modal.keywords.tip'], { ns: 'appDebug' })}
</div>
{/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */}
<div className="relative h-22">
<Textarea
aria-label={
t(($) => $['feature.moderation.modal.provider.keywords'], {
ns: 'appDebug',
}) as string
}
value={localeData.config?.keywords || ''}
onValueChange={handleDataKeywordsChange}
className="size-full resize-none pb-8"
placeholder={
t(($) => $['feature.moderation.modal.keywords.placeholder'], {
ns: 'appDebug',
}) || ''
}
/>
<div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 system-2xs-medium-uppercase text-text-quaternary">
<span>
{(localeData.config?.keywords || '').split('\n').filter(Boolean).length}
</span>
/
<span className="text-text-tertiary">
100
{t(($) => $['feature.moderation.modal.keywords.line'], { ns: 'appDebug' })}
</span>
</div>
<div className="grid grid-cols-3 gap-2">
{providers.map((provider) => (
<button
type="button"
key={provider.key}
className={cn(
'flex min-h-17 flex-col items-start justify-center gap-1.5 rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg px-3 py-2 text-left text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
localeData.type !== provider.key &&
'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
localeData.type === provider.key &&
'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs',
localeData.type === 'openai_moderation' &&
provider.key === 'openai_moderation' &&
!isOpenAIProviderConfigured &&
'text-text-disabled',
)}
onClick={() => handleDataTypeChange(provider.key)}
>
<div className="flex size-8 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-background-default-dodge">
<ProviderIcon type={provider.key} />
</div>
<span className="w-full truncate system-xs-regular">{provider.name}</span>
</button>
))}
</div>
{!isLoading &&
!isOpenAIProviderConfigured &&
localeData.type === 'openai_moderation' && (
<div className="mt-2 flex items-center rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 py-2">
<span className="mr-1 i-custom-vender-line-general-info-circle h-4 w-4 text-[#F79009]" />
<div className="flex items-center text-xs font-medium text-gray-700">
{t(($) => $['feature.moderation.modal.openaiNotConfig.before'], {
ns: 'appDebug',
})}
<button
type="button"
className="cursor-pointer text-primary-600"
onClick={handleOpenSettingsModal}
>
&nbsp;
{t(($) => $['settings.provider'], { ns: 'navigation' })}
&nbsp;
</button>
{t(($) => $['feature.moderation.modal.openaiNotConfig.after'], {
ns: 'appDebug',
})}
</div>
</div>
)}
</div>
)}
{localeData.type === 'api' && (
<div className="flex flex-col gap-1">
<div className="flex h-6 items-center justify-between">
{localeData.type === 'keywords' && (
<div className="flex flex-col gap-1">
<div className="system-sm-medium text-text-secondary">
{t(($) => $['apiBasedExtension.selector.title'], { ns: 'common' })}
{t(($) => $['feature.moderation.modal.provider.keywords'], { ns: 'appDebug' })}
</div>
<div className="system-xs-regular text-text-tertiary">
{t(($) => $['feature.moderation.modal.keywords.tip'], { ns: 'appDebug' })}
</div>
{/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */}
<div className="relative h-22">
<Textarea
aria-label={
t(($) => $['feature.moderation.modal.provider.keywords'], {
ns: 'appDebug',
}) as string
}
value={localeData.config?.keywords || ''}
onValueChange={handleDataKeywordsChange}
className="size-full resize-none pb-8"
placeholder={
t(($) => $['feature.moderation.modal.keywords.placeholder'], {
ns: 'appDebug',
}) || ''
}
/>
<div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 system-2xs-medium-uppercase text-text-quaternary">
<span>
{(localeData.config?.keywords || '').split('\n').filter(Boolean).length}
</span>
/
<span className="text-text-tertiary">
100
{t(($) => $['feature.moderation.modal.keywords.line'], { ns: 'appDebug' })}
</span>
</div>
</div>
<a
href={docLink('/use-dify/workspace/api-extension/api-extension')}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center system-xs-regular text-text-tertiary hover:text-primary-600"
>
<span className="mr-1 i-custom-vender-line-education-book-open-01 size-3 text-text-tertiary group-hover:text-primary-600" />
{t(($) => $['apiBasedExtension.link'], { ns: 'common' })}
</a>
</div>
<ApiBasedExtensionSelector
value={localeData.config?.api_based_extension_id || ''}
onChange={handleDataApiBasedChange}
/>
</div>
)}
{systemTypes.findIndex((t) => t === localeData.type) < 0 &&
currentProvider?.form_schema && (
<FormGeneration
forms={currentProvider?.form_schema}
value={localeData.config}
onChange={handleDataExtraChange}
/>
)}
<div className="flex flex-col gap-2">
<LabeledDivider>
{t(($) => $['feature.moderation.title'], { ns: 'appDebug' })}
</LabeledDivider>
<ModerationContent
key={`inputs-${localeData.type}-${localeData.config?.inputs_config?.preset_response ?? ''}`}
title={
t(($) => $['feature.moderation.modal.content.input'], { ns: 'appDebug' }) || ''
}
config={localeData.config?.inputs_config || { enabled: false, preset_response: '' }}
onConfigChange={(config) => handleDataContentChange('inputs_config', config)}
info={
(localeData.type === 'api' &&
t(($) => $['feature.moderation.modal.content.fromApi'], { ns: 'appDebug' })) ||
''
}
showPreset={localeData.type !== 'api'}
/>
<ModerationContent
key={`outputs-${localeData.type}-${localeData.config?.outputs_config?.preset_response ?? ''}`}
title={
t(($) => $['feature.moderation.modal.content.output'], { ns: 'appDebug' }) || ''
}
config={localeData.config?.outputs_config || { enabled: false, preset_response: '' }}
onConfigChange={(config) => handleDataContentChange('outputs_config', config)}
info={
(localeData.type === 'api' &&
t(($) => $['feature.moderation.modal.content.fromApi'], { ns: 'appDebug' })) ||
''
}
showPreset={localeData.type !== 'api'}
/>
<div className="py-0.5 system-xs-regular text-text-tertiary">
{t(($) => $['feature.moderation.modal.content.condition'], { ns: 'appDebug' })}
{localeData.type === 'api' && (
<div className="flex flex-col gap-1">
<div className="flex h-6 items-center justify-between">
<div className="system-sm-medium text-text-secondary">
{t(($) => $['apiBasedExtension.selector.title'], { ns: 'common' })}
</div>
<a
href={docLink('/use-dify/workspace/api-extension/api-extension')}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center system-xs-regular text-text-tertiary hover:text-primary-600"
>
<span className="mr-1 i-custom-vender-line-education-book-open-01 size-3 text-text-tertiary group-hover:text-primary-600" />
{t(($) => $['apiBasedExtension.link'], { ns: 'common' })}
</a>
</div>
<ApiBasedExtensionSelector
value={localeData.config?.api_based_extension_id || ''}
onChange={handleDataApiBasedChange}
/>
</div>
)}
{systemTypes.findIndex((t) => t === localeData.type) < 0 &&
currentProvider?.form_schema && (
<FormGeneration
forms={currentProvider?.form_schema}
value={localeData.config}
onChange={handleDataExtraChange}
/>
)}
<div className="flex flex-col gap-2">
<LabeledDivider>
{t(($) => $['feature.moderation.title'], { ns: 'appDebug' })}
</LabeledDivider>
<ModerationContent
key={`inputs-${localeData.type}-${localeData.config?.inputs_config?.preset_response ?? ''}`}
title={
t(($) => $['feature.moderation.modal.content.input'], { ns: 'appDebug' }) || ''
}
config={localeData.config?.inputs_config || { enabled: false, preset_response: '' }}
onConfigChange={(config) => handleDataContentChange('inputs_config', config)}
info={
(localeData.type === 'api' &&
t(($) => $['feature.moderation.modal.content.fromApi'], { ns: 'appDebug' })) ||
''
}
showPreset={localeData.type !== 'api'}
/>
<ModerationContent
key={`outputs-${localeData.type}-${localeData.config?.outputs_config?.preset_response ?? ''}`}
title={
t(($) => $['feature.moderation.modal.content.output'], { ns: 'appDebug' }) || ''
}
config={
localeData.config?.outputs_config || { enabled: false, preset_response: '' }
}
onConfigChange={(config) => handleDataContentChange('outputs_config', config)}
info={
(localeData.type === 'api' &&
t(($) => $['feature.moderation.modal.content.fromApi'], { ns: 'appDebug' })) ||
''
}
showPreset={localeData.type !== 'api'}
/>
<div className="py-0.5 system-xs-regular text-text-tertiary">
{t(($) => $['feature.moderation.modal.content.condition'], { ns: 'appDebug' })}
</div>
</div>
</div>
</div>
<div className="flex h-19 items-center justify-end gap-2 px-6 pt-5 pb-6">
<Button onClick={onCancel} size="medium" className="min-w-18">
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button
variant="primary"
size="medium"
onClick={handleSave}
disabled={localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured}
className="min-w-18"
>
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
<div className="flex h-19 items-center justify-end gap-2 px-6 pt-5 pb-6">
<Button type="button" onClick={onCancel} size="medium" className="min-w-18">
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button
variant="primary"
size="medium"
type="submit"
disabled={localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured}
className="min-w-18"
>
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)

View File

@@ -1,5 +1,6 @@
import type { AppModelSelectionResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { FileUploadConfigResponse } from '@/models/common'
import type { Model, Resolution, TransferMethod, TtsAutoPlay } from '@/types/app'
import type { Resolution, TransferMethod, TtsAutoPlay } from '@/types/app'
type EnabledOrDisabled = {
enabled?: boolean
@@ -13,7 +14,7 @@ export type OpeningStatement = EnabledOrDisabled & {
}
export type SuggestedQuestionsAfterAnswer = EnabledOrDisabled & {
model?: Model
model?: AppModelSelectionResponse
prompt?: string
}

View File

@@ -1,9 +1,14 @@
import type { ExternalDataTool } from '@/models/common'
import type { AgentConfig } from '@/models/debug'
import type { ModelConfig, VisionFile } from '@/types/app'
export { TransferMethod } from '@/types/app'
export type TextGenerationConfig = Omit<ModelConfig, 'model'> & {
export type TextGenerationConfig = Omit<
ModelConfig,
'model' | 'system_parameters' | 'agent_mode'
> & {
agent_mode: Pick<AgentConfig, 'enabled' | 'tools'> & { strategy?: AgentConfig['strategy'] }
external_data_tools: ExternalDataTool[]
}

View File

@@ -84,6 +84,33 @@ describe('CreateAppModal', () => {
mockAppCount = 1
})
it.each([
{ appIconType: null, appIcon: null, appIconBackground: null },
{
appIconType: 'link' as const,
appIcon: 'https://example.com/icon.png',
appIconBackground: null,
},
])('preserves the existing icon when editing other fields: %j', async (iconProps) => {
const user = userEvent.setup()
const { onConfirm } = await setup({ isEditModal: true, ...iconProps })
await user.clear(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'))
await user.type(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), 'Renamed app')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
await waitFor(() =>
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Renamed app',
icon_type: iconProps.appIconType,
icon: iconProps.appIcon,
icon_background: iconProps.appIconBackground,
}),
),
)
})
describe('Rendering', () => {
it('should render create title and actions when creating', async () => {
await setup({ appName: 'My App', isEditModal: false })
@@ -456,7 +483,7 @@ describe('CreateAppModal', () => {
expect(onConfirm.mock.calls[0]![0]).toMatchObject({ description: 'Updated description' })
})
it('should omit icon_background when submitting with image icon', async () => {
it('preserves the existing null background when submitting an unchanged image icon', async () => {
const { onConfirm } = await setup({
appIconType: 'image',
appIcon: 'file-123',
@@ -474,7 +501,7 @@ describe('CreateAppModal', () => {
icon_type: 'image',
icon: 'file-123',
})
expect(payload.icon_background).toBeUndefined()
expect(payload.icon_background).toBeNull()
})
it('should include max_active_requests and updated answer icon when saving', async () => {

View File

@@ -1,6 +1,10 @@
'use client'
import type {
AppDetailWithSite,
UpdateAppPayload,
} from '@dify/contracts/api/console/apps/types.gen'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { AppIconType } from '@/types/app'
import type { IconPickerDefaultValue, IconPickerValue } from '@/app/components/base/icon-picker'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { IconButton } from '@langgenius/dify-ui/icon-button'
@@ -28,28 +32,18 @@ export type CreateAppModalProps = {
isEditModal?: boolean
appName: string
appDescription: string
appIconType: AppIconType | null
appIcon: string
appIconType: AppDetailWithSite['icon_type']
appIcon: AppDetailWithSite['icon']
appIconBackground?: string | null
appIconUrl?: string | null
appMode?: string
appUseIconAsAnswerIcon?: boolean
max_active_requests?: number | null
onConfirm: (info: {
name: string
icon_type: AppIconType
icon: string
icon_background?: string
description: string
use_icon_as_answer_icon?: boolean
max_active_requests?: number | null
}) => Promise<void>
onConfirm: (info: UpdateAppPayload) => Promise<void>
confirmDisabled?: boolean
onHide: () => void
}
type CreateAppPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
const SUBMIT_APP_HOTKEY = 'Mod+Enter' satisfies Hotkey
const CreateAppModal = ({
@@ -74,11 +68,32 @@ const CreateAppModal = ({
const { t } = useTranslation(['app', 'common', 'explore'])
const [name, setName] = React.useState(appName)
const [appIcon, setAppIcon] = useState(() =>
appIconType === 'image'
? { type: 'image' as const, fileId: _appIcon, url: appIconUrl ?? '' }
: { type: 'emoji' as const, icon: _appIcon, background: appIconBackground },
)
const [selectedIcon, setSelectedIcon] = useState<IconPickerValue | null>(null)
const pickerDefaultValue: IconPickerDefaultValue | undefined =
selectedIcon ??
(appIconType === 'image' && _appIcon
? { type: 'image', fileId: _appIcon, url: appIconUrl ?? '' }
: appIconType === 'emoji' && _appIcon
? { type: 'emoji', icon: _appIcon, background: appIconBackground }
: undefined)
const currentIcon = selectedIcon
? {
icon_type: selectedIcon.type,
icon: selectedIcon.type === 'emoji' ? selectedIcon.icon : selectedIcon.fileId,
icon_background: selectedIcon.type === 'emoji' ? selectedIcon.background : undefined,
icon_url: selectedIcon.type === 'image' ? selectedIcon.url : undefined,
}
: {
icon_type: appIconType,
icon: _appIcon,
icon_background: appIconBackground,
icon_url: appIconUrl,
}
const {
icon_type: currentIconType,
icon: currentIconValue,
icon_background: currentIconBackground,
} = currentIcon
const [showIconPicker, setShowIconPicker] = useState(false)
const [description, setDescription] = useState(appDescription || '')
const [useIconAsAnswerIcon, setUseIconAsAnswerIcon] = useState(appUseIconAsAnswerIcon || false)
@@ -116,11 +131,11 @@ const CreateAppModal = ({
}
const parsedMaxActiveRequests = Number(maxActiveRequestsInput)
const isValid = maxActiveRequestsInput.trim() !== '' && !Number.isNaN(parsedMaxActiveRequests)
const payload: CreateAppPayload = {
const payload: UpdateAppPayload = {
name,
icon_type: appIcon.type,
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
icon_background: appIcon.type === 'emoji' ? (appIcon.background ?? undefined) : undefined,
icon_type: currentIconType,
icon: currentIconValue,
icon_background: currentIconBackground,
description,
use_icon_as_answer_icon: useIconAsAnswerIcon,
}
@@ -134,7 +149,9 @@ const CreateAppModal = ({
isAppQuotaUnavailable,
isAppsFull,
name,
appIcon,
currentIconType,
currentIconValue,
currentIconBackground,
description,
useIconAsAnswerIcon,
onConfirm,
@@ -209,10 +226,12 @@ const CreateAppModal = ({
setShowIconPicker(true)
}}
className="cursor-pointer"
iconType={appIcon.type}
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
background={appIcon.type === 'image' ? undefined : appIcon.background}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
iconType={currentIcon.icon_type === 'link' ? 'image' : currentIcon.icon_type}
icon={currentIcon.icon ?? undefined}
background={currentIcon.icon_background}
imageUrl={
currentIcon.icon_type === 'link' ? currentIcon.icon : currentIcon.icon_url
}
/>
<Input
id={nameInputId}
@@ -313,11 +332,9 @@ const CreateAppModal = ({
</Dialog>
<IconPickerDialog
open={showIconPicker}
defaultValue={appIcon}
defaultValue={pickerDefaultValue}
onOpenChange={setShowIconPicker}
onConfirm={(payload) => {
setAppIcon(payload)
}}
onConfirm={setSelectedIcon}
/>
</>
)

View File

@@ -109,7 +109,7 @@ const TextGeneration: FC<Props> = ({ appId, className, isWorkflow, appData }) =>
transfer_methods:
file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods,
// legacy of image upload compatible
image_file_size_limit: appParams?.system_parameters.image_file_size_limit,
image_file_size_limit: appParams?.system_parameters?.image_file_size_limit,
fileUploadConfig: appParams?.system_parameters,
// oxlint-disable-next-line typescript/no-explicit-any
} as any)

View File

@@ -1,36 +1,93 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import type { FileUploaderInAttachmentWrapperProps } from '@/app/components/base/file-uploader/file-uploader-in-attachment'
import type { fileUpload } from '@/app/components/base/file-uploader/utils'
import { zAppAgentModePayload } from '@dify/contracts/api/console/apps/zod.gen'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useRef, useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { useFile } from '@/app/components/base/file-uploader/hooks'
import { FileContextProvider, useStore } from '@/app/components/base/file-uploader/store'
import { InputVarType } from '@/app/components/workflow/types'
import AppInputsForm from '../app-inputs-form'
const mockFileUpload = vi.hoisted(() => vi.fn<typeof fileUpload>())
vi.mock('@/next/navigation', () => ({
useParams: () => ({}),
usePathname: () => '/app/agent-app/configuration',
}))
vi.mock('@/app/components/base/file-uploader/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/app/components/base/file-uploader/utils')>()),
fileUpload: mockFileUpload,
}))
vi.mock('uuid', () => ({ v4: () => 'file-1' }))
function UploadControls({ fileConfig }: Pick<FileUploaderInAttachmentWrapperProps, 'fileConfig'>) {
const files = useStore((state) => state.files)
const { handleLocalFileUpload, handleClearFiles, handleReUploadFile } = useFile(fileConfig)
return (
<div>
<span data-testid="file-uploader-value">{JSON.stringify(files)}</span>
<button
data-testid="file-uploader"
onClick={() => handleLocalFileUpload(new File(['png'], 'demo.png', { type: 'image/png' }))}
>
Upload
</button>
<button data-testid="file-uploader-empty" onClick={handleClearFiles}>
Upload Empty
</button>
{files
.filter((file) => file.progress === -1)
.map((file) => (
<button key={file.id} onClick={() => handleReUploadFile(file.id)}>
Retry {file.name}
</button>
))}
</div>
)
}
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({
onChange,
value,
}: {
onChange: (files: Array<Record<string, unknown>>) => void
value: Array<Record<string, unknown>>
}) => (
<div>
<span data-testid="file-uploader-value">{JSON.stringify(value)}</span>
<button
data-testid="file-uploader"
onClick={() => onChange([{ id: 'file-1', name: 'demo.png' }])}
>
Upload
</button>
<button data-testid="file-uploader-empty" onClick={() => onChange([])}>
Upload Empty
</button>
</div>
fileConfig,
}: FileUploaderInAttachmentWrapperProps) => (
<FileContextProvider value={value} onChange={onChange}>
<UploadControls fileConfig={fileConfig} />
</FileContextProvider>
),
}))
type AppInputsFormProps = ComponentProps<typeof AppInputsForm>
const parseAgentToolInputs = (inputs: Record<string, unknown> | undefined) =>
zAppAgentModePayload.parse({
enabled: true,
strategy: 'react',
max_iteration: 5,
tools: [
{
enabled: true,
provider_type: 'builtin',
provider_id: 'test/provider',
tool_name: 'call_app',
tool_parameters: {
target_app: {
app_id: 'nested-app',
type: 'app-selector',
inputs,
files: [],
},
},
},
],
})
const renderControlledForm = ({
inputsForms,
initialInputs,
@@ -42,6 +99,7 @@ const renderControlledForm = ({
}) => {
const Wrapper = () => {
const [inputs, setInputs] = useState(initialInputs)
const [open, setOpen] = useState(true)
const inputsRef = useRef(initialInputs)
const handleFormChange = (nextInputs: Record<string, unknown>) => {
@@ -51,12 +109,17 @@ const renderControlledForm = ({
}
return (
<AppInputsForm
inputsForms={inputsForms}
inputs={inputs}
inputsRef={inputsRef}
onFormChange={handleFormChange}
/>
<>
<button onClick={() => setOpen(!open)}>{open ? 'Close inputs' : 'Open inputs'}</button>
{open && (
<AppInputsForm
inputsForms={inputsForms}
inputs={inputs}
inputsRef={inputsRef}
onFormChange={handleFormChange}
/>
)}
</>
)
}
@@ -66,6 +129,19 @@ const renderControlledForm = ({
describe('AppInputsForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFileUpload.mockImplementation(({ onSuccessCallback }) =>
onSuccessCallback({
id: 'uploaded-file-1',
name: 'demo.png',
size: 3,
mime_type: 'image/png',
extension: 'png',
created_at: 1,
created_by: 'account-1',
preview_url: null,
source_url: '',
}),
)
})
it('should return null when no form items are provided', () => {
@@ -148,34 +224,87 @@ describe('AppInputsForm', () => {
expect(onFormChange).toHaveBeenCalledWith({ tone: 'formal' })
})
it('should update uploaded single file values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { attachment: null } }
it.each([InputVarType.singleFile, InputVarType.multiFiles])(
'should publish uploaded %s metadata without retaining the local File object',
async (type) => {
const user = userEvent.setup()
const onFormChange = vi.fn<(value: Record<string, unknown>) => void>()
render(
<AppInputsForm
inputsForms={[
renderControlledForm({
inputsForms: [
{
variable: 'attachment',
label: 'Attachment',
type: InputVarType.singleFile,
type,
required: false,
allowed_file_types: [],
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
},
]}
inputs={{ attachment: null }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
],
initialInputs: {},
onFormChange,
})
await user.click(screen.getByRole('button', { name: 'Upload' }))
await waitFor(() =>
expect(screen.getByTestId('file-uploader-value')).toHaveTextContent('uploaded-file-1'),
)
const selectedInputs = onFormChange.mock.lastCall?.[0]
expect(() => parseAgentToolInputs(selectedInputs)).not.toThrow()
const persistedFile = {
id: 'file-1',
name: 'demo.png',
size: 3,
type: 'image/png',
progress: 100,
transferMethod: 'local_file',
supportFileType: 'image',
uploadedId: 'uploaded-file-1',
base64Url: 'data:image/png;base64,cG5n',
}
expect(selectedInputs).toEqual({
attachment: type === InputVarType.singleFile ? persistedFile : [persistedFile],
})
expect(screen.getByTestId('file-uploader-value')).toHaveTextContent('uploaded-file-1')
},
)
it('should retain failed uploads across reopening until retry succeeds', async () => {
const user = userEvent.setup()
const onFormChange = vi.fn<(value: Record<string, unknown>) => void>()
mockFileUpload.mockImplementationOnce(({ onErrorCallback }) =>
onErrorCallback(new Error('Upload failed')),
)
fireEvent.click(screen.getByTestId('file-uploader'))
expect(onFormChange).toHaveBeenCalledWith({
attachment: { id: 'file-1', name: 'demo.png' },
renderControlledForm({
inputsForms: [
{
variable: 'attachment',
label: 'Attachment',
type: InputVarType.singleFile,
required: false,
allowed_file_types: ['image'],
allowed_file_upload_methods: ['local_file'],
},
],
initialInputs: {},
onFormChange,
})
await user.click(screen.getByRole('button', { name: 'Upload' }))
await screen.findByRole('button', { name: 'Retry demo.png' })
const originalFile = mockFileUpload.mock.calls[0]?.[0].file
expect(() => parseAgentToolInputs(onFormChange.mock.lastCall?.[0])).toThrow()
await user.click(screen.getByRole('button', { name: 'Close inputs' }))
expect(screen.queryByRole('button', { name: 'Retry demo.png' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Open inputs' }))
await user.click(screen.getByRole('button', { name: 'Retry demo.png' }))
expect(mockFileUpload.mock.lastCall?.[0].file).toBe(originalFile)
expect(screen.queryByRole('button', { name: 'Retry demo.png' })).not.toBeInTheDocument()
expect(() => parseAgentToolInputs(onFormChange.mock.lastCall?.[0])).not.toThrow()
})
it('should update paragraph fields and preserve sibling input values', async () => {
@@ -234,32 +363,35 @@ describe('AppInputsForm', () => {
expect(onFormChange).toHaveBeenCalledWith({ files: [] })
})
it('should preserve existing single-file values and forward empty single-file uploads as undefined', () => {
const onFormChange = vi.fn()
it('should remove cleared single-file inputs while keeping agent tool parameters publishable', async () => {
const user = userEvent.setup()
const onFormChange = vi.fn<(value: Record<string, unknown>) => void>()
const existingFile = { id: 'existing-file', name: 'existing.png' }
const siblingInputs = { enabled: false, count: 0, note: '', nullable: null }
render(
<AppInputsForm
inputsForms={[
{
variable: 'attachment',
label: 'Attachment',
type: InputVarType.singleFile,
required: false,
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
},
]}
inputs={{ attachment: existingFile }}
inputsRef={{ current: { attachment: existingFile } }}
onFormChange={onFormChange}
/>,
)
renderControlledForm({
inputsForms: [
{
variable: 'attachment',
label: 'Attachment',
type: InputVarType.singleFile,
required: false,
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
},
],
initialInputs: { ...siblingInputs, attachment: existingFile },
onFormChange,
})
expect(screen.getByTestId('file-uploader-value')).toHaveTextContent('"existing-file"')
fireEvent.click(screen.getByTestId('file-uploader-empty'))
expect(onFormChange).toHaveBeenCalledWith({ attachment: undefined })
await user.click(screen.getByRole('button', { name: 'Upload Empty' }))
expect(() => parseAgentToolInputs(onFormChange.mock.lastCall?.[0])).not.toThrow()
expect(onFormChange).toHaveBeenLastCalledWith(siblingInputs)
expect(onFormChange.mock.lastCall?.[0]).not.toHaveProperty('attachment')
expect(screen.getByTestId('file-uploader-value')).toHaveTextContent('[]')
})
})

View File

@@ -1,3 +1,4 @@
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { Input } from '@langgenius/dify-ui/input'
import {
Select,
@@ -13,6 +14,13 @@ import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { InputVarType } from '@/app/components/workflow/types'
const toAppInputFile = (file: FileEntity): FileEntity => {
if (file.progress !== 100 || !file.uploadedId) return file
const persistedFile = { ...file }
delete persistedFile.originalFile
return persistedFile
}
type Props = Readonly<{
inputsForms: any[]
inputs: Record<string, any>
@@ -25,10 +33,10 @@ const AppInputsForm = ({ inputsForms, inputs, inputsRef, onFormChange }: Props)
const handleFormChange = useCallback(
(variable: string, value: any) => {
onFormChange({
...inputsRef.current,
[variable]: value,
})
const nextInputs = { ...inputsRef.current }
if (value === undefined) delete nextInputs[variable]
else nextInputs[variable] = value
onFormChange(nextInputs)
},
[onFormChange, inputsRef],
)
@@ -96,7 +104,9 @@ const AppInputsForm = ({ inputsForms, inputs, inputsRef, onFormChange }: Props)
return (
<FileUploaderInAttachmentWrapper
value={inputs[variable] ? [inputs[variable]] : []}
onChange={(files) => handleFormChange(variable, files[0])}
onChange={(files) =>
handleFormChange(variable, files[0] ? toAppInputFile(files[0]) : undefined)
}
fileConfig={{
allowed_file_types: form.allowed_file_types,
allowed_file_extensions: form.allowed_file_extensions,
@@ -111,7 +121,7 @@ const AppInputsForm = ({ inputsForms, inputs, inputsRef, onFormChange }: Props)
return (
<FileUploaderInAttachmentWrapper
value={inputs[variable]}
onChange={(files) => handleFormChange(variable, files)}
onChange={(files) => handleFormChange(variable, files.map(toAppInputFile))}
fileConfig={{
allowed_file_types: form.allowed_file_types,
allowed_file_extensions: form.allowed_file_extensions,

View File

@@ -152,7 +152,7 @@ export const useTextGenerationAppState = ({
...file_upload,
transfer_methods:
file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods,
image_file_size_limit: appParams?.system_parameters.image_file_size_limit,
image_file_size_limit: appParams?.system_parameters?.image_file_size_limit,
fileUploadConfig: appParams?.system_parameters,
} as VisionSettings)
setPromptConfig({

View File

@@ -2,13 +2,13 @@ import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import type { ReactElement } from 'react'
import type { AppPublisherProps } from '@/app/components/app/app-publisher/types'
import type { App } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryWrapper, seedFeatures } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import FeaturesTrigger from '../features-trigger'
const mockUseIsChatMode = vi.fn()
@@ -53,7 +53,7 @@ const mockPublishWorkflow = vi.fn()
const mockUpdatePublishedWorkflow = vi.fn()
const mockResetWorkflowVersionHistory = vi.fn()
const mockInvalidateAppTriggers = vi.fn()
const mockFetchAppDetail = vi.fn()
const mockAppResponse = vi.fn()
const mockInvalidateQueries = vi.fn()
const mockSetPublishedAt = vi.fn()
const mockSetLastPublishedHasUserInput = vi.fn()
@@ -229,8 +229,9 @@ vi.mock('@/service/use-tools', () => ({
useInvalidateAppTriggers: () => mockInvalidateAppTriggers,
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
vi.mock('@/service/base', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/base')>()),
request: async (url: string) => Response.json(await mockAppResponse(url)),
}))
vi.mock('@/hooks/use-theme', () => ({
@@ -277,8 +278,8 @@ describe('FeaturesTrigger', () => {
mockUseNodes.mockReturnValue([])
mockUseEdges.mockReturnValue([])
// Set up app store state
useAppStore.setState({ appDetail: { id: 'app-id' } as unknown as App })
mockFetchAppDetail.mockResolvedValue({ id: 'app-id', name: 'Updated App' })
useAppStore.setState({ appDetail: createAppDetailFixture({ id: 'app-id' }) })
mockAppResponse.mockResolvedValue(createAppDetailFixture({ id: 'app-id', name: 'Updated App' }))
mockInvalidateQueries.mockResolvedValue(undefined)
mockPublishWorkflow.mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
})
@@ -631,7 +632,7 @@ describe('FeaturesTrigger', () => {
type: 'success',
message: 'common.api.actionSuccess',
})
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' })
expect(mockAppResponse).toHaveBeenCalledWith(expect.stringContaining('/apps/app-id'))
expect(useAppStore.getState().appDetail).toEqual(
expect.objectContaining({
name: 'Updated App',
@@ -801,7 +802,7 @@ describe('FeaturesTrigger', () => {
// Arrange
const user = userEvent.setup()
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
mockFetchAppDetail.mockRejectedValueOnce(new Error('fetch failed'))
mockAppResponse.mockRejectedValueOnce(new Error('fetch failed'))
renderWithToast(<FeaturesTrigger />)

View File

@@ -30,8 +30,7 @@ import { BlockEnum, InputVarType, isTriggerNode } from '@/app/components/workflo
import { toast } from '@/app/notifications'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useTheme from '@/hooks/use-theme'
import { fetchAppDetail } from '@/service/apps'
import { consoleQuery } from '@/service/console'
import { consoleClient, consoleQuery } from '@/service/console'
import { useInvalidateAppTriggers } from '@/service/use-tools'
import {
useInvalidateAppWorkflow,
@@ -164,7 +163,7 @@ const FeaturesTrigger = () => {
try {
if (!appID) return
const res = await fetchAppDetail({ url: '/apps', id: appID })
const res = await consoleClient.apps.byAppId.get({ params: { app_id: appID } })
setAppDetail({ ...res })
} catch (error) {
console.error(error)

View File

@@ -1,3 +1,4 @@
import type { AppMode } from '@dify/contracts/api/console/apps/types.gen'
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { TriggerNodeType } from '@/app/components/workflow/types'
import type { IOtherOptions } from '@/service/base'
@@ -24,7 +25,7 @@ type DebuggableTriggerType = Exclude<TriggerType, typeof TriggerType.UserInput>
type AppDetailLike = {
id?: string
mode?: AppModeEnum
mode?: AppMode
}
type TTSParamsLike = {

Some files were not shown because too many files have changed in this diff Show More