mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
refactor(knowledge-fs): reorganize settings ownership
This commit is contained in:
parent
a1f6a28a1c
commit
fa3e52fa9e
@ -7,13 +7,16 @@ import type { Member } from '@/models/common'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { queryClientAtom } from 'jotai-tanstack-query'
|
||||
import { useHydrateAtoms } from 'jotai/utils'
|
||||
import { render } from '@/test/console/render'
|
||||
import { createSystemFeaturesFixture } from '@/test/console/system-features'
|
||||
import { KnowledgeSettingsForm } from '../form'
|
||||
import { KnowledgeSettingsPage } from '../page'
|
||||
|
||||
const serviceMock = vi.hoisted(() => ({
|
||||
deleteSpace: vi.fn(),
|
||||
getMigration: vi.fn(),
|
||||
getSpace: vi.fn(),
|
||||
patchExternalAccess: vi.fn(),
|
||||
patchSettings: vi.fn(),
|
||||
patchSpace: vi.fn(),
|
||||
@ -30,6 +33,20 @@ const routerMock = vi.hoisted(() => ({
|
||||
replace: vi.fn(),
|
||||
}))
|
||||
|
||||
const knowledgeQueryMock = vi.hoisted(() => ({
|
||||
externalAccess: undefined as unknown,
|
||||
permissions: { data: [] } as unknown,
|
||||
settings: undefined as unknown,
|
||||
space: undefined as unknown,
|
||||
}))
|
||||
|
||||
const membersQueryMock = vi.hoisted(() => ({
|
||||
data: { accounts: [] as Member[] },
|
||||
isError: false,
|
||||
isPending: false,
|
||||
refetch: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
const queryKeys = {
|
||||
accountProfile: [['console', 'account', 'profile', 'get'], { type: 'query' }],
|
||||
externalAccess: ['knowledge-fs', 'external-access'],
|
||||
@ -41,6 +58,11 @@ const queryKeys = {
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => routerMock,
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => membersQueryMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
@ -57,12 +79,26 @@ vi.mock('@/service/client', () => ({
|
||||
mutationOptions: () => ({ mutationFn: serviceMock.deleteSpace }),
|
||||
},
|
||||
externalAccess: {
|
||||
get: { key: () => queryKeys.externalAccess },
|
||||
get: {
|
||||
key: () => queryKeys.externalAccess,
|
||||
queryOptions: () => ({
|
||||
queryFn: () => Promise.resolve(knowledgeQueryMock.externalAccess),
|
||||
queryKey: queryKeys.externalAccess,
|
||||
staleTime: Infinity,
|
||||
}),
|
||||
},
|
||||
put: {
|
||||
mutationOptions: () => ({ mutationFn: serviceMock.patchExternalAccess }),
|
||||
},
|
||||
},
|
||||
get: { key: () => queryKeys.space },
|
||||
get: {
|
||||
key: () => queryKeys.space,
|
||||
queryOptions: () => ({
|
||||
queryFn: () => serviceMock.getSpace(),
|
||||
queryKey: queryKeys.space,
|
||||
staleTime: Infinity,
|
||||
}),
|
||||
},
|
||||
members: {
|
||||
put: {
|
||||
mutationOptions: () => ({ mutationFn: serviceMock.replaceMembers }),
|
||||
@ -72,10 +108,24 @@ vi.mock('@/service/client', () => ({
|
||||
mutationOptions: () => ({ mutationFn: serviceMock.patchSpace }),
|
||||
},
|
||||
permissions: {
|
||||
get: { key: () => queryKeys.permissions },
|
||||
get: {
|
||||
key: () => queryKeys.permissions,
|
||||
queryOptions: () => ({
|
||||
queryFn: () => Promise.resolve(knowledgeQueryMock.permissions),
|
||||
queryKey: queryKeys.permissions,
|
||||
staleTime: Infinity,
|
||||
}),
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
get: { key: () => queryKeys.settings },
|
||||
get: {
|
||||
key: () => queryKeys.settings,
|
||||
queryOptions: () => ({
|
||||
queryFn: () => Promise.resolve(knowledgeQueryMock.settings),
|
||||
queryKey: queryKeys.settings,
|
||||
staleTime: Infinity,
|
||||
}),
|
||||
},
|
||||
migrations: {
|
||||
byMigrationId: {
|
||||
get: {
|
||||
@ -261,10 +311,7 @@ function renderForm({
|
||||
accountProfile = {},
|
||||
externalAccess: externalAccessOverride = externalAccess,
|
||||
members = [],
|
||||
onDraftFinish,
|
||||
onDraftStart,
|
||||
queryClient: queryClientOverride,
|
||||
serverConflict,
|
||||
settings: settingsOverride = settings,
|
||||
space: spaceOverride = space,
|
||||
}: {
|
||||
@ -279,10 +326,7 @@ function renderForm({
|
||||
}>
|
||||
externalAccess?: typeof externalAccess
|
||||
members?: Member[]
|
||||
onDraftFinish?: () => void
|
||||
onDraftStart?: () => void
|
||||
queryClient?: QueryClient
|
||||
serverConflict?: boolean
|
||||
settings?: KnowledgeFsSettingsResponse
|
||||
space?: KnowledgeFsSpaceDetailResponse
|
||||
} = {}) {
|
||||
@ -308,28 +352,30 @@ function renderForm({
|
||||
},
|
||||
})
|
||||
queryClient.setQueryData(queryKeys.systemFeatures, createSystemFeaturesFixture())
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
return render(
|
||||
<KnowledgeSettingsForm
|
||||
externalAccess={externalAccessOverride}
|
||||
members={members}
|
||||
permissions={[]}
|
||||
serverConflict={serverConflict}
|
||||
settings={settingsOverride}
|
||||
space={spaceOverride}
|
||||
onDraftFinish={onDraftFinish}
|
||||
onDraftStart={onDraftStart}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
knowledgeQueryMock.externalAccess = externalAccessOverride
|
||||
knowledgeQueryMock.permissions = { data: [] }
|
||||
knowledgeQueryMock.settings = settingsOverride
|
||||
knowledgeQueryMock.space = spaceOverride
|
||||
membersQueryMock.data = { accounts: members }
|
||||
queryClient.setQueryData(queryKeys.externalAccess, externalAccessOverride)
|
||||
queryClient.setQueryData(queryKeys.permissions, { data: [] })
|
||||
queryClient.setQueryData(queryKeys.settings, settingsOverride)
|
||||
queryClient.setQueryData(queryKeys.space, spaceOverride)
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => {
|
||||
useHydrateAtoms([[queryClientAtom, queryClient]], { dangerouslyForceHydrate: true })
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
return {
|
||||
queryClient,
|
||||
...render(<KnowledgeSettingsPage knowledgeSpaceId="space-1" />, { wrapper: Wrapper }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('KnowledgeSettingsForm', () => {
|
||||
describe('KnowledgeSettingsPage workflows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
serviceMock.deleteSpace.mockResolvedValue(undefined)
|
||||
serviceMock.getSpace.mockResolvedValue(space)
|
||||
serviceMock.patchExternalAccess.mockResolvedValue(externalAccess)
|
||||
serviceMock.patchSettings.mockResolvedValue({ settings })
|
||||
serviceMock.patchSpace.mockResolvedValue(space)
|
||||
@ -382,9 +428,8 @@ describe('KnowledgeSettingsForm', () => {
|
||||
expect(toastMock.success).toHaveBeenCalledWith('common.api.actionSuccess')
|
||||
})
|
||||
|
||||
it('finishes the basic info draft before refreshing saved server data', async () => {
|
||||
it('keeps basic fields locked while refreshing saved server data', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDraftFinish = vi.fn()
|
||||
let finishRefresh!: () => void
|
||||
const refreshPromise = new Promise<void>((resolve) => {
|
||||
finishRefresh = resolve
|
||||
@ -395,8 +440,8 @@ describe('KnowledgeSettingsForm', () => {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.spyOn(queryClient, 'invalidateQueries').mockReturnValue(refreshPromise)
|
||||
renderForm({ onDraftFinish, queryClient })
|
||||
serviceMock.getSpace.mockReturnValueOnce(refreshPromise.then(() => space))
|
||||
renderForm({ queryClient })
|
||||
|
||||
const nameInput = screen.getByRole('textbox', { name: 'datasetSettings.form.name' })
|
||||
await user.clear(nameInput)
|
||||
@ -408,7 +453,6 @@ describe('KnowledgeSettingsForm', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => expect(serviceMock.patchSpace).toHaveBeenCalledOnce())
|
||||
expect(onDraftFinish).toHaveBeenCalledOnce()
|
||||
expect(nameInput).toBeDisabled()
|
||||
finishRefresh()
|
||||
await waitFor(() => expect(nameInput).toBeEnabled())
|
||||
@ -1168,8 +1212,6 @@ describe('KnowledgeSettingsForm', () => {
|
||||
|
||||
it('requires a rerank model for a legacy knowledge base and saves it as enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDraftFinish = vi.fn()
|
||||
const onDraftStart = vi.fn()
|
||||
serviceMock.patchSettings.mockResolvedValueOnce({
|
||||
migration: {
|
||||
changed_kind: 'retrieval',
|
||||
@ -1194,8 +1236,6 @@ describe('KnowledgeSettingsForm', () => {
|
||||
updated_at: '2026-07-28T00:01:00Z',
|
||||
})
|
||||
renderForm({
|
||||
onDraftFinish,
|
||||
onDraftStart,
|
||||
settings: {
|
||||
...settings,
|
||||
configuration_state: 'setup-required',
|
||||
@ -1244,10 +1284,9 @@ describe('KnowledgeSettingsForm', () => {
|
||||
},
|
||||
expect.anything(),
|
||||
)
|
||||
expect(onDraftStart).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByText('dataset.newKnowledge.settings.rerankModelRequired')).toBeNull()
|
||||
await waitFor(() => expect(serviceMock.getMigration).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(onDraftFinish).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(toastMock.success).toHaveBeenCalledWith('common.api.actionSuccess'))
|
||||
})
|
||||
|
||||
it('saves a reasoning model selection immediately without enabling the basic info save', async () => {
|
||||
@ -1277,9 +1316,7 @@ describe('KnowledgeSettingsForm', () => {
|
||||
serviceMock.patchSettings
|
||||
.mockReturnValueOnce(firstSave)
|
||||
.mockResolvedValueOnce({ settings: { ...settings, revision: 7 } })
|
||||
const onDraftFinish = vi.fn()
|
||||
const onDraftStart = vi.fn()
|
||||
renderForm({ onDraftFinish, onDraftStart })
|
||||
renderForm()
|
||||
|
||||
const reasoningSelector = screen.getByRole('button', {
|
||||
name: 'dataset.newKnowledge.settings.systemReasoningModelLabel',
|
||||
@ -1298,10 +1335,6 @@ describe('KnowledgeSettingsForm', () => {
|
||||
).not.toHaveAttribute('aria-disabled', 'true')
|
||||
await user.click(rerankSelector)
|
||||
|
||||
expect(onDraftStart).toHaveBeenCalledTimes(2)
|
||||
expect(onDraftStart.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
serviceMock.patchSettings.mock.invocationCallOrder[0]!,
|
||||
)
|
||||
expect(serviceMock.patchSettings).toHaveBeenCalledOnce()
|
||||
expect(serviceMock.patchSettings).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@ -1340,7 +1373,7 @@ describe('KnowledgeSettingsForm', () => {
|
||||
},
|
||||
expect.anything(),
|
||||
)
|
||||
await waitFor(() => expect(onDraftFinish).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(toastMock.success).toHaveBeenCalledWith('common.api.actionSuccess'))
|
||||
})
|
||||
|
||||
it('continues a queued model selection only after its preceding migration succeeds', async () => {
|
||||
@ -1496,35 +1529,23 @@ describe('KnowledgeSettingsForm', () => {
|
||||
|
||||
it('blocks a stale draft after the server baseline changes and restores the latest value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDraftFinish = vi.fn()
|
||||
const onDraftStart = vi.fn()
|
||||
const renderWithName = (name: string, serverConflict = false) => (
|
||||
<KnowledgeSettingsForm
|
||||
externalAccess={externalAccess}
|
||||
members={[]}
|
||||
permissions={[]}
|
||||
serverConflict={serverConflict}
|
||||
settings={settings}
|
||||
space={{
|
||||
...space,
|
||||
technical_summary: {
|
||||
...space.technical_summary,
|
||||
name,
|
||||
},
|
||||
}}
|
||||
onDraftFinish={onDraftFinish}
|
||||
onDraftStart={onDraftStart}
|
||||
/>
|
||||
)
|
||||
const view = renderForm({ onDraftFinish, onDraftStart })
|
||||
const { queryClient } = renderForm()
|
||||
const nameInput = screen.getByRole('textbox', { name: 'datasetSettings.form.name' })
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'Version B')
|
||||
|
||||
expect(onDraftStart).toHaveBeenCalled()
|
||||
view.rerender(renderWithName('Version C', true))
|
||||
act(() => {
|
||||
queryClient.setQueryData(queryKeys.space, {
|
||||
...space,
|
||||
resource_version: space.resource_version + 1,
|
||||
technical_summary: {
|
||||
...space.technical_summary,
|
||||
name: 'Version C',
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(nameInput).toHaveValue('Version B')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'dataset.newKnowledge.settings.serverConflict',
|
||||
)
|
||||
expect(
|
||||
@ -1536,7 +1557,6 @@ describe('KnowledgeSettingsForm', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(nameInput).toHaveValue('Version C')
|
||||
expect(onDraftFinish).toHaveBeenCalledOnce()
|
||||
expect(serviceMock.patchSpace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@ -2,123 +2,90 @@ import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { queryClientAtom } from 'jotai-tanstack-query'
|
||||
import { useHydrateAtoms } from 'jotai/utils'
|
||||
import { render } from '@/test/console/render'
|
||||
import { KnowledgeSettingsPage } from '../page'
|
||||
|
||||
const useQueryOptionsMock = vi.hoisted(() => vi.fn())
|
||||
const navigationMock = vi.hoisted(() => ({
|
||||
replace: vi.fn(),
|
||||
searchParams: new URLSearchParams(),
|
||||
}))
|
||||
|
||||
const serviceMock = vi.hoisted(() => ({
|
||||
getExternalAccess: vi.fn(),
|
||||
getPermissions: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getSpace: vi.fn(),
|
||||
queryOptions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ replace: navigationMock.replace }),
|
||||
useSearchParams: () => navigationMock.searchParams,
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQuery: (options: Parameters<typeof actual.useQuery>[0]) => {
|
||||
useQueryOptionsMock(options)
|
||||
return actual.useQuery(options)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const membersQueryMock = vi.hoisted(() => ({
|
||||
data: undefined as { accounts: [] } | undefined,
|
||||
isError: false,
|
||||
isPending: true,
|
||||
refetch: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => membersQueryMock,
|
||||
}))
|
||||
|
||||
vi.mock('../form', () => ({
|
||||
KnowledgeSettingsForm: ({
|
||||
onDraftFinish,
|
||||
onDraftStart,
|
||||
serverConflict,
|
||||
}: {
|
||||
onDraftFinish: () => void
|
||||
onDraftStart: () => void
|
||||
serverConflict: boolean
|
||||
}) => (
|
||||
<div>
|
||||
settings-form
|
||||
<span>{serverConflict ? 'server-conflict' : 'no-conflict'}</span>
|
||||
<button type="button" onClick={onDraftStart}>
|
||||
start-draft
|
||||
</button>
|
||||
<button type="button" onClick={onDraftFinish}>
|
||||
finish-draft
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
KnowledgeSettingsForm: () => <div>settings-form</div>,
|
||||
}))
|
||||
|
||||
const queryData = vi.hoisted(() => ({
|
||||
settings: {
|
||||
active_profile_available: true,
|
||||
active_profile_revisions: { embedding: 1, retrieval: 1 },
|
||||
capabilities: {
|
||||
deep: true,
|
||||
index: true,
|
||||
ingest: true,
|
||||
query: true,
|
||||
research: true,
|
||||
source_sync: true,
|
||||
},
|
||||
configuration_state: 'active',
|
||||
embedding: null,
|
||||
issues: [],
|
||||
retrieval: null,
|
||||
revision: 1,
|
||||
const queryKeys = {
|
||||
externalAccess: ['knowledge-fs', 'external-access'],
|
||||
permissions: ['knowledge-fs', 'permissions'],
|
||||
settings: ['knowledge-fs', 'settings'],
|
||||
space: ['knowledge-fs', 'space'],
|
||||
}
|
||||
|
||||
const space = {
|
||||
control_space_id: 'space-1',
|
||||
created_at: '2026-07-28T00:00:00Z',
|
||||
knowledge_space_id: 'knowledge-1',
|
||||
owner_account_id: 'owner-1',
|
||||
permission_keys: ['knowledge_space_access_config', 'knowledge_space_edit'],
|
||||
resource_version: 1,
|
||||
state: 'active',
|
||||
technical_status: 'available',
|
||||
technical_summary: null,
|
||||
updated_at: '2026-07-28T00:00:00Z',
|
||||
visibility: 'only_me',
|
||||
}
|
||||
|
||||
const settings = {
|
||||
active_profile_available: true,
|
||||
active_profile_revisions: { embedding: 1, retrieval: 1 },
|
||||
capabilities: {
|
||||
deep: true,
|
||||
index: true,
|
||||
ingest: true,
|
||||
query: true,
|
||||
research: true,
|
||||
source_sync: true,
|
||||
},
|
||||
space: {
|
||||
control_space_id: 'space-1',
|
||||
created_at: '2026-07-28T00:00:00Z',
|
||||
knowledge_space_id: 'knowledge-1',
|
||||
owner_account_id: 'owner-1',
|
||||
permission_keys: ['knowledge_space_access_config', 'knowledge_space_edit'],
|
||||
resource_version: 1,
|
||||
state: 'active',
|
||||
technical_status: 'available',
|
||||
technical_summary: null,
|
||||
updated_at: '2026-07-28T00:00:00Z',
|
||||
visibility: 'only_me',
|
||||
},
|
||||
}))
|
||||
configuration_state: 'active',
|
||||
embedding: null,
|
||||
issues: [],
|
||||
retrieval: null,
|
||||
revision: 1,
|
||||
}
|
||||
|
||||
vi.mock('@/service/client', () => {
|
||||
const query = (key: string, data: unknown) => ({
|
||||
key: () => ['knowledge-fs', key],
|
||||
queryOptions: () => ({
|
||||
queryFn: () => Promise.resolve(data),
|
||||
queryKey: ['knowledge-fs', key],
|
||||
}),
|
||||
const query = (key: keyof typeof queryKeys, queryFn: () => Promise<unknown>) => ({
|
||||
queryOptions: () => {
|
||||
const options = { queryFn, queryKey: queryKeys[key] }
|
||||
serviceMock.queryOptions(options)
|
||||
return options
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
consoleQuery: {
|
||||
knowledgeFs: {
|
||||
spaces: {
|
||||
byControlSpaceId: {
|
||||
externalAccess: {
|
||||
get: query('external-access', {
|
||||
agent_enabled: false,
|
||||
mcp_enabled: false,
|
||||
revision: 1,
|
||||
service_api_enabled: false,
|
||||
workflow_enabled: false,
|
||||
}),
|
||||
},
|
||||
get: query('space', queryData.space),
|
||||
permissions: { get: query('permissions', { data: [] }) },
|
||||
settings: { get: query('settings', queryData.settings) },
|
||||
externalAccess: { get: query('externalAccess', serviceMock.getExternalAccess) },
|
||||
get: query('space', serviceMock.getSpace),
|
||||
permissions: { get: query('permissions', serviceMock.getPermissions) },
|
||||
settings: { get: query('settings', serviceMock.getSettings) },
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -126,15 +93,32 @@ vi.mock('@/service/client', () => {
|
||||
}
|
||||
})
|
||||
|
||||
function renderPage() {
|
||||
function renderPage({
|
||||
seed = false,
|
||||
settingsData = settings,
|
||||
}: {
|
||||
seed?: boolean
|
||||
settingsData?: typeof settings
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
if (seed) {
|
||||
queryClient.setQueryData(queryKeys.space, space)
|
||||
queryClient.setQueryData(queryKeys.settings, settingsData)
|
||||
queryClient.setQueryData(queryKeys.permissions, { data: [] })
|
||||
queryClient.setQueryData(queryKeys.externalAccess, {
|
||||
agent_enabled: false,
|
||||
mcp_enabled: false,
|
||||
revision: 1,
|
||||
service_api_enabled: false,
|
||||
workflow_enabled: false,
|
||||
})
|
||||
}
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => {
|
||||
useHydrateAtoms([[queryClientAtom, queryClient]], { dangerouslyForceHydrate: true })
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
return {
|
||||
queryClient,
|
||||
...render(<KnowledgeSettingsPage knowledgeSpaceId="space-1" />, { wrapper: Wrapper }),
|
||||
@ -143,114 +127,69 @@ function renderPage() {
|
||||
|
||||
describe('KnowledgeSettingsPage', () => {
|
||||
beforeEach(() => {
|
||||
membersQueryMock.data = undefined
|
||||
membersQueryMock.isError = false
|
||||
membersQueryMock.isPending = true
|
||||
membersQueryMock.refetch.mockClear()
|
||||
useQueryOptionsMock.mockClear()
|
||||
navigationMock.replace.mockClear()
|
||||
vi.clearAllMocks()
|
||||
navigationMock.searchParams = new URLSearchParams()
|
||||
queryData.settings.capabilities.ingest = true
|
||||
serviceMock.getExternalAccess.mockResolvedValue({
|
||||
agent_enabled: false,
|
||||
mcp_enabled: false,
|
||||
revision: 1,
|
||||
service_api_enabled: false,
|
||||
workflow_enabled: false,
|
||||
})
|
||||
serviceMock.getPermissions.mockResolvedValue({ data: [] })
|
||||
serviceMock.getSettings.mockResolvedValue(settings)
|
||||
serviceMock.getSpace.mockResolvedValue(space)
|
||||
})
|
||||
|
||||
it('keeps the settings form gated while workspace members are loading', async () => {
|
||||
it('shows the page skeleton while its server graph is loading', () => {
|
||||
serviceMock.getSpace.mockReturnValue(new Promise(() => {}))
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('settings-form')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('common.loading')
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent(
|
||||
'dataset.newKnowledge.settings.basicInfo',
|
||||
)
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent(
|
||||
'dataset.newKnowledge.settings.dangerZone',
|
||||
)
|
||||
expect(screen.getByText('dataset.newKnowledge.settings.basicInfo')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.newKnowledge.settings.dangerZone')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('common.loading')
|
||||
expect(screen.queryByText('settings-form')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.newKnowledge.settings.basicInfo')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.newKnowledge.settings.dangerZone')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the page error state and retries the members request', async () => {
|
||||
it('shows the query error state and retries the failed server graph', async () => {
|
||||
const user = userEvent.setup()
|
||||
membersQueryMock.isPending = false
|
||||
membersQueryMock.isError = true
|
||||
serviceMock.getSpace.mockRejectedValueOnce(new Error('unavailable'))
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByRole('alert')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
|
||||
expect(membersQueryMock.refetch).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByText('settings-form')).not.toBeInTheDocument()
|
||||
expect(serviceMock.getSpace).toHaveBeenCalledTimes(2)
|
||||
expect(await screen.findByText('settings-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not poll settings after model selections are saved', async () => {
|
||||
membersQueryMock.data = { accounts: [] }
|
||||
membersQueryMock.isPending = false
|
||||
renderPage()
|
||||
it('renders the form from cached queries without polling settings', async () => {
|
||||
renderPage({ seed: true })
|
||||
|
||||
expect(await screen.findByText('settings-form')).toBeInTheDocument()
|
||||
|
||||
const settingsOptions = useQueryOptionsMock.mock.calls
|
||||
const settingsOptions = serviceMock.queryOptions.mock.calls
|
||||
.map(([options]) => options)
|
||||
.find((options) => options.queryKey?.[1] === 'settings')
|
||||
|
||||
.find((options) => options.queryKey === queryKeys.settings)
|
||||
expect(settingsOptions).not.toHaveProperty('refetchInterval')
|
||||
})
|
||||
|
||||
it('keeps an active draft mounted and reports a conflict when the server version changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
membersQueryMock.data = { accounts: [] }
|
||||
membersQueryMock.isPending = false
|
||||
const { queryClient } = renderPage()
|
||||
|
||||
expect(await screen.findByText('no-conflict')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'start-draft' }))
|
||||
act(() => {
|
||||
queryClient.setQueryData(['knowledge-fs', 'space'], {
|
||||
...queryData.space,
|
||||
resource_version: 2,
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('server-conflict')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'finish-draft' }))
|
||||
expect(await screen.findByText('no-conflict')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not report a basic info conflict when immediate retrieval settings refresh', async () => {
|
||||
const user = userEvent.setup()
|
||||
membersQueryMock.data = { accounts: [] }
|
||||
membersQueryMock.isPending = false
|
||||
const { queryClient } = renderPage()
|
||||
|
||||
expect(await screen.findByText('no-conflict')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'start-draft' }))
|
||||
act(() => {
|
||||
queryClient.setQueryData(['knowledge-fs', 'settings'], {
|
||||
...queryData.settings,
|
||||
revision: 2,
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('no-conflict')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns to a validated source page when its blocked capability becomes available', async () => {
|
||||
membersQueryMock.data = { accounts: [] }
|
||||
membersQueryMock.isPending = false
|
||||
navigationMock.searchParams = new URLSearchParams({
|
||||
capability: 'ingest',
|
||||
returnTo: '/datasets/new/space-1/documents',
|
||||
})
|
||||
queryData.settings.capabilities.ingest = false
|
||||
const { queryClient } = renderPage()
|
||||
const blockedSettings = {
|
||||
...settings,
|
||||
capabilities: { ...settings.capabilities, ingest: false },
|
||||
}
|
||||
const { queryClient } = renderPage({ seed: true, settingsData: blockedSettings })
|
||||
expect(await screen.findByText('settings-form')).toBeInTheDocument()
|
||||
expect(navigationMock.replace).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData(['knowledge-fs', 'settings'], {
|
||||
...queryData.settings,
|
||||
capabilities: { ...queryData.settings.capabilities, ingest: true },
|
||||
queryClient.setQueryData(queryKeys.settings, {
|
||||
...blockedSettings,
|
||||
capabilities: { ...blockedSettings.capabilities, ingest: true },
|
||||
})
|
||||
})
|
||||
await waitFor(() =>
|
||||
|
||||
@ -0,0 +1,93 @@
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { atom, createStore, Provider, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { render } from '@/test/console/render'
|
||||
import { KnowledgeSettingsStateBoundary } from '../state/boundary'
|
||||
import { knowledgeSettingsSpaceIdAtom } from '../state/inputs'
|
||||
import {
|
||||
knowledgeSettingsHasUnsavedWorkAtom,
|
||||
startKnowledgeSettingsBasicDraftAtom,
|
||||
} from '../state/workflow'
|
||||
|
||||
const globalProbeAtom = atom('global-before')
|
||||
|
||||
function WorkflowProbe({ name }: { name: string }) {
|
||||
const globalValue = useAtomValue(globalProbeAtom)
|
||||
const hasUnsavedWork = useAtomValue(knowledgeSettingsHasUnsavedWorkAtom)
|
||||
const knowledgeSpaceId = useAtomValue(knowledgeSettingsSpaceIdAtom)
|
||||
const startDraft = useSetAtom(startKnowledgeSettingsBasicDraftAtom)
|
||||
|
||||
return (
|
||||
<section aria-label={name}>
|
||||
<span>{globalValue}</span>
|
||||
<span>{hasUnsavedWork ? 'dirty' : 'clean'}</span>
|
||||
<span>{knowledgeSpaceId}</span>
|
||||
<button onClick={startDraft} type="button">
|
||||
start draft
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function GlobalProbeControl() {
|
||||
const setGlobalValue = useSetAtom(globalProbeAtom)
|
||||
|
||||
return (
|
||||
<button onClick={() => setGlobalValue('global-after')} type="button">
|
||||
update global
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe('KnowledgeSettingsStateBoundary', () => {
|
||||
it('keeps parent atoms visible while isolating workflow state between instances', async () => {
|
||||
const user = userEvent.setup()
|
||||
const store = createStore()
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalProbeControl />
|
||||
<KnowledgeSettingsStateBoundary knowledgeSpaceId="space-1">
|
||||
<WorkflowProbe name="first settings instance" />
|
||||
</KnowledgeSettingsStateBoundary>
|
||||
<KnowledgeSettingsStateBoundary knowledgeSpaceId="space-1">
|
||||
<WorkflowProbe name="second settings instance" />
|
||||
</KnowledgeSettingsStateBoundary>
|
||||
</Provider>,
|
||||
)
|
||||
|
||||
const first = within(screen.getByRole('region', { name: 'first settings instance' }))
|
||||
const second = within(screen.getByRole('region', { name: 'second settings instance' }))
|
||||
|
||||
await user.click(first.getByRole('button', { name: 'start draft' }))
|
||||
expect(first.getByText('dirty')).toBeInTheDocument()
|
||||
expect(second.getByText('clean')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'update global' }))
|
||||
expect(first.getByText('global-after')).toBeInTheDocument()
|
||||
expect(second.getByText('global-after')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resets scoped workflow state when the route identity changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const store = createStore()
|
||||
const tree = (knowledgeSpaceId: string) => (
|
||||
<Provider store={store}>
|
||||
<KnowledgeSettingsStateBoundary knowledgeSpaceId={knowledgeSpaceId}>
|
||||
<WorkflowProbe name="settings instance" />
|
||||
</KnowledgeSettingsStateBoundary>
|
||||
</Provider>
|
||||
)
|
||||
const rendered = render(tree('space-1'))
|
||||
const instance = within(screen.getByRole('region', { name: 'settings instance' }))
|
||||
|
||||
await user.click(instance.getByRole('button', { name: 'start draft' }))
|
||||
expect(instance.getByText('dirty')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(tree('space-2'))
|
||||
|
||||
const resetInstance = within(screen.getByRole('region', { name: 'settings instance' }))
|
||||
expect(resetInstance.getByText('clean')).toBeInTheDocument()
|
||||
expect(resetInstance.getByText('space-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
454
web/features/new-rag/settings/basic-information.tsx
Normal file
454
web/features/new-rag/settings/basic-information.tsx
Normal file
@ -0,0 +1,454 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
KnowledgeFsControlSpaceVisibility,
|
||||
KnowledgeFsPermissionResponse,
|
||||
KnowledgeFsSpaceDetailResponse,
|
||||
} from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import {
|
||||
DEFAULT_KNOWLEDGE_SPACE_ICON_BACKGROUND,
|
||||
KnowledgeSpaceIcon,
|
||||
} from '../components/knowledge-space-icon'
|
||||
import { KNOWLEDGE_DESCRIPTION_MAX_LENGTH, KNOWLEDGE_NAME_MAX_LENGTH } from '../constants'
|
||||
import { KnowledgeSettingsMembers } from './members'
|
||||
import { SettingsFieldRow } from './settings-field-row'
|
||||
import {
|
||||
invalidateKnowledgeSettingsAtom,
|
||||
knowledgeSettingsPermissionsAtom,
|
||||
knowledgeSettingsSpaceAtom,
|
||||
} from './state/queries'
|
||||
import {
|
||||
finishKnowledgeSettingsBasicDraftAtom,
|
||||
setKnowledgeSettingsSavePendingAtom,
|
||||
startKnowledgeSettingsBasicDraftAtom,
|
||||
} from './state/workflow'
|
||||
|
||||
const NAME_ERROR_ID = 'knowledge-name-error'
|
||||
const DESCRIPTION_ERROR_ID = 'knowledge-description-error'
|
||||
type BasicSaveSlice = 'members' | 'space'
|
||||
|
||||
type BasicDraft = {
|
||||
description: string
|
||||
icon: string
|
||||
iconBackground: string
|
||||
name: string
|
||||
selectedMemberIds: string[]
|
||||
visibility: KnowledgeFsControlSpaceVisibility
|
||||
}
|
||||
|
||||
function sortedIds(ids: string[]) {
|
||||
return [...ids].sort().join(':')
|
||||
}
|
||||
|
||||
function draftFromServer(
|
||||
space: KnowledgeFsSpaceDetailResponse,
|
||||
permissions: KnowledgeFsPermissionResponse[],
|
||||
): BasicDraft {
|
||||
return {
|
||||
description: space.technical_summary?.description ?? '',
|
||||
icon: space.technical_summary?.icon ?? '📙',
|
||||
iconBackground:
|
||||
space.technical_summary?.icon_background ?? DEFAULT_KNOWLEDGE_SPACE_ICON_BACKGROUND,
|
||||
name: space.technical_summary?.name ?? '',
|
||||
selectedMemberIds: permissions
|
||||
.filter(
|
||||
(permission) =>
|
||||
permission.status === 'active' && permission.account_id !== space.owner_account_id,
|
||||
)
|
||||
.map((permission) => permission.account_id),
|
||||
visibility: space.visibility,
|
||||
}
|
||||
}
|
||||
|
||||
function draftsMatch(left: BasicDraft, right: BasicDraft) {
|
||||
return (
|
||||
left.name === right.name &&
|
||||
left.description === right.description &&
|
||||
left.icon === right.icon &&
|
||||
left.iconBackground === right.iconBackground &&
|
||||
left.visibility === right.visibility &&
|
||||
sortedIds(left.selectedMemberIds) === sortedIds(right.selectedMemberIds)
|
||||
)
|
||||
}
|
||||
|
||||
function BasicInformationSkeleton() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tSettings } = useTranslation('datasetSettings')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<h2 className="flex h-8 items-center system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['newKnowledge.settings.basicInfo'])}
|
||||
</h2>
|
||||
{[
|
||||
tSettings(($) => $['form.nameAndIcon']),
|
||||
tSettings(($) => $['form.desc']),
|
||||
tSettings(($) => $['form.permissions']),
|
||||
].map((label) => (
|
||||
<SettingsFieldRow key={label} label={label}>
|
||||
<SkeletonRectangle className="h-9 w-full rounded-lg" />
|
||||
</SettingsFieldRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BasicInformationSection() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const { t: tSettings } = useTranslation('datasetSettings')
|
||||
const { t: tWorkflow } = useTranslation('workflow')
|
||||
const space = useAtomValue(knowledgeSettingsSpaceAtom)
|
||||
const permissions = useAtomValue(knowledgeSettingsPermissionsAtom)
|
||||
const startDraft = useSetAtom(startKnowledgeSettingsBasicDraftAtom)
|
||||
const finishDraft = useSetAtom(finishKnowledgeSettingsBasicDraftAtom)
|
||||
const setSavePending = useSetAtom(setKnowledgeSettingsSavePendingAtom)
|
||||
const invalidateSettings = useSetAtom(invalidateKnowledgeSettingsAtom)
|
||||
const membersQuery = useMembers()
|
||||
const [draft, setDraft] = useState<BasicDraft>()
|
||||
const [nameTouched, setNameTouched] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const draftRef = useRef<BasicDraft | undefined>(undefined)
|
||||
const draftBaseVersionRef = useRef<string | undefined>(undefined)
|
||||
const completedSaveFingerprintsRef = useRef<Partial<Record<BasicSaveSlice, string>>>({})
|
||||
const spaceMutation = useMutation(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.patch.mutationOptions(),
|
||||
)
|
||||
const membersMutation = useMutation(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.members.put.mutationOptions(),
|
||||
)
|
||||
|
||||
if (!space) return null
|
||||
if (membersQuery.isPending) return <BasicInformationSkeleton />
|
||||
if (membersQuery.isError) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-xl border border-components-panel-border bg-background-section p-4"
|
||||
role="alert"
|
||||
>
|
||||
<span aria-hidden className="i-ri-error-warning-line size-5 text-text-destructive" />
|
||||
<p className="min-w-0 flex-1 system-sm-regular text-text-secondary">
|
||||
{tCommon(($) => $['api.actionFailed'])}
|
||||
</p>
|
||||
<Button onClick={() => void membersQuery.refetch()}>
|
||||
{tCommon(($) => $['operation.retry'])}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const serverDraft = draftFromServer(space, permissions)
|
||||
const serverVersion = [
|
||||
space.control_space_id,
|
||||
space.resource_version,
|
||||
permissions
|
||||
.map((permission) => `${permission.account_id}:${permission.revision}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
].join(':')
|
||||
const current = draft ?? serverDraft
|
||||
const serverConflict =
|
||||
draft !== undefined &&
|
||||
draftBaseVersionRef.current !== undefined &&
|
||||
draftBaseVersionRef.current !== serverVersion
|
||||
const canEdit = space.permission_keys.includes('knowledge_space_edit')
|
||||
const canManageAccess = space.permission_keys.includes('knowledge_space_access_config')
|
||||
const basicDirty = !draftsMatch(current, serverDraft)
|
||||
const spaceDirty =
|
||||
current.name !== serverDraft.name ||
|
||||
current.description !== serverDraft.description ||
|
||||
current.icon !== serverDraft.icon ||
|
||||
current.iconBackground !== serverDraft.iconBackground ||
|
||||
current.visibility !== serverDraft.visibility
|
||||
const membersDirty =
|
||||
sortedIds(current.selectedMemberIds) !== sortedIds(serverDraft.selectedMemberIds)
|
||||
const nameInvalid = !current.name.trim()
|
||||
const descriptionInvalid =
|
||||
Array.from(current.description).length > KNOWLEDGE_DESCRIPTION_MAX_LENGTH
|
||||
const membersInvalid =
|
||||
canManageAccess &&
|
||||
current.visibility === 'partial_members' &&
|
||||
current.selectedMemberIds.length === 0
|
||||
const isSaving = spaceMutation.isPending || membersMutation.isPending || isRefreshing
|
||||
const fieldsDisabled = !canEdit || isSaving
|
||||
const saveDisabled =
|
||||
!basicDirty || nameInvalid || descriptionInvalid || membersInvalid || serverConflict
|
||||
|
||||
const updateDraft = (update: (value: BasicDraft) => BasicDraft) => {
|
||||
const next = update(draftRef.current ?? current)
|
||||
if (draftsMatch(next, serverDraft)) {
|
||||
draftRef.current = undefined
|
||||
draftBaseVersionRef.current = undefined
|
||||
setDraft(undefined)
|
||||
finishDraft()
|
||||
return
|
||||
}
|
||||
draftRef.current = next
|
||||
draftBaseVersionRef.current ??= serverVersion
|
||||
startDraft()
|
||||
setDraft(next)
|
||||
}
|
||||
|
||||
const resetDraft = () => {
|
||||
draftRef.current = undefined
|
||||
draftBaseVersionRef.current = undefined
|
||||
setDraft(undefined)
|
||||
setNameTouched(false)
|
||||
finishDraft()
|
||||
}
|
||||
|
||||
const showSaveError = (error?: unknown) =>
|
||||
toast.error(
|
||||
error instanceof Response && error.status === 403
|
||||
? t(($) => $['newKnowledge.permissionRestricted'])
|
||||
: t(($) => $['newKnowledge.settings.saveFailed']),
|
||||
)
|
||||
|
||||
const performSave = async () => {
|
||||
if (saveDisabled || isSaving || !canEdit) return
|
||||
setSavePending({ owner: 'basic', pending: true })
|
||||
try {
|
||||
const saveSlice = async (
|
||||
slice: BasicSaveSlice,
|
||||
payload: unknown,
|
||||
save: () => Promise<unknown>,
|
||||
) => {
|
||||
const fingerprint = JSON.stringify(payload)
|
||||
if (completedSaveFingerprintsRef.current[slice] === fingerprint) return
|
||||
await save()
|
||||
completedSaveFingerprintsRef.current[slice] = fingerprint
|
||||
}
|
||||
|
||||
if (spaceDirty) {
|
||||
const body = {
|
||||
...(current.description !== serverDraft.description
|
||||
? { description: current.description }
|
||||
: {}),
|
||||
...(current.icon !== serverDraft.icon ? { icon: current.icon } : {}),
|
||||
...(current.iconBackground !== serverDraft.iconBackground
|
||||
? { icon_background: current.iconBackground }
|
||||
: {}),
|
||||
...(current.name !== serverDraft.name ? { name: current.name.trim() } : {}),
|
||||
...(current.visibility !== serverDraft.visibility
|
||||
? { visibility: current.visibility }
|
||||
: {}),
|
||||
}
|
||||
await saveSlice('space', body, () =>
|
||||
spaceMutation.mutateAsync({
|
||||
body,
|
||||
params: { control_space_id: space.control_space_id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (membersDirty && canManageAccess) {
|
||||
const roleByAccountId = new Map(
|
||||
permissions.map((permission) => [permission.account_id, permission.role]),
|
||||
)
|
||||
const body = {
|
||||
members: current.selectedMemberIds.map((accountId) => ({
|
||||
account_id: accountId,
|
||||
role: roleByAccountId.get(accountId) ?? 'viewer',
|
||||
})),
|
||||
}
|
||||
await saveSlice('members', body, () =>
|
||||
membersMutation.mutateAsync({
|
||||
body,
|
||||
params: { control_space_id: space.control_space_id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
completedSaveFingerprintsRef.current = {}
|
||||
toast.success(tCommon(($) => $['api.actionSuccess']))
|
||||
setIsRefreshing(true)
|
||||
draftBaseVersionRef.current = undefined
|
||||
finishDraft()
|
||||
void invalidateSettings().then(
|
||||
() => {
|
||||
draftRef.current = undefined
|
||||
setDraft(undefined)
|
||||
setIsRefreshing(false)
|
||||
setSavePending({ owner: 'basic', pending: false })
|
||||
},
|
||||
() => {
|
||||
setIsRefreshing(false)
|
||||
setSavePending({ owner: 'basic', pending: false })
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
setSavePending({ owner: 'basic', pending: false })
|
||||
showSaveError(error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{serverConflict && (
|
||||
<div
|
||||
className="mb-3 flex items-center gap-2 rounded-lg border border-text-warning/20 bg-state-warning-hover px-3 py-2"
|
||||
role="alert"
|
||||
>
|
||||
<span aria-hidden className="i-ri-error-warning-line size-4 text-text-warning" />
|
||||
<span className="min-w-0 flex-1 system-xs-regular text-text-warning">
|
||||
{t(($) => $['newKnowledge.settings.serverConflict'])}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form
|
||||
className="flex flex-col gap-4 overflow-hidden pt-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setNameTouched(true)
|
||||
void performSave()
|
||||
}}
|
||||
>
|
||||
<h2 className="flex h-8 items-center system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['newKnowledge.settings.basicInfo'])}
|
||||
</h2>
|
||||
|
||||
<SettingsFieldRow label={tSettings(($) => $['form.nameAndIcon'])}>
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tSettings(($) => $['form.nameAndIcon'])}
|
||||
disabled={fieldsDisabled}
|
||||
className="shrink-0 rounded-lg outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed"
|
||||
onClick={() => setIconPickerOpen(true)}
|
||||
>
|
||||
<KnowledgeSpaceIcon
|
||||
background={current.iconBackground}
|
||||
icon={current.icon}
|
||||
size="small"
|
||||
/>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Input
|
||||
aria-label={tSettings(($) => $['form.name'])}
|
||||
aria-describedby={nameTouched && nameInvalid ? NAME_ERROR_ID : undefined}
|
||||
aria-invalid={nameTouched && nameInvalid}
|
||||
autoComplete="off"
|
||||
name="knowledge-name"
|
||||
value={current.name}
|
||||
maxLength={KNOWLEDGE_NAME_MAX_LENGTH}
|
||||
disabled={fieldsDisabled}
|
||||
className={cn(nameTouched && nameInvalid && 'ring-1 ring-text-destructive')}
|
||||
onBlur={() => setNameTouched(true)}
|
||||
onChange={(event) =>
|
||||
updateDraft((value) => ({
|
||||
...value,
|
||||
name: event.target.value.slice(0, KNOWLEDGE_NAME_MAX_LENGTH),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{nameTouched && nameInvalid && (
|
||||
<p
|
||||
id={NAME_ERROR_ID}
|
||||
className="mt-1 system-xs-regular text-text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{t(($) => $['newKnowledge.settings.nameRequired'])}
|
||||
</p>
|
||||
)}
|
||||
{current.name.length >= KNOWLEDGE_NAME_MAX_LENGTH * 0.9 && (
|
||||
<p className="mt-1 text-right system-xs-medium text-text-warning-secondary">
|
||||
{current.name.length} / {KNOWLEDGE_NAME_MAX_LENGTH}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsFieldRow>
|
||||
|
||||
<SettingsFieldRow label={tSettings(($) => $['form.desc'])}>
|
||||
<div>
|
||||
<Textarea
|
||||
aria-label={tSettings(($) => $['form.desc'])}
|
||||
aria-describedby={descriptionInvalid ? DESCRIPTION_ERROR_ID : undefined}
|
||||
aria-invalid={descriptionInvalid}
|
||||
autoComplete="off"
|
||||
name="knowledge-description"
|
||||
value={current.description}
|
||||
disabled={fieldsDisabled}
|
||||
placeholder={t(($) => $['newKnowledge.settings.descriptionPlaceholder'])}
|
||||
className={cn(
|
||||
'min-h-20 resize-none',
|
||||
descriptionInvalid && 'ring-1 ring-text-destructive',
|
||||
)}
|
||||
onValueChange={(description) => updateDraft((value) => ({ ...value, description }))}
|
||||
/>
|
||||
{descriptionInvalid && (
|
||||
<p
|
||||
id={DESCRIPTION_ERROR_ID}
|
||||
className="mt-1 system-xs-regular text-text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{tWorkflow(($) => $['chatVariable.modal.descriptionTooLong'], {
|
||||
maxLength: KNOWLEDGE_DESCRIPTION_MAX_LENGTH,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsFieldRow>
|
||||
|
||||
<SettingsFieldRow label={tSettings(($) => $['form.permissions'])}>
|
||||
<KnowledgeSettingsMembers
|
||||
disabled={!canEdit || !canManageAccess || isSaving}
|
||||
hasError={membersInvalid}
|
||||
members={membersQuery.data?.accounts ?? []}
|
||||
ownerAccountId={space.owner_account_id}
|
||||
selectedMemberIds={current.selectedMemberIds}
|
||||
visibility={current.visibility}
|
||||
onSelectedMemberIdsChange={(selectedMemberIds) =>
|
||||
updateDraft((value) => ({ ...value, selectedMemberIds }))
|
||||
}
|
||||
onVisibilityChange={(visibility) => updateDraft((value) => ({ ...value, visibility }))}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={(!basicDirty && !serverConflict) || isSaving}
|
||||
onClick={resetDraft}
|
||||
>
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={saveDisabled} loading={isSaving}>
|
||||
{t(($) => $['newKnowledge.settings.saveChanges'])}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<AppIconPicker
|
||||
open={iconPickerOpen}
|
||||
enableImageUpload={false}
|
||||
initialEmoji={{ background: current.iconBackground, icon: current.icon }}
|
||||
onOpenChange={setIconPickerOpen}
|
||||
onSelect={(selection) => {
|
||||
if (selection.type !== 'emoji') return
|
||||
updateDraft((value) => ({
|
||||
...value,
|
||||
icon: selection.icon,
|
||||
iconBackground: selection.background,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { parseKnowledgeModelCapability, validateNewKnowledgeReturnTo } from '../routes'
|
||||
import { knowledgeSettingsSpaceIdAtom } from './state/inputs'
|
||||
import { knowledgeSettingsSettingsAtom } from './state/queries'
|
||||
|
||||
export function CapabilityReturnController() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const knowledgeSpaceId = useAtomValue(knowledgeSettingsSpaceIdAtom)
|
||||
const settings = useAtomValue(knowledgeSettingsSettingsAtom)
|
||||
const returnWasBlockedRef = useRef(false)
|
||||
const returnInitializedRef = useRef(false)
|
||||
const returnCapability = parseKnowledgeModelCapability(searchParams.get('capability'))
|
||||
const returnTo = validateNewKnowledgeReturnTo(knowledgeSpaceId, searchParams.get('returnTo'))
|
||||
|
||||
useEffect(() => {
|
||||
if (!returnCapability || !returnTo || !settings) return
|
||||
const available = settings.capabilities[returnCapability]
|
||||
if (!returnInitializedRef.current) {
|
||||
returnInitializedRef.current = true
|
||||
returnWasBlockedRef.current = !available
|
||||
return
|
||||
}
|
||||
if (returnWasBlockedRef.current && available) router.replace(returnTo)
|
||||
}, [returnCapability, returnTo, router, settings])
|
||||
|
||||
return null
|
||||
}
|
||||
131
web/features/new-rag/settings/delete-knowledge.tsx
Normal file
131
web/features/new-rag/settings/delete-knowledge.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { newKnowledgeListPath } from '../routes'
|
||||
import { knowledgeSettingsSpaceAtom } from './state/queries'
|
||||
import { knowledgeSettingsHasPendingSaveAtom } from './state/workflow'
|
||||
|
||||
export function DeleteKnowledgeAction() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const router = useRouter()
|
||||
const space = useAtomValue(knowledgeSettingsSpaceAtom)
|
||||
const hasPendingSave = useAtomValue(knowledgeSettingsHasPendingSaveAtom)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
const mutation = useMutation(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.delete.mutationOptions(),
|
||||
)
|
||||
|
||||
if (!space) return null
|
||||
const canEdit = space.permission_keys.includes('knowledge_space_edit')
|
||||
const canDelete = space.permission_keys.includes('knowledge_space_delete')
|
||||
if (!canEdit || !canDelete) return null
|
||||
const name = space.technical_summary?.name ?? ''
|
||||
|
||||
const deleteKnowledge = async () => {
|
||||
if (confirmation !== name || mutation.isPending) return
|
||||
try {
|
||||
await mutation.mutateAsync({
|
||||
params: { control_space_id: space.control_space_id },
|
||||
})
|
||||
setOpen(false)
|
||||
router.replace(newKnowledgeListPath)
|
||||
} catch {
|
||||
toast.error(tCommon(($) => $['api.actionFailed']))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-px bg-divider-subtle" />
|
||||
<div className="flex min-w-0 flex-col gap-4 pt-7 sm:flex-row sm:gap-1">
|
||||
<h2 className="flex h-8 w-full shrink-0 items-center system-sm-semibold text-text-destructive sm:w-45">
|
||||
{t(($) => $['newKnowledge.settings.dangerZone'])}
|
||||
</h2>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-4 rounded-xl border border-components-button-destructive-secondary-border px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="system-sm-medium text-text-secondary">
|
||||
{t(($) => $['newKnowledge.settings.deleteTitle'])}
|
||||
</p>
|
||||
<p className="mt-0.5 body-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.settings.deleteDescription'])}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
tone="destructive"
|
||||
disabled={hasPendingSave}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{tCommon(($) => $['operation.delete'])}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) setConfirmation('')
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent initialFocus={cancelRef}>
|
||||
<div className="px-6 pt-6">
|
||||
<AlertDialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t(($) => $['newKnowledge.settings.deleteDialogTitle'], { name })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="mt-2 body-sm-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.settings.deleteDialogDescription'])}
|
||||
</AlertDialogDescription>
|
||||
<label
|
||||
htmlFor="knowledge-delete-confirmation"
|
||||
className="mt-5 block system-sm-medium text-text-secondary"
|
||||
>
|
||||
{t(($) => $['newKnowledge.settings.deleteConfirmPrompt'], { name })}
|
||||
</label>
|
||||
<Input
|
||||
id="knowledge-delete-confirmation"
|
||||
autoComplete="off"
|
||||
name="knowledge-delete-confirmation"
|
||||
placeholder={name}
|
||||
value={confirmation}
|
||||
className="mt-2 w-full"
|
||||
onChange={(event) => setConfirmation(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton ref={cancelRef}>
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
disabled={confirmation !== name || mutation.isPending}
|
||||
loading={mutation.isPending}
|
||||
onClick={() => void deleteKnowledge()}
|
||||
>
|
||||
{tCommon(($) => $['operation.delete'])}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
158
web/features/new-rag/settings/external-access.tsx
Normal file
158
web/features/new-rag/settings/external-access.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import type { KnowledgeFsExternalAccessResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import type { ExternalAccessDraft } from './model'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { SettingsFieldRow } from './settings-field-row'
|
||||
import {
|
||||
invalidateKnowledgeSettingsAtom,
|
||||
knowledgeSettingsExternalAccessAtom,
|
||||
knowledgeSettingsSpaceAtom,
|
||||
} from './state/queries'
|
||||
import { setKnowledgeSettingsSavePendingAtom } from './state/workflow'
|
||||
|
||||
const API_ACCESS_DESCRIPTION_ID = 'knowledge-api-access-description'
|
||||
const WORKFLOW_ACCESS_DESCRIPTION_ID = 'knowledge-workflow-access-description'
|
||||
|
||||
const READ_ONLY_EXTERNAL_ACCESS: KnowledgeFsExternalAccessResponse = {
|
||||
agent_enabled: false,
|
||||
mcp_enabled: false,
|
||||
revision: 1,
|
||||
service_api_enabled: false,
|
||||
workflow_enabled: false,
|
||||
}
|
||||
|
||||
function externalAccessDraftFromServer(
|
||||
externalAccess: KnowledgeFsExternalAccessResponse,
|
||||
): ExternalAccessDraft {
|
||||
return {
|
||||
apiEnabled: externalAccess.service_api_enabled && externalAccess.agent_enabled,
|
||||
workflowEnabled: externalAccess.workflow_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
export function ExternalAccessSection() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const space = useAtomValue(knowledgeSettingsSpaceAtom)
|
||||
const externalAccess =
|
||||
useAtomValue(knowledgeSettingsExternalAccessAtom) ?? READ_ONLY_EXTERNAL_ACCESS
|
||||
const invalidateSettings = useSetAtom(invalidateKnowledgeSettingsAtom)
|
||||
const setSavePending = useSetAtom(setKnowledgeSettingsSavePendingAtom)
|
||||
const [draft, setDraft] = useState<ExternalAccessDraft>()
|
||||
const queuedDraftRef = useRef<ExternalAccessDraft | undefined>(undefined)
|
||||
const saveInFlightRef = useRef(false)
|
||||
const mutation = useMutation(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.externalAccess.put.mutationOptions(),
|
||||
)
|
||||
|
||||
if (!space) return null
|
||||
const serverDraft = externalAccessDraftFromServer(externalAccess)
|
||||
const current = draft ?? serverDraft
|
||||
const canEdit = space.permission_keys.includes('knowledge_space_edit')
|
||||
const canManageAccess = space.permission_keys.includes('knowledge_space_access_config')
|
||||
const disabled = !canEdit || !canManageAccess
|
||||
|
||||
const showSaveError = (error?: unknown) =>
|
||||
toast.error(
|
||||
error instanceof Response && error.status === 403
|
||||
? t(($) => $['newKnowledge.permissionRestricted'])
|
||||
: t(($) => $['newKnowledge.settings.saveFailed']),
|
||||
)
|
||||
|
||||
const save = async (nextDraft: ExternalAccessDraft) => {
|
||||
setDraft(nextDraft)
|
||||
if (disabled) return
|
||||
if (saveInFlightRef.current) {
|
||||
queuedDraftRef.current = nextDraft
|
||||
return
|
||||
}
|
||||
|
||||
saveInFlightRef.current = true
|
||||
setSavePending({ owner: 'external-access', pending: true })
|
||||
let pendingDraft: ExternalAccessDraft | undefined = nextDraft
|
||||
let savedDraft = nextDraft
|
||||
while (pendingDraft) {
|
||||
queuedDraftRef.current = undefined
|
||||
try {
|
||||
await mutation.mutateAsync({
|
||||
body: {
|
||||
agent_enabled: pendingDraft.apiEnabled,
|
||||
mcp_enabled: externalAccess.mcp_enabled,
|
||||
service_api_enabled: pendingDraft.apiEnabled,
|
||||
workflow_enabled: pendingDraft.workflowEnabled,
|
||||
},
|
||||
params: { control_space_id: space.control_space_id },
|
||||
})
|
||||
savedDraft = pendingDraft
|
||||
pendingDraft = queuedDraftRef.current
|
||||
} catch (error) {
|
||||
queuedDraftRef.current = undefined
|
||||
saveInFlightRef.current = false
|
||||
setSavePending({ owner: 'external-access', pending: false })
|
||||
showSaveError(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saveInFlightRef.current = false
|
||||
toast.success(tCommon(($) => $['api.actionSuccess']))
|
||||
try {
|
||||
await invalidateSettings()
|
||||
} finally {
|
||||
setDraft(savedDraft)
|
||||
setSavePending({ owner: 'external-access', pending: false })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="h-px bg-divider-subtle" />
|
||||
<SettingsFieldRow label={t(($) => $['newKnowledge.settings.apiAccessLabel'])}>
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<Switch
|
||||
aria-label={t(($) => $['newKnowledge.apiAgentAccess'])}
|
||||
aria-describedby={API_ACCESS_DESCRIPTION_ID}
|
||||
checked={current.apiEnabled}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(apiEnabled) =>
|
||||
void save({ apiEnabled, workflowEnabled: current.workflowEnabled })
|
||||
}
|
||||
/>
|
||||
<p
|
||||
id={API_ACCESS_DESCRIPTION_ID}
|
||||
className="min-w-0 flex-1 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{t(($) => $['newKnowledge.settings.apiAccessDescription'])}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsFieldRow>
|
||||
|
||||
<SettingsFieldRow label={t(($) => $['newKnowledge.settings.workflowAccessLabel'])}>
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<Switch
|
||||
aria-label={t(($) => $['newKnowledge.workflowAccess'])}
|
||||
aria-describedby={WORKFLOW_ACCESS_DESCRIPTION_ID}
|
||||
checked={current.workflowEnabled}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(workflowEnabled) =>
|
||||
void save({ apiEnabled: current.apiEnabled, workflowEnabled })
|
||||
}
|
||||
/>
|
||||
<p
|
||||
id={WORKFLOW_ACCESS_DESCRIPTION_ID}
|
||||
className="min-w-0 flex-1 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{t(($) => $['newKnowledge.settings.workflowAccessDescription'])}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsFieldRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
132
web/features/new-rag/settings/model.ts
Normal file
132
web/features/new-rag/settings/model.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import type {
|
||||
KnowledgeFsProductRetrievalProfile,
|
||||
KnowledgeFsProfileModelSelection,
|
||||
KnowledgeFsSettingsResponse,
|
||||
} from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
export const TOP_K_MIN = 1
|
||||
export const TOP_K_MAX = 10
|
||||
export const SCORE_THRESHOLD_MIN = 0
|
||||
export const SCORE_THRESHOLD_MAX = 1
|
||||
|
||||
export type ExternalAccessDraft = {
|
||||
apiEnabled: boolean
|
||||
workflowEnabled: boolean
|
||||
}
|
||||
|
||||
export type RetrievalSettingsDraft = {
|
||||
embeddingModel: DefaultModel | undefined
|
||||
reasoningModel: DefaultModel | undefined
|
||||
rerankModel: DefaultModel | undefined
|
||||
retrievalMode: KnowledgeFsProductRetrievalProfile['defaultMode']
|
||||
scoreThreshold: number
|
||||
scoreThresholdEnabled: boolean
|
||||
topK: number
|
||||
}
|
||||
|
||||
function pluginIdForModel(model: DefaultModel) {
|
||||
if (model.plugin_id) return model.plugin_id
|
||||
const [organization, pluginName] = model.provider.split('/').filter(Boolean)
|
||||
if (organization && pluginName) return `${organization}/${pluginName}`
|
||||
return model.provider ? `langgenius/${model.provider}` : ''
|
||||
}
|
||||
|
||||
function canonicalProvider(pluginId: string, provider: string) {
|
||||
if (provider.includes('/')) return provider
|
||||
return `${pluginId}/${provider}`
|
||||
}
|
||||
|
||||
function providerSlugForModel(model: DefaultModel) {
|
||||
const pluginId = pluginIdForModel(model)
|
||||
const canonicalPrefix = `${pluginId}/`
|
||||
if (model.provider.startsWith(canonicalPrefix))
|
||||
return model.provider.slice(canonicalPrefix.length)
|
||||
return model.provider.split('/').filter(Boolean).at(-1) ?? model.provider
|
||||
}
|
||||
|
||||
export function modelPayload(model: DefaultModel): KnowledgeFsProfileModelSelection {
|
||||
return {
|
||||
model: model.model,
|
||||
pluginId: pluginIdForModel(model),
|
||||
provider: providerSlugForModel(model),
|
||||
}
|
||||
}
|
||||
|
||||
export function modelFingerprint(model: DefaultModel | undefined) {
|
||||
return JSON.stringify(
|
||||
model
|
||||
? {
|
||||
model: model.model,
|
||||
pluginId: pluginIdForModel(model),
|
||||
provider: providerSlugForModel(model),
|
||||
}
|
||||
: null,
|
||||
)
|
||||
}
|
||||
|
||||
export function retrievalFingerprint({
|
||||
retrievalMode,
|
||||
reasoningModel,
|
||||
rerankModel,
|
||||
scoreThreshold,
|
||||
scoreThresholdEnabled,
|
||||
topK,
|
||||
}: RetrievalSettingsDraft) {
|
||||
return JSON.stringify({
|
||||
mode: retrievalMode,
|
||||
reasoningModel: modelFingerprint(reasoningModel),
|
||||
rerankModel: modelFingerprint(rerankModel),
|
||||
scoreThreshold,
|
||||
scoreThresholdEnabled,
|
||||
topK,
|
||||
})
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function retrievalDraftFromSettings(
|
||||
settings: KnowledgeFsSettingsResponse,
|
||||
): RetrievalSettingsDraft {
|
||||
const embedding = settings.embedding
|
||||
const retrieval = settings.retrieval
|
||||
return {
|
||||
embeddingModel: embedding
|
||||
? {
|
||||
model: embedding.model,
|
||||
plugin_id: embedding.plugin_id,
|
||||
provider: canonicalProvider(embedding.plugin_id, embedding.provider),
|
||||
}
|
||||
: undefined,
|
||||
reasoningModel: retrieval
|
||||
? {
|
||||
model: retrieval.reasoning_model.model,
|
||||
plugin_id: retrieval.reasoning_model.plugin_id,
|
||||
provider: canonicalProvider(
|
||||
retrieval.reasoning_model.plugin_id,
|
||||
retrieval.reasoning_model.provider,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
rerankModel: retrieval?.rerank.model
|
||||
? {
|
||||
model: retrieval.rerank.model.model,
|
||||
plugin_id: retrieval.rerank.model.pluginId,
|
||||
provider: canonicalProvider(
|
||||
retrieval.rerank.model.pluginId,
|
||||
retrieval.rerank.model.provider,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
retrievalMode: retrieval?.default_mode ?? 'fast',
|
||||
scoreThreshold: clamp(
|
||||
retrieval?.score_threshold.value ?? 0.5,
|
||||
SCORE_THRESHOLD_MIN,
|
||||
SCORE_THRESHOLD_MAX,
|
||||
),
|
||||
scoreThresholdEnabled: retrieval?.score_threshold.enabled ?? false,
|
||||
topK: clamp(retrieval?.top_k ?? 3, TOP_K_MIN, TOP_K_MAX),
|
||||
}
|
||||
}
|
||||
177
web/features/new-rag/settings/navigation-guard.tsx
Normal file
177
web/features/new-rag/settings/navigation-guard.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { knowledgeSettingsHasUnsavedWorkAtom } from './state/workflow'
|
||||
|
||||
export function KnowledgeSettingsNavigationGuard() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const router = useRouter()
|
||||
const hasUnsavedWork = useAtomValue(knowledgeSettingsHasUnsavedWorkAtom)
|
||||
const [discardDialogOpen, setDiscardDialogOpen] = useState(false)
|
||||
const pendingNavigationRef = useRef<string | undefined>(undefined)
|
||||
const historyGuardArmedRef = useRef(false)
|
||||
const historyGuardReleaseRef = useRef(false)
|
||||
const browserBackPendingRef = useRef(false)
|
||||
const allowNavigationRef = useRef(false)
|
||||
|
||||
const armHistoryGuard = useCallback(() => {
|
||||
globalThis.history.pushState(globalThis.history.state, '', globalThis.location.href)
|
||||
historyGuardArmedRef.current = true
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasUnsavedWork) {
|
||||
if (
|
||||
!historyGuardArmedRef.current &&
|
||||
!browserBackPendingRef.current &&
|
||||
!allowNavigationRef.current
|
||||
)
|
||||
armHistoryGuard()
|
||||
return
|
||||
}
|
||||
if (!historyGuardArmedRef.current) return
|
||||
historyGuardReleaseRef.current = true
|
||||
historyGuardArmedRef.current = false
|
||||
globalThis.history.back()
|
||||
}, [armHistoryGuard, hasUnsavedWork])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
if (historyGuardReleaseRef.current) {
|
||||
historyGuardReleaseRef.current = false
|
||||
return
|
||||
}
|
||||
if (allowNavigationRef.current) {
|
||||
const destination = pendingNavigationRef.current
|
||||
pendingNavigationRef.current = undefined
|
||||
if (destination) router.push(destination)
|
||||
return
|
||||
}
|
||||
if (!historyGuardArmedRef.current) return
|
||||
|
||||
historyGuardArmedRef.current = false
|
||||
browserBackPendingRef.current = true
|
||||
setDiscardDialogOpen(true)
|
||||
}
|
||||
|
||||
globalThis.addEventListener('popstate', handlePopState)
|
||||
return () => globalThis.removeEventListener('popstate', handlePopState)
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedWork) return
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return
|
||||
const anchor = event
|
||||
.composedPath()
|
||||
.find((target): target is HTMLAnchorElement => target instanceof HTMLAnchorElement)
|
||||
if (
|
||||
!anchor ||
|
||||
anchor.hasAttribute('download') ||
|
||||
(anchor.target && anchor.target !== '_self')
|
||||
)
|
||||
return
|
||||
|
||||
const destination = new URL(anchor.href, globalThis.location.href)
|
||||
const current = new URL(globalThis.location.href)
|
||||
if (
|
||||
destination.origin !== current.origin ||
|
||||
(destination.pathname === current.pathname &&
|
||||
destination.search === current.search &&
|
||||
destination.hash === current.hash)
|
||||
)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
pendingNavigationRef.current = `${destination.pathname}${destination.search}${destination.hash}`
|
||||
setDiscardDialogOpen(true)
|
||||
}
|
||||
|
||||
globalThis.addEventListener('beforeunload', handleBeforeUnload)
|
||||
document.addEventListener('click', handleDocumentClick, true)
|
||||
return () => {
|
||||
globalThis.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
document.removeEventListener('click', handleDocumentClick, true)
|
||||
}
|
||||
}, [hasUnsavedWork])
|
||||
|
||||
const confirmDiscardAndNavigate = () => {
|
||||
const destination = pendingNavigationRef.current
|
||||
setDiscardDialogOpen(false)
|
||||
allowNavigationRef.current = true
|
||||
if (browserBackPendingRef.current) {
|
||||
browserBackPendingRef.current = false
|
||||
pendingNavigationRef.current = undefined
|
||||
globalThis.history.back()
|
||||
return
|
||||
}
|
||||
if (destination && historyGuardArmedRef.current) {
|
||||
historyGuardArmedRef.current = false
|
||||
globalThis.history.back()
|
||||
return
|
||||
}
|
||||
pendingNavigationRef.current = undefined
|
||||
if (destination) router.push(destination)
|
||||
}
|
||||
|
||||
const closeDiscardDialog = () => {
|
||||
setDiscardDialogOpen(false)
|
||||
pendingNavigationRef.current = undefined
|
||||
if (!browserBackPendingRef.current) return
|
||||
browserBackPendingRef.current = false
|
||||
if (hasUnsavedWork) armHistoryGuard()
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={discardDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) setDiscardDialogOpen(true)
|
||||
else closeDiscardDialog()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<div className="px-6 pt-6">
|
||||
<AlertDialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{tCommon(($) => $['operation.confirmAction'])}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="mt-2 body-sm-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.discardDraftDescription'])}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>{tCommon(($) => $['operation.cancel'])}</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton tone="destructive" onClick={confirmDiscardAndNavigate}>
|
||||
{t(($) => $['newKnowledge.discardDraftConfirm'])}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@ -1,24 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import type { KnowledgeFsExternalAccessResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { parseKnowledgeModelCapability, validateNewKnowledgeReturnTo } from '../routes'
|
||||
import { CapabilityReturnController } from './capability-return-controller'
|
||||
import { KnowledgeSettingsForm } from './form'
|
||||
|
||||
const READ_ONLY_EXTERNAL_ACCESS: KnowledgeFsExternalAccessResponse = {
|
||||
agent_enabled: false,
|
||||
mcp_enabled: false,
|
||||
revision: 1,
|
||||
service_api_enabled: false,
|
||||
workflow_enabled: false,
|
||||
}
|
||||
import { KnowledgeSettingsStateBoundary } from './state/boundary'
|
||||
import {
|
||||
knowledgeSettingsHasErrorAtom,
|
||||
knowledgeSettingsIsPendingAtom,
|
||||
knowledgeSettingsSettingsAtom,
|
||||
knowledgeSettingsSpaceAtom,
|
||||
retryKnowledgeSettingsAtom,
|
||||
} from './state/queries'
|
||||
|
||||
function KnowledgeSettingsSkeleton() {
|
||||
const { t } = useTranslation('dataset')
|
||||
@ -33,35 +28,18 @@ function KnowledgeSettingsSkeleton() {
|
||||
<h2 className="flex h-8 items-center system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['newKnowledge.settings.basicInfo'])}
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex h-8 w-45 items-center system-sm-semibold text-text-secondary">
|
||||
{tSettings(($) => $['form.nameAndIcon'])}
|
||||
{[
|
||||
tSettings(($) => $['form.nameAndIcon']),
|
||||
tSettings(($) => $['form.desc']),
|
||||
tSettings(($) => $['form.permissions']),
|
||||
].map((label) => (
|
||||
<div key={label} className="flex gap-1">
|
||||
<div className="flex h-7 w-45 items-center system-sm-semibold text-text-secondary">
|
||||
{label}
|
||||
</div>
|
||||
<SkeletonRectangle className="h-9 flex-1 rounded-lg" />
|
||||
</div>
|
||||
<SkeletonRectangle className="h-8 flex-1 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex h-7 w-45 items-center system-sm-semibold text-text-secondary">
|
||||
{tSettings(($) => $['form.desc'])}
|
||||
</div>
|
||||
<SkeletonRectangle className="h-20 flex-1 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex h-7 w-45 items-center system-sm-semibold text-text-secondary">
|
||||
{tSettings(($) => $['form.permissions'])}
|
||||
</div>
|
||||
<SkeletonRectangle className="h-9 flex-1 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<SkeletonRectangle className="h-8 w-17.75 rounded-lg" />
|
||||
<SkeletonRectangle className="h-8 w-28.75 rounded-lg" />
|
||||
</div>
|
||||
<div className="h-px bg-divider-subtle" />
|
||||
<div className="flex gap-1">
|
||||
<div className="flex h-7 w-45 items-center system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['newKnowledge.settings.apiAccessLabel'])}
|
||||
</div>
|
||||
<SkeletonRectangle className="h-7 flex-1 rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
<div className="h-px bg-divider-subtle" />
|
||||
<div className="flex gap-1">
|
||||
<div className="w-45 shrink-0">
|
||||
@ -72,152 +50,32 @@ function KnowledgeSettingsSkeleton() {
|
||||
{t(($) => $['newKnowledge.settings.retrievalDescription'])}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<div>
|
||||
<div className="flex h-7 items-center system-sm-medium text-text-secondary">
|
||||
{t(($) => $['newKnowledge.settings.systemReasoningModelLabel'])}
|
||||
</div>
|
||||
<SkeletonRectangle className="h-9 w-full rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex h-7 items-center system-sm-medium text-text-secondary">
|
||||
{t(($) => $['newKnowledge.settings.embeddingModelLabel'])}
|
||||
</div>
|
||||
<SkeletonRectangle className="h-9 w-full rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex h-7 items-center system-sm-medium text-text-secondary">
|
||||
{tCommon(($) => $['modelProvider.rerankModel.key'])}
|
||||
</div>
|
||||
<SkeletonRectangle className="h-9 w-full rounded-lg" />
|
||||
</div>
|
||||
<SkeletonRectangle className="h-8 w-48 rounded-lg" />
|
||||
<div className="flex gap-4">
|
||||
<SkeletonRectangle className="h-16 flex-1 rounded-lg" />
|
||||
<SkeletonRectangle className="h-16 flex-1 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonRectangle className="h-64 flex-1 rounded-lg" />
|
||||
</div>
|
||||
<div className="h-px bg-divider-subtle" />
|
||||
<div className="flex gap-1 pt-7">
|
||||
<div className="w-45 shrink-0">
|
||||
<h2 className="flex h-8 items-center system-sm-semibold text-text-destructive">
|
||||
{t(($) => $['newKnowledge.settings.dangerZone'])}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex min-h-14.5 flex-1 items-center justify-between rounded-xl border border-components-panel-border bg-background-section px-4">
|
||||
<div>
|
||||
<p className="system-sm-medium text-text-secondary">
|
||||
{t(($) => $['newKnowledge.settings.deleteTitle'])}
|
||||
</p>
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.settings.deleteDescription'])}
|
||||
</p>
|
||||
</div>
|
||||
<SkeletonRectangle className="h-8 w-16 rounded-lg" />
|
||||
</div>
|
||||
<h2 className="flex h-8 w-45 items-center system-sm-semibold text-text-destructive">
|
||||
{t(($) => $['newKnowledge.settings.dangerZone'])}
|
||||
</h2>
|
||||
<SkeletonRectangle className="h-16 flex-1 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function KnowledgeSettingsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) {
|
||||
function KnowledgeSettingsContent() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const { t: tSettings } = useTranslation('datasetSettings')
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const returnCapability = parseKnowledgeModelCapability(searchParams.get('capability'))
|
||||
const returnTo = validateNewKnowledgeReturnTo(knowledgeSpaceId, searchParams.get('returnTo'))
|
||||
const returnWasBlockedRef = useRef(false)
|
||||
const returnInitializedRef = useRef(false)
|
||||
const spaceQuery = useQuery(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.get.queryOptions({
|
||||
input: { params: { control_space_id: knowledgeSpaceId } },
|
||||
}),
|
||||
)
|
||||
const settingsQuery = useQuery(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.settings.get.queryOptions({
|
||||
input: { params: { control_space_id: knowledgeSpaceId } },
|
||||
}),
|
||||
)
|
||||
const canManageAccess =
|
||||
spaceQuery.data?.permission_keys.includes('knowledge_space_access_config') ?? false
|
||||
const permissionsQuery = useQuery({
|
||||
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.permissions.get.queryOptions({
|
||||
input: { params: { control_space_id: knowledgeSpaceId } },
|
||||
}),
|
||||
enabled: canManageAccess,
|
||||
})
|
||||
const externalAccessQuery = useQuery({
|
||||
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.externalAccess.get.queryOptions({
|
||||
input: { params: { control_space_id: knowledgeSpaceId } },
|
||||
}),
|
||||
enabled: canManageAccess,
|
||||
})
|
||||
const membersQuery = useMembers()
|
||||
const [activeDraftVersion, setActiveDraftVersion] = useState<{
|
||||
conflict: string
|
||||
form: string
|
||||
}>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!returnCapability || !returnTo || !settingsQuery.data) return
|
||||
const available = settingsQuery.data.capabilities[returnCapability]
|
||||
if (!returnInitializedRef.current) {
|
||||
returnInitializedRef.current = true
|
||||
returnWasBlockedRef.current = !available
|
||||
return
|
||||
}
|
||||
if (returnWasBlockedRef.current && available) router.replace(returnTo)
|
||||
}, [returnCapability, returnTo, router, settingsQuery.data])
|
||||
|
||||
const isPending =
|
||||
spaceQuery.isPending ||
|
||||
settingsQuery.isPending ||
|
||||
membersQuery.isPending ||
|
||||
(canManageAccess && (permissionsQuery.isPending || externalAccessQuery.isPending))
|
||||
const hasError =
|
||||
spaceQuery.isError ||
|
||||
settingsQuery.isError ||
|
||||
membersQuery.isError ||
|
||||
(canManageAccess && (permissionsQuery.isError || externalAccessQuery.isError))
|
||||
const settingsFormVersion = [
|
||||
knowledgeSpaceId,
|
||||
spaceQuery.data?.resource_version ?? 0,
|
||||
settingsQuery.data?.revision ?? 0,
|
||||
externalAccessQuery.data?.revision ?? 0,
|
||||
permissionsQuery.data?.data
|
||||
.map((permission) => `${permission.account_id}:${permission.revision}`)
|
||||
.sort()
|
||||
.join('|') ?? '',
|
||||
].join(':')
|
||||
const basicDraftVersion = [
|
||||
knowledgeSpaceId,
|
||||
spaceQuery.data?.resource_version ?? 0,
|
||||
permissionsQuery.data?.data
|
||||
.map((permission) => `${permission.account_id}:${permission.revision}`)
|
||||
.sort()
|
||||
.join('|') ?? '',
|
||||
].join(':')
|
||||
const settingsFormKey = activeDraftVersion?.form ?? settingsFormVersion
|
||||
const serverConflict =
|
||||
activeDraftVersion !== undefined && activeDraftVersion.conflict !== basicDraftVersion
|
||||
|
||||
const retry = () => {
|
||||
const requests: Promise<unknown>[] = [
|
||||
spaceQuery.refetch(),
|
||||
settingsQuery.refetch(),
|
||||
membersQuery.refetch(),
|
||||
]
|
||||
if (canManageAccess) {
|
||||
requests.push(permissionsQuery.refetch(), externalAccessQuery.refetch())
|
||||
}
|
||||
void Promise.all(requests)
|
||||
}
|
||||
const isPending = useAtomValue(knowledgeSettingsIsPendingAtom)
|
||||
const hasError = useAtomValue(knowledgeSettingsHasErrorAtom)
|
||||
const space = useAtomValue(knowledgeSettingsSpaceAtom)
|
||||
const settings = useAtomValue(knowledgeSettingsSettingsAtom)
|
||||
const retry = useSetAtom(retryKnowledgeSettingsAtom)
|
||||
|
||||
return (
|
||||
<div className="min-h-full w-full overflow-y-auto px-6 pt-3 pb-6">
|
||||
<CapabilityReturnController />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h1 className="system-xl-semibold text-text-primary">{tSettings(($) => $.title)}</h1>
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
@ -227,7 +85,6 @@ export function KnowledgeSettingsPage({ knowledgeSpaceId }: { knowledgeSpaceId:
|
||||
|
||||
<div className="mt-3 w-full max-w-196">
|
||||
{isPending && <KnowledgeSettingsSkeleton />}
|
||||
|
||||
{!isPending && hasError && (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-xl border border-components-panel-border bg-background-section p-4"
|
||||
@ -237,28 +94,19 @@ export function KnowledgeSettingsPage({ knowledgeSpaceId }: { knowledgeSpaceId:
|
||||
<p className="min-w-0 flex-1 system-sm-regular text-text-secondary">
|
||||
{tCommon(($) => $['api.actionFailed'])}
|
||||
</p>
|
||||
<Button onClick={retry}>{tCommon(($) => $['operation.retry'])}</Button>
|
||||
<Button onClick={() => void retry()}>{tCommon(($) => $['operation.retry'])}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPending && !hasError && spaceQuery.data && settingsQuery.data && (
|
||||
<KnowledgeSettingsForm
|
||||
key={settingsFormKey}
|
||||
externalAccess={externalAccessQuery.data ?? READ_ONLY_EXTERNAL_ACCESS}
|
||||
members={membersQuery.data?.accounts ?? []}
|
||||
permissions={permissionsQuery.data?.data ?? []}
|
||||
serverConflict={serverConflict}
|
||||
settings={settingsQuery.data}
|
||||
space={spaceQuery.data}
|
||||
onDraftFinish={() => setActiveDraftVersion(undefined)}
|
||||
onDraftStart={() =>
|
||||
setActiveDraftVersion(
|
||||
(current) => current ?? { conflict: basicDraftVersion, form: settingsFormVersion },
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !hasError && space && settings && <KnowledgeSettingsForm />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function KnowledgeSettingsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) {
|
||||
return (
|
||||
<KnowledgeSettingsStateBoundary knowledgeSpaceId={knowledgeSpaceId}>
|
||||
<KnowledgeSettingsContent />
|
||||
</KnowledgeSettingsStateBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
657
web/features/new-rag/settings/retrieval-settings.tsx
Normal file
657
web/features/new-rag/settings/retrieval-settings.tsx
Normal file
@ -0,0 +1,657 @@
|
||||
'use client'
|
||||
|
||||
import type { KnowledgeFsSettingsPayload } from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import type { RetrievalSettingsDraft } from './model'
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@langgenius/dify-ui/number-field'
|
||||
import { Slider } from '@langgenius/dify-ui/slider'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { KnowledgeModelReadinessNotice } from '../components/knowledge-model-readiness-notice'
|
||||
import { RetrievalModeSegmentedControl } from '../components/retrieval-mode-segmented-control'
|
||||
import {
|
||||
modelFingerprint,
|
||||
modelPayload,
|
||||
retrievalDraftFromSettings,
|
||||
retrievalFingerprint,
|
||||
SCORE_THRESHOLD_MAX,
|
||||
SCORE_THRESHOLD_MIN,
|
||||
TOP_K_MAX,
|
||||
TOP_K_MIN,
|
||||
} from './model'
|
||||
import {
|
||||
invalidateKnowledgeSettingsAtom,
|
||||
knowledgeSettingsSettingsAtom,
|
||||
knowledgeSettingsSpaceAtom,
|
||||
} from './state/queries'
|
||||
import {
|
||||
finishKnowledgeSettingsRetrievalDraftAtom,
|
||||
setKnowledgeSettingsSavePendingAtom,
|
||||
startKnowledgeSettingsRetrievalDraftAtom,
|
||||
} from './state/workflow'
|
||||
|
||||
const REASONING_MODEL_LABEL_ID = 'knowledge-reasoning-model-label'
|
||||
const REASONING_MODEL_ERROR_ID = 'knowledge-reasoning-model-error'
|
||||
const EMBEDDING_MODEL_LABEL_ID = 'knowledge-embedding-model-label'
|
||||
const EMBEDDING_MODEL_ERROR_ID = 'knowledge-embedding-model-error'
|
||||
const RERANK_MODEL_LABEL_ID = 'knowledge-rerank-model-label'
|
||||
const RERANK_MODEL_ERROR_ID = 'knowledge-rerank-model-error'
|
||||
|
||||
export function RetrievalSettingsSection() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const { t: tSettings } = useTranslation('datasetSettings')
|
||||
const { t: tAppDebug } = useTranslation('appDebug')
|
||||
const space = useAtomValue(knowledgeSettingsSpaceAtom)
|
||||
const settings = useAtomValue(knowledgeSettingsSettingsAtom)
|
||||
const invalidateSettings = useSetAtom(invalidateKnowledgeSettingsAtom)
|
||||
const startDraftSession = useSetAtom(startKnowledgeSettingsRetrievalDraftAtom)
|
||||
const finishDraftSession = useSetAtom(finishKnowledgeSettingsRetrievalDraftAtom)
|
||||
const setSavePending = useSetAtom(setKnowledgeSettingsSavePendingAtom)
|
||||
const { data: reasoningModelList } = useModelList(ModelTypeEnum.textGeneration)
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
|
||||
const [draft, setDraft] = useState<RetrievalSettingsDraft>()
|
||||
const [pendingMigrationId, setPendingMigrationId] = useState<string>()
|
||||
const [pendingEmbeddingModel, setPendingEmbeddingModel] = useState<DefaultModel>()
|
||||
const [embeddingDialogOpen, setEmbeddingDialogOpen] = useState(false)
|
||||
const liveDraftRef = useRef<RetrievalSettingsDraft | undefined>(undefined)
|
||||
const draftSessionActiveRef = useRef(false)
|
||||
const embeddingBaselineRef = useRef<string | undefined>(undefined)
|
||||
const retrievalBaselineRef = useRef<string | undefined>(undefined)
|
||||
const settingsRevisionRef = useRef<number | undefined>(undefined)
|
||||
const queuedDraftRef = useRef<RetrievalSettingsDraft | undefined>(undefined)
|
||||
const migratingDraftRef = useRef<RetrievalSettingsDraft | undefined>(undefined)
|
||||
const activeMigrationIdRef = useRef<string | undefined>(undefined)
|
||||
const handledMigrationIdRef = useRef<string | undefined>(undefined)
|
||||
const saveInFlightRef = useRef(false)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const settingsMutation = useMutation(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.settings.patch.mutationOptions({
|
||||
context: { silent: true },
|
||||
}),
|
||||
)
|
||||
const migrationQuery = useQuery({
|
||||
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.settings.migrations.byMigrationId.get.queryOptions(
|
||||
{
|
||||
input: {
|
||||
params: {
|
||||
control_space_id: space?.control_space_id ?? 'pending',
|
||||
migration_id: pendingMigrationId ?? 'pending',
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
enabled: Boolean(space && pendingMigrationId),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.run_state === 'queued' || query.state.data?.run_state === 'running'
|
||||
? 2000
|
||||
: false,
|
||||
})
|
||||
|
||||
const showSaveSuccess = useCallback(
|
||||
() => toast.success(tCommon(($) => $['api.actionSuccess'])),
|
||||
[tCommon],
|
||||
)
|
||||
const showSaveError = useCallback(
|
||||
(error?: unknown) =>
|
||||
toast.error(
|
||||
error instanceof Response && error.status === 403
|
||||
? t(($) => $['newKnowledge.permissionRestricted'])
|
||||
: t(($) => $['newKnowledge.settings.saveFailed']),
|
||||
),
|
||||
[t],
|
||||
)
|
||||
|
||||
const beginDraft = (patch: Partial<RetrievalSettingsDraft>) => {
|
||||
if (!settings) throw new Error('Knowledge settings are unavailable')
|
||||
const serverDraft = retrievalDraftFromSettings(settings)
|
||||
const current = liveDraftRef.current ?? draft ?? serverDraft
|
||||
if (!draftSessionActiveRef.current) {
|
||||
embeddingBaselineRef.current = modelFingerprint(current.embeddingModel)
|
||||
retrievalBaselineRef.current = retrievalFingerprint(current)
|
||||
if (!draft) settingsRevisionRef.current = settings.revision
|
||||
draftSessionActiveRef.current = true
|
||||
startDraftSession()
|
||||
setSavePending({ owner: 'retrieval', pending: true })
|
||||
}
|
||||
const next = { ...current, ...patch }
|
||||
liveDraftRef.current = next
|
||||
setDraft(next)
|
||||
return next
|
||||
}
|
||||
|
||||
const saveDraft = async (nextDraft: RetrievalSettingsDraft) => {
|
||||
if (!space || !settings) return 'skipped' as const
|
||||
const nextEmbeddingFingerprint = modelFingerprint(nextDraft.embeddingModel)
|
||||
const nextRetrievalFingerprint = retrievalFingerprint(nextDraft)
|
||||
const embeddingBaseline =
|
||||
embeddingBaselineRef.current ??
|
||||
modelFingerprint(retrievalDraftFromSettings(settings).embeddingModel)
|
||||
const retrievalBaseline =
|
||||
retrievalBaselineRef.current ?? retrievalFingerprint(retrievalDraftFromSettings(settings))
|
||||
const embeddingDirty = nextEmbeddingFingerprint !== embeddingBaseline
|
||||
const retrievalDirty = nextRetrievalFingerprint !== retrievalBaseline
|
||||
const initialModelSetup = !settings.active_profile_available
|
||||
const invalid =
|
||||
(embeddingDirty && !nextDraft.embeddingModel) ||
|
||||
(retrievalDirty && (!nextDraft.reasoningModel || !nextDraft.rerankModel)) ||
|
||||
(initialModelSetup && retrievalDirty && !nextDraft.embeddingModel) ||
|
||||
(!initialModelSetup && embeddingDirty && retrievalDirty)
|
||||
if (invalid || (!embeddingDirty && !retrievalDirty)) return 'skipped' as const
|
||||
|
||||
const body: KnowledgeFsSettingsPayload = {
|
||||
expectedRevision: settingsRevisionRef.current ?? settings.revision,
|
||||
}
|
||||
if (embeddingDirty && nextDraft.embeddingModel)
|
||||
body.embedding = modelPayload(nextDraft.embeddingModel)
|
||||
if (retrievalDirty && nextDraft.reasoningModel && nextDraft.rerankModel) {
|
||||
body.retrieval = {
|
||||
defaultMode: nextDraft.retrievalMode,
|
||||
reasoningModel: modelPayload(nextDraft.reasoningModel),
|
||||
rerank: {
|
||||
enabled: true,
|
||||
model: modelPayload(nextDraft.rerankModel),
|
||||
},
|
||||
scoreThreshold: {
|
||||
enabled: nextDraft.scoreThresholdEnabled,
|
||||
stage: nextDraft.retrievalMode === 'research' ? 'mode-final' : 'rerank',
|
||||
value: nextDraft.scoreThreshold,
|
||||
},
|
||||
topK: nextDraft.topK,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await settingsMutation.mutateAsync({
|
||||
body,
|
||||
params: { control_space_id: space.control_space_id },
|
||||
})
|
||||
settingsRevisionRef.current = result.settings.revision
|
||||
if (result.migration) {
|
||||
migratingDraftRef.current = nextDraft
|
||||
activeMigrationIdRef.current = result.migration.id
|
||||
handledMigrationIdRef.current = undefined
|
||||
setPendingMigrationId(result.migration.id)
|
||||
return 'migration' as const
|
||||
}
|
||||
if (embeddingDirty) embeddingBaselineRef.current = nextEmbeddingFingerprint
|
||||
if (retrievalDirty) retrievalBaselineRef.current = nextRetrievalFingerprint
|
||||
await invalidateSettings()
|
||||
return 'saved' as const
|
||||
} catch (error) {
|
||||
showSaveError(error)
|
||||
return 'failed' as const
|
||||
}
|
||||
}
|
||||
|
||||
const performSave = async (nextDraft: RetrievalSettingsDraft) => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
if (!space?.permission_keys.includes('knowledge_space_edit')) return
|
||||
if (activeMigrationIdRef.current || saveInFlightRef.current) {
|
||||
queuedDraftRef.current = nextDraft
|
||||
return
|
||||
}
|
||||
|
||||
saveInFlightRef.current = true
|
||||
let candidate: RetrievalSettingsDraft | undefined = nextDraft
|
||||
let result: Awaited<ReturnType<typeof saveDraft>> = 'skipped'
|
||||
let didSave = false
|
||||
try {
|
||||
while (candidate) {
|
||||
queuedDraftRef.current = undefined
|
||||
result = await saveDraft(candidate)
|
||||
if (result === 'saved') didSave = true
|
||||
if (result === 'failed' || result === 'migration') break
|
||||
candidate = queuedDraftRef.current
|
||||
}
|
||||
} finally {
|
||||
saveInFlightRef.current = false
|
||||
}
|
||||
|
||||
if (result === 'failed' || result === 'migration') {
|
||||
if (result === 'failed') setSavePending({ owner: 'retrieval', pending: false })
|
||||
return
|
||||
}
|
||||
const latestDraft = liveDraftRef.current
|
||||
const latestSaved =
|
||||
latestDraft &&
|
||||
modelFingerprint(latestDraft.embeddingModel) === embeddingBaselineRef.current &&
|
||||
retrievalFingerprint(latestDraft) === retrievalBaselineRef.current
|
||||
if (!latestSaved) {
|
||||
setSavePending({ owner: 'retrieval', pending: false })
|
||||
return
|
||||
}
|
||||
if (didSave) showSaveSuccess()
|
||||
liveDraftRef.current = latestDraft
|
||||
if (latestDraft) setDraft(latestDraft)
|
||||
draftSessionActiveRef.current = false
|
||||
finishDraftSession()
|
||||
setSavePending({ owner: 'retrieval', pending: false })
|
||||
}
|
||||
|
||||
const scheduleSave = (nextDraft: RetrievalSettingsDraft) => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => void performSave(nextDraft), 400)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingMigrationId || handledMigrationIdRef.current === pendingMigrationId) return
|
||||
const migration = migrationQuery.data
|
||||
if (migration?.run_state === 'queued' || migration?.run_state === 'running') return
|
||||
|
||||
if (migration?.run_state === 'succeeded') {
|
||||
handledMigrationIdRef.current = pendingMigrationId
|
||||
const savedDraft = migratingDraftRef.current
|
||||
if (savedDraft) {
|
||||
embeddingBaselineRef.current = modelFingerprint(savedDraft.embeddingModel)
|
||||
retrievalBaselineRef.current = retrievalFingerprint(savedDraft)
|
||||
}
|
||||
void invalidateSettings().then(() => {
|
||||
migratingDraftRef.current = undefined
|
||||
activeMigrationIdRef.current = undefined
|
||||
setPendingMigrationId(undefined)
|
||||
const queuedDraft = queuedDraftRef.current
|
||||
queuedDraftRef.current = undefined
|
||||
if (queuedDraft) {
|
||||
void performSave(queuedDraft)
|
||||
return
|
||||
}
|
||||
const latestDraft = liveDraftRef.current
|
||||
if (latestDraft) setDraft(latestDraft)
|
||||
draftSessionActiveRef.current = false
|
||||
finishDraftSession()
|
||||
setSavePending({ owner: 'retrieval', pending: false })
|
||||
showSaveSuccess()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
migrationQuery.isError ||
|
||||
migration?.run_state === 'failed' ||
|
||||
migration?.run_state === 'canceled'
|
||||
) {
|
||||
handledMigrationIdRef.current = pendingMigrationId
|
||||
migratingDraftRef.current = undefined
|
||||
activeMigrationIdRef.current = undefined
|
||||
// oxlint-disable-next-line eslint-react/set-state-in-effect -- A terminal remote migration retires the local polling session.
|
||||
setPendingMigrationId(undefined)
|
||||
setSavePending({ owner: 'retrieval', pending: false })
|
||||
showSaveError()
|
||||
}
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Migration completion is keyed by the observer state; queued saves use the latest ref snapshot.
|
||||
}, [
|
||||
finishDraftSession,
|
||||
invalidateSettings,
|
||||
migrationQuery.data,
|
||||
migrationQuery.isError,
|
||||
pendingMigrationId,
|
||||
setSavePending,
|
||||
showSaveError,
|
||||
showSaveSuccess,
|
||||
])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
if (!space || !settings) return null
|
||||
const current = draft ?? retrievalDraftFromSettings(settings)
|
||||
const canEdit = space.permission_keys.includes('knowledge_space_edit')
|
||||
const initialModelSetup = !settings.active_profile_available
|
||||
const embeddingDirty = draft
|
||||
? modelFingerprint(current.embeddingModel) !== embeddingBaselineRef.current
|
||||
: false
|
||||
const retrievalDirty = draft
|
||||
? retrievalFingerprint(current) !== retrievalBaselineRef.current
|
||||
: false
|
||||
const retrievalFieldsDisabled = !canEdit || (!initialModelSetup && embeddingDirty)
|
||||
const readinessFieldLabel = (field: (typeof settings.issues)[number]['field']) => {
|
||||
if (field === 'embedding') return tSettings(($) => $['form.embeddingModel'])
|
||||
if (field === 'reasoning') return tCommon(($) => $['modelProvider.systemReasoningModel.key'])
|
||||
if (field === 'rerank') return tCommon(($) => $['modelProvider.rerankModel.key'])
|
||||
return t(($) => $['newKnowledge.overview.attention.modelReadiness.bindingMissing'])
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{settings.configuration_state !== 'pending-validation' &&
|
||||
(settings.configuration_state !== 'active' || settings.issues.length > 0) && (
|
||||
<KnowledgeModelReadinessNotice
|
||||
className="mb-3"
|
||||
description={
|
||||
settings.issues.length > 0
|
||||
? settings.issues.map(({ field }) => readinessFieldLabel(field)).join(' · ')
|
||||
: settings.active_profile_available
|
||||
? t(($) => $['newKnowledge.overview.attention.modelReadiness.description'])
|
||||
: undefined
|
||||
}
|
||||
title={
|
||||
settings.configuration_state === 'validation-failed'
|
||||
? tCommon(($) => $['api.actionFailed'])
|
||||
: tCommon(($) => $['modelProvider.toBeConfigured'])
|
||||
}
|
||||
tone={settings.configuration_state === 'validation-failed' ? 'destructive' : 'warning'}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-divider-subtle" />
|
||||
<section className="flex min-w-0 flex-col gap-4 sm:flex-row sm:gap-1">
|
||||
<div className="w-full shrink-0 sm:w-45">
|
||||
<h2 className="flex h-8 items-center system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['newKnowledge.settings.retrievalTitle'])}
|
||||
</h2>
|
||||
<p className="body-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.settings.retrievalDescription'])}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3.5">
|
||||
<div>
|
||||
<div
|
||||
id={REASONING_MODEL_LABEL_ID}
|
||||
className="flex h-7 items-center system-sm-medium text-text-secondary"
|
||||
>
|
||||
{t(($) => $['newKnowledge.settings.systemReasoningModelLabel'])}
|
||||
<span aria-hidden className="ml-0.5 text-text-destructive">
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<ModelSelector
|
||||
ariaDescribedBy={!current.reasoningModel ? REASONING_MODEL_ERROR_ID : undefined}
|
||||
ariaInvalid={!current.reasoningModel}
|
||||
ariaLabelledBy={REASONING_MODEL_LABEL_ID}
|
||||
ariaRequired
|
||||
value={current.reasoningModel}
|
||||
models={reasoningModelList}
|
||||
disabled={retrievalFieldsDisabled}
|
||||
className="w-full"
|
||||
onValueChange={(reasoningModel) => {
|
||||
const next = beginDraft({ reasoningModel })
|
||||
void performSave(next)
|
||||
}}
|
||||
/>
|
||||
{!current.reasoningModel && (
|
||||
<p
|
||||
id={REASONING_MODEL_ERROR_ID}
|
||||
className="mt-1 system-xs-regular text-text-destructive"
|
||||
>
|
||||
{t(($) => $['newKnowledge.settings.systemReasoningModelRequired'])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
id={EMBEDDING_MODEL_LABEL_ID}
|
||||
className="flex h-7 items-center system-sm-medium text-text-secondary"
|
||||
>
|
||||
{t(($) => $['newKnowledge.settings.embeddingModelLabel'])}
|
||||
<span aria-hidden className="ml-0.5 text-text-destructive">
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<ModelSelector
|
||||
ariaDescribedBy={!current.embeddingModel ? EMBEDDING_MODEL_ERROR_ID : undefined}
|
||||
ariaInvalid={!current.embeddingModel}
|
||||
ariaLabelledBy={EMBEDDING_MODEL_LABEL_ID}
|
||||
ariaRequired
|
||||
value={current.embeddingModel}
|
||||
models={embeddingModelList}
|
||||
disabled={!canEdit || (!initialModelSetup && retrievalDirty)}
|
||||
className="w-full"
|
||||
onValueChange={(model) => {
|
||||
if ((space.technical_summary?.document_count ?? 0) > 0) {
|
||||
setPendingEmbeddingModel(model)
|
||||
setEmbeddingDialogOpen(true)
|
||||
return
|
||||
}
|
||||
const next = beginDraft({ embeddingModel: model })
|
||||
void performSave(next)
|
||||
}}
|
||||
/>
|
||||
{!current.embeddingModel && (
|
||||
<p
|
||||
id={EMBEDDING_MODEL_ERROR_ID}
|
||||
className="mt-1 system-xs-regular text-text-destructive"
|
||||
>
|
||||
{t(($) => $['newKnowledge.settings.embeddingModelRequired'])}
|
||||
</p>
|
||||
)}
|
||||
{embeddingDirty && (space.technical_summary?.document_count ?? 0) > 0 && (
|
||||
<p className="mt-1 flex items-start gap-1 system-xs-regular text-text-warning-secondary">
|
||||
<span aria-hidden className="mt-0.5 i-ri-alert-fill size-3.5 shrink-0" />
|
||||
{t(($) => $['newKnowledge.settings.embeddingChangeWarning'])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
id={RERANK_MODEL_LABEL_ID}
|
||||
className="flex h-7 items-center system-sm-medium text-text-secondary"
|
||||
>
|
||||
{tCommon(($) => $['modelProvider.rerankModel.key'])}
|
||||
<span aria-hidden className="ml-0.5 text-text-destructive">
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<ModelSelector
|
||||
ariaDescribedBy={!current.rerankModel ? RERANK_MODEL_ERROR_ID : undefined}
|
||||
ariaInvalid={!current.rerankModel}
|
||||
ariaLabelledBy={RERANK_MODEL_LABEL_ID}
|
||||
ariaRequired
|
||||
value={current.rerankModel}
|
||||
models={rerankModelList}
|
||||
disabled={retrievalFieldsDisabled}
|
||||
className="w-full"
|
||||
onValueChange={(rerankModel) => {
|
||||
const next = beginDraft({ rerankModel })
|
||||
void performSave(next)
|
||||
}}
|
||||
/>
|
||||
{!current.rerankModel && (
|
||||
<p
|
||||
id={RERANK_MODEL_ERROR_ID}
|
||||
className="mt-1 system-xs-regular text-text-destructive"
|
||||
>
|
||||
{t(($) => $['newKnowledge.settings.rerankModelRequired'])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
id="knowledge-retrieval-depth-label"
|
||||
className="flex h-7 items-center system-sm-medium text-text-secondary"
|
||||
>
|
||||
{t(($) => $['newKnowledge.settings.retrievalDepth'])}
|
||||
</div>
|
||||
<RetrievalModeSegmentedControl
|
||||
aria-labelledby="knowledge-retrieval-depth-label"
|
||||
disabled={retrievalFieldsDisabled}
|
||||
value={current.retrievalMode}
|
||||
onChange={(retrievalMode) => {
|
||||
const next = beginDraft({ retrievalMode })
|
||||
void performSave(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
htmlFor="knowledge-top-k"
|
||||
className="flex h-7 items-center system-sm-medium text-text-secondary"
|
||||
>
|
||||
{t(($) => $['newKnowledge.settings.topKLabel'])}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<NumberField
|
||||
id="knowledge-top-k"
|
||||
min={TOP_K_MIN}
|
||||
max={TOP_K_MAX}
|
||||
step={1}
|
||||
value={current.topK}
|
||||
disabled={retrievalFieldsDisabled}
|
||||
onValueChange={(value) => {
|
||||
const next = beginDraft({ topK: value ?? TOP_K_MIN })
|
||||
scheduleSave(next)
|
||||
}}
|
||||
onValueCommitted={() => {
|
||||
if (liveDraftRef.current) void performSave(liveDraftRef.current)
|
||||
}}
|
||||
>
|
||||
<NumberFieldGroup className="w-18 shrink-0">
|
||||
<NumberFieldInput
|
||||
aria-label={t(($) => $['newKnowledge.settings.topKLabel'])}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement />
|
||||
<NumberFieldDecrement />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
<Slider
|
||||
aria-label={t(($) => $['newKnowledge.settings.topKLabel'])}
|
||||
min={TOP_K_MIN}
|
||||
max={TOP_K_MAX}
|
||||
value={current.topK}
|
||||
disabled={retrievalFieldsDisabled}
|
||||
onValueChange={(topK) => scheduleSave(beginDraft({ topK }))}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.settings.topKMinimum'])}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex h-7 items-center gap-2">
|
||||
<Switch
|
||||
aria-label={tAppDebug(($) => $['datasetConfig.score_threshold'])}
|
||||
checked={current.scoreThresholdEnabled}
|
||||
disabled={retrievalFieldsDisabled || !current.rerankModel}
|
||||
onCheckedChange={(scoreThresholdEnabled) => {
|
||||
const next = beginDraft({ scoreThresholdEnabled })
|
||||
void performSave(next)
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="knowledge-score-threshold"
|
||||
className="system-sm-medium text-text-secondary"
|
||||
>
|
||||
{tAppDebug(($) => $['datasetConfig.score_threshold'])}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<NumberField
|
||||
id="knowledge-score-threshold"
|
||||
min={SCORE_THRESHOLD_MIN}
|
||||
max={SCORE_THRESHOLD_MAX}
|
||||
step={0.01}
|
||||
value={current.scoreThreshold}
|
||||
disabled={retrievalFieldsDisabled || !current.scoreThresholdEnabled}
|
||||
onValueChange={(value) =>
|
||||
scheduleSave(beginDraft({ scoreThreshold: value ?? SCORE_THRESHOLD_MIN }))
|
||||
}
|
||||
onValueCommitted={() => {
|
||||
if (liveDraftRef.current) void performSave(liveDraftRef.current)
|
||||
}}
|
||||
>
|
||||
<NumberFieldGroup className="w-18 shrink-0">
|
||||
<NumberFieldInput
|
||||
aria-label={tAppDebug(($) => $['datasetConfig.score_threshold'])}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement />
|
||||
<NumberFieldDecrement />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
<Slider
|
||||
aria-label={tAppDebug(($) => $['datasetConfig.score_threshold'])}
|
||||
min={SCORE_THRESHOLD_MIN}
|
||||
max={SCORE_THRESHOLD_MAX}
|
||||
step={0.01}
|
||||
value={current.scoreThreshold}
|
||||
disabled={retrievalFieldsDisabled || !current.scoreThresholdEnabled}
|
||||
onValueChange={(scoreThreshold) => scheduleSave(beginDraft({ scoreThreshold }))}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.settings.scoreRange'])}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AlertDialog
|
||||
open={embeddingDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setEmbeddingDialogOpen(open)
|
||||
if (!open) setPendingEmbeddingModel(undefined)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<div className="px-6 pt-6">
|
||||
<AlertDialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{tSettings(($) => $['form.embeddingModel'])}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="mt-2 body-sm-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.settings.embeddingChangeWarning'])}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton onClick={() => setPendingEmbeddingModel(undefined)}>
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
tone="default"
|
||||
onClick={() => {
|
||||
const model = pendingEmbeddingModel
|
||||
setEmbeddingDialogOpen(false)
|
||||
setPendingEmbeddingModel(undefined)
|
||||
if (!model) return
|
||||
const next = beginDraft({ embeddingModel: model })
|
||||
void performSave(next)
|
||||
}}
|
||||
>
|
||||
{tCommon(($) => $['operation.confirm'])}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
12
web/features/new-rag/settings/settings-field-row.tsx
Normal file
12
web/features/new-rag/settings/settings-field-row.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function SettingsFieldRow({ children, label }: { children: ReactNode; label: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:gap-1">
|
||||
<div className="flex h-7 w-full shrink-0 items-center pt-1 system-sm-semibold text-text-secondary sm:w-45">
|
||||
{label}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
web/features/new-rag/settings/state/boundary.tsx
Normal file
29
web/features/new-rag/settings/state/boundary.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useHydrateAtoms } from 'jotai/utils'
|
||||
import { knowledgeSettingsSpaceIdAtom } from './inputs'
|
||||
import { knowledgeSettingsScopedAtoms } from './workflow'
|
||||
|
||||
export function KnowledgeSettingsStateBoundary({
|
||||
children,
|
||||
knowledgeSpaceId,
|
||||
}: {
|
||||
children: ReactNode
|
||||
knowledgeSpaceId: string
|
||||
}) {
|
||||
useHydrateAtoms([[knowledgeSettingsSpaceIdAtom, knowledgeSpaceId]], {
|
||||
dangerouslyForceHydrate: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<ScopeProvider
|
||||
key={knowledgeSpaceId}
|
||||
atoms={knowledgeSettingsScopedAtoms}
|
||||
name="KnowledgeSettingsPage"
|
||||
>
|
||||
{children}
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
5
web/features/new-rag/settings/state/inputs.ts
Normal file
5
web/features/new-rag/settings/state/inputs.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { atomWithLazy } from 'jotai/utils'
|
||||
|
||||
export const knowledgeSettingsSpaceIdAtom = atomWithLazy<string>(() => {
|
||||
throw new Error('Missing knowledge settings space id')
|
||||
})
|
||||
88
web/features/new-rag/settings/state/queries.ts
Normal file
88
web/features/new-rag/settings/state/queries.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithQuery } from 'jotai-tanstack-query'
|
||||
import { selectAtom } from 'jotai/utils'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { knowledgeSettingsSpaceIdAtom } from './inputs'
|
||||
|
||||
const spaceQueryAtom = atomWithQuery((get) =>
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.get.queryOptions({
|
||||
input: { params: { control_space_id: get(knowledgeSettingsSpaceIdAtom) } },
|
||||
}),
|
||||
)
|
||||
|
||||
const settingsQueryAtom = atomWithQuery((get) =>
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.settings.get.queryOptions({
|
||||
input: { params: { control_space_id: get(knowledgeSettingsSpaceIdAtom) } },
|
||||
}),
|
||||
)
|
||||
|
||||
export const knowledgeSettingsSpaceAtom = selectAtom(spaceQueryAtom, (query) => query.data)
|
||||
export const knowledgeSettingsSettingsAtom = selectAtom(settingsQueryAtom, (query) => query.data)
|
||||
|
||||
export const knowledgeSettingsCanManageAccessAtom = atom(
|
||||
(get) =>
|
||||
get(knowledgeSettingsSpaceAtom)?.permission_keys.includes('knowledge_space_access_config') ??
|
||||
false,
|
||||
)
|
||||
|
||||
const permissionsQueryAtom = atomWithQuery((get) => ({
|
||||
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.permissions.get.queryOptions({
|
||||
input: { params: { control_space_id: get(knowledgeSettingsSpaceIdAtom) } },
|
||||
}),
|
||||
enabled: get(knowledgeSettingsCanManageAccessAtom),
|
||||
}))
|
||||
|
||||
const externalAccessQueryAtom = atomWithQuery((get) => ({
|
||||
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.externalAccess.get.queryOptions({
|
||||
input: { params: { control_space_id: get(knowledgeSettingsSpaceIdAtom) } },
|
||||
}),
|
||||
enabled: get(knowledgeSettingsCanManageAccessAtom),
|
||||
}))
|
||||
|
||||
export const knowledgeSettingsPermissionsAtom = selectAtom(
|
||||
permissionsQueryAtom,
|
||||
(query) => query.data?.data ?? [],
|
||||
)
|
||||
export const knowledgeSettingsExternalAccessAtom = selectAtom(
|
||||
externalAccessQueryAtom,
|
||||
(query) => query.data,
|
||||
)
|
||||
|
||||
export const knowledgeSettingsIsPendingAtom = atom((get) => {
|
||||
const canManageAccess = get(knowledgeSettingsCanManageAccessAtom)
|
||||
return (
|
||||
get(spaceQueryAtom).isPending ||
|
||||
get(settingsQueryAtom).isPending ||
|
||||
(canManageAccess &&
|
||||
(get(permissionsQueryAtom).isPending || get(externalAccessQueryAtom).isPending))
|
||||
)
|
||||
})
|
||||
|
||||
export const knowledgeSettingsHasErrorAtom = atom((get) => {
|
||||
const canManageAccess = get(knowledgeSettingsCanManageAccessAtom)
|
||||
return (
|
||||
get(spaceQueryAtom).isError ||
|
||||
get(settingsQueryAtom).isError ||
|
||||
(canManageAccess && (get(permissionsQueryAtom).isError || get(externalAccessQueryAtom).isError))
|
||||
)
|
||||
})
|
||||
|
||||
export const retryKnowledgeSettingsAtom = atom(null, async (get) => {
|
||||
const requests: Promise<unknown>[] = [
|
||||
get(spaceQueryAtom).refetch(),
|
||||
get(settingsQueryAtom).refetch(),
|
||||
]
|
||||
if (get(knowledgeSettingsCanManageAccessAtom))
|
||||
requests.push(get(permissionsQueryAtom).refetch(), get(externalAccessQueryAtom).refetch())
|
||||
await Promise.all(requests)
|
||||
})
|
||||
|
||||
export const invalidateKnowledgeSettingsAtom = atom(null, async (get) => {
|
||||
const requests: Promise<unknown>[] = [
|
||||
get(spaceQueryAtom).refetch(),
|
||||
get(settingsQueryAtom).refetch(),
|
||||
]
|
||||
if (get(knowledgeSettingsCanManageAccessAtom))
|
||||
requests.push(get(permissionsQueryAtom).refetch(), get(externalAccessQueryAtom).refetch())
|
||||
await Promise.all(requests)
|
||||
})
|
||||
47
web/features/new-rag/settings/state/workflow.ts
Normal file
47
web/features/new-rag/settings/state/workflow.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
const basicDraftActiveAtom = atom(false)
|
||||
const retrievalDraftActiveAtom = atom(false)
|
||||
const pendingSaveOwnersAtom = atom<Set<string>>(new globalThis.Set<string>())
|
||||
|
||||
export const knowledgeSettingsScopedAtoms = [
|
||||
basicDraftActiveAtom,
|
||||
retrievalDraftActiveAtom,
|
||||
pendingSaveOwnersAtom,
|
||||
] as const
|
||||
|
||||
export const knowledgeSettingsHasUnsavedWorkAtom = atom(
|
||||
(get) => get(basicDraftActiveAtom) || get(retrievalDraftActiveAtom),
|
||||
)
|
||||
|
||||
export const knowledgeSettingsHasPendingSaveAtom = atom(
|
||||
(get) => get(pendingSaveOwnersAtom).size > 0,
|
||||
)
|
||||
|
||||
export const setKnowledgeSettingsSavePendingAtom = atom(
|
||||
null,
|
||||
(_get, set, { owner, pending }: { owner: string; pending: boolean }) => {
|
||||
set(pendingSaveOwnersAtom, (current) => {
|
||||
const owners = new globalThis.Set(current)
|
||||
if (pending) owners.add(owner)
|
||||
else owners.delete(owner)
|
||||
return owners
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const startKnowledgeSettingsBasicDraftAtom = atom(null, (_get, set) => {
|
||||
set(basicDraftActiveAtom, true)
|
||||
})
|
||||
|
||||
export const finishKnowledgeSettingsBasicDraftAtom = atom(null, (_get, set) => {
|
||||
set(basicDraftActiveAtom, false)
|
||||
})
|
||||
|
||||
export const startKnowledgeSettingsRetrievalDraftAtom = atom(null, (_get, set) => {
|
||||
set(retrievalDraftActiveAtom, true)
|
||||
})
|
||||
|
||||
export const finishKnowledgeSettingsRetrievalDraftAtom = atom(null, (_get, set) => {
|
||||
set(retrievalDraftActiveAtom, false)
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user