refactor(web): consume generated app detail contracts (#42982)
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -82,7 +82,6 @@ const TextGenerationItem: FC<TextGenerationItemProps> = ({ modelAndParameter })
|
||||
datasets: [...postDatasets],
|
||||
} as any,
|
||||
},
|
||||
system_parameters: modelConfig.system_parameters,
|
||||
}
|
||||
const { completion, handleSend, isResponding, messageId } = useTextGeneration()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -110,7 +110,6 @@ export const useConfigFromDebugContext = () => {
|
||||
number_limits: visionConfig.number_limits,
|
||||
},
|
||||
annotation_reply: annotationConfig,
|
||||
system_parameters: modelConfig.system_parameters,
|
||||
|
||||
supportAnnotation: true,
|
||||
appId,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
{t(($) => $['settings.provider'], { ns: 'navigation' })}
|
||||
|
||||
</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}
|
||||
>
|
||||
|
||||
{t(($) => $['settings.provider'], { ns: 'navigation' })}
|
||||
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('[]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user