fix(dataset): align New RAG acceptance flows

This commit is contained in:
Stephen Zhou 2026-07-29 22:41:42 +08:00
parent effcefca34
commit 2f892071d2
No known key found for this signature in database
28 changed files with 801 additions and 195 deletions

View File

@ -17,6 +17,7 @@ AUTHENTICATED_HEADERS: tuple[str, ...] = (
HEADER_NAME_CSRF_TOKEN,
HEADER_NAME_IDEMPOTENCY_KEY,
HEADER_NAME_REQUEST_ID,
"X-Trace-ID",
)
FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN)
EMBED_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE)

View File

@ -25,7 +25,7 @@ def test_authenticated_cors_allows_request_metadata_headers() -> None:
response = app.test_client().options(
"/console/api/probe",
headers={
"Access-Control-Request-Headers": "Idempotency-Key, X-Request-ID",
"Access-Control-Request-Headers": "Idempotency-Key, X-Request-ID, X-Trace-ID",
"Access-Control-Request-Method": "POST",
"Origin": "http://localhost:3000",
},
@ -34,3 +34,4 @@ def test_authenticated_cors_allows_request_metadata_headers() -> None:
allowed_headers = response.headers.get("Access-Control-Allow-Headers", "").lower()
assert "idempotency-key" in allowed_headers
assert "x-request-id" in allowed_headers
assert "x-trace-id" in allowed_headers

View File

@ -814,8 +814,7 @@ describe('AddSourcePage', () => {
expect(screen.queryByDisplayValue('secret-value')).not.toBeInTheDocument()
})
it('binds the default Dify Firecrawl credential for the real KnowledgeFS provider', async () => {
const user = userEvent.setup()
it('automatically binds the default Dify Firecrawl credential for the real KnowledgeFS provider', async () => {
queryState.providers.data = { items: [difyManagedFirecrawlProvider] }
queryState.datasourceAuth.data = { result: [firecrawlDatasourceAuth] }
clientMock.createConnection.mockResolvedValue({
@ -831,7 +830,6 @@ describe('AddSourcePage', () => {
})
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: connectFirecrawlButtonName }))
await waitFor(() =>
expect(clientMock.createConnection).toHaveBeenCalledWith({
@ -851,6 +849,9 @@ describe('AddSourcePage', () => {
params: { control_space_id: 'space-1' },
}),
)
expect(
screen.queryByRole('button', { name: connectFirecrawlButtonName }),
).not.toBeInTheDocument()
expect(screen.queryByLabelText(/Api Key/)).not.toBeInTheDocument()
})

View File

@ -14,7 +14,7 @@ type BulkDocumentReindexResult = {
bulkJobId: string
items: Array<{
asset?: unknown
compilationJob?: unknown
compilation_job?: { id: string; stage: 'queued' }
documentId?: string
status: 'not_found' | 'queued'
statusUrl?: string
@ -420,7 +420,7 @@ const queuedReindexResult = (): BulkDocumentReindexResult => ({
sizeBytes: 1200,
version: 2,
},
compilationJob: { id: 'compilation-job-1', stage: 'queued' },
compilation_job: { id: 'compilation-job-1', stage: 'queued' },
status: 'queued',
statusUrl: '/knowledge-fs/status/compilation-job-1',
},
@ -478,7 +478,6 @@ describe('DocumentDetailPage', () => {
})
it('loads the document, revisions, chunks, and task status through generated contracts', async () => {
const user = userEvent.setup()
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
expect(documentOptions).toHaveBeenCalledWith(
@ -505,17 +504,9 @@ describe('DocumentDetailPage', () => {
params: { control_space_id: 'space-1' },
query: { limit: 100 },
})
await user.click(
screen.getByRole('button', {
name: /dataset\.newKnowledge\.documentActions/,
}),
)
expect(screen.getAllByRole('menuitem')).toHaveLength(5)
expect(screen.getByRole('menuitem', { name: 'common.operation.rename' })).toBeInTheDocument()
expect(
screen.queryByRole('menuitem', { name: 'dataset.newKnowledge.reindexDocument' }),
screen.queryByRole('button', { name: /dataset\.newKnowledge\.documentActions/ }),
).not.toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'common.operation.delete' })).toBeInTheDocument()
})
it('does not construct a chunks request while the document is loading', () => {
@ -1366,7 +1357,17 @@ describe('DocumentDetailPage', () => {
await waitFor(() => expect(toastState.success).toHaveBeenCalled())
tasksQuery.data = {
pages: [{ items: [task({ documentRevision: 4, state: 'running' })] }],
pages: [
{
items: [
task({
documentRevision: 3,
id: 'compilation-job-1',
state: 'running',
}),
],
},
],
}
rendered.rerender(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
@ -1398,6 +1399,47 @@ describe('DocumentDetailPage', () => {
).toBe(5000)
})
it('recognizes the accepted re-index task when it recompiles the active revision', async () => {
vi.useFakeTimers()
try {
const rendered = render(
<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />,
)
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocument' }),
)
await Promise.resolve()
await Promise.resolve()
})
tasksQuery.data = {
pages: [
{
items: [
task({
documentRevision: 3,
id: 'compilation-job-1',
state: 'running',
}),
],
},
],
}
rendered.rerender(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocument' }),
).toHaveAttribute('data-disabled')
await act(() => vi.advanceTimersByTimeAsync(30000))
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.retryReindexDocument' }),
).not.toBeInTheDocument()
} finally {
vi.useRealTimers()
}
})
it('surfaces unified task-list authorization failures and blocks re-indexing', () => {
tasksQuery.data = undefined
tasksQuery.error = { status: 403 }

View File

@ -731,17 +731,8 @@ describe('DocumentsPage', () => {
expect(rowActions).toBeEnabled()
await user.click(rowActions)
const rowMenuItems = await screen.findAllByRole('menuitem')
expect(rowMenuItems).toHaveLength(6)
expect(rowMenuItems[0]).toHaveAccessibleName('common.operation.rename')
expect(rowMenuItems[1]).toHaveAccessibleName('dataset.newKnowledge.reindexDocument')
expect(rowMenuItems[2]).toHaveAccessibleName('dataset.newKnowledge.disableSource')
expect(rowMenuItems[3]).toHaveAccessibleName('dataset.batchAction.archive')
expect(rowMenuItems[4]).toHaveAccessibleName('dataset.newKnowledge.downloadDocuments')
expect(rowMenuItems[5]).toHaveAccessibleName('common.operation.delete')
await user.click(
screen.getByRole('menuitem', { name: 'dataset.newKnowledge.downloadDocuments' }),
)
expect(toastMock.info).toHaveBeenCalledWith('dataset.newKnowledge.documentActionsUnavailable')
expect(rowMenuItems).toHaveLength(1)
expect(rowMenuItems[0]).toHaveAccessibleName('dataset.newKnowledge.reindexDocument')
expect(screen.getByRole('searchbox')).toHaveValue('report')
expect(screen.getByRole('combobox')).toHaveTextContent(
@ -751,6 +742,35 @@ describe('DocumentsPage', () => {
expect(screen.queryByText('Ready handbook.pdf')).not.toBeInTheDocument()
})
it('starts re-indexing from a document row action', async () => {
const user = userEvent.setup()
documentsQuery.data = {
pages: [{ items: [document({ id: 'one', title: 'One.pdf' })] }],
}
render(<DocumentsPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: /dataset\.newKnowledge\.documentActions/,
}),
)
await user.click(
await screen.findByRole('menuitem', {
name: 'dataset.newKnowledge.reindexDocument',
}),
)
expect(reindexMutation.mutateAsync).toHaveBeenCalledWith({
body: { documentIds: ['one'] },
params: { control_space_id: 'space-1' },
})
await waitFor(() =>
expect(toastMock.success).toHaveBeenCalledWith(
'dataset.newKnowledge.documentsReindexStarted',
),
)
})
it('opens the upload form and consumes the one-shot URL request', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = render(<DocumentsPage knowledgeSpaceId="space-1" />, {
@ -2096,24 +2116,10 @@ describe('DocumentsPage', () => {
expect(reindex).toBeEnabled()
const orderedActions = within(actions).getAllByRole('button')
expect(orderedActions[0]).toHaveAccessibleName('dataset.newKnowledge.reindexDocuments')
expect(orderedActions[1]).toHaveAccessibleName('dataset.newKnowledge.downloadDocuments')
expect(orderedActions[2]).toHaveAccessibleName('dataset.newKnowledge.deleteDocuments')
expect(orderedActions[3]).toHaveAccessibleName('dataset.newKnowledge.clearDocumentSelection')
expect(orderedActions[1]).toHaveAccessibleName('dataset.newKnowledge.clearDocumentSelection')
expect(actions.firstElementChild).toHaveTextContent(
'dataset.newKnowledge.documentsSelected:{"count":1}',
)
const download = within(actions).getByRole('button', {
name: 'dataset.newKnowledge.downloadDocuments',
})
const remove = within(actions).getByRole('button', {
name: 'dataset.newKnowledge.deleteDocuments',
})
expect(download).toBeEnabled()
expect(remove).toBeEnabled()
await user.click(download)
await user.click(remove)
expect(toastMock.info).toHaveBeenCalledTimes(2)
expect(toastMock.info).toHaveBeenCalledWith('dataset.newKnowledge.documentActionsUnavailable')
await user.dblClick(reindex)
expect(reindexMutation.mutateAsync).toHaveBeenCalledOnce()
expect(reindexMutation.mutateAsync).toHaveBeenCalledWith({

View File

@ -386,6 +386,7 @@ describe('KnowledgeSettingsForm', () => {
name: /^dataset\.newKnowledge\.settings\.deleteConfirmPrompt/,
})
expect(confirmationInput).toHaveAttribute('placeholder', 'Camera Technical Spec')
expect(confirmButton).toBeDisabled()
await user.type(confirmationInput, 'Camera')
expect(confirmButton).toBeDisabled()

View File

@ -205,6 +205,25 @@ describe('QualityPage', () => {
})
})
it('shows both required-field messages after an empty golden question submission', async () => {
const user = userEvent.setup()
renderPage()
await screen.findByText('What is the refund policy?')
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.qualityPage.addGolden' }),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.qualityPage.save' }))
expect(
screen.getByText('dataset.newKnowledge.qualityPage.questionRequired'),
).toBeInTheDocument()
expect(
screen.getByText('dataset.newKnowledge.qualityPage.annotationRequired'),
).toBeInTheDocument()
expect(serviceMock.createGolden).not.toHaveBeenCalled()
})
it('resolves the protected trace reference before navigating', async () => {
const user = userEvent.setup()
navigationMock.tab = 'bad-cases'

View File

@ -92,6 +92,38 @@ describe('retrieval test model', () => {
expect(evidence[0]).toEqual(expect.objectContaining({ id: 'chunk-1', score: 0.77 }))
})
it('keeps document references carried by research citations', () => {
expect(
extractRetrievalEvidence({
data: [
{
evidence_bundle: {
items: [
{
citations: [
{
documentAssetId: 'asset-1',
documentVersion: 2,
},
],
nodeId: 'node-1',
score: 0.45,
text: 'Research evidence with a durable document citation.',
},
],
},
},
],
}),
).toEqual([
expect.objectContaining({
documentId: 'asset-1',
revision: 'Revision 2',
score: 0.45,
}),
])
})
it('merges trace and research histories newest-first', () => {
const records = retrievalTestRecords(
[

View File

@ -13,6 +13,9 @@ const apiMock = vi.hoisted(() => ({
refetchPartials: vi.fn(),
refetchTasks: vi.fn(),
refetchTraces: vi.fn(),
streamQuery: vi.fn(),
documentReferences: {} as Record<string, { id: string; title: string }>,
evidence: undefined as Record<string, unknown> | undefined,
traceDetail: undefined as Record<string, unknown> | undefined,
traces: [] as Array<Record<string, unknown>>,
}))
@ -27,6 +30,10 @@ vi.mock('@/next/navigation', () => ({
}),
}))
vi.mock('../services/knowledge-query-events', () => ({
streamKnowledgeQuery: apiMock.streamQuery,
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
@ -44,6 +51,16 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
data: apiMock.traceDetail,
isPending: false,
}
if (resource === 'evidence')
return {
data: apiMock.evidence,
isPending: false,
}
if (resource === 'retrieval-document-references')
return {
data: apiMock.documentReferences,
isPending: false,
}
if (resource === 'tasks')
return {
data: { data: [] },
@ -65,6 +82,7 @@ vi.mock('@/service/client', () => ({
spaces: {
byControlSpaceId: {
queries: { admission: { post: apiMock.queryAdmission } },
logicalDocuments: { get: vi.fn() },
goldenQuestions: { post: apiMock.createGolden },
quality: { badCases: { post: apiMock.createBadCase } },
researchTasks: {
@ -136,7 +154,12 @@ describe('RetrievalTestPage', () => {
updated_at: 1_800_000_000,
})
apiMock.refetchTasks.mockResolvedValue(undefined)
apiMock.refetchTraces.mockResolvedValue(undefined)
apiMock.streamQuery.mockResolvedValue(undefined)
apiMock.queryAdmission.mockResolvedValue({})
apiMock.createBadCase.mockResolvedValue({ id: 'bad-case-1' })
apiMock.documentReferences = {}
apiMock.evidence = undefined
apiMock.traceDetail = undefined
apiMock.traces = []
navigationMock.trace = undefined
@ -236,7 +259,9 @@ describe('RetrievalTestPage', () => {
render(<RetrievalTestPage knowledgeSpaceId="space-1" />)
expect(
screen.getByRole('heading', { name: 'An older production question' }),
screen.getByRole('heading', {
name: 'dataset.newKnowledge.retrievalTest.result:{"mode":"dataset.newKnowledge.settings.retrievalMode.deep"}',
}),
).toBeInTheDocument()
await user.click(
screen.getByRole('button', {
@ -255,4 +280,87 @@ describe('RetrievalTestPage', () => {
}),
)
})
it('opens retrieval evidence through its logical document instead of its asset', async () => {
apiMock.traces = [
{
completed: true,
created_at: '2026-07-29T00:00:00.000Z',
id: 'trace-1',
mode: 'fast',
profile: {},
query: 'What is the refund policy?',
scores: {},
stages: [],
},
]
apiMock.evidence = {
data: [
{
kind: 'resource',
metadata: {
documentId: 'asset-1',
score: 0.9,
text: 'Refunds are available within 30 days.',
},
name: 'chunk-1',
path: '/queries/trace-1/evidence/chunk-1',
resourceType: 'node',
targetId: 'chunk-1',
},
],
}
apiMock.documentReferences = {
'asset-1': { id: 'document-1', title: 'refund-policy.txt' },
}
const user = userEvent.setup()
render(<RetrievalTestPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByText('What is the refund policy?'))
expect(
screen.getByRole('link', { name: 'dataset.newKnowledge.retrievalTest.open' }),
).toHaveAttribute('href', '/datasets/new/space-1/documents/document-1')
expect(screen.getByText('refund-policy.txt')).toBeInTheDocument()
})
it('keeps a failed run in Records and renders the failure inline', async () => {
apiMock.streamQuery.mockRejectedValueOnce(new Error('provider timed out'))
const user = userEvent.setup()
render(<RetrievalTestPage knowledgeSpaceId="space-1" />)
await user.type(
screen.getByLabelText('dataset.newKnowledge.retrievalTest.queryPlaceholder'),
'Why did this fail?',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retrievalTest.run' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.retrievalTest.failedTitle',
)
expect(screen.getAllByText('Why did this fail?')).toHaveLength(2)
expect(screen.getByText('provider timed out')).toBeInTheDocument()
})
it('maps an empty unpublished knowledge space to the designed no-results state', async () => {
apiMock.streamQuery.mockRejectedValueOnce(
new Response('Published runtime snapshot unavailable', { status: 503 }),
)
const user = userEvent.setup()
render(<RetrievalTestPage knowledgeSpaceId="space-1" />)
await user.type(
screen.getByLabelText('dataset.newKnowledge.retrievalTest.queryPlaceholder'),
'Anything here?',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retrievalTest.run' }))
expect(
await screen.findByText('dataset.newKnowledge.retrievalTest.noChunksTitle'),
).toBeInTheDocument()
expect(
screen.queryByText('dataset.newKnowledge.retrievalTest.failedTitle'),
).not.toBeInTheDocument()
expect(screen.getAllByText('Anything here?')).toHaveLength(2)
})
})

View File

@ -70,6 +70,29 @@ const settingsState = vi.hoisted(() => ({
configurationState: 'active' as 'active' | 'setup-required',
refetch: vi.fn(),
}))
const workflowState = vi.hoisted(() => ({
data: undefined as
| {
canceled_at: null
checkpoint: string
completed_at: null
created_at: string
execution_attempts: number
id: string
kind: string
knowledge_space_id: string
last_error_code: null
max_execution_attempts: number
progress_completed: number
progress_failed: number
progress_skipped: number
progress_total: number
source_id: string
state: string
updated_at: string
}
| undefined,
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
@ -86,15 +109,18 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
}
: undefined,
}),
useQuery: () => ({
data: {
configuration_state: settingsState.configurationState,
embedding: null,
retrieval: null,
revision: 1,
},
refetch: settingsState.refetch,
}),
useQuery: (options: { queryKey?: unknown[] }) =>
options.queryKey?.[1] === 'source-workflow'
? { data: workflowState.data }
: {
data: {
configuration_state: settingsState.configurationState,
embedding: null,
retrieval: null,
revision: 1,
},
refetch: settingsState.refetch,
},
useQueryClient: () => ({ invalidateQueries: invalidateQueriesMock }),
}
})
@ -132,6 +158,15 @@ vi.mock('@/service/client', () => ({
}),
},
},
sourceWorkflows: {
byRunId: {
get: {
queryOptions: ({ input }: { input: { params: { run_id: string } } }) => ({
queryKey: ['knowledge-fs', 'source-workflow', input.params.run_id],
}),
},
},
},
sources: {
get: {
infiniteOptions: infiniteOptionsMock,
@ -158,6 +193,26 @@ const source = (overrides: Partial<Source>): Source => ({
...overrides,
})
const workflow = (state = 'queued') => ({
canceled_at: null,
checkpoint: 'sync',
completed_at: null,
created_at: '2026-07-20T10:00:00Z',
execution_attempts: 1,
id: 'workflow-1',
kind: 'sync',
knowledge_space_id: 'space-1',
last_error_code: null,
max_execution_attempts: 3,
progress_completed: 0,
progress_failed: 0,
progress_skipped: 0,
progress_total: 1,
source_id: 'source-1',
state,
updated_at: '2026-07-20T10:00:00Z',
})
describe('SourcesPage', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -169,7 +224,8 @@ describe('SourcesPage', () => {
sourcesQuery.isPending = false
clientMock.deleteSource.mockResolvedValue({ status: 'accepted' })
clientMock.patchSource.mockResolvedValue(source({}))
clientMock.syncSource.mockResolvedValue({ state: 'queued' })
workflowState.data = undefined
clientMock.syncSource.mockResolvedValue(workflow())
permissionState.workspacePermissionKeys = ['dataset.acl.edit', 'dataset.external.connect']
settingsState.configurationState = 'active'
settingsState.refetch.mockImplementation(async () => ({
@ -516,7 +572,7 @@ describe('SourcesPage', () => {
}),
)
render(<SourcesPage knowledgeSpaceId="space-1" />)
const { rerender } = render(<SourcesPage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.sourceActions:{"name":"Product documentation"}',
@ -536,7 +592,14 @@ describe('SourcesPage', () => {
'dataset.newKnowledge.sourceStatus.syncing',
),
).toBeInTheDocument()
expect(
within(screen.getByRole('row', { name: /Product documentation/ })).getByText(
'dataset.newKnowledge.sourceSyncProgress:{"completed":0,"total":1}',
),
).toBeInTheDocument()
finishRefresh?.()
workflowState.data = workflow('completed')
rerender(<SourcesPage knowledgeSpaceId="space-1" />)
await waitFor(() =>
expect(
within(screen.getByRole('row', { name: /Product documentation/ })).getByText(

View File

@ -497,6 +497,105 @@ function ConnectionForm({
)
}
function ManagedProviderConnection({
credentialId,
knowledgeSpaceId,
onConnected,
onReconcile,
provider,
}: {
credentialId: string
knowledgeSpaceId: string
onConnected: (connection: Connection) => void
onReconcile: () => Promise<Connection | undefined>
provider: Provider
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const [attempt, setAttempt] = useState(0)
const [error, setError] = useState(false)
const requestRef = useRef<
| {
attempt: number
promise: Promise<Connection | undefined>
}
| undefined
>(undefined)
useEffect(() => {
if (requestRef.current?.attempt !== attempt) {
requestRef.current = {
attempt,
promise: (async () => {
try {
return sourceConnectionFromApi(
await consoleClient.knowledgeFs.spaces.byControlSpaceId.sourceConnections.post({
body: {
authKind: 'endpoint',
configuration: {
...FIRECRAWL_CONFIGURATION,
credentialId,
},
credentials: {},
name: FIRECRAWL_CONNECTION_NAME,
providerId: provider.id,
},
params: { control_space_id: knowledgeSpaceId },
}),
)
} catch {
return onReconcile()
}
})(),
}
}
let subscribed = true
void requestRef.current.promise
.then((connection) => {
if (!subscribed) return
if (connection) onConnected(connection)
else setError(true)
})
.catch(() => {
if (subscribed) setError(true)
})
return () => {
subscribed = false
}
}, [attempt, credentialId, knowledgeSpaceId, onConnected, onReconcile, provider.id])
return (
<div className="flex min-h-40 flex-col items-center justify-center rounded-xl bg-background-section p-4 text-center">
{error ? (
<>
<span aria-hidden className="i-ri-error-warning-line size-5 text-text-destructive" />
<p role="alert" className="mt-2 system-sm-semibold text-text-primary">
{t(($) => $['newKnowledge.connectionFailed'])}
</p>
<Button
className="mt-3"
onClick={() => {
requestRef.current = undefined
setError(false)
setAttempt((current) => current + 1)
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</>
) : (
<>
<Loading />
<p role="status" className="mt-3 system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.connectingProvider'])}
</p>
</>
)}
</div>
)
}
function UnconfiguredProvider({
knowledgeSpaceId,
onConnected,
@ -518,10 +617,20 @@ function UnconfiguredProvider({
const [configuring, setConfiguring] = useState(false)
const difyManaged = isDifyManagedProvider(provider)
if ((difyManaged && credentialId) || configuring)
if (difyManaged && credentialId)
return (
<ManagedProviderConnection
credentialId={credentialId}
knowledgeSpaceId={knowledgeSpaceId}
onConnected={onConnected}
onReconcile={onReconcile}
provider={provider}
/>
)
if (configuring)
return (
<ConnectionForm
credentialId={credentialId}
knowledgeSpaceId={knowledgeSpaceId}
onConnected={onConnected}
onDraftChange={onDraftChange}

View File

@ -43,8 +43,8 @@ import {
const connectedProviders = {
onlineDocuments: [
{ icon: 'i-custom-public-common-notion', label: 'Notion' },
{ icon: 'i-ri-file-text-line', label: 'Google Docs' },
{ icon: 'i-ri-links-line', label: 'Confluence' },
{ icon: 'i-ri-file-text-fill text-[#4d8bf5]', label: 'Google Docs' },
{ icon: 'i-custom-public-common-confluence', label: 'Confluence' },
],
onlineDrive: [
{ icon: 'i-custom-public-common-google-drive', label: 'Google Drive' },

View File

@ -129,7 +129,7 @@ const providerOptions = {
},
{
aliases: ['google docs', 'googledocs', 'google drive', 'googledrive'],
icon: 'i-ri-file-text-line',
icon: 'i-ri-file-text-fill text-[#4d8bf5]',
label: 'Google Docs',
},
{
@ -484,7 +484,11 @@ function ProviderSelector({
>
<ProviderBrandIcon
fallbackIcon={option.icon}
icon={datasourceProviderIcon(optionProvider)}
icon={
option.label === 'Google Docs'
? undefined
: datasourceProviderIcon(optionProvider)
}
/>
{option.label}
</RadioItem>

View File

@ -50,8 +50,8 @@ const DEFAULT_MAX_PAGES = 100
const providers = {
onlineDocuments: [
{ icon: 'i-custom-public-common-notion', label: 'Notion' },
{ icon: 'i-ri-file-text-line', label: 'Google Docs' },
{ icon: 'i-ri-links-line', label: 'Confluence' },
{ icon: 'i-ri-file-text-fill text-[#4d8bf5]', label: 'Google Docs' },
{ icon: 'i-custom-public-common-confluence', label: 'Confluence' },
],
onlineDrive: [
{ icon: 'i-custom-public-common-google-drive', label: 'Google Drive' },

View File

@ -5,24 +5,23 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useTranslation } from 'react-i18next'
export function DocumentActionsDropdown({
className,
documentTitle,
onReindex,
showReindex = true,
}: {
className?: string
documentTitle: string
onReindex?: () => void
showReindex?: boolean
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const unavailable = () => toast.info(t(($) => $['newKnowledge.documentActionsUnavailable']))
if (!showReindex || !onReindex) return null
return (
<DropdownMenu modal={false}>
@ -36,32 +35,9 @@ export function DocumentActionsDropdown({
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[200px]">
<DropdownMenuItem className="gap-2 px-3" onClick={unavailable}>
<span aria-hidden className="i-ri-edit-line size-4" />
{tCommon(($) => $['operation.rename'])}
</DropdownMenuItem>
{showReindex && (
<DropdownMenuItem className="gap-2 px-3" onClick={unavailable}>
<span aria-hidden className="i-ri-loop-left-line size-4" />
{t(($) => $['newKnowledge.reindexDocument'])}
</DropdownMenuItem>
)}
<DropdownMenuItem className="gap-2 px-3" onClick={unavailable}>
<span aria-hidden className="i-ri-indeterminate-circle-line size-4" />
{t(($) => $['newKnowledge.disableSource'])}
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 px-3" onClick={unavailable}>
<span aria-hidden className="i-ri-archive-2-line size-4" />
{t(($) => $['batchAction.archive'])}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 px-3" onClick={unavailable}>
<span aria-hidden className="i-ri-download-line size-4" />
{t(($) => $['newKnowledge.downloadDocuments'])}
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" className="gap-2 px-3" onClick={unavailable}>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
{tCommon(($) => $['operation.delete'])}
<DropdownMenuItem className="gap-2 px-3" onClick={onReindex}>
<span aria-hidden className="i-ri-loop-left-line size-4" />
{t(($) => $['newKnowledge.reindexDocument'])}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -13,7 +13,6 @@ import {
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { DocumentActionsDropdown } from './document-actions-dropdown'
export function DocumentDetailHeader({
backPath,
@ -135,11 +134,6 @@ export function DocumentDetailHeader({
<span aria-hidden className="i-ri-refresh-line size-4" />
{t(($) => $['newKnowledge.reindexDocument'])}
</Button>
<DocumentActionsDropdown
className="size-8 justify-center"
documentTitle={document.title}
showReindex={false}
/>
</div>
</div>
{isFetchNextRevisionPageError && (

View File

@ -106,6 +106,7 @@ const DocumentRow = memo(
documentHref,
formatTimeFromNow,
onSelectedChange,
onReindex,
readOnlyReasonId,
selected,
selectionDisabled,
@ -118,6 +119,7 @@ const DocumentRow = memo(
documentHref: string
formatTimeFromNow: (time: number) => string
onSelectedChange: (documentId: string) => void
onReindex: (documentId: string) => void
readOnlyReasonId?: string
selected: boolean
selectionDisabled: boolean
@ -209,7 +211,10 @@ const DocumentRow = memo(
{Number.isNaN(updatedTime) ? document.updatedAt : formatTimeFromNow(updatedTime)}
</td>
<td className="w-10 align-middle">
<DocumentActionsDropdown documentTitle={document.title} />
<DocumentActionsDropdown
documentTitle={document.title}
onReindex={() => onReindex(document.id)}
/>
</td>
</tr>
)
@ -306,6 +311,7 @@ export function DocumentsList({
onFilterChange,
onLoadMore,
onOpenTasks,
onReindexDocument,
onSearchChange,
onSelectAll,
onSelectDocument,
@ -345,6 +351,7 @@ export function DocumentsList({
onFilterChange: (filter: DocumentFilter) => void
onLoadMore: () => void
onOpenTasks: () => void
onReindexDocument: (documentId: string) => void
onSearchChange: (search: string) => void
onSelectAll: () => void
onSelectDocument: (documentId: string) => void
@ -512,6 +519,7 @@ export function DocumentsList({
documentHref={getDocumentHref(document.id)}
formatTimeFromNow={formatTimeFromNow}
onSelectedChange={onSelectDocument}
onReindex={onReindexDocument}
readOnlyReasonId={
!canEdit
? readOnlyReasonId
@ -685,21 +693,6 @@ export function DocumentBulkActions({
{disabledReason}
</span>
)}
<Button
className="shrink-0"
size="small"
onClick={() => toast.info(t(($) => $['newKnowledge.documentActionsUnavailable']))}
>
{t(($) => $['newKnowledge.downloadDocuments'])}
</Button>
<Button
className="shrink-0"
size="small"
tone="destructive"
onClick={() => toast.info(t(($) => $['newKnowledge.documentActionsUnavailable']))}
>
{t(($) => $['newKnowledge.deleteDocuments'])}
</Button>
<Button
variant="ghost"
size="small"

View File

@ -1604,6 +1604,45 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
validSelectedDocumentIds,
])
const handleReindexDocument = useCallback(
async (documentId: string) => {
if (!canWrite || reindexPendingRef.current) return
reindexPendingRef.current = true
setReindexing(true)
try {
if (!(await ensureModelSetupReady())) return
const result = await reindexDocuments({
body: { documentIds: [documentId] },
params: { control_space_id: knowledgeSpaceId },
})
if (!result.items[0] || result.items[0].status === 'not_found')
toast.error(
t(($) => $['newKnowledge.documentsReindexPartial'], {
missing: 1,
queued: 0,
}),
)
else toast.success(t(($) => $['newKnowledge.documentsReindexStarted']))
refreshDocumentsAndTasks()
} catch (error) {
if (responseStatus(error) === 403) handleWritePermissionDenied()
else toast.error(t(($) => $['newKnowledge.documentsReindexFailed']))
} finally {
reindexPendingRef.current = false
setReindexing(false)
}
},
[
canWrite,
ensureModelSetupReady,
handleWritePermissionDenied,
knowledgeSpaceId,
refreshDocumentsAndTasks,
reindexDocuments,
t,
],
)
const handleTaskEvent = useCallback(
(taskId: string, taskVersion: string, event: ProcessingTaskEvent) => {
const eventVersion = event.event === 'progress' ? event.data.updatedAt : taskVersion
@ -2267,6 +2306,7 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
onFilterChange={setFilter}
onLoadMore={loadMoreResults}
onOpenTasks={() => setTasksOpen(true)}
onReindexDocument={(documentId) => void handleReindexDocument(documentId)}
onSearchChange={setSearch}
onSelectAll={toggleAllFiltered}
onSelectDocument={toggleDocument}

View File

@ -939,9 +939,8 @@ export function KnowledgeSettingsForm({
value={topK}
disabled={retrievalFieldsDisabled}
className="w-18 shrink-0"
onBlur={() => setTopK(clamp(topK, TOP_K_MIN, TOP_K_MAX))}
onChange={(event) => {
setTopK(Number(event.target.value))
setTopK(clamp(Number(event.target.value), TOP_K_MIN, TOP_K_MAX))
}}
/>
<Slider
@ -1167,6 +1166,7 @@ export function KnowledgeSettingsForm({
id="knowledge-delete-confirmation"
autoComplete="off"
name="knowledge-delete-confirmation"
placeholder={initialName}
value={deleteConfirmation}
className="mt-2 w-full"
onChange={(event) => setDeleteConfirmation(event.target.value)}

View File

@ -352,7 +352,7 @@ export function KnowledgeSpaceShell({
</Button>
</div>
</aside>
<section className="min-h-0 min-w-0 flex-1 overflow-auto rounded-lg bg-components-panel-bg shadow-xs">
<section className="flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-lg bg-components-panel-bg shadow-xs">
{children}
</section>
</div>

View File

@ -94,7 +94,7 @@ export function GoldenQuestionDialog({
onValueChange={setQuestion}
/>
{questionInvalid && (
<FieldError className="py-0.5 body-xs-regular text-text-destructive">
<FieldError match className="py-0.5 body-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.qualityPage.questionRequired'])}
</FieldError>
)}
@ -112,11 +112,11 @@ export function GoldenQuestionDialog({
onValueChange={setAnnotation}
/>
{annotationInvalid && (
<FieldError className="py-0.5 body-xs-regular text-text-destructive">
<FieldError match className="py-0.5 body-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.qualityPage.annotationRequired'])}
</FieldError>
)}
{!annotationInvalid && error && <FieldError>{error}</FieldError>}
{!annotationInvalid && error && <FieldError match>{error}</FieldError>}
</Field>
<Field name="tags">
<FieldLabel>{t(($) => $['newKnowledge.qualityPage.tags'])}</FieldLabel>

View File

@ -558,8 +558,8 @@ export function QualityPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
)}
</div>
) : (
<div className="flex min-h-105 flex-col items-center justify-center text-center">
<span aria-hidden className="i-ri-star-line size-8 text-text-tertiary" />
<div className="flex min-h-[calc(100vh-15rem)] flex-col items-center justify-center text-center">
<span aria-hidden className="i-ri-thumb-up-line size-8 text-text-tertiary" />
<h2 className="mt-4 system-md-semibold text-text-primary">
{t(($) => $['newKnowledge.qualityPage.goldenEmptyTitle'])}
</h2>
@ -689,8 +689,8 @@ export function QualityPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
)}
</div>
) : (
<div className="flex min-h-105 flex-col items-center justify-center text-center">
<span aria-hidden className="i-ri-checkbox-circle-line size-8 text-text-tertiary" />
<div className="flex min-h-[calc(100vh-15rem)] flex-col items-center justify-center text-center">
<span aria-hidden className="i-ri-check-line size-8 text-text-tertiary" />
<h2 className="mt-4 system-md-semibold text-text-primary">
{t(($) => $['newKnowledge.qualityPage.badCasesEmptyTitle'])}
</h2>

View File

@ -18,6 +18,14 @@ export type RetrievalEvidence = {
}
export type RetrievalTestRecord =
| {
createdAt: number
id: string
kind: 'local'
mode: Exclude<RetrievalTestMode, 'research'>
query: string
status: 'completed' | 'failed' | 'running'
}
| {
createdAt: number
id: string
@ -80,6 +88,7 @@ function evidenceFromValue(
if (!record) return undefined
const metadata = objectValue(record.metadata) ?? {}
const document = objectValue(record.document) ?? objectValue(metadata.document) ?? {}
const citation = Array.isArray(record.citations) ? (objectValue(record.citations[0]) ?? {}) : {}
const text = firstString(
record.text,
record.content,
@ -117,6 +126,8 @@ function evidenceFromValue(
document.id,
metadata.document_id,
metadata.documentId,
citation.documentAssetId,
citation.document_asset_id,
record.target_id,
record.targetId,
)
@ -153,6 +164,8 @@ function evidenceFromValue(
record.id,
record.chunk_id,
record.chunkId,
record.node_id,
record.nodeId,
record.target_id,
record.targetId,
metadata.id,
@ -181,6 +194,12 @@ function evidenceFromValue(
metadata.revision_label,
metadata.revisionLabel,
typeof metadata.documentVersion === 'number' ? String(metadata.documentVersion) : undefined,
typeof citation.documentVersion === 'number'
? `Revision ${citation.documentVersion}`
: undefined,
typeof citation.document_version === 'number'
? `Revision ${citation.document_version}`
: undefined,
),
score,
text,

View File

@ -13,7 +13,7 @@ import type { KnowledgeQueryEvent } from './services/knowledge-query-events'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
@ -38,7 +38,7 @@ type LocalQueryRun = {
mode: Exclude<RetrievalTestMode, 'research'>
query: string
startedAt: number
status: 'completed' | 'failed' | 'running'
status: 'completed' | 'failed' | 'no-results' | 'running'
traceId?: string
}
@ -69,7 +69,7 @@ function ScorePill({ score }: { score: number }) {
const normalized = Math.max(0, Math.min(1, score))
return (
<span className="bg-components-badge-bg relative inline-flex h-6 min-w-12 shrink-0 items-center justify-center overflow-hidden rounded-md border border-components-panel-border px-2 system-xs-semibold text-text-secondary">
{normalized.toFixed(2)}
Score {normalized.toFixed(2)}
<span
aria-hidden
className="absolute inset-x-0 bottom-0 h-0.5 origin-left bg-util-colors-blue-blue-500"
@ -79,18 +79,46 @@ function ScorePill({ score }: { score: number }) {
)
}
async function queryFailure(error: unknown) {
let status: number | undefined
let message = error instanceof Error ? error.message : ''
if (error instanceof Response) {
status = error.status
try {
const body = await error.clone().text()
if (body) message = body
} catch {
// The status and default copy are still enough to render a stable failure state.
}
} else if (error && typeof error === 'object' && 'status' in error) {
status = typeof error.status === 'number' ? error.status : undefined
}
const unavailableEmptySnapshot =
status === 503 &&
/published runtime snapshot unavailable|publication unavailable/i.test(message)
return {
message: unavailableEmptySnapshot ? undefined : message || undefined,
status: unavailableEmptySnapshot ? ('no-results' as const) : ('failed' as const),
}
}
function EvidenceCard({
documentReference,
evidence,
index,
knowledgeSpaceId,
}: {
documentReference?: {
id: string
title: string
}
evidence: RetrievalEvidence
index: number
knowledgeSpaceId: string
}) {
const { t } = useTranslation('dataset')
const openHref = evidence.documentId
? newKnowledgeDocumentDetailPath(knowledgeSpaceId, evidence.documentId)
const openHref = documentReference
? newKnowledgeDocumentDetailPath(knowledgeSpaceId, documentReference.id)
: undefined
return (
@ -122,7 +150,7 @@ function EvidenceCard({
className="i-ri-file-pdf-2-fill size-4 shrink-0 text-util-colors-red-red-500"
/>
<span className="min-w-0 flex-1 truncate system-xs-medium">
{evidence.documentName ?? evidence.title}
{documentReference?.title ?? evidence.documentName ?? evidence.title}
</span>
{evidence.revision && (
<span className="shrink-0 system-xs-regular">{evidence.revision}</span>
@ -197,6 +225,27 @@ function EmptyState({
)
}
function FailedResult({ description, onRetry }: { description: string; onRetry: () => void }) {
const { t } = useTranslation('dataset')
return (
<div
role="alert"
className="flex items-start gap-3 rounded-xl border border-components-panel-border bg-background-section px-4 py-3"
>
<span aria-hidden className="mt-0.5 i-ri-error-warning-fill size-4 text-text-destructive" />
<span className="min-w-0 flex-1">
<span className="block system-sm-semibold text-text-primary">
{t(($) => $['newKnowledge.retrievalTest.failedTitle'])}
</span>
<span className="mt-0.5 block body-xs-regular text-text-tertiary">{description}</span>
</span>
<Button size="small" variant="secondary" onClick={onRetry}>
{t(($) => $['newKnowledge.retrievalTest.retry'])}
</Button>
</div>
)
}
function QualityActions({
decision,
noResults,
@ -428,6 +477,11 @@ function RecordButton({
<span className="line-clamp-2 system-sm-medium text-text-primary">{record.query}</span>
<span className="mt-1 block system-xs-regular text-text-tertiary">
{t(($) => $[`newKnowledge.settings.retrievalMode.${record.mode}`])}
{' · '}
{new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
}).format(record.createdAt)}
</span>
</span>
</button>
@ -480,6 +534,24 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
() => retrievalTestRecords(tracesQuery.data?.data ?? [], researchTasksQuery.data?.data ?? []),
[researchTasksQuery.data?.data, tracesQuery.data?.data],
)
const displayRecords = useMemo<RetrievalTestRecord[]>(() => {
if (!localRun) return records
const traceAlreadyListed =
localRun.traceId &&
records.some((record) => record.kind === 'trace' && record.id === localRun.traceId)
if (traceAlreadyListed) return records
return [
{
createdAt: localRun.startedAt,
id: localRun.id,
kind: 'local',
mode: localRun.mode,
query: localRun.query,
status: localRun.status === 'no-results' ? 'completed' : localRun.status,
},
...records,
]
}, [localRun, records])
const selectedRecord = records.find(
(record) => record.id === selected?.id && record.kind === selected.kind,
)
@ -494,30 +566,28 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
? localRun?.traceId
: undefined
const traceDetailQuery = useQuery(
consoleQuery.knowledgeFs.spaces.byControlSpaceId.traces.byTraceId.get.queryOptions({
input: selectedTraceId
? {
params: {
control_space_id: knowledgeSpaceId,
trace_id: selectedTraceId,
},
}
: skipToken,
const traceDetailQuery = useQuery({
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.traces.byTraceId.get.queryOptions({
input: {
params: {
control_space_id: knowledgeSpaceId,
trace_id: selectedTraceId ?? '',
},
},
}),
)
enabled: Boolean(selectedTraceId),
})
const traceEvidenceQuery = useQuery({
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.traces.byTraceId.evidence.get.queryOptions({
input: selectedTraceId
? {
params: {
control_space_id: knowledgeSpaceId,
trace_id: selectedTraceId,
},
query: { limit: 100 },
}
: skipToken,
input: {
params: {
control_space_id: knowledgeSpaceId,
trace_id: selectedTraceId ?? '',
},
query: { limit: 100 },
},
}),
enabled: Boolean(selectedTraceId),
})
const researchPartialsQuery = useQuery({
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.researchTasks.byTaskId.partials.get.queryOptions(
@ -545,6 +615,34 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
: selected?.kind === 'research'
? researchEvidence
: historicalEvidence
const evidenceDocumentReferencesQuery = useQuery({
queryKey: ['retrieval-document-references', knowledgeSpaceId],
enabled: currentEvidence.some((evidence) => evidence.documentId),
queryFn: async () => {
const references: Record<string, { id: string; title: string }> = {}
const visitedCursors = new Set<string>()
let cursor: string | undefined
do {
const response =
await consoleClient.knowledgeFs.spaces.byControlSpaceId.logicalDocuments.get({
params: { control_space_id: knowledgeSpaceId },
...(cursor ? { query: { cursor } } : {}),
})
response.data.forEach((document) => {
if (document.active)
references[document.active.document_asset_id] = {
id: document.id,
title: document.title,
}
})
const nextCursor = response.next_cursor ?? undefined
if (!nextCursor || visitedCursors.has(nextCursor)) break
visitedCursors.add(nextCursor)
cursor = nextCursor
} while (cursor)
return references
},
})
const resultKey = selected ? `${selected.kind}:${selected.id}` : undefined
const selectedQuery =
selected?.kind === 'local'
@ -560,6 +658,7 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
(selected?.kind === 'trace' && !selectedRecord && traceDetailQuery.isPending) ||
(selected?.kind === 'trace' && traceEvidenceQuery.isPending)
const selectedFailed = selected?.kind === 'local' && localRun?.status === 'failed'
const selectedHasNoResults = selected?.kind === 'local' && localRun?.status === 'no-results'
const visibleEvidence = showAll ? currentEvidence : currentEvidence.slice(0, 3)
const selectRecord = (record: RetrievalTestRecord) => {
@ -677,13 +776,14 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
await tracesQuery.refetch()
} catch (error) {
if (controller.signal.aborted) return
const failure = await queryFailure(error)
setLocalRun((current) =>
current?.id === id
? {
...current,
endedAt: Date.now(),
error: error instanceof Error ? error.message : undefined,
status: 'failed',
error: failure.message,
status: failure.status,
}
: current,
)
@ -802,12 +902,14 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
<h2 className="system-xs-semibold-uppercase text-text-tertiary">
{t(($) => $['newKnowledge.retrievalTest.records'])}
</h2>
<span className="ml-2 system-xs-regular text-text-quaternary">{records.length}</span>
<span className="ml-2 system-xs-regular text-text-quaternary">
{displayRecords.length}
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{records.length > 0 ? (
{displayRecords.length > 0 ? (
<div className="space-y-1">
{records.map((record) => (
{displayRecords.map((record) => (
<RecordButton
key={`${record.kind}:${record.id}`}
record={record}
@ -838,7 +940,11 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
<h2 className="min-w-0 flex-1 truncate title-md-semi-bold text-text-primary">
{selected?.kind === 'research'
? t(($) => $['newKnowledge.retrievalTest.researchResult'])
: selectedQuery}
: t(($) => $['newKnowledge.retrievalTest.result'], {
mode: selectedMode
? t(($) => $[`newKnowledge.settings.retrievalMode.${selectedMode}`])
: '',
})}
</h2>
<span className="bg-components-badge-bg rounded-md px-2 py-1 system-xs-semibold text-text-secondary capitalize">
{selectedMode
@ -881,9 +987,7 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
{selectedIsLoading && <ResultSkeleton />}
{selectedFailed && (
<EmptyState
failed
title={t(($) => $['newKnowledge.retrievalTest.failedTitle'])}
<FailedResult
description={
localRun?.error || t(($) => $['newKnowledge.retrievalTest.failedDescription'])
}
@ -891,12 +995,15 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
/>
)}
{!selectedIsLoading && !selectedFailed && currentEvidence.length === 0 && (
<EmptyState
title={t(($) => $['newKnowledge.retrievalTest.noChunksTitle'])}
description={t(($) => $['newKnowledge.retrievalTest.noChunksDescription'])}
/>
)}
{!selectedIsLoading &&
!selectedFailed &&
!researchTaskIsActive(selectedResearchTask) &&
(selectedHasNoResults || currentEvidence.length === 0) && (
<EmptyState
title={t(($) => $['newKnowledge.retrievalTest.noChunksTitle'])}
description={t(($) => $['newKnowledge.retrievalTest.noChunksDescription'])}
/>
)}
{currentEvidence.length > 0 && (
<div className={cn(selectedResearchTask && 'mt-5')}>
@ -913,6 +1020,11 @@ export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: stri
{visibleEvidence.map((evidence, index) => (
<EvidenceCard
key={evidence.id}
documentReference={
evidence.documentId
? evidenceDocumentReferencesQuery.data?.[evidence.documentId]
: undefined
}
evidence={evidence}
index={index}
knowledgeSpaceId={knowledgeSpaceId}

View File

@ -33,7 +33,7 @@ import {
} from '@langgenius/dify-ui/select'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { toast } from '@langgenius/dify-ui/toast'
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -45,7 +45,7 @@ import { consoleClient, consoleQuery } from '@/service/client'
import { hasPermission } from '@/utils/permission'
import { KnowledgeModelSetupDialog } from './components/knowledge-model-setup-dialog'
import { newKnowledgeAddSourcePath } from './routes'
import { sourceFromApi } from './source-models'
import { sourceFromApi, sourceWorkflowFromApi } from './source-models'
import { useKnowledgeModelSetupGuard } from './use-knowledge-model-setup-guard'
type SourceStatus = Source['status']
@ -55,6 +55,23 @@ type SourceSort = 'name-asc' | 'name-desc'
const PAGE_SIZE = 50
const MAX_AUTO_FILTER_PAGES = 4
const SOURCE_POLL_INTERVAL = 3000
const SOURCE_WORKFLOW_POLL_INTERVAL = 1500
const SOURCE_WORKFLOW_SUCCESS_STATES = new Set([
'complete',
'completed',
'success',
'succeeded',
'zero_results',
])
const SOURCE_WORKFLOW_FAILURE_STATES = new Set([
'canceled',
'cancelled',
'error',
'exhausted',
'failed',
'timed_out',
'timeout',
])
const statusDotStatus: Record<SourceStatus, StatusDotStatus> = {
active: 'success',
@ -76,6 +93,17 @@ function createIdempotencyKey() {
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`
}
function normalizedWorkflowState(state: string) {
return state.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_')
}
function sourceWorkflowStatus(state: string): SourceStatus {
const normalized = normalizedWorkflowState(state)
if (SOURCE_WORKFLOW_FAILURE_STATES.has(normalized)) return 'error'
if (SOURCE_WORKFLOW_SUCCESS_STATES.has(normalized)) return 'active'
return 'syncing'
}
function getOpenableSourceUri(uri: string) {
try {
const url = new URL(uri)
@ -254,6 +282,30 @@ function SourceRow({
const { t: tCommon } = useTranslation('common')
const queryClient = useQueryClient()
const [pendingAction, setPendingAction] = useState<SourceAction>()
const [acceptedSyncRun, setAcceptedSyncRun] = useState<ReturnType<typeof sourceWorkflowFromApi>>()
const syncWorkflowQuery = useQuery({
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.sourceWorkflows.byRunId.get.queryOptions({
input: {
params: {
control_space_id: knowledgeSpaceId,
run_id: acceptedSyncRun?.id ?? '',
},
},
}),
enabled: Boolean(acceptedSyncRun),
refetchInterval: (query) => {
const workflow = query.state.data ? sourceWorkflowFromApi(query.state.data) : acceptedSyncRun
return workflow && sourceWorkflowStatus(workflow.state) === 'syncing'
? SOURCE_WORKFLOW_POLL_INTERVAL
: false
},
})
const syncWorkflow = syncWorkflowQuery.data
? sourceWorkflowFromApi(syncWorkflowQuery.data)
: acceptedSyncRun
const visibleSource = syncWorkflow
? { ...source, status: sourceWorkflowStatus(syncWorkflow.state) }
: source
const providerName = metadataString(source.metadata, 'providerName')
const syncPolicy = metadataString(source.metadata, 'syncPolicy')
const lastSync = metadataString(source.metadata, 'lastSyncedAt')
@ -318,7 +370,11 @@ function SourceRow({
headers: { 'Idempotency-Key': createIdempotencyKey() },
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
}),
() => onSourceChange({ ...source, status: 'syncing' }),
(workflow) => {
const run = sourceWorkflowFromApi(workflow)
setAcceptedSyncRun(run)
onSourceChange({ ...source, status: sourceWorkflowStatus(run.state) })
},
onSourceReconciled,
ensureModelSetupReady,
)
@ -358,7 +414,7 @@ function SourceRow({
<tr
className={cn(
'border-t border-divider-subtle',
source.status === 'disabled' && '[&>td:not(:first-child)]:opacity-60',
visibleSource.status === 'disabled' && '[&>td:not(:first-child)]:opacity-60',
)}
>
<td className="w-7 py-2 pr-3">
@ -379,13 +435,13 @@ function SourceRow({
<td className="w-24 py-2 pr-3 sm:w-35">
<span className="inline-flex items-center gap-1.5 system-xs-medium text-text-primary">
<StatusDot
status={statusDotStatus[source.status]}
status={statusDotStatus[visibleSource.status]}
className={cn(
'shrink-0',
source.status === 'syncing' && 'animate-pulse motion-reduce:animate-none',
visibleSource.status === 'syncing' && 'animate-pulse motion-reduce:animate-none',
)}
/>
{t(($) => $[`newKnowledge.sourceStatus.${source.status}`])}
{t(($) => $[`newKnowledge.sourceStatus.${visibleSource.status}`])}
</span>
</td>
<td className="hidden w-30 py-2 pr-3 system-xs-regular text-text-secondary lg:table-cell">
@ -394,13 +450,27 @@ function SourceRow({
<td
className={cn(
'hidden w-40 py-2 pr-3 system-xs-regular lg:table-cell',
source.status === 'error' ? 'text-text-destructive' : 'text-text-secondary',
visibleSource.status === 'error' ? 'text-text-destructive' : 'text-text-secondary',
)}
>
{source.status === 'error' ? (
{visibleSource.status === 'syncing' && syncWorkflow ? (
<span className="inline-flex items-center gap-1.5 text-text-accent">
<span
aria-hidden
className="i-ri-loader-4-line size-3.5 animate-spin motion-reduce:animate-none"
/>
{t(($) => $['newKnowledge.sourceSyncProgress'], {
completed:
syncWorkflow.progressCompleted +
syncWorkflow.progressFailed +
syncWorkflow.progressSkipped,
total: syncWorkflow.progressTotal ?? '—',
})}
</span>
) : visibleSource.status === 'error' ? (
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="i-ri-error-warning-fill size-3.5" />
{t(($) => $['newKnowledge.sourceSyncFailed'])}
{syncWorkflow?.lastErrorCode ?? t(($) => $['newKnowledge.sourceSyncFailed'])}
</span>
) : (
(lastSync ?? '—')
@ -408,7 +478,7 @@ function SourceRow({
</td>
<td className="w-20 py-2 text-right">
<div className="flex items-center justify-end gap-1">
{canSync && source.status === 'error' && (
{canSync && visibleSource.status === 'error' && (
<Button
size="small"
variant="secondary"
@ -422,7 +492,7 @@ function SourceRow({
<SourceActions
canEdit={canEdit}
canSync={canSync}
source={source}
source={visibleSource}
pendingAction={pendingAction}
onSync={syncSource}
onToggle={toggleSource}

View File

@ -45,6 +45,7 @@ export function useDocumentReindex({
const [submissionRecoveryBusy, setSubmissionRecoveryBusy] = useState(false)
const [submittedReindex, setSubmittedReindex] = useState<{
baselineRevision: number
taskId: string
timedOut: boolean
}>()
const permissionRecoveryPendingRef = useRef(false)
@ -56,6 +57,7 @@ export function useDocumentReindex({
consoleQuery.knowledgeFs.spaces.byControlSpaceId.documents.reindex.post.mutationOptions(),
)
const taskStatus = useDocumentTaskStatus({
acceptedTaskId: submittedReindex?.taskId,
documentId,
enabled,
knowledgeSpaceId,
@ -69,9 +71,7 @@ export function useDocumentReindex({
const latestTaskRef = useRef(latestTask)
latestTaskRef.current = latestTask
const submittedTaskObserved = Boolean(
latestTask &&
submittedReindex &&
latestTask.documentRevision > submittedReindex.baselineRevision,
latestTask && submittedReindex && latestTask.id === submittedReindex.taskId,
)
const submissionPending = Boolean(submittedReindex && !submittedTaskObserved)
@ -107,7 +107,8 @@ export function useDocumentReindex({
const timeout = window.setTimeout(
() =>
setSubmittedReindex((current) =>
current?.baselineRevision === submittedReindex.baselineRevision
current?.baselineRevision === submittedReindex.baselineRevision &&
current.taskId === submittedReindex.taskId
? { ...current, timedOut: true }
: current,
),
@ -125,9 +126,7 @@ export function useDocumentReindex({
previousState === 'retry_wait'
previousTaskStateRef.current = latestTask?.state
const taskMatchesAcceptedSubmission = Boolean(
latestTask &&
submittedReindex &&
latestTask.documentRevision > submittedReindex.baselineRevision,
latestTask && submittedReindex && latestTask.id === submittedReindex.taskId,
)
if (taskMatchesAcceptedSubmission && latestTask) acceptedTaskIdRef.current = latestTask.id
const terminalTaskKey = latestTask ? `${latestTask.id}:${latestTask.updatedAt}` : undefined
@ -185,12 +184,18 @@ export function useDocumentReindex({
toast.error(t(($) => $['newKnowledge.documentNotFoundTitle']))
return
}
const taskId =
typeof result.items[0].compilation_job?.id === 'string'
? result.items[0].compilation_job.id
: undefined
if (!taskId) throw new Error('Re-index response did not include a compilation task id')
setSubmittedReindex({
baselineRevision: Math.max(
baselineRevision,
documentActiveRevision,
latestTaskRef.current?.documentRevision ?? documentActiveRevision,
),
taskId,
timedOut: false,
})
await Promise.all([

View File

@ -21,6 +21,7 @@ function documentTaskIsActive(state: string | undefined) {
}
export function useDocumentTaskStatus({
acceptedTaskId,
documentId,
enabled,
knowledgeSpaceId,
@ -28,6 +29,7 @@ export function useDocumentTaskStatus({
submissionNeedsRecheck,
submissionPending,
}: {
acceptedTaskId?: string
documentId: string
enabled: boolean
knowledgeSpaceId: string
@ -77,7 +79,12 @@ export function useDocumentTaskStatus({
() => tasksData?.pages.flatMap((page) => documentTaskListFromApi(page).items) ?? [],
[tasksData],
)
const acceptedTask = useMemo(
() => (acceptedTaskId ? tasks.find((candidate) => candidate.id === acceptedTaskId) : undefined),
[acceptedTaskId, tasks],
)
const latestTask = useMemo(() => {
if (acceptedTask) return acceptedTask
const task = newestTaskByDocument(
tasks.filter(
(candidate) =>
@ -85,9 +92,10 @@ export function useDocumentTaskStatus({
),
).get(documentId)
return task && task.documentRevision >= minimumRevision ? task : undefined
}, [documentId, minimumRevision, tasks])
}, [acceptedTask, documentId, minimumRevision, tasks])
const lookupSatisfied = acceptedTaskId ? Boolean(acceptedTask) : Boolean(latestTask)
const lookupExhausted = Boolean(
!latestTask && hasNextPage && (tasksData?.pages.length ?? 0) >= lookupPageLimit,
!lookupSatisfied && hasNextPage && (tasksData?.pages.length ?? 0) >= lookupPageLimit,
)
useEffect(() => {
@ -96,7 +104,7 @@ export function useDocumentTaskStatus({
!enabled ||
isFetchingNextPage ||
tasksError ||
latestTask ||
lookupSatisfied ||
!hasNextPage ||
lookupExhausted
)
@ -108,7 +116,7 @@ export function useDocumentTaskStatus({
hasNextPage,
isFetchingNextPage,
isPending,
latestTask,
lookupSatisfied,
lookupExhausted,
tasksError,
])
@ -116,7 +124,7 @@ export function useDocumentTaskStatus({
return {
continueLookup: () => setLookupPageLimit((current) => current + TASK_LOOKUP_PAGE_BATCH),
isFetchingNextPage,
isLookingUp: Boolean(!latestTask && hasNextPage && !lookupExhausted),
isLookingUp: Boolean(!lookupSatisfied && hasNextPage && !lookupExhausted),
isPending,
latestTask,
lookupExhausted,

View File

@ -143,7 +143,7 @@
"newKnowledge.allSources": "All sources",
"newKnowledge.apiAccessActive": "Active",
"newKnowledge.apiAccessInactive": "Inactive",
"newKnowledge.apiAgentAccess": "Agent Access",
"newKnowledge.apiAgentAccess": "API Access",
"newKnowledge.appsUnavailable": "— apps",
"newKnowledge.authKind.api-key": "API key",
"newKnowledge.authKind.endpoint": "Endpoint",
@ -253,13 +253,13 @@
"newKnowledge.documentUploadExclusion.quota": "workspace quota exceeded",
"newKnowledge.documentUploadExclusion.target": "document target is no longer valid",
"newKnowledge.documentUploadFailed": "We couldn't upload these documents. Try again.",
"newKnowledge.documentUploadFormats": "PDF, DOCX, Markdown, HTML, XLSX, TXT — up to 15 MB each",
"newKnowledge.documentUploadFormats": "PDF, DOCX, Markdown, HTML, CSV, JSONL, XLSX, TXT — up to 15 MB each",
"newKnowledge.documentUploadPartial": "{{accepted}} documents started; {{excluded}} could not be added: {{details}}",
"newKnowledge.documentUploadRejected": "No documents were accepted: {{details}}",
"newKnowledge.documentUploadStarted": "Document processing started.",
"newKnowledge.documents": "Documents",
"newKnowledge.documentsDescription": "Knowledge assets and their revisions — where each document came from and whether the latest revision is indexed.",
"newKnowledge.documentsDropHint": "or drop PDF, DOCX, Markdown, HTML, XLSX, or TXT to upload",
"newKnowledge.documentsDropHint": "or drop PDF, DOCX, Markdown, HTML, CSV, JSONL, XLSX, or TXT to upload",
"newKnowledge.documentsEmptyDescription": "Connect a source or upload files to start building this knowledge space.",
"newKnowledge.documentsEmptyTitle": "No documents yet",
"newKnowledge.documentsErrorDescription": "We couldn't load documents. Try again.",
@ -520,9 +520,10 @@
"newKnowledge.retrievalTest.planning": "Plan research",
"newKnowledge.retrievalTest.processLog": "Process log",
"newKnowledge.retrievalTest.quality": "Quality",
"newKnowledge.retrievalTest.queryPlaceholder": "Ask a real question about this knowledge base…",
"newKnowledge.retrievalTest.queryPlaceholder": "Enter a query to test retrieval…",
"newKnowledge.retrievalTest.records": "Records",
"newKnowledge.retrievalTest.researchResult": "Research result",
"newKnowledge.retrievalTest.result": "{{mode}} result",
"newKnowledge.retrievalTest.retrieving": "Retrieve evidence",
"newKnowledge.retrievalTest.retry": "Retry",
"newKnowledge.retrievalTest.run": "Run",
@ -531,7 +532,7 @@
"newKnowledge.retrievalTest.savedGoldenQuestion": "Saved as golden question",
"newKnowledge.retrievalTest.showAllChunks": "Show all {{count}} chunks",
"newKnowledge.retrievalTest.startResearch": "Start research",
"newKnowledge.retrievalTest.title": "Evidence",
"newKnowledge.retrievalTest.title": "Retrieval Test",
"newKnowledge.retrievalTest.viewInQuality": "View in Quality",
"newKnowledge.retryCrawl": "Retry crawl",
"newKnowledge.retryProviderLoad": "Retry",
@ -596,6 +597,7 @@
"newKnowledge.sourceStatus.error": "Error",
"newKnowledge.sourceStatus.syncing": "Syncing",
"newKnowledge.sourceSyncFailed": "Failed",
"newKnowledge.sourceSyncProgress": "{{completed}} / {{total}} pages",
"newKnowledge.sourceType.connector": "Connected app",
"newKnowledge.sourceType.object-storage": "Online drive",
"newKnowledge.sourceType.upload": "Upload",