refactor(web): migrate console contracts to generated types (#38231)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Stephen Zhou 2026-07-01 09:40:12 +08:00 committed by GitHub
parent 089c3f4af0
commit 3ad06bebd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 473 additions and 743 deletions

View File

@ -129,6 +129,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
})
vi.mock('@/service/use-apps', () => ({
normalizeAppPagination: <T,>(response: T) => response,
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,

View File

@ -104,6 +104,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
})
vi.mock('@/service/use-apps', () => ({
normalizeAppPagination: <T,>(response: T) => response,
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,

View File

@ -76,7 +76,7 @@ vi.mock('@/service/client', () => ({
},
},
apps: {
list: {
get: {
queryOptions: (options: {
input?: { query?: { limit?: number } }
select?: (response: {
@ -96,7 +96,7 @@ vi.mock('@/service/client', () => ({
total: 0,
}
return {
queryKey: ['console', 'apps', 'list', options.input],
queryKey: ['console', 'apps', 'get', options.input],
queryFn: () => Promise.resolve(response),
initialData: response,
select: options.select,
@ -217,7 +217,7 @@ const homeContinueWorkAppsInput = {
const createHomeQueryClient = () => {
const queryClient = createTestQueryClient()
queryClient.setQueryData(['console', 'apps', 'list', homeContinueWorkAppsInput], {
queryClient.setQueryData(['console', 'apps', 'get', homeContinueWorkAppsInput], {
data: [],
has_more: false,
limit: 8,

View File

@ -251,7 +251,7 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
id: 'tag1',
name: 'test-tag',
type: 'app',
binding_count: 1,
binding_count: '',
}
expect(tag).toHaveProperty('id')
@ -259,13 +259,13 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
expect(tag).toHaveProperty('type')
expect(tag).toHaveProperty('binding_count')
expect(tag.type).toBe('app')
expect(typeof tag.binding_count).toBe('number')
expect(typeof tag.binding_count).toBe('string')
})
it('should handle tag arrays correctly', () => {
const tags = [
{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 1 },
{ id: 'tag2', name: 'Tag 2', type: 'app', binding_count: 0 },
{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: '' },
{ id: 'tag2', name: 'Tag 2', type: 'app', binding_count: '' },
]
expect(Array.isArray(tags)).toBe(true)
@ -278,7 +278,7 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
id: 'test-app',
name: 'Test App',
tags: [
{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 1 },
{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: '' },
],
}
@ -308,22 +308,22 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
it('should handle malformed tag data gracefully', () => {
const mixedData = [
{ id: 'valid1', name: 'Valid Tag', type: 'app', binding_count: 1 },
{ id: 'valid1', name: 'Valid Tag', type: 'app', binding_count: '' },
{ id: 'invalid1' }, // Missing required properties
null,
undefined,
{ id: 'valid2', name: 'Another Valid', type: 'app', binding_count: 0 },
{ id: 'valid2', name: 'Another Valid', type: 'app', binding_count: '' },
]
// Filter out invalid entries
const validTags = mixedData.filter((tag): tag is { id: string, name: string, type: string, binding_count: number } =>
const validTags = mixedData.filter((tag): tag is { id: string, name: string, type: string, binding_count: string } =>
tag != null
&& typeof tag === 'object'
&& 'id' in tag
&& 'name' in tag
&& 'type' in tag
&& 'binding_count' in tag
&& typeof tag.binding_count === 'number',
&& typeof tag.binding_count === 'string',
)
expect(validTags.length).toBe(2)

View File

@ -20,6 +20,7 @@ import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { consoleQuery } from '@/service/client'
import { updateUserProfile } from '@/service/common'
import { normalizeAppPagination } from '@/service/use-apps'
import DeleteAccount from '../delete-account'
import AvatarWithEdit from './AvatarWithEdit'
@ -35,7 +36,7 @@ const descriptionClassName = `
export default function AccountPage() {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: appList } = useQuery(consoleQuery.apps.list.queryOptions({
const { data: appList } = useQuery(consoleQuery.apps.get.queryOptions({
input: {
query: {
page: 1,
@ -43,6 +44,7 @@ export default function AccountPage() {
name: '',
},
},
select: normalizeAppPagination,
}))
const apps = appList?.data || []
const queryClient = useQueryClient()

View File

@ -29,18 +29,22 @@ vi.mock(import('@/config'), async (importOriginal) => {
vi.mock('@/service/client', () => ({
consoleQuery: {
notification: {
queryOptions: (options?: Record<string, unknown>) => ({
queryKey: ['console', 'notification'],
queryFn: (...args: unknown[]) => mockNotification(...args),
...options,
}),
},
notificationDismiss: {
mutationOptions: (options?: Record<string, unknown>) => ({
mutationKey: ['console', 'notificationDismiss'],
mutationFn: (...args: unknown[]) => mockNotificationDismiss(...args),
...options,
}),
get: {
queryOptions: (options?: Record<string, unknown>) => ({
queryKey: ['console', 'notification', 'get'],
queryFn: (...args: unknown[]) => mockNotification(...args),
...options,
}),
},
dismiss: {
post: {
mutationOptions: (options?: Record<string, unknown>) => ({
mutationKey: ['console', 'notification', 'dismiss', 'post'],
mutationFn: (...args: unknown[]) => mockNotificationDismiss(...args),
...options,
}),
},
},
},
},
}))
@ -149,7 +153,7 @@ describe('InSiteMessageNotification', () => {
},
},
expect.objectContaining({
mutationKey: ['console', 'notificationDismiss'],
mutationKey: ['console', 'notification', 'dismiss', 'post'],
}),
)
})
@ -187,7 +191,7 @@ describe('InSiteMessageNotification', () => {
},
},
expect.objectContaining({
mutationKey: ['console', 'notificationDismiss'],
mutationKey: ['console', 'notification', 'dismiss', 'post'],
}),
)
})

View File

@ -60,18 +60,19 @@ function parseNotificationBody(body: string): NotificationBodyPayload | null {
function InSiteMessageNotification() {
const { t } = useTranslation()
const dismissNotificationMutation = useMutation(consoleQuery.notificationDismiss.mutationOptions())
const dismissNotificationMutation = useMutation(consoleQuery.notification.dismiss.post.mutationOptions())
const { data } = useQuery(consoleQuery.notification.queryOptions({
const { data } = useQuery(consoleQuery.notification.get.queryOptions({
enabled: IS_CLOUD_EDITION,
}))
const notification = data?.notifications?.[0]
const parsedBody = notification ? parseNotificationBody(notification.body) : null
if (!IS_CLOUD_EDITION || !notification)
if (!IS_CLOUD_EDITION || !notification || !notification.notification_id)
return null
const notificationId = notification.notification_id
const fallbackActions: InSiteMessageActionItem[] = [
{
type: 'default',
@ -89,15 +90,15 @@ function InSiteMessageNotification() {
dismissNotificationMutation.mutate({
body: {
notification_id: notification.notification_id,
notification_id: notificationId,
},
})
}
return (
<InSiteMessage
key={notification.notification_id}
notificationId={notification.notification_id}
key={notificationId}
notificationId={notificationId}
title={notification.title}
subtitle={notification.subtitle}
headerBgUrl={notification.title_pic_url}

View File

@ -400,7 +400,7 @@ describe('AppCard', () => {
author_name: 'Readonly Author',
created_by: 'another-user',
maintainer: 'another-user',
tags: [{ id: 'tag-preview', name: 'Readonly Tag', type: 'app' as const, binding_count: 0 }],
tags: [{ id: 'tag-preview', name: 'Readonly Tag', type: 'app' as const, binding_count: '' }],
permission_keys: [AppACLPermission.Preview],
})
@ -501,7 +501,7 @@ describe('AppCard', () => {
it('should handle app with tags', () => {
const appWithTags = {
...mockApp,
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 }],
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: '' }],
}
render(<AppCard app={appWithTags} />)
// Verify the tag selector component renders
@ -510,10 +510,10 @@ describe('AppCard', () => {
it('should display refreshed tag names from app props when tag ids stay the same', () => {
const firstApp = createMockApp({
tags: [{ id: 'tag1', name: 'Old Tag', type: 'app' as const, binding_count: 0 }],
tags: [{ id: 'tag1', name: 'Old Tag', type: 'app' as const, binding_count: '' }],
})
const refreshedApp = createMockApp({
tags: [{ id: 'tag1', name: 'New Tag', type: 'app' as const, binding_count: 0 }],
tags: [{ id: 'tag1', name: 'New Tag', type: 'app' as const, binding_count: '' }],
})
const { rerender } = render(<AppCard app={firstApp} />)
@ -531,7 +531,7 @@ describe('AppCard', () => {
mockAppContext.userProfile = { id: 'user-2' }
const editableApp = createMockApp({
maintainer: 'user-1',
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 }],
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: '' }],
permission_keys: [AppACLPermission.Edit],
})
@ -546,7 +546,7 @@ describe('AppCard', () => {
mockAppContext.userProfile = { id: 'user-2' }
const tagManageApp = createMockApp({
maintainer: 'user-1',
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 }],
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: '' }],
permission_keys: [AppACLPermission.ViewLayout],
})
@ -561,7 +561,7 @@ describe('AppCard', () => {
mockAppContext.userProfile = { id: 'user-2' }
const readonlyApp = createMockApp({
maintainer: 'user-1',
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 }],
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: '' }],
permission_keys: [AppACLPermission.ViewLayout],
})
@ -1429,9 +1429,9 @@ describe('AppCard', () => {
const multiTagApp = {
...mockApp,
tags: [
{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 },
{ id: 'tag2', name: 'Tag 2', type: 'app' as const, binding_count: 0 },
{ id: 'tag3', name: 'Tag 3', type: 'app' as const, binding_count: 0 },
{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: '' },
{ id: 'tag2', name: 'Tag 2', type: 'app' as const, binding_count: '' },
{ id: 'tag3', name: 'Tag 3', type: 'app' as const, binding_count: '' },
],
}
render(<AppCard app={multiTagApp} />)
@ -1586,7 +1586,7 @@ describe('AppCard', () => {
it('should stop propagation when clicking tag selector area', () => {
const multiTagApp = createMockApp({
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 }],
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: '' }],
})
render(<AppCard app={multiTagApp} />)

View File

@ -46,15 +46,17 @@ vi.mock('@/service/client', () => ({
},
consoleQuery: {
apps: {
list: {
get: {
infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options),
},
starredList: {
queryOptions: (options: unknown) => mockAppStarredListQueryOptions(options),
starred: {
get: {
queryOptions: (options: unknown) => mockAppStarredListQueryOptions(options),
},
},
},
tags: {
list: {
get: {
queryOptions: (options: unknown) => options,
},
},
@ -229,6 +231,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
})
vi.mock('@/service/use-apps', () => ({
normalizeAppPagination: (response: unknown) => response,
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,

View File

@ -1,7 +1,7 @@
'use client'
import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen'
import type { AppListCategory } from './app-type-filter-shared'
import type { AppListSortBy } from '@/contract/console/apps'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
@ -13,6 +13,9 @@ import { AppSortFilter } from './app-sort-filter'
import { AppTypeFilter } from './app-type-filter'
import CreatorsFilter from './creators-filter'
type AppListQuery = NonNullable<GetAppsData['query']>
type AppListSortBy = NonNullable<AppListQuery['sort_by']>
type AppListHeaderFiltersProps = {
category: AppListCategory
tagIDs: string[]

View File

@ -1,6 +1,6 @@
'use client'
import type { AppListSortBy } from '@/contract/console/apps'
import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen'
import {
DropdownMenu,
DropdownMenuContent,
@ -12,6 +12,9 @@ import {
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
type AppListQuery = NonNullable<GetAppsData['query']>
type AppListSortBy = NonNullable<AppListQuery['sort_by']>
type AppSortFilterProps = {
value: AppListSortBy
onChange: (value: AppListSortBy) => void

View File

@ -25,8 +25,12 @@ vi.mock('@tanstack/react-query', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
workflowOnlineUsers: {
queryOptions: mockQueryOptions,
workflows: {
onlineUsers: {
post: {
queryOptions: mockQueryOptions,
},
},
},
},
},
@ -35,7 +39,7 @@ vi.mock('@/service/client', () => ({
const getLastQueryOptions = () => {
const lastCall = mockQueryOptions.mock.lastCall
if (!lastCall)
throw new Error('workflowOnlineUsers.queryOptions was not called')
throw new Error('workflows.onlineUsers.post.queryOptions was not called')
return lastCall[0] as QueryOptions
}

View File

@ -35,7 +35,7 @@ export const useWorkflowOnlineUsers = ({
enabled,
}: UseWorkflowOnlineUsersParams) => {
const shouldFetch = enabled && appIds.length > 0
const { data: onlineUsersMap = {} } = useQuery(consoleQuery.apps.workflowOnlineUsers.queryOptions({
const { data: onlineUsersMap = {} } = useQuery(consoleQuery.apps.workflows.onlineUsers.post.queryOptions({
input: { body: { app_ids: appIds } },
enabled: shouldFetch,
select: normalizeWorkflowOnlineUsers,

View File

@ -1,6 +1,6 @@
'use client'
import type { AppListQuery, AppListSortBy } from '@/contract/console/apps'
import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { keepPreviousData, useInfiniteQuery, useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
@ -13,6 +13,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { CheckModal } from '@/hooks/use-pay'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { normalizeAppPagination } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { hasPermission } from '@/utils/permission'
import { AppCard } from './app-card'
@ -31,6 +32,9 @@ import { StudioListHeader } from './studio-list-header'
const STARRED_APP_LIMIT = 100
type AppListQuery = NonNullable<GetAppsData['query']>
type AppListSortBy = NonNullable<AppListQuery['sort_by']>
type Props = Readonly<{
controlRefreshList?: number
}>
@ -109,7 +113,7 @@ function List({
error,
refetch,
} = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
...consoleQuery.apps.get.infiniteOptions({
input: pageParam => ({
query: {
...appListQuery,
@ -120,6 +124,10 @@ function List({
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
select: data => ({
...data,
pages: data.pages.map(normalizeAppPagination),
}),
refetchInterval: systemFeatures.enable_collaboration_mode ? 10000 : false,
})
@ -133,10 +141,11 @@ function List({
data: starredAppList,
refetch: refetchStarredAppList,
} = useQuery({
...consoleQuery.apps.starredList.queryOptions({
...consoleQuery.apps.starred.get.queryOptions({
input: {
query: starredAppListQuery,
},
select: normalizeAppPagination,
}),
})

View File

@ -300,7 +300,7 @@ describe('DatasetCard Component', () => {
const dataset = createMockDataset({
name: 'Preview Only Dataset',
permission_keys: [DatasetACLPermission.Preview],
tags: [{ id: 'tag-preview', name: 'Readonly Tag', type: 'knowledge' as const, binding_count: 0 }],
tags: [{ id: 'tag-preview', name: 'Readonly Tag', type: 'knowledge' as const, binding_count: '' }],
})
render(<DatasetCard dataset={dataset} />)

View File

@ -52,7 +52,7 @@ describe('useDatasetCardState', () => {
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 }],
tags: [{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: '' }],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',

View File

@ -77,7 +77,7 @@ vi.mock('@/service/client', () => ({
},
},
apps: {
list: {
get: {
queryOptions: (options: {
input?: { query?: { limit?: number } }
select?: (response: {
@ -91,7 +91,7 @@ vi.mock('@/service/client', () => ({
const limit = options.input?.query?.limit ?? mockWorkspaceApps.length
if (mockWorkspaceAppsLoading) {
return {
queryKey: ['console', 'apps', 'list', options],
queryKey: ['console', 'apps', 'get', options],
queryFn: () => new Promise(() => {}),
select: options.select,
}
@ -104,7 +104,7 @@ vi.mock('@/service/client', () => ({
total: mockWorkspaceApps.length,
}
return {
queryKey: ['console', 'apps', 'list', options],
queryKey: ['console', 'apps', 'get', options],
queryFn: () => Promise.resolve(response),
initialData: response,
select: options.select,

View File

@ -25,6 +25,7 @@ import { DSLImportMode } from '@/models/app'
import dynamic from '@/next/dynamic'
import { consoleQuery } from '@/service/client'
import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore'
import { normalizeAppPagination } from '@/service/use-apps'
import { trackCreateApp } from '@/utils/create-app-tracking'
import { hasPermission } from '@/utils/permission'
import { ExploreAppListHeader } from './explore-app-list-header'
@ -72,9 +73,9 @@ function getExploreAppListQueryOptions(locale?: string) {
}
function getContinueWorkAppsQueryOptions() {
return consoleQuery.apps.list.queryOptions({
return consoleQuery.apps.get.queryOptions({
input: homeContinueWorkAppsInput,
select: (response): WorkspaceApp[] => response.data ?? [],
select: (response): WorkspaceApp[] => normalizeAppPagination(response).data,
})
}

View File

@ -1,5 +1,4 @@
import type { Plan } from '@/app/components/billing/type'
import type { IWorkspace } from '@/models/common'
import type { TenantListItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import {
SelectContent,
SelectGroup,
@ -9,30 +8,42 @@ import {
} from '@langgenius/dify-ui/select'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
import { PlanBadge } from '@/app/components/header/plan-badge'
type WorkplaceSelectorContentProps = {
workspaces: IWorkspace[]
workspaces: TenantListItemResponse[]
popupClassName?: string
}
type WorkplaceSelectorItemProps = {
workspace: IWorkspace
workspace: TenantListItemResponse
}
const workspacePlans = new Set<string>(Object.values(Plan))
function isWorkspacePlan(plan: string | null | undefined): plan is Plan {
return !!plan && workspacePlans.has(plan)
}
const WorkplaceSelectorItem = memo(({
workspace,
}: WorkplaceSelectorItemProps) => (
<SelectItem value={workspace.id} className="gap-2 py-1 pr-2 pl-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">
{workspace.name[0]?.toLocaleUpperCase()}
</span>
</div>
<SelectItemText className="system-md-regular">{workspace.name}</SelectItemText>
<PlanBadge plan={workspace.plan as Plan} />
</SelectItem>
))
}: WorkplaceSelectorItemProps) => {
const workspaceName = workspace.name || workspace.id
const workspacePlan = isWorkspacePlan(workspace.plan) ? workspace.plan : Plan.sandbox
return (
<SelectItem value={workspace.id} className="gap-2 py-1 pr-2 pl-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">
{workspaceName[0]?.toLocaleUpperCase()}
</span>
</div>
<SelectItemText className="system-md-regular">{workspaceName}</SelectItemText>
<PlanBadge plan={workspacePlan} />
</SelectItem>
)
})
WorkplaceSelectorItem.displayName = 'WorkplaceSelectorItem'
export const WorkplaceSelectorContent = memo(({

View File

@ -34,8 +34,8 @@ const workspaceMenuAlignOffset = -28
const workspaceCardSkeletonClassName = 'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none'
const workspacePlans = new Set<string>(Object.values(Plan))
function isWorkspacePlan(plan: string): plan is Plan {
return workspacePlans.has(plan)
function isWorkspacePlan(plan: string | null | undefined): plan is Plan {
return !!plan && workspacePlans.has(plan)
}
function WorkspaceCardSkeleton({

View File

@ -1,6 +1,6 @@
'use client'
import type { IWorkspace } from '@/models/common'
import type { TenantListItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@ -21,8 +21,13 @@ const workspaceSwitchActionIconClassName = 'size-3.5 shrink-0'
const workspaceSwitchListClassName = 'max-h-[240px] overflow-y-auto overscroll-contain scroll-py-1'
const workspaceSwitchI18nKey = (key: string) => key as 'mainNav.workspace.settings'
type WorkspaceSort = 'lastOpened' | 'createdAt'
type WorkspaceListItem = TenantListItemResponse & {
last_opened_at?: number | null
}
const getWorkspaceLastOpenedAt = (workspace: IWorkspace) => workspace.last_opened_at ?? 0
const getWorkspaceName = (workspace: WorkspaceListItem) => workspace.name || workspace.id
const getWorkspaceCreatedAt = (workspace: WorkspaceListItem) => workspace.created_at ?? 0
const getWorkspaceLastOpenedAt = (workspace: WorkspaceListItem) => workspace.last_opened_at ?? 0
function WorkspaceSwitchControls({
searchText,
@ -118,7 +123,7 @@ function WorkspaceSwitchControls({
}
type WorkspaceSwitcherProps = {
workspaces: IWorkspace[]
workspaces: WorkspaceListItem[]
onSwitchWorkspace: (workspaceId: string) => void
}
@ -131,15 +136,15 @@ export function WorkspaceSwitcher({
const displayedWorkspaces = useMemo(() => {
const normalizedSearchText = workspaceSearchText.trim().toLowerCase()
const filteredWorkspaces = normalizedSearchText
? workspaces.filter(workspace => workspace.name.toLowerCase().includes(normalizedSearchText))
? workspaces.filter(workspace => getWorkspaceName(workspace).toLowerCase().includes(normalizedSearchText))
: [...workspaces]
if (workspaceSort === 'createdAt')
return filteredWorkspaces.sort((a, b) => b.created_at - a.created_at)
return filteredWorkspaces.sort((a, b) => getWorkspaceCreatedAt(b) - getWorkspaceCreatedAt(a))
return filteredWorkspaces.sort((a, b) => {
return getWorkspaceLastOpenedAt(b) - getWorkspaceLastOpenedAt(a)
|| b.created_at - a.created_at
|| getWorkspaceCreatedAt(b) - getWorkspaceCreatedAt(a)
})
}, [workspaceSearchText, workspaceSort, workspaces])
@ -152,27 +157,31 @@ export function WorkspaceSwitcher({
onSortChange={setWorkspaceSort}
/>
<div className={workspaceSwitchListClassName}>
{displayedWorkspaces.map(workspace => (
<button
type="button"
key={workspace.id}
aria-current={workspace.current ? 'true' : undefined}
title={workspace.name}
className={cn(
'flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset',
workspace.current && 'bg-state-base-hover',
)}
onClick={() => {
onSwitchWorkspace(workspace.id)
}}
>
<WorkspaceMenuItemContent
icon={<WorkspaceIcon name={workspace.name} className="h-5 w-5 rounded-md" />}
label={workspace.name}
trailing={workspace.current ? <span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" /> : undefined}
/>
</button>
))}
{displayedWorkspaces.map((workspace) => {
const workspaceName = getWorkspaceName(workspace)
return (
<button
type="button"
key={workspace.id}
aria-current={workspace.current ? 'true' : undefined}
title={workspaceName}
className={cn(
'flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset',
workspace.current && 'bg-state-base-hover',
)}
onClick={() => {
onSwitchWorkspace(workspace.id)
}}
>
<WorkspaceMenuItemContent
icon={<WorkspaceIcon name={workspaceName} className="h-5 w-5 rounded-md" />}
label={workspaceName}
trailing={workspace.current ? <span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" /> : undefined}
/>
</button>
)
})}
</div>
</>
)

View File

@ -34,7 +34,7 @@ const apps: App[] = [
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
list: {
get: {
infiniteOptions: ({
input,
getNextPageParam,
@ -70,6 +70,7 @@ vi.mock('@/service/client', () => ({
}))
vi.mock('@/service/use-apps', () => ({
normalizeAppPagination: <T,>(response: T) => response,
useAppDetail: (appId: string) => ({
data: apps.find(app => app.id === appId),
}),

View File

@ -14,7 +14,7 @@ import AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-sel
import { AppPicker } from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker'
import { AppTrigger } from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger'
import { consoleQuery } from '@/service/client'
import { useAppDetail } from '@/service/use-apps'
import { normalizeAppPagination, useAppDetail } from '@/service/use-apps'
const PAGE_SIZE = 20
@ -58,7 +58,7 @@ export function AppSelector({
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
...consoleQuery.apps.get.infiniteOptions({
input: pageParam => ({
query: {
...appListQuery,
@ -69,6 +69,10 @@ export function AppSelector({
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
select: data => ({
...data,
pages: data.pages.map(normalizeAppPagination),
}),
})
const displayedApps = useMemo(() => {

View File

@ -228,7 +228,7 @@ describe('SnippetCard', () => {
render(
<SnippetCard
snippet={createSnippet({ tags: [{ id: 'tag-1', name: 'Sales', type: 'snippet', binding_count: 1 }] })}
snippet={createSnippet({ tags: [{ id: 'tag-1', name: 'Sales', type: 'snippet', binding_count: '' }] })}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onTagsChange}
/>,

View File

@ -41,7 +41,7 @@ describe('SnippetListItem', () => {
render(
<SnippetListItem
snippet={createSnippet({
tags: [{ id: 'tag-1', name: 'Search', type: 'snippet', binding_count: 1 }],
tags: [{ id: 'tag-1', name: 'Search', type: 'snippet', binding_count: '' }],
})}
isHovered={false}
onMouseEnter={vi.fn()}

View File

@ -10,7 +10,7 @@ vi.mock('@tanstack/react-query', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
tags: {
list: {
get: {
queryOptions: vi.fn(input => ({ queryKey: ['tags', input] })),
},
},
@ -24,8 +24,8 @@ describe('SnippetTagsFilter', () => {
vi.clearAllMocks()
mockUseQuery.mockReturnValue({
data: [
{ id: 'tag-1', name: 'Sales', type: 'snippet', binding_count: 1 },
{ id: 'tag-2', name: 'Support', type: 'snippet', binding_count: 2 },
{ id: 'tag-1', name: 'Sales', type: 'snippet', binding_count: '' },
{ id: 'tag-2', name: 'Support', type: 'snippet', binding_count: '' },
],
} as ReturnType<typeof useQuery>)
})

View File

@ -27,7 +27,7 @@ const SnippetTagsFilter = ({
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const { data: tagList = [] } = useQuery(consoleQuery.tags.list.queryOptions({
const { data: tagList = [] } = useQuery(consoleQuery.tags.get.queryOptions({
input: {
query: {
type: 'snippet',

View File

@ -1,8 +1,9 @@
'use client'
import type { TenantListItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import type { Plan as PlanType } from '@/app/components/billing/type'
import type { ICurrentWorkspace, IWorkspace } from '@/models/common'
import type { ICurrentWorkspace } from '@/models/common'
import {
Select,
SelectTrigger,
@ -13,13 +14,19 @@ import { WorkplaceSelectorContent } from '@/app/components/header/account-dropdo
import { PlanBadge } from '@/app/components/header/plan-badge'
type AppliedEducationContentProps = {
workspaces: IWorkspace[]
workspaces: TenantListItemResponse[]
currentWorkspace: ICurrentWorkspace
plan: PlanType
action: ReactNode
onSwitchWorkspace: (tenantId: string) => void
}
const workspacePlans = new Set<string>(Object.values(Plan))
function isWorkspacePlan(plan: string | null | undefined): plan is Plan {
return !!plan && workspacePlans.has(plan)
}
const AppliedEducationContent = ({
workspaces,
currentWorkspace,
@ -29,10 +36,10 @@ const AppliedEducationContent = ({
}: AppliedEducationContentProps) => {
const { t } = useTranslation()
const currentWorkspaceInList = workspaces.find(workspace => workspace.current)
const workspacePlan = Object.values(Plan).includes(currentWorkspaceInList?.plan as Plan)
? currentWorkspaceInList?.plan as Plan
: Object.values(Plan).includes(plan as Plan)
? plan as Plan
const workspacePlan = isWorkspacePlan(currentWorkspaceInList?.plan)
? currentWorkspaceInList.plan
: isWorkspacePlan(plan)
? plan
: Plan.sandbox
const workspaceName = currentWorkspaceInList?.name || currentWorkspace?.name
const workspaceId = currentWorkspaceInList?.id || currentWorkspace?.id

View File

@ -1,123 +0,0 @@
import { type } from '@orpc/contract'
import { base } from '../base'
type AgentDriveSkillItem = {
path: string
skill_md_key: string
archive_key?: string | null
name: string
description: string
size?: number | null
mime_type?: string | null
hash?: string | null
created_at?: number | null
}
type AgentDriveSkillFile = {
available_in_drive: boolean
drive_key?: string | null
name: string
path: string
type: string
}
type AgentDriveSkillMarkdown = {
binary: boolean
key: string
size?: number | null
text?: string | null
truncated: boolean
}
type AgentDriveSkillInspect = AgentDriveSkillItem & {
file_tree?: Array<Record<string, unknown>>
files?: AgentDriveSkillFile[]
skill_md: AgentDriveSkillMarkdown
source: string
warnings?: string[]
}
const agentDriveSkillsByAgentContract = base
.route({
path: '/agent/{agent_id}/drive/skills',
method: 'GET',
})
.input(type<{
params: {
agent_id: string
}
}>())
.output(type<{ items: AgentDriveSkillItem[] }>())
const agentDriveSkillInspectByAgentContract = base
.route({
path: '/agent/{agent_id}/drive/skills/{skill_path}/inspect',
method: 'GET',
})
.input(type<{
params: {
agent_id: string
skill_path: string
}
}>())
.output(type<AgentDriveSkillInspect>())
const agentDriveSkillsByAppContract = base
.route({
path: '/apps/{app_id}/agent/drive/skills',
method: 'GET',
})
.input(type<{
params: {
app_id: string
}
query?: {
node_id?: string
}
}>())
.output(type<{ items: AgentDriveSkillItem[] }>())
const agentDriveSkillInspectByAppContract = base
.route({
path: '/apps/{app_id}/agent/drive/skills/{skill_path}/inspect',
method: 'GET',
})
.input(type<{
params: {
app_id: string
skill_path: string
}
query?: {
node_id?: string
}
}>())
.output(type<AgentDriveSkillInspect>())
export const agentDriveContracts = {
byAgentId: {
drive: {
skills: {
get: agentDriveSkillsByAgentContract,
bySkillPath: {
inspect: {
get: agentDriveSkillInspectByAgentContract,
},
},
},
},
},
byAppId: {
agent: {
drive: {
skills: {
get: agentDriveSkillsByAppContract,
bySkillPath: {
inspect: {
get: agentDriveSkillInspectByAppContract,
},
},
},
},
},
},
}

View File

@ -1,13 +0,0 @@
import { agent } from '@dify/contracts/api/console/agent/orpc.gen'
import { agentDriveContracts } from './agent-drive'
export const agentRouterContract = {
...agent,
byAgentId: {
...agent.byAgentId,
drive: {
...agent.byAgentId.drive,
...agentDriveContracts.byAgentId.drive,
},
},
}

View File

@ -1,110 +0,0 @@
import type { AppListResponse, WorkflowOnlineUsersResponse } from '@/models/app'
import type { CommonResponse } from '@/models/common'
import type { AppModeEnum } from '@/types/app'
import { apps } from '@dify/contracts/api/console/apps/orpc.gen'
import { type } from '@orpc/contract'
import { base } from '../base'
import { agentDriveContracts } from './agent-drive'
export type AppListSortBy = 'last_modified' | 'recently_created' | 'earliest_created'
type AppListMode = AppModeEnum | 'agent' | 'channel' | 'all'
export type AppListQuery = {
page?: number
limit?: number
name?: string
mode?: AppListMode
tag_ids?: string[]
creator_ids?: string[]
is_created_by_me?: boolean
sort_by?: AppListSortBy
}
export const appListContract = base
.route({
path: '/apps',
method: 'GET',
})
.input(type<{
query?: AppListQuery
}>())
.output(type<AppListResponse>())
export const appDeleteContract = base
.route({
path: '/apps/{appId}',
method: 'DELETE',
})
.input(type<{
params: {
appId: string
}
}>())
.output(type<unknown>())
export const appStarredListContract = base
.route({
path: '/apps/starred',
method: 'GET',
})
.input(type<{
query?: AppListQuery
}>())
.output(type<AppListResponse>())
export const appStarContract = base
.route({
path: '/apps/{appId}/star',
method: 'POST',
})
.input(type<{
params: {
appId: string
}
}>())
.output(type<CommonResponse>())
export const appUnstarContract = base
.route({
path: '/apps/{appId}/star',
method: 'DELETE',
})
.input(type<{
params: {
appId: string
}
}>())
.output(type<CommonResponse>())
export const workflowOnlineUsersContract = base
.route({
path: '/apps/workflows/online-users',
method: 'POST',
})
.input(type<{
body: {
app_ids: string[]
}
}>())
.output(type<WorkflowOnlineUsersResponse>())
export const appsRouterContract = {
...apps,
list: appListContract,
deleteApp: appDeleteContract,
starredList: appStarredListContract,
star: appStarContract,
unstar: appUnstarContract,
workflowOnlineUsers: workflowOnlineUsersContract,
byAppId: {
...apps.byAppId,
agent: {
...apps.byAppId.agent,
...agentDriveContracts.byAppId.agent,
drive: {
...apps.byAppId.agent.drive,
...agentDriveContracts.byAppId.agent.drive,
},
},
},
}

View File

@ -1,36 +0,0 @@
import { type } from '@orpc/contract'
import { base } from '../base'
type ConsoleNotification = {
body: string
frequency: 'once' | 'always'
lang: string
notification_id: string
subtitle: string
title: string
title_pic_url?: string
}
type ConsoleNotificationResponse = {
notifications: ConsoleNotification[]
should_show: boolean
}
export const notificationContract = base
.route({
path: '/notification',
method: 'GET',
})
.output(type<ConsoleNotificationResponse>())
export const notificationDismissContract = base
.route({
path: '/notification/dismiss',
method: 'POST',
})
.input(type<{
body: {
notification_id: string
}
}>())
.output(type<unknown>())

View File

@ -1,102 +0,0 @@
import { tags } from '@dify/contracts/api/console/tags/orpc.gen'
import { type } from '@orpc/contract'
import { base } from '../base'
export type TagType = 'knowledge' | 'app' | 'snippet'
export type Tag = {
id: string
name: string
type: TagType
binding_count: number
}
export const tagListContract = base
.route({
path: '/tags',
method: 'GET',
})
.input(type<{
query: {
type: TagType
}
}>())
.output(type<Tag[]>())
export const tagCreateContract = base
.route({
path: '/tags',
method: 'POST',
})
.input(type<{
body: {
name: string
type: TagType
}
}>())
.output(type<Tag>())
export const tagUpdateContract = base
.route({
path: '/tags/{tagId}',
method: 'PATCH',
})
.input(type<{
params: {
tagId: string
}
body: {
name: string
}
}>())
.output(type<Tag>())
export const tagDeleteContract = base
.route({
path: '/tags/{tagId}',
method: 'DELETE',
})
.input(type<{
params: {
tagId: string
}
}>())
.output(type<unknown>())
export const tagBindingCreateContract = base
.route({
path: '/tag-bindings',
method: 'POST',
})
.input(type<{
body: {
tag_ids: string[]
target_id: string
type: TagType
}
}>())
.output(type<unknown>())
export const tagBindingRemoveContract = base
.route({
path: '/tag-bindings/remove',
method: 'POST',
})
.input(type<{
body: {
tag_ids: string[]
target_id: string
type: TagType
}
}>())
.output(type<unknown>())
export const tagsRouterContract = {
...tags,
list: tagListContract,
create: tagCreateContract,
update: tagUpdateContract,
delete: tagDeleteContract,
bind: tagBindingCreateContract,
unbind: tagBindingRemoveContract,
}

View File

@ -1,65 +0,0 @@
import { workspaces } from '@dify/contracts/api/console/workspaces/orpc.gen'
import { type } from '@orpc/contract'
import { base } from '../base'
type WorkspaceListItem = {
id: string
name: string
plan: string
status: string
created_at: number
current: boolean
}
export type GetWorkspacesResponse = {
workspaces: WorkspaceListItem[]
}
export type SwitchWorkspaceRequest = {
tenant_id: string
}
type SwitchedWorkspace = {
id: string
name: string | null
plan: string | null
status: string | null
created_at: number | null
role: string | null
in_trial: boolean | null
trial_end_reason: string | null
custom_config: Record<string, unknown> | null
trial_credits: number | null
trial_credits_used: number | null
next_credit_reset_date: number | null
}
export type SwitchWorkspaceResponse = {
result: 'success' | 'fail'
new_tenant: SwitchedWorkspace
}
export const workspacesGetContract = base
.route({
path: '/workspaces',
method: 'GET',
})
.output(type<GetWorkspacesResponse>())
export const workspaceSwitchContract = base
.route({
path: '/workspaces/switch',
method: 'POST',
})
.input(type<{
body: SwitchWorkspaceRequest
}>())
.output(type<SwitchWorkspaceResponse>())
export const workspacesRouterContract = {
...workspaces,
get: workspacesGetContract,
switch: {
post: workspaceSwitchContract,
},
}

View File

@ -1,10 +1,12 @@
import { account } from '@dify/contracts/api/console/account/orpc.gen'
import { activate } from '@dify/contracts/api/console/activate/orpc.gen'
import { agent } from '@dify/contracts/api/console/agent/orpc.gen'
import { allWorkspaces } from '@dify/contracts/api/console/all-workspaces/orpc.gen'
import { apiBasedExtension } from '@dify/contracts/api/console/api-based-extension/orpc.gen'
import { apiKeyAuth } from '@dify/contracts/api/console/api-key-auth/orpc.gen'
import { appDslVersion } from '@dify/contracts/api/console/app-dsl-version/orpc.gen'
import { app } from '@dify/contracts/api/console/app/orpc.gen'
import { apps } from '@dify/contracts/api/console/apps/orpc.gen'
import { auth } from '@dify/contracts/api/console/auth/orpc.gen'
import { codeBasedExtension } from '@dify/contracts/api/console/code-based-extension/orpc.gen'
import { compliance } from '@dify/contracts/api/console/compliance/orpc.gen'
@ -20,6 +22,7 @@ import { installedApps } from '@dify/contracts/api/console/installed-apps/orpc.g
import { instructionGenerate } from '@dify/contracts/api/console/instruction-generate/orpc.gen'
import { login } from '@dify/contracts/api/console/login/orpc.gen'
import { logout } from '@dify/contracts/api/console/logout/orpc.gen'
import { notification } from '@dify/contracts/api/console/notification/orpc.gen'
import { notion } from '@dify/contracts/api/console/notion/orpc.gen'
import { oauth } from '@dify/contracts/api/console/oauth/orpc.gen'
import { rag } from '@dify/contracts/api/console/rag/orpc.gen'
@ -32,37 +35,36 @@ import { ruleStructuredOutputGenerate } from '@dify/contracts/api/console/rule-s
import { spec } from '@dify/contracts/api/console/spec/orpc.gen'
import { systemFeatures } from '@dify/contracts/api/console/system-features/orpc.gen'
import { tagBindings } from '@dify/contracts/api/console/tag-bindings/orpc.gen'
import { tags } from '@dify/contracts/api/console/tags/orpc.gen'
import { test } from '@dify/contracts/api/console/test/orpc.gen'
import { trialModels } from '@dify/contracts/api/console/trial-models/orpc.gen'
import { website } from '@dify/contracts/api/console/website/orpc.gen'
import { workflowGenerate } from '@dify/contracts/api/console/workflow-generate/orpc.gen'
import { workflow } from '@dify/contracts/api/console/workflow/orpc.gen'
import { workspaces } from '@dify/contracts/api/console/workspaces/orpc.gen'
import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen'
import { rbacAccessConfigContract } from './console/access-control'
import { agentRouterContract } from './console/agent'
import { appsRouterContract } from './console/apps'
import { billingRouterContract } from './console/billing'
import { exploreRouterContract } from './console/explore'
import { filesRouterContract } from './console/files'
import { modelProvidersRouterContract } from './console/model-providers'
import { notificationContract, notificationDismissContract } from './console/notification'
import { pluginsRouterContract } from './console/plugins'
import { snippetsRouterContract } from './console/snippets'
import { tagsRouterContract } from './console/tags'
import { triggersRouterContract } from './console/trigger'
import { trialAppsRouterContract } from './console/try-app'
import { workflowDraftRouterContract } from './console/workflow'
import { workflowCommentContracts } from './console/workflow-comment'
import { workspacesRouterContract } from './console/workspaces'
const communityContract = {
account,
activate,
agent,
allWorkspaces,
apiBasedExtension,
apiKeyAuth,
app,
appDslVersion,
apps,
auth,
codeBasedExtension,
compliance,
@ -78,6 +80,7 @@ const communityContract = {
instructionGenerate,
login,
logout,
notification,
notion,
oauth,
rag,
@ -90,31 +93,27 @@ const communityContract = {
spec,
systemFeatures,
tagBindings,
tags,
test,
trialModels,
website,
workflow,
workflowGenerate,
workspaces,
}
export const consoleRouterContract = {
enterprise: enterpriseContract,
...communityContract,
agent: agentRouterContract,
apps: appsRouterContract,
billing: billingRouterContract,
explore: exploreRouterContract,
files: filesRouterContract,
modelProviders: modelProvidersRouterContract,
notification: notificationContract,
notificationDismiss: notificationDismissContract,
plugins: pluginsRouterContract,
rbacAccessConfig: rbacAccessConfigContract,
snippets: snippetsRouterContract,
tags: tagsRouterContract,
triggers: triggersRouterContract,
trialApps: trialAppsRouterContract,
workflowComments: workflowCommentContracts,
workflowDraft: workflowDraftRouterContract,
workspaces: workspacesRouterContract,
}

View File

@ -94,7 +94,7 @@ vi.mock('jotai-tanstack-query', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
list: {
get: {
infiniteOptions: (options: InfiniteQueryOptions) => ({
...options,
queryKey: ['sourceApps'],

View File

@ -7,6 +7,7 @@ import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { dslAppName, isWorkflowDsl } from '@/features/deployments/shared/domain/dsl'
import { consoleQuery } from '@/service/client'
import { normalizeAppPagination } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { dslFileAtom, dslFileReadVersionAtom, effectiveMethodAtom, selectedAppAtom, sourceSearchTextAtom } from './primitives'
import { SOURCE_APPS_PAGE_SIZE } from './utils'
@ -71,7 +72,7 @@ export const importDslReadyAtom = atom((get) => {
export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
const sourceSearchText = get(sourceSearchTextAtom)
return consoleQuery.apps.list.infiniteOptions({
return consoleQuery.apps.get.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
@ -83,6 +84,10 @@ export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
select: data => ({
...data,
pages: data.pages.map(normalizeAppPagination),
}),
enabled: get(effectiveMethodAtom) === 'bindApp',
})
})

View File

@ -103,7 +103,7 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => {
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
list: {
get: {
infiniteOptions: (options: InfiniteQueryOptions) => ({
...options,
queryKey: ['sourceApps', options.input],

View File

@ -22,6 +22,7 @@ import {
} from 'jotai-tanstack-query'
import * as z from 'zod'
import { consoleQuery } from '@/service/client'
import { normalizeAppPagination } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { encodeDslContent, isWorkflowDsl } from '../../shared/domain/dsl'
import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags'
@ -245,7 +246,7 @@ export const createReleaseSourceAppSearchTextAtom = atom('')
export const createReleaseSourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
const searchText = get(createReleaseSourceAppSearchTextAtom)
return consoleQuery.apps.list.infiniteOptions({
return consoleQuery.apps.get.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
@ -257,6 +258,10 @@ export const createReleaseSourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
select: data => ({
...data,
pages: data.pages.map(normalizeAppPagination),
}),
enabled: Boolean(
get(createReleaseDialogOpenAtom)
&& effectiveCreateReleaseSourceMode(get) === 'sourceApp'

View File

@ -1,4 +1,4 @@
import type { Tag } from '@/contract/console/tags'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DatasetCardTags } from '../components/dataset-card-tags'
@ -30,8 +30,8 @@ vi.mock('@/features/tag-management/components/tag-selector', () => ({
describe('DatasetCardTags', () => {
const mockTags: Tag[] = [
{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 },
{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 },
{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: '' },
{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: '' },
]
const defaultProps = {
@ -163,7 +163,7 @@ describe('DatasetCardTags', () => {
id: `tag-${i}`,
name: `Tag ${i}`,
type: 'knowledge',
binding_count: 0,
binding_count: '',
}))
render(<DatasetCardTags {...defaultProps} tags={manyTags} />)
expect(screen.getByText('20 tags')).toBeInTheDocument()

View File

@ -1,4 +1,4 @@
import type { Tag } from '@/contract/console/tags'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TagFilter } from '../components/tag-filter'
@ -22,10 +22,10 @@ vi.mock('@/context/app-context', () => ({
}))
const mockTags: Tag[] = [
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: 3 },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: 5 },
{ id: 'tag-3', name: 'Database', type: 'knowledge', binding_count: 2 },
{ id: 'tag-4', name: 'API Design', type: 'app', binding_count: 1 },
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: '' },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: '' },
{ id: 'tag-3', name: 'Database', type: 'knowledge', binding_count: '' },
{ id: 'tag-4', name: 'API Design', type: 'app', binding_count: '' },
]
const defaultProps = {

View File

@ -1,4 +1,4 @@
import type { Tag } from '@/contract/console/tags'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
@ -37,15 +37,17 @@ vi.mock('@tanstack/react-query', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
tags: {
update: {
mutationOptions: () => ({
mutationFn: ({ params, body }: { params: { tagId: string }, body: { name: string } }) => tagMocks.updateTag(params.tagId, body.name),
}),
},
delete: {
mutationOptions: () => ({
mutationFn: ({ params }: { params: { tagId: string } }) => tagMocks.deleteTag(params.tagId),
}),
byTagId: {
patch: {
mutationOptions: () => ({
mutationFn: ({ params, body }: { params: { tag_id: string }, body: { name: string } }) => tagMocks.updateTag(params.tag_id, body.name),
}),
},
delete: {
mutationOptions: () => ({
mutationFn: ({ params }: { params: { tag_id: string } }) => tagMocks.deleteTag(params.tag_id),
}),
},
},
},
},
@ -76,7 +78,7 @@ const baseTag: Tag = {
id: 'tag-1',
name: 'Frontend',
type: 'app',
binding_count: 3,
binding_count: '3',
}
const i18n = {
@ -197,7 +199,7 @@ describe('TagItemEditor', () => {
describe('Remove Flow', () => {
it('should delete immediately when binding count is zero', async () => {
const user = userEvent.setup()
const removableTag: Tag = { ...baseTag, binding_count: 0 }
const removableTag: Tag = { ...baseTag, binding_count: '' }
render(<TagItemEditor tag={removableTag} />)
const removeButton = screen.getByRole('button', { name: i18n.removeTag })
@ -250,7 +252,7 @@ describe('TagItemEditor', () => {
it('should notify error and keep tag when delete request fails', async () => {
const user = userEvent.setup()
vi.mocked(tagMocks.deleteTag).mockRejectedValueOnce(new Error('delete failed'))
const removableTag: Tag = { ...baseTag, binding_count: 0 }
const removableTag: Tag = { ...baseTag, binding_count: '' }
render(<TagItemEditor tag={removableTag} />)
const removeButton = screen.getByRole('button', { name: i18n.removeTag })

View File

@ -1,4 +1,4 @@
import type { Tag } from '@/contract/console/tags'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
@ -53,32 +53,34 @@ vi.mock('@/context/app-context', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
tags: {
list: {
get: {
queryOptions: () => ({}),
},
create: {
post: {
mutationOptions: () => ({
mutationFn: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }) => createTag(body.name, body.type),
mutationFn: ({ body }: { body: { name: string, type: 'app' | 'knowledge' | 'snippet' } }) => createTag(body.name, body.type),
}),
},
update: {
mutationOptions: () => ({
mutationFn: () => Promise.resolve(undefined),
}),
},
delete: {
mutationOptions: () => ({
mutationFn: () => Promise.resolve(undefined),
}),
byTagId: {
patch: {
mutationOptions: () => ({
mutationFn: () => Promise.resolve(undefined),
}),
},
delete: {
mutationOptions: () => ({
mutationFn: () => Promise.resolve(undefined),
}),
},
},
},
},
}))
const mockTags: Tag[] = [
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: 3 },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: 5 },
{ id: 'tag-3', name: 'Database', type: 'knowledge', binding_count: 2 },
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: '' },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: '' },
{ id: 'tag-3', name: 'Database', type: 'knowledge', binding_count: '' },
]
const defaultProps = {
@ -100,7 +102,7 @@ describe('TagManagementModal', () => {
vi.clearAllMocks()
mockUseQueryData.current = mockTags
mockWorkspacePermissionKeys.value = ['app.tag.manage', 'dataset.tag.manage', 'snippets.create_and_modify']
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: '' })
})
describe('Rendering', () => {
@ -277,7 +279,7 @@ describe('TagManagementModal', () => {
it('should handle tag creation with knowledge type', async () => {
const user = userEvent.setup()
vi.mocked(createTag).mockResolvedValue({ id: 'new-k', name: 'KnowledgeTag', type: 'knowledge', binding_count: 0 })
vi.mocked(createTag).mockResolvedValue({ id: 'new-k', name: 'KnowledgeTag', type: 'knowledge', binding_count: '' })
render(<TagManagementModal {...defaultProps} type="knowledge" />)

View File

@ -1,5 +1,5 @@
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
import type { TagComboboxItem } from '../components/tag-combobox-item'
import type { Tag, TagType } from '@/contract/console/tags'
import { Combobox } from '@langgenius/dify-ui/combobox'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@ -30,12 +30,12 @@ const i18n = {
}
const appTags: Tag[] = [
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: 3 },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: 5 },
{ id: 'tag-3', name: 'API', type: 'app', binding_count: 1 },
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: '' },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: '' },
{ id: 'tag-3', name: 'API', type: 'app', binding_count: '' },
]
const knowledgeTag: Tag = { id: 'tag-k1', name: 'KnowledgeDB', type: 'knowledge', binding_count: 2 }
const knowledgeTag: Tag = { id: 'tag-k1', name: 'KnowledgeDB', type: 'knowledge', binding_count: '' }
type PanelHarnessProps = {
type?: TagType
@ -68,7 +68,7 @@ const PanelHarness = ({
id: `__create_tag__:${inputValue}`,
name: inputValue,
type,
binding_count: 0,
binding_count: '',
isCreateOption: true,
}, ...tags]
}, [inputValue, tagList, type])
@ -251,7 +251,7 @@ describe('TagSearchContent', () => {
<PanelHarness
type="snippet"
value={[]}
tagList={[{ id: 'snippet-tag-1', name: 'Reusable', type: 'snippet', binding_count: 1 }]}
tagList={[{ id: 'snippet-tag-1', name: 'Reusable', type: 'snippet', binding_count: '' }]}
/>,
)

View File

@ -1,5 +1,5 @@
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import type { ComponentProps } from 'react'
import type { Tag } from '@/contract/console/tags'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TagSelector } from '../components/tag-selector'
@ -54,12 +54,12 @@ vi.mock('@tanstack/react-query', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
tags: {
list: {
get: {
queryOptions: () => ({}),
},
create: {
post: {
mutationOptions: () => ({
mutationFn: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }) => createTag(body.name, body.type),
mutationFn: ({ body }: { body: { name: string, type: 'app' | 'knowledge' | 'snippet' } }) => createTag(body.name, body.type),
}),
},
},
@ -98,8 +98,8 @@ const i18n = {
}
const appTags: Tag[] = [
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: 3 },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: 5 },
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: '' },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: '' },
]
const defaultProps = {
@ -113,7 +113,7 @@ describe('TagSelector', () => {
vi.clearAllMocks()
mockWorkspacePermissionKeys.value = ['app.tag.manage', 'dataset.tag.manage', 'snippets.create_and_modify']
mockUseQueryData.current = appTags
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: '' })
vi.mocked(bindTag).mockResolvedValue(undefined)
vi.mocked(unBindTag).mockResolvedValue(undefined)
})
@ -124,14 +124,14 @@ describe('TagSelector', () => {
})
it('renders the no tag trigger when no current tag is visible and binding is unavailable', () => {
render(<TagSelector {...defaultProps} value={[{ id: 'orphan', name: 'Orphan', type: 'app', binding_count: 0 }]} />)
render(<TagSelector {...defaultProps} value={[{ id: 'orphan', name: 'Orphan', type: 'app', binding_count: '' }]} />)
expect(screen.queryByText('Orphan')).not.toBeInTheDocument()
expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
expect(screen.queryByText(i18n.addTag)).not.toBeInTheDocument()
})
it('renders the add tag trigger when no current tag is visible and binding is available', () => {
render(<TagSelector {...defaultProps} value={[{ id: 'orphan', name: 'Orphan', type: 'app', binding_count: 0 }]} canBindOrUnbindTags />)
render(<TagSelector {...defaultProps} value={[{ id: 'orphan', name: 'Orphan', type: 'app', binding_count: '' }]} canBindOrUnbindTags />)
expect(screen.queryByText('Orphan')).not.toBeInTheDocument()
expect(screen.getByText(i18n.addTag)).toBeInTheDocument()
})
@ -315,13 +315,13 @@ describe('TagSelector', () => {
it('opens snippet tag selector with snippets create-and-modify permission', async () => {
const user = userEvent.setup()
mockWorkspacePermissionKeys.value = ['snippets.create_and_modify']
mockUseQueryData.current = [{ id: 'snippet-tag-1', name: 'Reusable', type: 'snippet', binding_count: 1 }]
mockUseQueryData.current = [{ id: 'snippet-tag-1', name: 'Reusable', type: 'snippet', binding_count: '' }]
render(
<TagSelector
targetId="snippet-1"
type="snippet"
value={[{ id: 'snippet-tag-1', name: 'Reusable', type: 'snippet', binding_count: 1 }]}
value={[{ id: 'snippet-tag-1', name: 'Reusable', type: 'snippet', binding_count: '' }]}
/>,
)

View File

@ -1,4 +1,4 @@
import type { Tag } from '@/contract/console/tags'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { AppCardTags } from '../app-card-tags'
@ -28,8 +28,8 @@ vi.mock('@/features/tag-management/components/tag-selector', () => ({
}))
const tags: Tag[] = [
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: 1 },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: 2 },
{ id: 'tag-1', name: 'Frontend', type: 'app', binding_count: '' },
{ id: 'tag-2', name: 'Backend', type: 'app', binding_count: '' },
]
describe('AppCardTags', () => {

View File

@ -1,4 +1,4 @@
import type { Tag } from '@/contract/console/tags'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import { TagSelector } from '@/features/tag-management/components/tag-selector'
type AppCardTagsProps = {

View File

@ -1,5 +1,5 @@
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import type { MouseEvent } from 'react'
import type { Tag } from '@/contract/console/tags'
import { cn } from '@langgenius/dify-ui/cn'
import { TagSelector } from '@/features/tag-management/components/tag-selector'

View File

@ -1,10 +1,10 @@
import type { Tag, TagType } from '@/contract/console/tags'
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
type CreateTagOption = {
id: string
name: string
type: TagType
binding_count: number
binding_count: string
isCreateOption: true
}

View File

@ -1,5 +1,5 @@
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
import type { ComboboxRootProps } from '@langgenius/dify-ui/combobox'
import type { Tag, TagType } from '@/contract/console/tags'
import { cn } from '@langgenius/dify-ui/cn'
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
import { useQuery } from '@tanstack/react-query'
@ -31,7 +31,7 @@ export const TagFilter = ({
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const { data: tagList = [] } = useQuery(consoleQuery.tags.list.queryOptions({
const { data: tagList = [] } = useQuery(consoleQuery.tags.get.queryOptions({
input: {
query: {
type,

View File

@ -1,4 +1,4 @@
import type { Tag } from '@/contract/console/tags'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import {
AlertDialog,
AlertDialogActions,
@ -27,8 +27,8 @@ type TagItemEditorProps = {
}
export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
const { t } = useTranslation()
const updateTagMutation = useMutation(consoleQuery.tags.update.mutationOptions())
const deleteTagMutation = useMutation(consoleQuery.tags.delete.mutationOptions())
const updateTagMutation = useMutation(consoleQuery.tags.byTagId.patch.mutationOptions())
const deleteTagMutation = useMutation(consoleQuery.tags.byTagId.delete.mutationOptions())
const [isEditing, setIsEditing] = useState(false)
const editTag = (tagId: string, name: string) => {
if (name === tag.name) {
@ -43,7 +43,7 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
updateTagMutation.mutate({
params: {
tagId,
tag_id: tagId,
},
body: {
name,
@ -67,7 +67,7 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
deleteTagMutation.mutate({
params: {
tagId,
tag_id: tagId,
},
}, {
onSuccess: () => {
@ -109,7 +109,7 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
aria-label={`${t('operation.remove', { ns: 'common' })} ${tag.name}`}
className="group/remove shrink-0 cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => {
if (tag.binding_count)
if (Number(tag.binding_count ?? 0) > 0)
setShowRemoveModal(true)
else
handleRemove()

View File

@ -1,5 +1,5 @@
'use client'
import type { TagType } from '@/contract/console/tags'
import type { TagType } from '@dify/contracts/api/console/tags/types.gen'
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
@ -21,7 +21,7 @@ export const TagManagementModal = ({ show, type, onClose, onTagsChange }: TagMan
const { t } = useTranslation()
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type))
const { data: tagList = [] } = useQuery(consoleQuery.tags.list.queryOptions({
const { data: tagList = [] } = useQuery(consoleQuery.tags.get.queryOptions({
input: {
query: {
type,
@ -29,7 +29,7 @@ export const TagManagementModal = ({ show, type, onClose, onTagsChange }: TagMan
},
enabled: show && canManageTags,
}))
const createTagMutation = useMutation(consoleQuery.tags.create.mutationOptions())
const createTagMutation = useMutation(consoleQuery.tags.post.mutationOptions())
const [name, setName] = useState<string>('')
const createNewTag = () => {

View File

@ -1,5 +1,5 @@
import type { TagType } from '@dify/contracts/api/console/tags/types.gen'
import type { TagComboboxItem } from './tag-combobox-item'
import type { TagType } from '@/contract/console/tags'
import { ComboboxEmpty, ComboboxInput, ComboboxInputGroup, ComboboxItem, ComboboxItemIndicator, ComboboxItemText, ComboboxList, ComboboxSeparator, useComboboxFilteredItems } from '@langgenius/dify-ui/combobox'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -1,7 +1,7 @@
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
import type { ComboboxRootProps } from '@langgenius/dify-ui/combobox'
import type { ComponentProps } from 'react'
import type { TagComboboxItem } from './tag-combobox-item'
import type { Tag, TagType } from '@/contract/console/tags'
import { cn } from '@langgenius/dify-ui/cn'
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
import { toast } from '@langgenius/dify-ui/toast'
@ -78,8 +78,8 @@ export const TagSelector = ({
const {
isPending: isCreatingTag,
mutate: createTag,
} = useMutation(consoleQuery.tags.create.mutationOptions())
const { data: tagList = [] } = useQuery(consoleQuery.tags.list.queryOptions({
} = useMutation(consoleQuery.tags.post.mutationOptions())
const { data: tagList = [] } = useQuery(consoleQuery.tags.get.queryOptions({
input: {
query: {
type,
@ -125,7 +125,7 @@ export const TagSelector = ({
id: `__create_tag__:${inputValue}`,
name: inputValue,
type,
binding_count: 0,
binding_count: '0',
isCreateOption: true,
})
}

View File

@ -10,20 +10,22 @@ const {
unbindTag,
} = vi.hoisted(() => ({
bindTag: vi.fn(),
listKey: vi.fn((options: { type: 'query', input: { query: { type: string } } }) => ['console', 'tags', 'list', 'query', options.input.query.type]),
listKey: vi.fn((options: { type: 'query', input: { query: { type: string } } }) => ['console', 'tags', 'get', 'query', options.input.query.type]),
unbindTag: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
tags: {
bind: bindTag,
unbind: unbindTag,
tagBindings: {
post: bindTag,
remove: {
post: unbindTag,
},
},
},
consoleQuery: {
tags: {
list: {
get: {
key: listKey,
},
},
@ -92,7 +94,7 @@ describe('useTagMutations', () => {
})
await waitFor(() => {
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['console', 'tags', 'list', 'query', 'app'],
queryKey: ['console', 'tags', 'get', 'query', 'app'],
})
})
expect(listKey).toHaveBeenCalledWith({
@ -122,7 +124,7 @@ describe('useTagMutations', () => {
expect(unbindTag).not.toHaveBeenCalled()
await waitFor(() => {
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['console', 'tags', 'list', 'query', 'knowledge'],
queryKey: ['console', 'tags', 'get', 'query', 'knowledge'],
})
})
expect(listKey).toHaveBeenCalledWith({

View File

@ -1,4 +1,4 @@
import type { TagType } from '@/contract/console/tags'
import type { TagType } from '@dify/contracts/api/console/tags/types.gen'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { consoleClient, consoleQuery } from '@/service/client'
@ -20,7 +20,7 @@ export const useApplyTagBindingsMutation = () => {
const operations: Promise<unknown>[] = []
if (addTagIds.length) {
operations.push(consoleClient.tags.bind({
operations.push(consoleClient.tagBindings.post({
body: {
tag_ids: addTagIds,
target_id: targetId,
@ -30,7 +30,7 @@ export const useApplyTagBindingsMutation = () => {
}
if (removeTagIds.length) {
operations.push(consoleClient.tags.unbind({
operations.push(consoleClient.tagBindings.remove.post({
body: {
tag_ids: removeTagIds,
target_id: targetId,
@ -43,7 +43,7 @@ export const useApplyTagBindingsMutation = () => {
},
onSettled: (_data, _error, variables) => {
void queryClient.invalidateQueries({
queryKey: consoleQuery.tags.list.key({
queryKey: consoleQuery.tags.get.key({
type: 'query',
input: {
query: {

View File

@ -1,4 +1,4 @@
import type { TagType } from '@/contract/console/tags'
import type { TagType } from '@dify/contracts/api/console/tags/types.gen'
import type { PermissionKey } from '@/models/access-control'
import { SnippetPermission } from '@/app/components/snippets/utils/permission'

View File

@ -1,9 +1,9 @@
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import type { DataSourceNotionPage, DataSourceProvider } from './common'
import type { DatasourceType } from './pipeline'
import type { IndexingType } from '@/app/components/datasets/create/step-two'
import type { MetadataItemWithValue } from '@/app/components/datasets/metadata/types'
import type { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import type { Tag } from '@/contract/console/tags'
import type { AppIconType, AppModeEnum, RetrievalConfig, TransferMethod } from '@/types/app'
import type { SegmentImportStatus } from '@/types/dataset'
import type { I18nKeysByPrefix } from '@/types/i18n'

View File

@ -1,6 +1,6 @@
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import type { Viewport } from 'reactflow'
import type { Edge, Node } from '@/app/components/workflow/types'
import type { Tag } from '@/contract/console/tags'
import type { InputVar } from '@/models/pipeline'
export type SnippetSection = 'orchestrate'

View File

@ -1,7 +1,7 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import type { MutationFunctionContext } from '@tanstack/react-query'
import type { consoleQuery as ConsoleQuery } from './client'
import type { Tag } from '@/contract/console/tags'
import { QueryClient } from '@tanstack/react-query'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@ -32,7 +32,7 @@ const createTag = (overrides: Partial<Tag> = {}): Tag => ({
id: 'tag-1',
name: 'Frontend',
type: 'app',
binding_count: 1,
binding_count: '1',
...overrides,
})
@ -588,14 +588,14 @@ describe('consoleQuery tag mutation defaults', () => {
it('should add created tags to the matching list query cache', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const appListKey = consoleQuery.tags.list.queryKey({
const appListKey = consoleQuery.tags.get.queryKey({
input: {
query: {
type: 'app',
},
},
})
const knowledgeListKey = consoleQuery.tags.list.queryKey({
const knowledgeListKey = consoleQuery.tags.get.queryKey({
input: {
query: {
type: 'knowledge',
@ -613,13 +613,13 @@ describe('consoleQuery tag mutation defaults', () => {
queryClient.setQueryData(appListKey, [existingAppTag])
queryClient.setQueryData(knowledgeListKey, [existingKnowledgeTag])
const mutationOptions = consoleQuery.tags.create.mutationOptions()
const mutationOptions = consoleQuery.tags.post.mutationOptions()
await mutationOptions.onSuccess?.(
createdTag,
{
body: {
name: createdTag.name,
type: createdTag.type,
type: 'app',
},
},
undefined,
@ -633,14 +633,14 @@ describe('consoleQuery tag mutation defaults', () => {
it('should update matching tags across cached list queries', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const appListKey = consoleQuery.tags.list.queryKey({
const appListKey = consoleQuery.tags.get.queryKey({
input: {
query: {
type: 'app',
},
},
})
const knowledgeListKey = consoleQuery.tags.list.queryKey({
const knowledgeListKey = consoleQuery.tags.get.queryKey({
input: {
query: {
type: 'knowledge',
@ -661,14 +661,14 @@ describe('consoleQuery tag mutation defaults', () => {
const updatedTag = createTag({
...targetTag,
name: 'After',
binding_count: 5,
binding_count: '5',
})
const mutationOptions = consoleQuery.tags.update.mutationOptions()
const mutationOptions = consoleQuery.tags.byTagId.patch.mutationOptions()
await mutationOptions.onSuccess?.(
updatedTag,
{
params: {
tagId: targetTag.id,
tag_id: targetTag.id,
},
body: {
name: 'Ignored Client Name',
@ -688,14 +688,14 @@ describe('consoleQuery tag mutation defaults', () => {
it('should remove deleted tags across cached list queries', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const appListKey = consoleQuery.tags.list.queryKey({
const appListKey = consoleQuery.tags.get.queryKey({
input: {
query: {
type: 'app',
},
},
})
const knowledgeListKey = consoleQuery.tags.list.queryKey({
const knowledgeListKey = consoleQuery.tags.get.queryKey({
input: {
query: {
type: 'knowledge',
@ -713,12 +713,12 @@ describe('consoleQuery tag mutation defaults', () => {
queryClient.setQueryData(appListKey, [deletedTag, remainingTag])
queryClient.setQueryData(knowledgeListKey, [knowledgeTag])
const mutationOptions = consoleQuery.tags.delete.mutationOptions()
const mutationOptions = consoleQuery.tags.byTagId.delete.mutationOptions()
await mutationOptions.onSuccess?.(
undefined,
{
params: {
tagId: deletedTag.id,
tag_id: deletedTag.id,
},
},
undefined,

View File

@ -1,5 +1,6 @@
import type { AgentAppPagination } from '@dify/contracts/api/console/agent/types.gen'
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
import type {
GetReleaseResponse,
ListReleasesResponse,
@ -10,7 +11,6 @@ import type { AnyContractRouter, ContractRouterClient } from '@orpc/contract'
import type { JsonifiedClient } from '@orpc/openapi-client'
import type { RouterUtils } from '@orpc/tanstack-query'
import type { InfiniteData, QueryClient, QueryKey } from '@tanstack/react-query'
import type { Tag } from '@/contract/console/tags'
import type { consoleRouterContract } from '@/contract/router'
import { createORPCClient, onError } from '@orpc/client'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
@ -120,6 +120,10 @@ type AppDeployInvalidationOptions = {
type ConsoleQueryUtils = RouterUtils<JsonifiedClient<ContractRouterClient<typeof consoleRouterContract>>>
function isTagType(type: string | null | undefined): type is TagType {
return type === 'app' || type === 'knowledge' || type === 'snippet'
}
const defaultAppDeployInvalidationOptions = {
appInstances: true,
appInstanceSummaries: true,
@ -668,11 +672,14 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
},
},
tags: {
create: {
post: {
mutationOptions: {
onSuccess: (tag, _variables, _onMutateResult, context) => {
if (!isTagType(tag.type))
return
context.client.setQueryData(
consoleQuery.tags.list.queryKey({
consoleQuery.tags.get.queryKey({
input: {
query: {
type: tag.type,
@ -684,29 +691,31 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
},
},
},
update: {
mutationOptions: {
onSuccess: (updatedTag, variables, _onMutateResult, context) => {
context.client.setQueriesData(
{
queryKey: consoleQuery.tags.list.key({ type: 'query' }),
},
(oldTags: Tag[] | undefined) => oldTags?.map(tag => tag.id === variables.params.tagId
? updatedTag
: tag),
)
byTagId: {
patch: {
mutationOptions: {
onSuccess: (updatedTag, variables, _onMutateResult, context) => {
context.client.setQueriesData(
{
queryKey: consoleQuery.tags.get.key({ type: 'query' }),
},
(oldTags: Tag[] | undefined) => oldTags?.map(tag => tag.id === variables.params.tag_id
? updatedTag
: tag),
)
},
},
},
},
delete: {
mutationOptions: {
onSuccess: (_data, variables, _onMutateResult, context) => {
context.client.setQueriesData(
{
queryKey: consoleQuery.tags.list.key({ type: 'query' }),
},
(oldTags: Tag[] | undefined) => oldTags?.filter(tag => tag.id !== variables.params.tagId),
)
delete: {
mutationOptions: {
onSuccess: (_data, variables, _onMutateResult, context) => {
context.client.setQueriesData(
{
queryKey: consoleQuery.tags.get.key({ type: 'query' }),
},
(oldTags: Tag[] | undefined) => oldTags?.filter(tag => tag.id !== variables.params.tag_id),
)
},
},
},
},

View File

@ -0,0 +1,21 @@
import { agent } from '@dify/contracts/api/console/agent/orpc.gen'
import { apps } from '@dify/contracts/api/console/apps/orpc.gen'
import { notification } from '@dify/contracts/api/console/notification/orpc.gen'
import { tags } from '@dify/contracts/api/console/tags/orpc.gen'
import { workspaces } from '@dify/contracts/api/console/workspaces/orpc.gen'
import { describe, expect, it } from 'vitest'
import { loadConsoleContractForSegment } from './console-router-loader'
describe('loadConsoleContractForSegment', () => {
it.each([
['agent', 'agent', agent],
['apps', 'apps', apps],
['notification', 'notification', notification],
['tags', 'tags', tags],
['workspaces', 'workspaces', workspaces],
] as const)('loads the generated %s contract when generated types are usable', async (segment, contractKey, generatedContract) => {
const contract = await loadConsoleContractForSegment(segment)
expect((contract as Record<string, unknown>)[contractKey]).toBe(generatedContract)
})
})

View File

@ -12,30 +12,22 @@ async function loadGeneratedConsoleContract(segment: string) {
}
const customConsoleContractLoaders: Record<string, () => Promise<AnyContractRouter>> = {
agent: () => import('@/contract/console/agent').then(({ agentRouterContract }) => wrapConsoleContract('agent', agentRouterContract)),
apps: () => import('@/contract/console/apps').then(({ appsRouterContract }) => wrapConsoleContract('apps', appsRouterContract)),
billing: () => import('@/contract/console/billing').then(({ billingRouterContract }) => wrapConsoleContract('billing', billingRouterContract)),
enterprise: () => import('@dify/contracts/enterprise/orpc.gen').then(({ contract }) => wrapConsoleContract('enterprise', contract)),
explore: () => import('@/contract/console/explore').then(({ exploreRouterContract }) => wrapConsoleContract('explore', exploreRouterContract)),
files: () => import('@/contract/console/files').then(({ filesRouterContract }) => wrapConsoleContract('files', filesRouterContract)),
modelProviders: () =>
import('@/contract/console/model-providers').then(({ modelProvidersRouterContract }) => wrapConsoleContract('modelProviders', modelProvidersRouterContract)),
notification: () =>
import('@/contract/console/notification').then(({ notificationContract }) => wrapConsoleContract('notification', notificationContract)),
notificationDismiss: () =>
import('@/contract/console/notification').then(({ notificationDismissContract }) => wrapConsoleContract('notificationDismiss', notificationDismissContract)),
plugins: () => import('@/contract/console/plugins').then(({ pluginsRouterContract }) => wrapConsoleContract('plugins', pluginsRouterContract)),
rbacAccessConfig: () =>
import('@/contract/console/access-control').then(({ rbacAccessConfigContract }) => wrapConsoleContract('rbacAccessConfig', rbacAccessConfigContract)),
snippets: () => import('@/contract/console/snippets').then(({ snippetsRouterContract }) => wrapConsoleContract('snippets', snippetsRouterContract)),
tags: () => import('@/contract/console/tags').then(({ tagsRouterContract }) => wrapConsoleContract('tags', tagsRouterContract)),
triggers: () => import('@/contract/console/trigger').then(({ triggersRouterContract }) => wrapConsoleContract('triggers', triggersRouterContract)),
trialApps: () => import('@/contract/console/try-app').then(({ trialAppsRouterContract }) => wrapConsoleContract('trialApps', trialAppsRouterContract)),
workflowComments: () =>
import('@/contract/console/workflow-comment').then(({ workflowCommentContracts }) => wrapConsoleContract('workflowComments', workflowCommentContracts)),
workflowDraft: () =>
import('@/contract/console/workflow').then(({ workflowDraftRouterContract }) => wrapConsoleContract('workflowDraft', workflowDraftRouterContract)),
workspaces: () => import('@/contract/console/workspaces').then(({ workspacesRouterContract }) => wrapConsoleContract('workspaces', workspacesRouterContract)),
}
export async function loadConsoleContractForSegment(segment: string) {

View File

@ -1,21 +1,25 @@
import type { AppPagination, AppPartial } from '@dify/contracts/api/console/apps/types.gen'
import type { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import type {
ApiKeysListResponse,
AppDailyConversationsResponse,
AppDailyEndUsersResponse,
AppDailyMessagesResponse,
AppListResponse,
AppStatisticsResponse,
AppTokenCostsResponse,
AppVoicesListResponse,
WorkflowDailyConversationsResponse,
} from '@/models/app'
import type { App } from '@/types/app'
import type { App, AppIconType } from '@/types/app'
import {
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { AccessMode } from '@/models/access-control'
import { consoleClient, consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { get, post } from './base'
const NAME_SPACE = 'apps'
@ -27,6 +31,80 @@ type DateRangeParams = {
export const appDetailQueryKeyPrefix = [NAME_SPACE, 'detail']
const useAppFullListKey = [NAME_SPACE, 'full-list']
const appIconTypes = new Set<string>(['emoji', 'image', 'link'])
const appModes = new Set<string>(Object.values(AppModeEnum))
const accessModes = new Set<string>(Object.values(AccessMode))
function isAppIconType(iconType: string | null | undefined): iconType is AppIconType {
return !!iconType && appIconTypes.has(iconType)
}
function isAppMode(mode: string | null | undefined): mode is AppModeEnum {
return !!mode && appModes.has(mode)
}
function isAccessMode(accessMode: string | null | undefined): accessMode is AccessMode {
return !!accessMode && accessModes.has(accessMode)
}
function normalizeWorkflow(workflow: AppPartial['workflow']): App['workflow'] {
if (!workflow)
return undefined
return {
id: workflow.id,
created_at: workflow.created_at ?? 0,
created_by: workflow.created_by ?? undefined,
updated_at: workflow.updated_at ?? 0,
updated_by: workflow.updated_by ?? undefined,
}
}
function normalizeAppListItem(app: AppPartial): App {
const modelConfig = (app.model_config ?? {}) as App['model_config']
return {
id: app.id,
name: app.name,
description: app.description ?? '',
author_name: app.author_name ?? '',
icon_type: isAppIconType(app.icon_type) ? app.icon_type : null,
icon: app.icon ?? '',
icon_background: app.icon_background ?? null,
icon_url: app.icon_url,
use_icon_as_answer_icon: app.use_icon_as_answer_icon ?? false,
mode: isAppMode(app.mode) ? app.mode : AppModeEnum.CHAT,
enable_site: false,
enable_api: false,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
is_starred: app.is_starred,
model_config: modelConfig,
app_model_config: modelConfig,
created_at: app.created_at ?? 0,
created_by: app.created_by ?? undefined,
maintainer: app.maintainer ?? undefined,
updated_at: app.updated_at ?? 0,
site: {} as App['site'],
api_base_url: '',
tags: app.tags ?? [],
workflow: normalizeWorkflow(app.workflow),
deleted_tools: [],
access_mode: isAccessMode(app.access_mode) ? app.access_mode : AccessMode.PUBLIC,
max_active_requests: app.max_active_requests,
has_draft_trigger: app.has_draft_trigger ?? undefined,
workflow_kind: null,
permission_keys: app.permission_keys,
}
}
export function normalizeAppPagination(response: AppPagination): AppListResponse {
return {
...response,
data: response.data.map(normalizeAppListItem),
}
}
export const useGenerateRuleTemplate = (type: GeneratorType, disabled?: boolean) => {
return useQuery({
@ -54,7 +132,7 @@ export const useInvalidateAppList = () => {
const queryClient = useQueryClient()
return () => {
queryClient.invalidateQueries({
queryKey: consoleQuery.apps.list.key(),
queryKey: consoleQuery.apps.get.key(),
})
}
}
@ -63,16 +141,16 @@ export const useDeleteAppMutation = () => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: consoleQuery.apps.deleteApp.mutationKey(),
mutationKey: consoleQuery.apps.byAppId.delete.mutationKey(),
mutationFn: (appId: string) => {
return consoleClient.apps.deleteApp({
params: { appId },
return consoleClient.apps.byAppId.delete({
params: { app_id: appId },
})
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: consoleQuery.apps.list.key(),
queryKey: consoleQuery.apps.get.key(),
}),
queryClient.invalidateQueries({
queryKey: useAppFullListKey,
@ -88,20 +166,20 @@ export const useToggleAppStarMutation = () => {
return useMutation({
mutationFn: ({ appId, isStarred }: { appId: string, isStarred: boolean }) => {
return isStarred
? consoleClient.apps.unstar({
params: { appId },
? consoleClient.apps.byAppId.star.delete({
params: { app_id: appId },
})
: consoleClient.apps.star({
params: { appId },
: consoleClient.apps.byAppId.star.post({
params: { app_id: appId },
})
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: consoleQuery.apps.list.key(),
queryKey: consoleQuery.apps.get.key(),
}),
queryClient.invalidateQueries({
queryKey: consoleQuery.apps.starredList.key(),
queryKey: consoleQuery.apps.starred.get.key(),
}),
queryClient.invalidateQueries({
queryKey: useAppFullListKey,

View File

@ -1,6 +1,6 @@
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
import type { CollectionType } from '@/app/components/tools/types'
import type { UploadFileSetting } from '@/app/components/workflow/types'
import type { Tag } from '@/contract/console/tags'
import type { LanguagesSupported } from '@/i18n-config/language'
import type { AccessMode } from '@/models/access-control'
import type { ExternalDataTool } from '@/models/common'

View File

@ -1,4 +1,4 @@
import type { Tag } from '@/contract/console/tags'
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
type SnippetType = 'node' | 'group'