refactor(knowledge-fs): reorganize settings ownership

This commit is contained in:
Stephen Zhou 2026-09-01 16:46:05 +08:00
parent a1f6a28a1c
commit fa3e52fa9e
No known key found for this signature in database
17 changed files with 2283 additions and 2012 deletions

View File

@ -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()
})

View File

@ -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(() =>

View File

@ -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()
})
})

View 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,
}))
}}
/>
</>
)
}

View File

@ -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
}

View 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>
</>
)
}

View 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

View 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),
}
}

View 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>
)
}

View File

@ -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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -0,0 +1,5 @@
import { atomWithLazy } from 'jotai/utils'
export const knowledgeSettingsSpaceIdAtom = atomWithLazy<string>(() => {
throw new Error('Missing knowledge settings space id')
})

View 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)
})

View 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)
})