mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
fix(dataset): align New RAG acceptance flows
This commit is contained in:
parent
effcefca34
commit
2f892071d2
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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(
|
||||
[
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user