From 11d184ddae36047f35cb9389999de4e2c1b622f8 Mon Sep 17 00:00:00 2001
From: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Date: Mon, 31 Aug 2026 17:32:07 +0800
Subject: [PATCH] refactor(knowledge-fs): split quality page state ownership
---
.../new-rag/__tests__/quality-page.spec.tsx | 20 +-
.../new-rag/quality/bad-cases-panel.tsx | 399 ++++++++
.../quality/golden-questions-panel.tsx | 475 +++++++++
web/features/new-rag/quality/quality-model.ts | 38 +
web/features/new-rag/quality/quality-page.tsx | 926 +-----------------
.../new-rag/quality/quality-query-state.tsx | 38 +
.../quality/quality-row-menu-trigger.tsx | 13 +
7 files changed, 1010 insertions(+), 899 deletions(-)
create mode 100644 web/features/new-rag/quality/bad-cases-panel.tsx
create mode 100644 web/features/new-rag/quality/golden-questions-panel.tsx
create mode 100644 web/features/new-rag/quality/quality-model.ts
create mode 100644 web/features/new-rag/quality/quality-query-state.tsx
create mode 100644 web/features/new-rag/quality/quality-row-menu-trigger.tsx
diff --git a/web/features/new-rag/__tests__/quality-page.spec.tsx b/web/features/new-rag/__tests__/quality-page.spec.tsx
index e43c1956a43..50e0426393d 100644
--- a/web/features/new-rag/__tests__/quality-page.spec.tsx
+++ b/web/features/new-rag/__tests__/quality-page.spec.tsx
@@ -1,8 +1,7 @@
-import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { render } from '@/test/console/render'
+import { renderWithNuqs as render } from '@/test/nuqs-testing'
import { QualityPage } from '../quality/quality-page'
vi.mock('../quality/quality-evaluation-panel', () => ({
@@ -40,9 +39,6 @@ vi.mock('../knowledge-space-context', () => ({
vi.mock('@/next/navigation', () => ({
useRouter: () => routerMock,
- useSearchParams: () => ({
- get: (key: string) => (key === 'tab' ? navigationMock.tab : undefined),
- }),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
@@ -137,10 +133,12 @@ function renderPage() {
queries: { retry: false },
},
})
- const Wrapper = ({ children }: { children: ReactNode }) => (
- {children}
+ return render(
+
+
+ ,
+ { searchParams: navigationMock.tab ? `?tab=${navigationMock.tab}` : '' },
)
- return render(, { wrapper: Wrapper })
}
describe('QualityPage', () => {
@@ -268,7 +266,7 @@ describe('QualityPage', () => {
it('uses tab primitive relationships and keeps route state in sync', async () => {
const user = userEvent.setup()
- renderPage()
+ const { onUrlUpdate } = renderPage()
const goldenTab = await screen.findByRole('tab', {
name: 'dataset.newKnowledge.qualityPage.goldenTab',
@@ -280,7 +278,9 @@ describe('QualityPage', () => {
expect(badCasesTab).toHaveAttribute('tabindex', '-1')
await user.click(badCasesTab)
- expect(routerMock.replace).toHaveBeenCalledWith('/datasets/new/space-1/quality?tab=bad-cases')
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tab')).toBe('bad-cases')
+ expect(onUrlUpdate.mock.calls.at(-1)?.[0].options.history).toBe('replace')
})
it('renders an empty golden-question annotation without an empty interactive control', async () => {
diff --git a/web/features/new-rag/quality/bad-cases-panel.tsx b/web/features/new-rag/quality/bad-cases-panel.tsx
new file mode 100644
index 00000000000..33f2c1b3579
--- /dev/null
+++ b/web/features/new-rag/quality/bad-cases-panel.tsx
@@ -0,0 +1,399 @@
+'use client'
+
+import type { KnowledgeFsBadCaseResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
+import type { GoldenQuestionDraft } from './types'
+import { Button } from '@langgenius/dify-ui/button'
+import { cn } from '@langgenius/dify-ui/cn'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+} from '@langgenius/dify-ui/dropdown-menu'
+import { toast } from '@langgenius/dify-ui/toast'
+import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useRouter } from '@/next/navigation'
+import { consoleClient, consoleQuery } from '@/service/client'
+import { newKnowledgeRetrievalTestPath } from '../routes'
+import { GoldenQuestionDialog } from './golden-question-dialog'
+import {
+ formatQualityUpdatedAt,
+ goldenQuestionPayload,
+ qualityPageSize,
+ visibleQualityTags,
+} from './quality-model'
+import { QualityQueryState } from './quality-query-state'
+import { QualityRowMenuTrigger } from './quality-row-menu-trigger'
+
+type BadCasesPanelProps = {
+ canEdit: boolean
+ knowledgeSpaceId: string
+}
+
+function BadCaseReason({
+ question,
+ reason,
+ tags,
+}: {
+ question?: string
+ reason: string
+ tags: string[]
+}) {
+ const { t } = useTranslation('dataset')
+ const normalized = reason.toLowerCase()
+ if (normalized === 'low-score' || (normalized.includes('low') && normalized.includes('score')))
+ return t(($) => $['newKnowledge.qualityPage.reasonValues.lowScore'])
+ if (normalized.includes('outdated'))
+ return t(($) => $['newKnowledge.qualityPage.reasonValues.outdatedContent'])
+ if (
+ (tags.includes('retrieval-test') && reason.trim() === question?.trim()) ||
+ normalized.includes('retrieval') ||
+ normalized.includes('miss')
+ )
+ return t(($) => $['newKnowledge.qualityPage.reasonValues.retrievalMiss'])
+ if (normalized.includes('coverage') || normalized.includes('evidence'))
+ return t(($) => $['newKnowledge.qualityPage.reasonValues.coverageGap'])
+ return reason
+}
+
+function BadCaseStatus({ status }: { status: KnowledgeFsBadCaseResponse['status'] }) {
+ const { t } = useTranslation('dataset')
+ const visibleStatus = status === 'dismissed' ? 'fixed' : status
+ return (
+
+
+ {t(($) => $[`newKnowledge.qualityPage.status.${visibleStatus}`])}
+
+ )
+}
+
+export function BadCasesPanel({ canEdit, knowledgeSpaceId }: BadCasesPanelProps) {
+ const { t } = useTranslation('dataset')
+ const router = useRouter()
+ const queryClient = useQueryClient()
+ const [pendingId, setPendingId] = useState()
+ const [dialog, setDialog] = useState<{
+ id: string
+ key: string
+ value: GoldenQuestionDraft
+ }>()
+ const [dialogError, setDialogError] = useState()
+ const [dialogSubmitting, setDialogSubmitting] = useState(false)
+ const promotedGoldenQuestionIdsRef = useRef(new Map())
+ const badCaseQueryOptions =
+ consoleQuery.knowledgeFs.spaces.byControlSpaceId.quality.badCases.get.infiniteOptions({
+ input: (pageParam) => ({
+ params: { control_space_id: knowledgeSpaceId },
+ query: {
+ limit: qualityPageSize,
+ ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
+ },
+ }),
+ getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
+ initialPageParam: null as string | null,
+ })
+ const goldenQueryOptions =
+ consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.get.infiniteOptions({
+ input: (pageParam) => ({
+ params: { control_space_id: knowledgeSpaceId },
+ query: {
+ limit: qualityPageSize,
+ ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
+ },
+ }),
+ getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
+ initialPageParam: null as string | null,
+ })
+ const query = useInfiniteQuery(badCaseQueryOptions)
+ const createGoldenMutation = useMutation(
+ consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.post.mutationOptions(),
+ )
+ const deleteGoldenMutation = useMutation(
+ consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.byQuestionId.delete.mutationOptions(),
+ )
+ const items = (query.data?.pages.flatMap((page) => page.data) ?? []).filter(
+ (item) => item.status !== 'dismissed',
+ )
+
+ const invalidateQuality = async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: goldenQueryOptions.queryKey }),
+ queryClient.invalidateQueries({ queryKey: badCaseQueryOptions.queryKey }),
+ ])
+ }
+ const getBadCase = (badCaseId: string) =>
+ consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.get({
+ params: { bad_case_id: badCaseId, control_space_id: knowledgeSpaceId },
+ })
+ const markBadCaseDismissed = async (
+ badCase: KnowledgeFsBadCaseResponse,
+ tags: string[] = badCase.tags,
+ ) => {
+ try {
+ await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.patch({
+ body: {
+ expected_revision: badCase.revision,
+ status: 'dismissed',
+ tags,
+ },
+ params: { bad_case_id: badCase.id, control_space_id: knowledgeSpaceId },
+ })
+ } catch (error) {
+ const refreshed = await getBadCase(badCase.id).catch(() => undefined)
+ if (refreshed?.status !== 'dismissed') throw error
+ }
+ }
+
+ const submitPromotion = async (draft: GoldenQuestionDraft) => {
+ if (!dialog) return
+ setDialogError(undefined)
+ setDialogSubmitting(true)
+ try {
+ const badCase = await getBadCase(dialog.id)
+ let goldenQuestionId = promotedGoldenQuestionIdsRef.current.get(badCase.id)
+ if (!goldenQuestionId) {
+ const createdGoldenQuestion = await createGoldenMutation.mutateAsync({
+ body: {
+ ...goldenQuestionPayload(draft),
+ source_bad_case_id: badCase.id,
+ },
+ params: { control_space_id: knowledgeSpaceId },
+ })
+ goldenQuestionId = createdGoldenQuestion.id
+ promotedGoldenQuestionIdsRef.current.set(badCase.id, goldenQuestionId)
+ }
+ try {
+ await markBadCaseDismissed(badCase, visibleQualityTags(badCase.tags))
+ promotedGoldenQuestionIdsRef.current.delete(badCase.id)
+ } catch (error) {
+ try {
+ await deleteGoldenMutation.mutateAsync({
+ params: { control_space_id: knowledgeSpaceId, question_id: goldenQuestionId },
+ })
+ promotedGoldenQuestionIdsRef.current.delete(badCase.id)
+ } catch {
+ // Keep the created ID so retrying resumes dismissal without creating a duplicate.
+ }
+ throw error
+ }
+ toast.success(t(($) => $['newKnowledge.qualityPage.promotedToast']))
+ await invalidateQuality()
+ setDialog(undefined)
+ } catch {
+ setDialogError(t(($) => $.unknownError))
+ } finally {
+ setDialogSubmitting(false)
+ }
+ }
+
+ const replayBadCase = async (item: KnowledgeFsBadCaseResponse) => {
+ setPendingId(item.id)
+ try {
+ const reference =
+ await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.traceReference.get(
+ { params: { bad_case_id: item.id, control_space_id: knowledgeSpaceId } },
+ )
+ const search = new URLSearchParams({ retest: reference.trace_id, trace: reference.trace_id })
+ router.push(`${newKnowledgeRetrievalTestPath(knowledgeSpaceId)}?${search.toString()}`)
+ } catch {
+ toast.error(t(($) => $.unknownError))
+ } finally {
+ setPendingId(undefined)
+ }
+ }
+ const ignoreBadCase = async (item: KnowledgeFsBadCaseResponse) => {
+ setPendingId(item.id)
+ try {
+ const badCase = await getBadCase(item.id)
+ await markBadCaseDismissed(badCase)
+ await queryClient.invalidateQueries({ queryKey: badCaseQueryOptions.queryKey })
+ } catch {
+ toast.error(t(($) => $.unknownError))
+ } finally {
+ setPendingId(undefined)
+ }
+ }
+ const openTrace = async (badCaseId: string) => {
+ try {
+ const reference =
+ await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.traceReference.get(
+ { params: { bad_case_id: badCaseId, control_space_id: knowledgeSpaceId } },
+ )
+ router.push(`${newKnowledgeRetrievalTestPath(knowledgeSpaceId)}?trace=${reference.trace_id}`)
+ } catch {
+ toast.error(t(($) => $.unknownError))
+ }
+ }
+ const openPromotion = (item: KnowledgeFsBadCaseResponse) =>
+ setDialog({
+ id: item.id,
+ key: `promote-${item.id}-${Date.now()}`,
+ value: {
+ annotation: '',
+ expectedEvidenceIds: [],
+ matchPolicy: 'all',
+ question: item.question ?? '',
+ tags: visibleQualityTags(item.tags),
+ },
+ })
+
+ return (
+ <>
+ void query.refetch()}
+ >
+ {items.length ? (
+
+
+ {t(($) => $['newKnowledge.qualityPage.question'])}
+ {t(($) => $['newKnowledge.qualityPage.statusLabel'])}
+ {t(($) => $['newKnowledge.qualityPage.reason'])}
+ {t(($) => $['newKnowledge.qualityPage.updated'])}
+
+
+ {items.map((item) => (
+
+ {item.question}
+
+
+
+
+
+ {formatQualityUpdatedAt(item.updated_at)}
+
+
+ $['newKnowledge.qualityPage.questionActions'], {
+ question: item.question ?? '',
+ })}
+ />
+
+ {canEdit &&
+ (item.status === 'fixed' ? (
+ openPromotion(item)}
+ >
+
+ {t(($) => $['newKnowledge.qualityPage.toGolden'])}
+
+ ) : (
+ void replayBadCase(item)}
+ >
+
+ {t(($) => $['newKnowledge.qualityPage.replay'])}
+
+ ))}
+ void openTrace(item.id)}
+ >
+
+ {t(($) => $['newKnowledge.qualityPage.openTrace'])}
+
+ {canEdit && item.status !== 'fixed' && (
+ openPromotion(item)}
+ >
+
+ {t(($) => $['newKnowledge.qualityPage.toGolden'])}
+
+ )}
+ {canEdit && }
+ {canEdit && (
+ void ignoreBadCase(item)}
+ >
+
+ {t(($) => $['newKnowledge.qualityPage.ignore'])}
+
+ )}
+
+
+
+ ))}
+ {query.hasNextPage && (
+
+
+
+ )}
+
+ ) : (
+
+
+
+ {t(($) => $['newKnowledge.qualityPage.badCasesEmptyTitle'])}
+
+
+ {t(($) => $['newKnowledge.qualityPage.badCasesEmptyDescription'])}
+
+ {query.hasNextPage && (
+
+ )}
+
+ )}
+
+
+ {dialog && (
+ {
+ if (!open) {
+ setDialog(undefined)
+ setDialogError(undefined)
+ }
+ }}
+ onSubmit={submitPromotion}
+ />
+ )}
+ >
+ )
+}
diff --git a/web/features/new-rag/quality/golden-questions-panel.tsx b/web/features/new-rag/quality/golden-questions-panel.tsx
new file mode 100644
index 00000000000..dc4d7ab3ffe
--- /dev/null
+++ b/web/features/new-rag/quality/golden-questions-panel.tsx
@@ -0,0 +1,475 @@
+'use client'
+
+import type { KnowledgeFsGoldenQuestionResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
+import type { GoldenQuestionDraft } from './types'
+import {
+ AlertDialog,
+ AlertDialogActions,
+ AlertDialogCancelButton,
+ AlertDialogConfirmButton,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogTitle,
+} from '@langgenius/dify-ui/alert-dialog'
+import { Button } from '@langgenius/dify-ui/button'
+import { Checkbox } from '@langgenius/dify-ui/checkbox'
+import { cn } from '@langgenius/dify-ui/cn'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+} from '@langgenius/dify-ui/dropdown-menu'
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
+import { toast } from '@langgenius/dify-ui/toast'
+import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { useState } from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import Badge from '@/app/components/base/badge'
+import { consoleQuery } from '@/service/client'
+import { GoldenQuestionDialog } from './golden-question-dialog'
+import { GoldenQuestionImportDialog } from './golden-question-import-dialog'
+import {
+ emptyGoldenQuestionDraft,
+ formatQualityUpdatedAt,
+ goldenQuestionPayload,
+ qualityPageSize,
+ visibleQualityTags,
+} from './quality-model'
+import { QualityQueryState } from './quality-query-state'
+import { QualityRowMenuTrigger } from './quality-row-menu-trigger'
+
+type GoldenQuestionsPanelProps = {
+ actionSlot: HTMLDivElement | null
+ canEdit: boolean
+ knowledgeSpaceId: string
+}
+
+type GoldenQuestionDialogState =
+ | { key: string; mode: 'create'; value: GoldenQuestionDraft }
+ | { id: string; key: string; mode: 'edit'; value: GoldenQuestionDraft }
+
+function GoldenStatus({ status }: { status: 'active' | 'draft' | 'stale' }) {
+ const { t } = useTranslation('dataset')
+ return (
+
+ {t(($) => $[`newKnowledge.qualityPage.goldenStatus.${status}`])}
+
+ )
+}
+
+function GoldenAnnotation({ annotation }: { annotation: string }) {
+ if (!annotation.trim()) return —
+ return (
+
+
+ {annotation}
+
+ }
+ />
+
+ {annotation}
+
+
+ )
+}
+
+export function GoldenQuestionsPanel({
+ actionSlot,
+ canEdit,
+ knowledgeSpaceId,
+}: GoldenQuestionsPanelProps) {
+ const { t } = useTranslation('dataset')
+ const { t: tCommon } = useTranslation('common')
+ const queryClient = useQueryClient()
+ const [selected, setSelected] = useState>(() => new Set())
+ const [deleteIds, setDeleteIds] = useState>()
+ const [deleteSubmitting, setDeleteSubmitting] = useState(false)
+ const [dialog, setDialog] = useState()
+ const [dialogError, setDialogError] = useState()
+ const [dialogSubmitting, setDialogSubmitting] = useState(false)
+ const [importOpen, setImportOpen] = useState(false)
+ const queryOptions =
+ consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.get.infiniteOptions({
+ input: (pageParam) => ({
+ params: { control_space_id: knowledgeSpaceId },
+ query: {
+ limit: qualityPageSize,
+ ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
+ },
+ }),
+ getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
+ initialPageParam: null as string | null,
+ })
+ const query = useInfiniteQuery(queryOptions)
+ const createMutation = useMutation(
+ consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.post.mutationOptions(),
+ )
+ const updateMutation = useMutation(
+ consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.byQuestionId.patch.mutationOptions(),
+ )
+ const deleteMutation = useMutation(
+ consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.byQuestionId.delete.mutationOptions(),
+ )
+ const items = query.data?.pages.flatMap((page) => page.data) ?? []
+ const allSelected = items.length > 0 && selected.size === items.length
+ const partiallySelected = selected.size > 0 && !allSelected
+
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: queryOptions.queryKey })
+ const openCreate = () =>
+ setDialog({
+ key: `create-${Date.now()}`,
+ mode: 'create',
+ value: emptyGoldenQuestionDraft,
+ })
+ const openEdit = (item: KnowledgeFsGoldenQuestionResponse) =>
+ setDialog({
+ id: item.id,
+ key: `edit-${item.id}-${Date.now()}`,
+ mode: 'edit',
+ value: {
+ annotation: item.annotation,
+ expectedEvidenceIds: item.expected_evidence_ids ?? [],
+ matchPolicy: item.match_policy ?? 'all',
+ question: item.question,
+ tags: item.tags,
+ },
+ })
+ const toggleAll = () =>
+ setSelected(allSelected ? new Set() : new Set(items.map((item) => item.id)))
+ const toggleOne = (id: string) =>
+ setSelected((current) => {
+ const next = new Set(current)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+
+ const submitDialog = async (draft: GoldenQuestionDraft) => {
+ if (!dialog) return
+ setDialogError(undefined)
+ setDialogSubmitting(true)
+ try {
+ if (dialog.mode === 'edit') {
+ await updateMutation.mutateAsync({
+ body: goldenQuestionPayload(draft),
+ params: { control_space_id: knowledgeSpaceId, question_id: dialog.id },
+ })
+ toast.success(t(($) => $['newKnowledge.qualityPage.updatedToast']))
+ } else {
+ await createMutation.mutateAsync({
+ body: goldenQuestionPayload(draft),
+ params: { control_space_id: knowledgeSpaceId },
+ })
+ toast.success(t(($) => $['newKnowledge.qualityPage.createdToast']))
+ }
+ await invalidate()
+ setDialog(undefined)
+ } catch {
+ setDialogError(t(($) => $.unknownError))
+ } finally {
+ setDialogSubmitting(false)
+ }
+ }
+
+ const deleteGolden = async (ids: Set): Promise => {
+ setDeleteSubmitting(true)
+ try {
+ const results = await Promise.allSettled(
+ [...ids].map((questionId) =>
+ deleteMutation
+ .mutateAsync({
+ params: { control_space_id: knowledgeSpaceId, question_id: questionId },
+ })
+ .then(() => questionId),
+ ),
+ )
+ const deletedIds = new Set(
+ results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])),
+ )
+ const failedIds = new Set([...ids].filter((id) => !deletedIds.has(id)))
+ setSelected((current) => new Set([...current].filter((id) => !deletedIds.has(id))))
+ await invalidate().catch(() => undefined)
+ if (failedIds.size > 0) {
+ setDeleteIds(failedIds)
+ toast.error(t(($) => $.unknownError))
+ return false
+ }
+ toast.success(
+ t(
+ ($) =>
+ $[
+ ids.size === 1
+ ? 'newKnowledge.qualityPage.deletedToast_one'
+ : 'newKnowledge.qualityPage.deletedToast_other'
+ ],
+ { count: ids.size },
+ ),
+ )
+ setDeleteIds(undefined)
+ return true
+ } catch {
+ toast.error(t(($) => $.unknownError))
+ return false
+ } finally {
+ setDeleteSubmitting(false)
+ }
+ }
+
+ return (
+ <>
+ {actionSlot &&
+ canEdit &&
+ items.length > 0 &&
+ createPortal(
+ <>
+
+
+ >,
+ actionSlot,
+ )}
+
+ void query.refetch()}
+ >
+ {items.length ? (
+
+
+ $['newKnowledge.qualityPage.selectAll'])}
+ checked={allSelected}
+ disabled={!canEdit}
+ indeterminate={partiallySelected}
+ onCheckedChange={toggleAll}
+ />
+ {t(($) => $['newKnowledge.qualityPage.question'])}
+ {t(($) => $['newKnowledge.qualityPage.statusLabel'])}
+ {t(($) => $['newKnowledge.qualityPage.tags'])}
+ {t(($) => $['newKnowledge.qualityPage.annotation'])}
+ {t(($) => $['newKnowledge.qualityPage.updated'])}
+
+
+ {items.map((item) => (
+
+
$['newKnowledge.qualityPage.selectQuestion'], {
+ question: item.question,
+ })}
+ checked={selected.has(item.id)}
+ disabled={!canEdit}
+ onCheckedChange={() => toggleOne(item.id)}
+ />
+
+ {item.question ?? ''}
+
+ 0 ? 'active' : 'draft')
+ }
+ />
+
+ {visibleQualityTags(item.tags).map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+ {formatQualityUpdatedAt(item.updated_at)}
+
+ {canEdit ? (
+
+ $['newKnowledge.qualityPage.questionActions'], {
+ question: item.question,
+ })}
+ />
+
+ openEdit(item)}>
+
+ {t(($) => $['newKnowledge.qualityPage.edit'])}
+
+
+ setDeleteIds(new Set([item.id]))}
+ >
+
+ {t(($) => $['newKnowledge.qualityPage.delete'])}
+
+
+
+ ) : (
+
+ )}
+
+ ))}
+ {query.hasNextPage && (
+
+
+
+ )}
+
+ ) : (
+
+
+
+ {t(($) => $['newKnowledge.qualityPage.goldenEmptyTitle'])}
+
+
+ {t(($) => $['newKnowledge.qualityPage.goldenEmptyDescription'])}
+
+ {canEdit && (
+
+
+
+
+ )}
+
+ )}
+
+
+ {canEdit && selected.size > 0 && (
+
+
+ {t(
+ ($) =>
+ $[
+ selected.size === 1
+ ? 'newKnowledge.qualityPage.selectedCount_one'
+ : 'newKnowledge.qualityPage.selectedCount_other'
+ ],
+ { count: selected.size },
+ )}
+
+
+
+
+
+ )}
+
+ {dialog && (
+ {
+ if (!open) {
+ setDialog(undefined)
+ setDialogError(undefined)
+ }
+ }}
+ onSubmit={submitDialog}
+ />
+ )}
+ {importOpen && (
+
+ )}
+ {
+ if (!open && !deleteSubmitting) setDeleteIds(undefined)
+ }}
+ >
+
+
+
+ {tCommon(($) => $['operation.deleteConfirmTitle'])}
+
+
+ {tCommon(($) => $['operation.confirmAction'])}
+
+
+
+
+ {tCommon(($) => $['operation.cancel'])}
+
+ {
+ if (deleteIds) void deleteGolden(deleteIds)
+ }}
+ >
+ {tCommon(($) => $['operation.delete'])}
+
+
+
+
+ >
+ )
+}
diff --git a/web/features/new-rag/quality/quality-model.ts b/web/features/new-rag/quality/quality-model.ts
new file mode 100644
index 00000000000..3379ce37230
--- /dev/null
+++ b/web/features/new-rag/quality/quality-model.ts
@@ -0,0 +1,38 @@
+import type { GoldenQuestionDraft } from './types'
+
+const goldenLinkPrefix = 'golden-question:'
+
+export const qualityPageSize = 50
+
+export const emptyGoldenQuestionDraft: GoldenQuestionDraft = {
+ annotation: '',
+ expectedEvidenceIds: [],
+ matchPolicy: 'all',
+ question: '',
+ tags: [],
+}
+
+export function goldenQuestionPayload(draft: GoldenQuestionDraft) {
+ return {
+ annotation: draft.annotation,
+ expected_evidence_ids: draft.expectedEvidenceIds,
+ match_policy: draft.matchPolicy,
+ question: draft.question,
+ tags: draft.tags,
+ }
+}
+
+export function visibleQualityTags(tags: string[]) {
+ return tags.filter((tag) => !tag.startsWith(goldenLinkPrefix))
+}
+
+export function formatQualityUpdatedAt(value: string) {
+ const time = new Date(value)
+ const elapsedHours = Math.max(0, (Date.now() - time.getTime()) / 3_600_000)
+ const relativeTime = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
+ if (elapsedHours < 1) return relativeTime.format(0, 'minute')
+ if (elapsedHours < 24) return relativeTime.format(-Math.floor(elapsedHours), 'hour')
+ const elapsedDays = Math.floor(elapsedHours / 24)
+ if (elapsedDays < 7) return relativeTime.format(-elapsedDays, 'day')
+ return new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(time)
+}
diff --git a/web/features/new-rag/quality/quality-page.tsx b/web/features/new-rag/quality/quality-page.tsx
index 940be250ba9..7f661082210 100644
--- a/web/features/new-rag/quality/quality-page.tsx
+++ b/web/features/new-rag/quality/quality-page.tsx
@@ -1,479 +1,35 @@
'use client'
-import type { KnowledgeFsBadCaseResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
-import type { ReactNode } from 'react'
-import type { GoldenQuestionDraft } from './types'
-import {
- AlertDialog,
- AlertDialogActions,
- AlertDialogCancelButton,
- AlertDialogConfirmButton,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogTitle,
-} from '@langgenius/dify-ui/alert-dialog'
-import { Button } from '@langgenius/dify-ui/button'
-import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@langgenius/dify-ui/dropdown-menu'
-import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Tabs, TabsPanel } from '@langgenius/dify-ui/tabs'
-import { toast } from '@langgenius/dify-ui/toast'
-import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import { useRef, useState } from 'react'
+import { parseAsStringLiteral, useQueryState } from 'nuqs'
+import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import Badge from '@/app/components/base/badge'
-import Loading from '@/app/components/base/loading'
-import { useRouter, useSearchParams } from '@/next/navigation'
-import { consoleClient, consoleQuery } from '@/service/client'
import { useKnowledgeSpacePermission } from '../knowledge-space-context'
-import { newKnowledgeQualityPath, newKnowledgeRetrievalTestPath } from '../routes'
-import { GoldenQuestionDialog } from './golden-question-dialog'
-import { GoldenQuestionImportDialog } from './golden-question-import-dialog'
+import { BadCasesPanel } from './bad-cases-panel'
+import { GoldenQuestionsPanel } from './golden-questions-panel'
import { QualityEvaluationPanel } from './quality-evaluation-panel'
import { QualityTabList } from './quality-tab-list'
-const emptyDraft: GoldenQuestionDraft = {
- annotation: '',
- expectedEvidenceIds: [],
- matchPolicy: 'all',
- question: '',
- tags: [],
-}
-const goldenLinkPrefix = 'golden-question:'
-const pageSize = 50
+type QualityTab = 'bad' | 'evaluation' | 'golden'
-function visibleTags(tags: string[]) {
- return tags.filter((tag) => !tag.startsWith(goldenLinkPrefix))
-}
-
-function Reason({ question, reason, tags }: { question?: string; reason: string; tags: string[] }) {
- const { t } = useTranslation('dataset')
- const normalized = reason.toLowerCase()
- if (normalized === 'low-score' || (normalized.includes('low') && normalized.includes('score')))
- return t(($) => $['newKnowledge.qualityPage.reasonValues.lowScore'])
- if (normalized.includes('outdated'))
- return t(($) => $['newKnowledge.qualityPage.reasonValues.outdatedContent'])
- if (
- (tags.includes('retrieval-test') && reason.trim() === question?.trim()) ||
- normalized.includes('retrieval') ||
- normalized.includes('miss')
- )
- return t(($) => $['newKnowledge.qualityPage.reasonValues.retrievalMiss'])
- if (normalized.includes('coverage') || normalized.includes('evidence'))
- return t(($) => $['newKnowledge.qualityPage.reasonValues.coverageGap'])
- return reason
-}
-
-function RowMenuTrigger({ disabled, label }: { disabled?: boolean; label: string }) {
- return (
-
-
-
- )
-}
-
-function Status({ status }: { status: KnowledgeFsBadCaseResponse['status'] }) {
- const { t } = useTranslation('dataset')
- const visibleStatus = status === 'dismissed' ? 'fixed' : status
- return (
-
-
- {t(($) => $[`newKnowledge.qualityPage.status.${visibleStatus}`])}
-
- )
-}
-
-function GoldenStatus({ status }: { status: 'active' | 'draft' | 'stale' }) {
- const { t } = useTranslation('dataset')
- return (
-
- {t(($) => $[`newKnowledge.qualityPage.goldenStatus.${status}`])}
-
- )
-}
-
-function GoldenAnnotation({ annotation }: { annotation: string }) {
- if (!annotation.trim()) return —
-
- return (
-
-
- {annotation}
-
- }
- />
-
- {annotation}
-
-
- )
-}
-
-function QualityQueryState({
- children,
- error,
- loading,
- onRetry,
-}: {
- children: ReactNode
- error: boolean
- loading: boolean
- onRetry: () => void
-}) {
- const { t } = useTranslation('dataset')
-
- if (loading)
- return (
-
-
-
- )
-
- if (error)
- return (
-
-
-
- {t(($) => $.unknownError)}
-
-
-
- )
-
- return children
-}
-
-function goldenQuestionPayload(draft: GoldenQuestionDraft) {
- return {
- annotation: draft.annotation,
- expected_evidence_ids: draft.expectedEvidenceIds,
- match_policy: draft.matchPolicy,
- question: draft.question,
- tags: draft.tags,
- }
-}
+const qualityTabParser = parseAsStringLiteral(['bad-cases', 'evaluations'] as const).withOptions({
+ history: 'replace',
+})
export function QualityPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) {
const { t } = useTranslation('dataset')
- const { t: tCommon } = useTranslation('common')
- const router = useRouter()
- const searchParams = useSearchParams()
- const queryClient = useQueryClient()
const canEdit = useKnowledgeSpacePermission('knowledge_space_edit')
- const requestedTab = searchParams.get('tab')
- const activeTab =
- requestedTab === 'bad-cases' ? 'bad' : requestedTab === 'evaluations' ? 'evaluation' : 'golden'
- const [selected, setSelected] = useState>(() => new Set())
- const [deleteIds, setDeleteIds] = useState>()
- const [deleteSubmitting, setDeleteSubmitting] = useState(false)
- const [dialogError, setDialogError] = useState()
- const [dialogSubmitting, setDialogSubmitting] = useState(false)
- const [evaluationActionSlot, setEvaluationActionSlot] = useState(null)
+ const [queryTab, setQueryTab] = useQueryState('tab', qualityTabParser)
+ const activeTab: QualityTab =
+ queryTab === 'bad-cases' ? 'bad' : queryTab === 'evaluations' ? 'evaluation' : 'golden'
+ const [actionSlot, setActionSlot] = useState(null)
const [selectedEvaluationRunId, setSelectedEvaluationRunId] = useState()
- const [importOpen, setImportOpen] = useState(false)
- const [pendingBadCaseId, setPendingBadCaseId] = useState()
- const promotedGoldenQuestionIdsRef = useRef(new Map())
- const [dialog, setDialog] = useState<
- | { key: string; mode: 'create'; value: GoldenQuestionDraft }
- | { id: string; key: string; mode: 'edit'; value: GoldenQuestionDraft }
- | { id: string; key: string; mode: 'promote'; value: GoldenQuestionDraft }
- >()
- const goldenQueryOptions =
- consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.get.infiniteOptions({
- input: (pageParam) => ({
- params: { control_space_id: knowledgeSpaceId },
- query: {
- limit: pageSize,
- ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
- },
- }),
- getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
- initialPageParam: null as string | null,
- })
- const badCaseQueryOptions =
- consoleQuery.knowledgeFs.spaces.byControlSpaceId.quality.badCases.get.infiniteOptions({
- input: (pageParam) => ({
- params: { control_space_id: knowledgeSpaceId },
- query: {
- limit: pageSize,
- ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
- },
- }),
- getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
- initialPageParam: null as string | null,
- })
- const goldenQuery = useInfiniteQuery({
- ...goldenQueryOptions,
- enabled: activeTab === 'golden',
- })
- const badCaseQuery = useInfiniteQuery({
- ...badCaseQueryOptions,
- enabled: activeTab === 'bad',
- })
- const createGoldenMutation = useMutation(
- consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.post.mutationOptions(),
- )
- const updateGoldenMutation = useMutation(
- consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.byQuestionId.patch.mutationOptions(),
- )
- const deleteGoldenMutation = useMutation(
- consoleQuery.knowledgeFs.spaces.byControlSpaceId.goldenQuestions.byQuestionId.delete.mutationOptions(),
- )
- const goldenQuestions = goldenQuery.data?.pages.flatMap((page) => page.data) ?? []
- const badCases = (badCaseQuery.data?.pages.flatMap((page) => page.data) ?? []).filter(
- (item) => item.status !== 'dismissed',
- )
- const allSelected = goldenQuestions.length > 0 && selected.size === goldenQuestions.length
- const partiallySelected = selected.size > 0 && !allSelected
- const setTab = (tab: 'bad' | 'evaluation' | 'golden') => {
- if (tab !== 'golden') setSelected(new Set())
+
+ const setTab = (tab: QualityTab) => {
if (tab !== 'evaluation') setSelectedEvaluationRunId(undefined)
- router.replace(
- tab === 'bad'
- ? `${newKnowledgeQualityPath(knowledgeSpaceId)}?tab=bad-cases`
- : tab === 'evaluation'
- ? `${newKnowledgeQualityPath(knowledgeSpaceId)}?tab=evaluations`
- : newKnowledgeQualityPath(knowledgeSpaceId),
- )
+ void setQueryTab(tab === 'bad' ? 'bad-cases' : tab === 'evaluation' ? 'evaluations' : null)
}
- const updated = (value: string) => {
- const time = new Date(value)
- const elapsedHours = Math.max(0, (Date.now() - time.getTime()) / 3_600_000)
- const relativeTime = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
- if (elapsedHours < 1) return relativeTime.format(0, 'minute')
- if (elapsedHours < 24) return relativeTime.format(-Math.floor(elapsedHours), 'hour')
- const elapsedDays = Math.floor(elapsedHours / 24)
- if (elapsedDays < 7) return relativeTime.format(-elapsedDays, 'day')
- return new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(time)
- }
-
- const invalidateQuality = async () => {
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: goldenQueryOptions.queryKey }),
- queryClient.invalidateQueries({ queryKey: badCaseQueryOptions.queryKey }),
- ])
- }
-
- const getBadCase = (badCaseId: string) =>
- consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.get({
- params: { bad_case_id: badCaseId, control_space_id: knowledgeSpaceId },
- })
-
- const markBadCaseDismissed = async (
- badCase: KnowledgeFsBadCaseResponse,
- tags: string[] = badCase.tags,
- ) => {
- try {
- await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.patch({
- body: {
- expected_revision: badCase.revision,
- status: 'dismissed',
- tags,
- },
- params: { bad_case_id: badCase.id, control_space_id: knowledgeSpaceId },
- })
- } catch (error) {
- const refreshed = await getBadCase(badCase.id).catch(() => undefined)
- if (refreshed?.status !== 'dismissed') throw error
- }
- }
-
- const submitDialog = async (draft: GoldenQuestionDraft) => {
- if (!dialog) return
- setDialogError(undefined)
- setDialogSubmitting(true)
- try {
- if (dialog.mode === 'edit') {
- await updateGoldenMutation.mutateAsync({
- body: goldenQuestionPayload(draft),
- params: { control_space_id: knowledgeSpaceId, question_id: dialog.id },
- })
- toast.success(t(($) => $['newKnowledge.qualityPage.updatedToast']))
- } else if (dialog.mode === 'create') {
- await createGoldenMutation.mutateAsync({
- body: goldenQuestionPayload(draft),
- params: { control_space_id: knowledgeSpaceId },
- })
- toast.success(t(($) => $['newKnowledge.qualityPage.createdToast']))
- } else {
- const badCase = await getBadCase(dialog.id)
- let goldenQuestionId = promotedGoldenQuestionIdsRef.current.get(badCase.id)
- if (!goldenQuestionId) {
- const createdGoldenQuestion = await createGoldenMutation.mutateAsync({
- body: {
- ...goldenQuestionPayload(draft),
- source_bad_case_id: badCase.id,
- },
- params: { control_space_id: knowledgeSpaceId },
- })
- goldenQuestionId = createdGoldenQuestion.id
- promotedGoldenQuestionIdsRef.current.set(badCase.id, goldenQuestionId)
- }
- try {
- await markBadCaseDismissed(badCase, visibleTags(badCase.tags))
- promotedGoldenQuestionIdsRef.current.delete(badCase.id)
- } catch (error) {
- try {
- await deleteGoldenMutation.mutateAsync({
- params: { control_space_id: knowledgeSpaceId, question_id: goldenQuestionId },
- })
- promotedGoldenQuestionIdsRef.current.delete(badCase.id)
- } catch {
- // Keep the created ID so a retry resumes dismissal instead of creating a duplicate.
- }
- throw error
- }
- toast.success(t(($) => $['newKnowledge.qualityPage.promotedToast']))
- }
- await invalidateQuality()
- setDialog(undefined)
- } catch {
- setDialogError(t(($) => $.unknownError))
- } finally {
- setDialogSubmitting(false)
- }
- }
-
- const deleteGolden = async (ids: Set): Promise => {
- setDeleteSubmitting(true)
- try {
- const results = await Promise.allSettled(
- [...ids].map((questionId) =>
- deleteGoldenMutation
- .mutateAsync({
- params: { control_space_id: knowledgeSpaceId, question_id: questionId },
- })
- .then(() => questionId),
- ),
- )
- const deletedIds = new Set(
- results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])),
- )
- const failedIds = new Set([...ids].filter((id) => !deletedIds.has(id)))
- setSelected((current) => new Set([...current].filter((id) => !deletedIds.has(id))))
- await queryClient
- .invalidateQueries({ queryKey: goldenQueryOptions.queryKey })
- .catch(() => undefined)
- if (failedIds.size > 0) {
- setDeleteIds(failedIds)
- toast.error(t(($) => $.unknownError))
- return false
- }
- toast.success(
- t(
- ($) =>
- $[
- ids.size === 1
- ? 'newKnowledge.qualityPage.deletedToast_one'
- : 'newKnowledge.qualityPage.deletedToast_other'
- ],
- { count: ids.size },
- ),
- )
- setDeleteIds(undefined)
- return true
- } catch {
- toast.error(t(($) => $.unknownError))
- return false
- } finally {
- setDeleteSubmitting(false)
- }
- }
-
- const replayBadCase = async (item: KnowledgeFsBadCaseResponse) => {
- setPendingBadCaseId(item.id)
- try {
- const reference =
- await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.traceReference.get(
- {
- params: { bad_case_id: item.id, control_space_id: knowledgeSpaceId },
- },
- )
- const search = new URLSearchParams({
- retest: reference.trace_id,
- trace: reference.trace_id,
- })
- router.push(`${newKnowledgeRetrievalTestPath(knowledgeSpaceId)}?${search.toString()}`)
- } catch {
- toast.error(t(($) => $.unknownError))
- } finally {
- setPendingBadCaseId(undefined)
- }
- }
-
- const ignoreBadCase = async (item: KnowledgeFsBadCaseResponse) => {
- setPendingBadCaseId(item.id)
- try {
- const badCase = await getBadCase(item.id)
- await markBadCaseDismissed(badCase)
- await queryClient.invalidateQueries({ queryKey: badCaseQueryOptions.queryKey })
- } catch {
- toast.error(t(($) => $.unknownError))
- } finally {
- setPendingBadCaseId(undefined)
- }
- }
-
- const openTrace = async (badCaseId: string) => {
- try {
- const reference =
- await consoleClient.knowledgeFs.spaces.byControlSpaceId.quality.badCases.byBadCaseId.traceReference.get(
- {
- params: { bad_case_id: badCaseId, control_space_id: knowledgeSpaceId },
- },
- )
- router.push(`${newKnowledgeRetrievalTestPath(knowledgeSpaceId)}?trace=${reference.trace_id}`)
- } catch {
- toast.error(t(($) => $.unknownError))
- }
- }
-
- const toggleAll = () =>
- setSelected(allSelected ? new Set() : new Set(goldenQuestions.map((item) => item.id)))
- const toggleOne = (id: string) =>
- setSelected((current) => {
- const next = new Set(current)
- if (next.has(id)) next.delete(id)
- else next.add(id)
- return next
- })
return (
)}
- setTab(value as typeof activeTab)}>
+
+ setTab(value as QualityTab)}>
{!selectedEvaluationRunId && (
-
- {canEdit && activeTab === 'golden' && goldenQuestions.length > 0 && (
- <>
-
-
- >
- )}
-
+
)}
- void goldenQuery.refetch()}
- >
- {goldenQuestions.length ? (
-
-
- $['newKnowledge.qualityPage.selectAll'])}
- checked={allSelected}
- disabled={!canEdit}
- indeterminate={partiallySelected}
- onCheckedChange={toggleAll}
- />
- {t(($) => $['newKnowledge.qualityPage.question'])}
- {t(($) => $['newKnowledge.qualityPage.statusLabel'])}
- {t(($) => $['newKnowledge.qualityPage.tags'])}
- {t(($) => $['newKnowledge.qualityPage.annotation'])}
- {t(($) => $['newKnowledge.qualityPage.updated'])}
-
-
- {goldenQuestions.map((item) => (
-
-
$['newKnowledge.qualityPage.selectQuestion'], {
- question: item.question,
- })}
- checked={selected.has(item.id)}
- disabled={!canEdit}
- onCheckedChange={() => toggleOne(item.id)}
- />
-
- {item.question ?? ''}
-
- 0 ? 'active' : 'draft')
- }
- />
-
- {visibleTags(item.tags).map((tag) => (
-
-
- {tag}
-
-
- ))}
-
-
-
- {updated(item.updated_at)}
-
- {canEdit ? (
-
- $['newKnowledge.qualityPage.questionActions'], {
- question: item.question,
- })}
- />
-
-
- setDialog({
- id: item.id,
- key: `edit-${item.id}-${Date.now()}`,
- mode: 'edit',
- value: {
- annotation: item.annotation,
- expectedEvidenceIds: item.expected_evidence_ids ?? [],
- matchPolicy: item.match_policy ?? 'all',
- question: item.question,
- tags: item.tags,
- },
- })
- }
- >
-
- {t(($) => $['newKnowledge.qualityPage.edit'])}
-
-
- setDeleteIds(new Set([item.id]))}
- >
-
- {t(($) => $['newKnowledge.qualityPage.delete'])}
-
-
-
- ) : (
-
- )}
-
- ))}
- {goldenQuery.hasNextPage && (
-
-
-
- )}
-
- ) : (
-
-
-
- {t(($) => $['newKnowledge.qualityPage.goldenEmptyTitle'])}
-
-
- {t(($) => $['newKnowledge.qualityPage.goldenEmptyDescription'])}
-
- {canEdit && (
-
-
-
-
- )}
-
- )}
-
+ {activeTab === 'golden' && (
+
+ )}
- void badCaseQuery.refetch()}
- >
- {badCases.length ? (
-
-
- {t(($) => $['newKnowledge.qualityPage.question'])}
- {t(($) => $['newKnowledge.qualityPage.statusLabel'])}
- {t(($) => $['newKnowledge.qualityPage.reason'])}
- {t(($) => $['newKnowledge.qualityPage.updated'])}
-
-
- {badCases.map((item) => (
-
-
- {item.question}
-
-
-
-
-
-
- {updated(item.updated_at)}
-
-
- $['newKnowledge.qualityPage.questionActions'], {
- question: item.question ?? '',
- })}
- />
-
- {canEdit &&
- (item.status === 'fixed' ? (
-
- setDialog({
- id: item.id,
- key: `promote-${item.id}-${Date.now()}`,
- mode: 'promote',
- value: {
- annotation: '',
- expectedEvidenceIds: [],
- matchPolicy: 'all',
- question: item.question ?? '',
- tags: visibleTags(item.tags),
- },
- })
- }
- >
-
- {t(($) => $['newKnowledge.qualityPage.toGolden'])}
-
- ) : (
- void replayBadCase(item)}
- >
-
- {t(($) => $['newKnowledge.qualityPage.replay'])}
-
- ))}
- void openTrace(item.id)}
- >
-
- {t(($) => $['newKnowledge.qualityPage.openTrace'])}
-
- {canEdit && item.status !== 'fixed' && (
-
- setDialog({
- id: item.id,
- key: `promote-${item.id}-${Date.now()}`,
- mode: 'promote',
- value: {
- annotation: '',
- expectedEvidenceIds: [],
- matchPolicy: 'all',
- question: item.question ?? '',
- tags: visibleTags(item.tags),
- },
- })
- }
- >
-
- {t(($) => $['newKnowledge.qualityPage.toGolden'])}
-
- )}
- {canEdit && }
- {canEdit && (
- void ignoreBadCase(item)}
- >
-
- {t(($) => $['newKnowledge.qualityPage.ignore'])}
-
- )}
-
-
-
- ))}
- {badCaseQuery.hasNextPage && (
-
-
-
- )}
-
- ) : (
-
-
-
- {t(($) => $['newKnowledge.qualityPage.badCasesEmptyTitle'])}
-
-
- {t(($) => $['newKnowledge.qualityPage.badCasesEmptyDescription'])}
-
- {badCaseQuery.hasNextPage && (
-
- )}
-
- )}
-
+ {activeTab === 'bad' && (
+
+ )}
-
+ {activeTab === 'evaluation' && (
+
+ )}
-
- {canEdit && activeTab === 'golden' && selected.size > 0 && (
-
-
- {t(
- ($) =>
- $[
- selected.size === 1
- ? 'newKnowledge.qualityPage.selectedCount_one'
- : 'newKnowledge.qualityPage.selectedCount_other'
- ],
- { count: selected.size },
- )}
-
-
-
-
-
- )}
-
- {dialog && (
- {
- if (!open) {
- setDialog(undefined)
- setDialogError(undefined)
- }
- }}
- onSubmit={submitDialog}
- />
- )}
- {importOpen && (
-
- )}
- {
- if (!open && !deleteSubmitting) setDeleteIds(undefined)
- }}
- >
-
-
-
- {tCommon(($) => $['operation.deleteConfirmTitle'])}
-
-
- {tCommon(($) => $['operation.confirmAction'])}
-
-
-
-
- {tCommon(($) => $['operation.cancel'])}
-
- {
- if (deleteIds) void deleteGolden(deleteIds)
- }}
- >
- {tCommon(($) => $['operation.delete'])}
-
-
-
-
)
}
diff --git a/web/features/new-rag/quality/quality-query-state.tsx b/web/features/new-rag/quality/quality-query-state.tsx
new file mode 100644
index 00000000000..c1308c30fd8
--- /dev/null
+++ b/web/features/new-rag/quality/quality-query-state.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode } from 'react'
+import { Button } from '@langgenius/dify-ui/button'
+import { useTranslation } from 'react-i18next'
+import Loading from '@/app/components/base/loading'
+
+export function QualityQueryState({
+ children,
+ error,
+ loading,
+ onRetry,
+}: {
+ children: ReactNode
+ error: boolean
+ loading: boolean
+ onRetry: () => void
+}) {
+ const { t } = useTranslation('dataset')
+
+ if (loading)
+ return (
+
+
+
+ )
+
+ if (error)
+ return (
+
+
+
+ {t(($) => $.unknownError)}
+
+
+
+ )
+
+ return children
+}
diff --git a/web/features/new-rag/quality/quality-row-menu-trigger.tsx b/web/features/new-rag/quality/quality-row-menu-trigger.tsx
new file mode 100644
index 00000000000..f7a49f1e5d1
--- /dev/null
+++ b/web/features/new-rag/quality/quality-row-menu-trigger.tsx
@@ -0,0 +1,13 @@
+import { DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
+
+export function QualityRowMenuTrigger({ disabled, label }: { disabled?: boolean; label: string }) {
+ return (
+
+
+
+ )
+}