refactor(knowledge-fs): split quality page state ownership

This commit is contained in:
Stephen Zhou 2026-08-31 17:32:07 +08:00
parent 3983f979b7
commit 11d184ddae
No known key found for this signature in database
7 changed files with 1010 additions and 899 deletions

View File

@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
return render(
<QueryClientProvider client={queryClient}>
<QualityPage knowledgeSpaceId="space-1" />
</QueryClientProvider>,
{ searchParams: navigationMock.tab ? `?tab=${navigationMock.tab}` : '' },
)
return render(<QualityPage knowledgeSpaceId="space-1" />, { 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 () => {

View File

@ -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 (
<div className="flex h-5 items-center gap-1.5 system-xs-medium text-text-primary">
<span
aria-hidden
className={cn(
'size-1.5 rounded-sm border shadow-xs',
visibleStatus === 'open' &&
'border-util-colors-warning-warning-600 bg-util-colors-warning-warning-400 shadow-util-colors-warning-warning-200',
visibleStatus === 'replaying' &&
'border-util-colors-blue-light-blue-light-600 bg-util-colors-blue-light-blue-light-400 shadow-util-colors-blue-light-blue-light-200',
visibleStatus === 'fixed' &&
'border-util-colors-green-green-600 bg-util-colors-green-green-400 shadow-util-colors-green-green-200',
)}
/>
{t(($) => $[`newKnowledge.qualityPage.status.${visibleStatus}`])}
</div>
)
}
export function BadCasesPanel({ canEdit, knowledgeSpaceId }: BadCasesPanelProps) {
const { t } = useTranslation('dataset')
const router = useRouter()
const queryClient = useQueryClient()
const [pendingId, setPendingId] = useState<string>()
const [dialog, setDialog] = useState<{
id: string
key: string
value: GoldenQuestionDraft
}>()
const [dialogError, setDialogError] = useState<string>()
const [dialogSubmitting, setDialogSubmitting] = useState(false)
const promotedGoldenQuestionIdsRef = useRef(new Map<string, string>())
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 (
<>
<QualityQueryState
error={query.isError}
loading={query.isLoading}
onRetry={() => void query.refetch()}
>
{items.length ? (
<div className="mt-2.5 w-full overflow-x-auto pt-3">
<div className="grid min-w-202 grid-cols-[minmax(240px,624px)_140px_180px_120px_80px] items-center gap-3 py-2.5 text-[11px] leading-4 font-medium text-text-tertiary">
<span>{t(($) => $['newKnowledge.qualityPage.question'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.statusLabel'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.reason'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.updated'])}</span>
<span />
</div>
{items.map((item) => (
<div
key={item.id}
className="grid h-12 min-w-202 grid-cols-[minmax(240px,624px)_140px_180px_120px_80px] items-center gap-3 border-t border-divider-subtle"
>
<span className="truncate system-sm-medium text-text-primary">{item.question}</span>
<BadCaseStatus status={item.status} />
<span className="system-xs-regular text-text-secondary">
<BadCaseReason question={item.question} reason={item.reason} tags={item.tags} />
</span>
<span className="system-xs-regular text-text-secondary">
{formatQualityUpdatedAt(item.updated_at)}
</span>
<DropdownMenu modal={false}>
<QualityRowMenuTrigger
disabled={pendingId === item.id}
label={t(($) => $['newKnowledge.qualityPage.questionActions'], {
question: item.question ?? '',
})}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={4} className="w-[200px]">
{canEdit &&
(item.status === 'fixed' ? (
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => openPromotion(item)}
>
<span aria-hidden className="i-ri-star-line size-4" />
{t(($) => $['newKnowledge.qualityPage.toGolden'])}
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="gap-2 px-3"
disabled={item.status === 'replaying' || pendingId === item.id}
onClick={() => void replayBadCase(item)}
>
<span aria-hidden className="i-ri-restart-line size-4" />
{t(($) => $['newKnowledge.qualityPage.replay'])}
</DropdownMenuItem>
))}
<DropdownMenuItem
className="gap-2 px-3"
disabled={pendingId === item.id}
onClick={() => void openTrace(item.id)}
>
<span aria-hidden className="i-ri-arrow-right-up-line size-4" />
{t(($) => $['newKnowledge.qualityPage.openTrace'])}
</DropdownMenuItem>
{canEdit && item.status !== 'fixed' && (
<DropdownMenuItem
className="gap-2 px-3"
disabled={pendingId === item.id}
onClick={() => openPromotion(item)}
>
<span aria-hidden className="i-ri-star-line size-4" />
{t(($) => $['newKnowledge.qualityPage.toGolden'])}
</DropdownMenuItem>
)}
{canEdit && <DropdownMenuSeparator />}
{canEdit && (
<DropdownMenuItem
className="gap-2 px-3"
disabled={pendingId === item.id}
onClick={() => void ignoreBadCase(item)}
>
<span aria-hidden className="i-ri-eye-off-line size-4" />
{t(($) => $['newKnowledge.qualityPage.ignore'])}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
{query.hasNextPage && (
<div className="flex min-w-202 justify-center border-t border-divider-subtle py-4">
<Button
loading={query.isFetchingNextPage}
disabled={query.isFetchingNextPage}
onClick={() => void query.fetchNextPage()}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
</div>
)}
</div>
) : (
<div className="mt-2.5 flex h-140 flex-col items-center justify-center text-center">
<span aria-hidden className="i-ri-check-line size-7 text-text-tertiary" />
<h2 className="mt-3 system-md-semibold text-text-primary">
{t(($) => $['newKnowledge.qualityPage.badCasesEmptyTitle'])}
</h2>
<p className="mt-1 max-w-136 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.qualityPage.badCasesEmptyDescription'])}
</p>
{query.hasNextPage && (
<Button
className="mt-4"
loading={query.isFetchingNextPage}
disabled={query.isFetchingNextPage}
onClick={() => void query.fetchNextPage()}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
)}
</div>
)}
</QualityQueryState>
{dialog && (
<GoldenQuestionDialog
key={dialog.key}
initialValue={dialog.value}
knowledgeSpaceId={knowledgeSpaceId}
mode="promote"
open
error={dialogError}
pending={
dialogSubmitting || createGoldenMutation.isPending || deleteGoldenMutation.isPending
}
onOpenChange={(open) => {
if (!open) {
setDialog(undefined)
setDialogError(undefined)
}
}}
onSubmit={submitPromotion}
/>
)}
</>
)
}

View File

@ -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 (
<span
className={cn(
'inline-flex w-fit items-center rounded-md px-1.5 py-0.5 system-2xs-medium-uppercase',
status === 'active' && 'bg-state-success-hover text-text-success',
status === 'draft' && 'bg-state-warning-hover text-text-warning',
status === 'stale' && 'bg-state-destructive-hover text-text-destructive',
)}
>
{t(($) => $[`newKnowledge.qualityPage.goldenStatus.${status}`])}
</span>
)
}
function GoldenAnnotation({ annotation }: { annotation: string }) {
if (!annotation.trim()) return <span className="system-xs-regular text-text-tertiary"></span>
return (
<Popover>
<PopoverTrigger
openOnHover
delay={300}
closeDelay={200}
render={
<button
type="button"
className="block w-fit max-w-full min-w-0 truncate text-left system-xs-regular text-text-secondary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{annotation}
</button>
}
/>
<PopoverContent placement="top" className="max-w-67 px-3 py-2">
<p className="system-xs-regular wrap-break-word text-text-tertiary">{annotation}</p>
</PopoverContent>
</Popover>
)
}
export function GoldenQuestionsPanel({
actionSlot,
canEdit,
knowledgeSpaceId,
}: GoldenQuestionsPanelProps) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const queryClient = useQueryClient()
const [selected, setSelected] = useState<Set<string>>(() => new Set())
const [deleteIds, setDeleteIds] = useState<Set<string>>()
const [deleteSubmitting, setDeleteSubmitting] = useState(false)
const [dialog, setDialog] = useState<GoldenQuestionDialogState>()
const [dialogError, setDialogError] = useState<string>()
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<string>): Promise<boolean> => {
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(
<>
<Button className="gap-1" onClick={() => setImportOpen(true)}>
<span aria-hidden className="i-ri-download-line size-4" />
{t(($) => $['newKnowledge.qualityPage.importCsv'])}
</Button>
<Button variant="primary" className="gap-1" onClick={openCreate}>
<span aria-hidden className="i-ri-add-line size-4" />
{t(($) => $['newKnowledge.qualityPage.addGolden'])}
</Button>
</>,
actionSlot,
)}
<QualityQueryState
error={query.isError}
loading={query.isLoading}
onRetry={() => void query.refetch()}
>
{items.length ? (
<div className="mt-2.5 w-full overflow-x-auto pt-3">
<div className="grid min-w-195 grid-cols-[16px_minmax(180px,2fr)_90px_minmax(120px,1fr)_minmax(160px,1.5fr)_minmax(100px,0.75fr)_32px] items-center gap-3 py-2.5 text-[11px] leading-4 font-medium text-text-tertiary">
<Checkbox
aria-label={t(($) => $['newKnowledge.qualityPage.selectAll'])}
checked={allSelected}
disabled={!canEdit}
indeterminate={partiallySelected}
onCheckedChange={toggleAll}
/>
<span>{t(($) => $['newKnowledge.qualityPage.question'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.statusLabel'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.tags'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.annotation'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.updated'])}</span>
<span />
</div>
{items.map((item) => (
<div
key={item.id}
className="grid h-12 min-w-195 grid-cols-[16px_minmax(180px,2fr)_90px_minmax(120px,1fr)_minmax(160px,1.5fr)_minmax(100px,0.75fr)_32px] items-center gap-3 border-t border-divider-subtle"
>
<Checkbox
aria-label={t(($) => $['newKnowledge.qualityPage.selectQuestion'], {
question: item.question,
})}
checked={selected.has(item.id)}
disabled={!canEdit}
onCheckedChange={() => toggleOne(item.id)}
/>
<span className="truncate system-sm-medium text-text-primary">
{item.question ?? ''}
</span>
<GoldenStatus
status={
item.status ??
((item.expected_evidence_ids?.length ?? 0) > 0 ? 'active' : 'draft')
}
/>
<div className="flex min-w-0 gap-1 overflow-hidden">
{visibleQualityTags(item.tags).map((tag) => (
<Badge key={tag} size="xs" variant="dimm" className="max-w-full min-w-0">
<span className="min-w-0 truncate system-2xs-medium normal-case">{tag}</span>
</Badge>
))}
</div>
<GoldenAnnotation annotation={item.annotation} />
<span className="system-xs-regular text-text-secondary">
{formatQualityUpdatedAt(item.updated_at)}
</span>
{canEdit ? (
<DropdownMenu modal={false}>
<QualityRowMenuTrigger
label={t(($) => $['newKnowledge.qualityPage.questionActions'], {
question: item.question,
})}
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
className="w-[200px]"
>
<DropdownMenuItem className="gap-2 px-3" onClick={() => openEdit(item)}>
<span aria-hidden className="i-ri-edit-line size-4" />
{t(($) => $['newKnowledge.qualityPage.edit'])}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={() => setDeleteIds(new Set([item.id]))}
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
{t(($) => $['newKnowledge.qualityPage.delete'])}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<span />
)}
</div>
))}
{query.hasNextPage && (
<div className="flex min-w-195 justify-center border-t border-divider-subtle py-4">
<Button
loading={query.isFetchingNextPage}
disabled={query.isFetchingNextPage}
onClick={() => void query.fetchNextPage()}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
</div>
)}
</div>
) : (
<div className="mt-2.5 flex h-140 flex-col items-center justify-center text-center">
<span aria-hidden className="i-ri-thumb-up-line size-7 text-text-tertiary" />
<h2 className="mt-3 system-md-semibold text-text-primary">
{t(($) => $['newKnowledge.qualityPage.goldenEmptyTitle'])}
</h2>
<p className="mt-1 max-w-lg system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.qualityPage.goldenEmptyDescription'])}
</p>
{canEdit && (
<div className="mt-4 flex gap-2">
<Button className="gap-1" onClick={() => setImportOpen(true)}>
<span aria-hidden className="i-ri-download-line size-4" />
{t(($) => $['newKnowledge.qualityPage.importCsv'])}
</Button>
<Button variant="primary" className="gap-1" onClick={openCreate}>
<span aria-hidden className="i-ri-add-line size-4" />
{t(($) => $['newKnowledge.qualityPage.addGolden'])}
</Button>
</div>
)}
</div>
)}
</QualityQueryState>
{canEdit && selected.size > 0 && (
<div className="fixed bottom-6 left-[calc(50%+var(--new-rag-sidebar-width)/2)] flex h-12 -translate-x-1/2 items-center gap-2 rounded-xl border border-components-panel-border bg-components-panel-bg px-3 shadow-xl">
<span className="system-sm-medium text-text-primary">
{t(
($) =>
$[
selected.size === 1
? 'newKnowledge.qualityPage.selectedCount_one'
: 'newKnowledge.qualityPage.selectedCount_other'
],
{ count: selected.size },
)}
</span>
<span aria-hidden className="h-5 w-px bg-divider-regular" />
<Button
variant="secondary"
tone="destructive"
onClick={() => setDeleteIds(new Set(selected))}
>
{t(($) => $['newKnowledge.qualityPage.deleteEllipsis'])}
</Button>
<button
type="button"
aria-label={t(($) => $['newKnowledge.qualityPage.clearSelection'])}
className="flex size-7 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => setSelected(new Set())}
>
<span aria-hidden className="i-ri-close-line size-4" />
</button>
</div>
)}
{dialog && (
<GoldenQuestionDialog
key={dialog.key}
initialValue={dialog.value}
knowledgeSpaceId={knowledgeSpaceId}
mode={dialog.mode}
open
error={dialogError}
pending={
dialogSubmitting ||
createMutation.isPending ||
updateMutation.isPending ||
deleteMutation.isPending
}
onOpenChange={(open) => {
if (!open) {
setDialog(undefined)
setDialogError(undefined)
}
}}
onSubmit={submitDialog}
/>
)}
{importOpen && (
<GoldenQuestionImportDialog
knowledgeSpaceId={knowledgeSpaceId}
open
onImported={invalidate}
onOpenChange={setImportOpen}
/>
)}
<AlertDialog
open={Boolean(deleteIds)}
onOpenChange={(open) => {
if (!open && !deleteSubmitting) setDeleteIds(undefined)
}}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{tCommon(($) => $['operation.deleteConfirmTitle'])}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{tCommon(($) => $['operation.confirmAction'])}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary" disabled={deleteSubmitting}>
{tCommon(($) => $['operation.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
loading={deleteSubmitting}
disabled={deleteSubmitting}
onClick={() => {
if (deleteIds) void deleteGolden(deleteIds)
}}
>
{tCommon(($) => $['operation.delete'])}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

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

View File

@ -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 (
<DropdownMenuTrigger
aria-label={label}
disabled={disabled}
className="ml-auto flex size-7 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled"
>
<span aria-hidden className="i-ri-more-fill size-4.5" />
</DropdownMenuTrigger>
)
}
function Status({ status }: { status: KnowledgeFsBadCaseResponse['status'] }) {
const { t } = useTranslation('dataset')
const visibleStatus = status === 'dismissed' ? 'fixed' : status
return (
<div className="flex h-5 items-center gap-1.5 system-xs-medium text-text-primary">
<span
aria-hidden
className={cn(
'size-1.5 rounded-sm border shadow-xs',
visibleStatus === 'open' &&
'border-util-colors-warning-warning-600 bg-util-colors-warning-warning-400 shadow-util-colors-warning-warning-200',
visibleStatus === 'replaying' &&
'border-util-colors-blue-light-blue-light-600 bg-util-colors-blue-light-blue-light-400 shadow-util-colors-blue-light-blue-light-200',
visibleStatus === 'fixed' &&
'border-util-colors-green-green-600 bg-util-colors-green-green-400 shadow-util-colors-green-green-200',
)}
/>
{t(($) => $[`newKnowledge.qualityPage.status.${visibleStatus}`])}
</div>
)
}
function GoldenStatus({ status }: { status: 'active' | 'draft' | 'stale' }) {
const { t } = useTranslation('dataset')
return (
<span
className={cn(
'inline-flex w-fit items-center rounded-md px-1.5 py-0.5 system-2xs-medium-uppercase',
status === 'active' && 'bg-state-success-hover text-text-success',
status === 'draft' && 'bg-state-warning-hover text-text-warning',
status === 'stale' && 'bg-state-destructive-hover text-text-destructive',
)}
>
{t(($) => $[`newKnowledge.qualityPage.goldenStatus.${status}`])}
</span>
)
}
function GoldenAnnotation({ annotation }: { annotation: string }) {
if (!annotation.trim()) return <span className="system-xs-regular text-text-tertiary"></span>
return (
<Popover>
<PopoverTrigger
openOnHover
delay={300}
closeDelay={200}
render={
<button
type="button"
className="block w-fit max-w-full min-w-0 truncate text-left system-xs-regular text-text-secondary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{annotation}
</button>
}
/>
<PopoverContent placement="top" className="max-w-67 px-3 py-2">
<p className="system-xs-regular wrap-break-word text-text-tertiary">{annotation}</p>
</PopoverContent>
</Popover>
)
}
function QualityQueryState({
children,
error,
loading,
onRetry,
}: {
children: ReactNode
error: boolean
loading: boolean
onRetry: () => void
}) {
const { t } = useTranslation('dataset')
if (loading)
return (
<div className="flex min-h-105 items-center justify-center">
<Loading />
</div>
)
if (error)
return (
<div className="flex min-h-105 flex-col items-center justify-center gap-3 text-center">
<span aria-hidden className="i-ri-error-warning-line size-8 text-text-warning" />
<p role="alert" className="system-sm-medium text-text-primary">
{t(($) => $.unknownError)}
</p>
<Button onClick={onRetry}>{t(($) => $.retry)}</Button>
</div>
)
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<Set<string>>(() => new Set())
const [deleteIds, setDeleteIds] = useState<Set<string>>()
const [deleteSubmitting, setDeleteSubmitting] = useState(false)
const [dialogError, setDialogError] = useState<string>()
const [dialogSubmitting, setDialogSubmitting] = useState(false)
const [evaluationActionSlot, setEvaluationActionSlot] = useState<HTMLDivElement | null>(null)
const [queryTab, setQueryTab] = useQueryState('tab', qualityTabParser)
const activeTab: QualityTab =
queryTab === 'bad-cases' ? 'bad' : queryTab === 'evaluations' ? 'evaluation' : 'golden'
const [actionSlot, setActionSlot] = useState<HTMLDivElement | null>(null)
const [selectedEvaluationRunId, setSelectedEvaluationRunId] = useState<string>()
const [importOpen, setImportOpen] = useState(false)
const [pendingBadCaseId, setPendingBadCaseId] = useState<string>()
const promotedGoldenQuestionIdsRef = useRef(new Map<string, string>())
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<string>): Promise<boolean> => {
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 (
<main
@ -492,451 +48,43 @@ export function QualityPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
</p>
</header>
)}
<Tabs value={activeTab} onValueChange={(value) => setTab(value as typeof activeTab)}>
<Tabs value={activeTab} onValueChange={(value) => setTab(value as QualityTab)}>
{!selectedEvaluationRunId && (
<div className="mt-2.5 flex h-14 items-end justify-between">
<QualityTabList />
<div
ref={activeTab === 'evaluation' ? setEvaluationActionSlot : undefined}
className="flex gap-2"
>
{canEdit && activeTab === 'golden' && goldenQuestions.length > 0 && (
<>
<Button onClick={() => setImportOpen(true)}>
<span aria-hidden className="i-ri-download-line size-4" />
{t(($) => $['newKnowledge.qualityPage.importCsv'])}
</Button>
<Button
variant="primary"
onClick={() =>
setDialog({ key: `create-${Date.now()}`, mode: 'create', value: emptyDraft })
}
>
<span aria-hidden className="i-ri-add-line size-4" />
{t(($) => $['newKnowledge.qualityPage.addGolden'])}
</Button>
</>
)}
</div>
<div ref={setActionSlot} className="flex gap-2" />
</div>
)}
<TabsPanel value="golden" tabIndex={-1}>
<QualityQueryState
error={goldenQuery.isError}
loading={goldenQuery.isLoading}
onRetry={() => void goldenQuery.refetch()}
>
{goldenQuestions.length ? (
<div className="mt-3 w-full overflow-x-auto">
<div className="grid h-8 min-w-195 grid-cols-[16px_minmax(180px,2fr)_90px_minmax(120px,1fr)_minmax(160px,1.5fr)_minmax(100px,0.75fr)_32px] items-center gap-3 text-[11px] leading-4 font-medium text-text-tertiary">
<Checkbox
aria-label={t(($) => $['newKnowledge.qualityPage.selectAll'])}
checked={allSelected}
disabled={!canEdit}
indeterminate={partiallySelected}
onCheckedChange={toggleAll}
/>
<span>{t(($) => $['newKnowledge.qualityPage.question'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.statusLabel'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.tags'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.annotation'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.updated'])}</span>
<span />
</div>
{goldenQuestions.map((item) => (
<div
key={item.id}
className="grid h-12 min-w-195 grid-cols-[16px_minmax(180px,2fr)_90px_minmax(120px,1fr)_minmax(160px,1.5fr)_minmax(100px,0.75fr)_32px] items-center gap-3 border-t border-divider-subtle"
>
<Checkbox
aria-label={t(($) => $['newKnowledge.qualityPage.selectQuestion'], {
question: item.question,
})}
checked={selected.has(item.id)}
disabled={!canEdit}
onCheckedChange={() => toggleOne(item.id)}
/>
<span className="truncate system-sm-medium text-text-primary">
{item.question ?? ''}
</span>
<GoldenStatus
status={
item.status ??
((item.expected_evidence_ids?.length ?? 0) > 0 ? 'active' : 'draft')
}
/>
<div className="flex min-w-0 gap-1 overflow-hidden">
{visibleTags(item.tags).map((tag) => (
<Badge key={tag} size="xs" variant="dimm" className="max-w-full min-w-0">
<span className="min-w-0 truncate system-2xs-medium normal-case">
{tag}
</span>
</Badge>
))}
</div>
<GoldenAnnotation annotation={item.annotation} />
<span className="system-xs-regular text-text-secondary">
{updated(item.updated_at)}
</span>
{canEdit ? (
<DropdownMenu modal={false}>
<RowMenuTrigger
label={t(($) => $['newKnowledge.qualityPage.questionActions'], {
question: item.question,
})}
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
className="w-[200px]"
>
<DropdownMenuItem
className="gap-2 px-3"
onClick={() =>
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,
},
})
}
>
<span aria-hidden className="i-ri-edit-line size-4" />
{t(($) => $['newKnowledge.qualityPage.edit'])}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={() => setDeleteIds(new Set([item.id]))}
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
{t(($) => $['newKnowledge.qualityPage.delete'])}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<span />
)}
</div>
))}
{goldenQuery.hasNextPage && (
<div className="flex min-w-195 justify-center border-t border-divider-subtle py-4">
<Button
loading={goldenQuery.isFetchingNextPage}
disabled={goldenQuery.isFetchingNextPage}
onClick={() => void goldenQuery.fetchNextPage()}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
</div>
)}
</div>
) : (
<div className="mt-2.5 flex h-140 flex-col items-center justify-center text-center">
<span aria-hidden className="i-ri-thumb-up-line size-7 text-text-tertiary" />
<h2 className="mt-3 system-md-semibold text-text-primary">
{t(($) => $['newKnowledge.qualityPage.goldenEmptyTitle'])}
</h2>
<p className="mt-1 max-w-lg system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.qualityPage.goldenEmptyDescription'])}
</p>
{canEdit && (
<div className="mt-4 flex gap-2">
<Button className="gap-1" onClick={() => setImportOpen(true)}>
<span aria-hidden className="i-ri-download-line size-4" />
{t(($) => $['newKnowledge.qualityPage.importCsv'])}
</Button>
<Button
variant="primary"
className="gap-1"
onClick={() =>
setDialog({
key: `create-${Date.now()}`,
mode: 'create',
value: emptyDraft,
})
}
>
<span aria-hidden className="i-ri-add-line size-4" />
{t(($) => $['newKnowledge.qualityPage.addGolden'])}
</Button>
</div>
)}
</div>
)}
</QualityQueryState>
{activeTab === 'golden' && (
<GoldenQuestionsPanel
actionSlot={actionSlot}
canEdit={canEdit}
knowledgeSpaceId={knowledgeSpaceId}
/>
)}
</TabsPanel>
<TabsPanel value="bad" tabIndex={-1}>
<QualityQueryState
error={badCaseQuery.isError}
loading={badCaseQuery.isLoading}
onRetry={() => void badCaseQuery.refetch()}
>
{badCases.length ? (
<div className="mt-3 w-full overflow-x-auto">
<div className="grid h-8 min-w-202 grid-cols-[minmax(240px,624px)_140px_180px_120px_80px] items-center gap-3 text-[11px] leading-4 font-medium text-text-tertiary">
<span>{t(($) => $['newKnowledge.qualityPage.question'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.statusLabel'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.reason'])}</span>
<span>{t(($) => $['newKnowledge.qualityPage.updated'])}</span>
<span />
</div>
{badCases.map((item) => (
<div
key={item.id}
className="grid h-12 min-w-202 grid-cols-[minmax(240px,624px)_140px_180px_120px_80px] items-center gap-3 border-t border-divider-subtle"
>
<span className="truncate system-sm-medium text-text-primary">
{item.question}
</span>
<Status status={item.status} />
<span className="system-xs-regular text-text-secondary">
<Reason question={item.question} reason={item.reason} tags={item.tags} />
</span>
<span className="system-xs-regular text-text-secondary">
{updated(item.updated_at)}
</span>
<DropdownMenu modal={false}>
<RowMenuTrigger
disabled={pendingBadCaseId === item.id}
label={t(($) => $['newKnowledge.qualityPage.questionActions'], {
question: item.question ?? '',
})}
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
className="w-[200px]"
>
{canEdit &&
(item.status === 'fixed' ? (
<DropdownMenuItem
className="gap-2 px-3"
onClick={() =>
setDialog({
id: item.id,
key: `promote-${item.id}-${Date.now()}`,
mode: 'promote',
value: {
annotation: '',
expectedEvidenceIds: [],
matchPolicy: 'all',
question: item.question ?? '',
tags: visibleTags(item.tags),
},
})
}
>
<span aria-hidden className="i-ri-star-line size-4" />
{t(($) => $['newKnowledge.qualityPage.toGolden'])}
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="gap-2 px-3"
disabled={item.status === 'replaying' || pendingBadCaseId === item.id}
onClick={() => void replayBadCase(item)}
>
<span aria-hidden className="i-ri-restart-line size-4" />
{t(($) => $['newKnowledge.qualityPage.replay'])}
</DropdownMenuItem>
))}
<DropdownMenuItem
className="gap-2 px-3"
disabled={pendingBadCaseId === item.id}
onClick={() => void openTrace(item.id)}
>
<span aria-hidden className="i-ri-arrow-right-up-line size-4" />
{t(($) => $['newKnowledge.qualityPage.openTrace'])}
</DropdownMenuItem>
{canEdit && item.status !== 'fixed' && (
<DropdownMenuItem
className="gap-2 px-3"
disabled={pendingBadCaseId === item.id}
onClick={() =>
setDialog({
id: item.id,
key: `promote-${item.id}-${Date.now()}`,
mode: 'promote',
value: {
annotation: '',
expectedEvidenceIds: [],
matchPolicy: 'all',
question: item.question ?? '',
tags: visibleTags(item.tags),
},
})
}
>
<span aria-hidden className="i-ri-star-line size-4" />
{t(($) => $['newKnowledge.qualityPage.toGolden'])}
</DropdownMenuItem>
)}
{canEdit && <DropdownMenuSeparator />}
{canEdit && (
<DropdownMenuItem
className="gap-2 px-3"
disabled={pendingBadCaseId === item.id}
onClick={() => void ignoreBadCase(item)}
>
<span aria-hidden className="i-ri-eye-off-line size-4" />
{t(($) => $['newKnowledge.qualityPage.ignore'])}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
{badCaseQuery.hasNextPage && (
<div className="flex min-w-202 justify-center border-t border-divider-subtle py-4">
<Button
loading={badCaseQuery.isFetchingNextPage}
disabled={badCaseQuery.isFetchingNextPage}
onClick={() => void badCaseQuery.fetchNextPage()}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
</div>
)}
</div>
) : (
<div className="mt-2.5 flex h-140 flex-col items-center justify-center text-center">
<span aria-hidden className="i-ri-check-line size-7 text-text-tertiary" />
<h2 className="mt-3 system-md-semibold text-text-primary">
{t(($) => $['newKnowledge.qualityPage.badCasesEmptyTitle'])}
</h2>
<p className="mt-1 max-w-136 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.qualityPage.badCasesEmptyDescription'])}
</p>
{badCaseQuery.hasNextPage && (
<Button
className="mt-4"
loading={badCaseQuery.isFetchingNextPage}
disabled={badCaseQuery.isFetchingNextPage}
onClick={() => void badCaseQuery.fetchNextPage()}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
)}
</div>
)}
</QualityQueryState>
{activeTab === 'bad' && (
<BadCasesPanel canEdit={canEdit} knowledgeSpaceId={knowledgeSpaceId} />
)}
</TabsPanel>
<TabsPanel value="evaluation" tabIndex={-1}>
<QualityEvaluationPanel
actionSlot={evaluationActionSlot}
canEdit={canEdit}
knowledgeSpaceId={knowledgeSpaceId}
selectedRunId={selectedEvaluationRunId}
onSelectedRunIdChange={setSelectedEvaluationRunId}
/>
{activeTab === 'evaluation' && (
<QualityEvaluationPanel
actionSlot={actionSlot}
canEdit={canEdit}
knowledgeSpaceId={knowledgeSpaceId}
selectedRunId={selectedEvaluationRunId}
onSelectedRunIdChange={setSelectedEvaluationRunId}
/>
)}
</TabsPanel>
{canEdit && activeTab === 'golden' && selected.size > 0 && (
<div className="fixed bottom-6 left-[calc(50%+var(--new-rag-sidebar-width)/2)] flex h-12 -translate-x-1/2 items-center gap-2 rounded-xl border border-components-panel-border bg-components-panel-bg px-3 shadow-xl">
<span className="system-sm-medium text-text-primary">
{t(
($) =>
$[
selected.size === 1
? 'newKnowledge.qualityPage.selectedCount_one'
: 'newKnowledge.qualityPage.selectedCount_other'
],
{ count: selected.size },
)}
</span>
<span aria-hidden className="h-5 w-px bg-divider-regular" />
<Button
variant="secondary"
tone="destructive"
onClick={() => setDeleteIds(new Set(selected))}
>
{t(($) => $['newKnowledge.qualityPage.deleteEllipsis'])}
</Button>
<button
type="button"
aria-label={t(($) => $['newKnowledge.qualityPage.clearSelection'])}
className="flex size-7 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => setSelected(new Set())}
>
<span aria-hidden className="i-ri-close-line size-4" />
</button>
</div>
)}
</Tabs>
{dialog && (
<GoldenQuestionDialog
key={dialog.key}
initialValue={dialog.value}
knowledgeSpaceId={knowledgeSpaceId}
mode={dialog.mode}
open
error={dialogError}
pending={
dialogSubmitting ||
createGoldenMutation.isPending ||
updateGoldenMutation.isPending ||
deleteGoldenMutation.isPending
}
onOpenChange={(open) => {
if (!open) {
setDialog(undefined)
setDialogError(undefined)
}
}}
onSubmit={submitDialog}
/>
)}
{importOpen && (
<GoldenQuestionImportDialog
knowledgeSpaceId={knowledgeSpaceId}
open
onImported={invalidateQuality}
onOpenChange={setImportOpen}
/>
)}
<AlertDialog
open={Boolean(deleteIds)}
onOpenChange={(open) => {
if (!open && !deleteSubmitting) setDeleteIds(undefined)
}}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{tCommon(($) => $['operation.deleteConfirmTitle'])}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{tCommon(($) => $['operation.confirmAction'])}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary" disabled={deleteSubmitting}>
{tCommon(($) => $['operation.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
loading={deleteSubmitting}
disabled={deleteSubmitting}
onClick={() => {
if (deleteIds) void deleteGolden(deleteIds)
}}
>
{tCommon(($) => $['operation.delete'])}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</main>
)
}

View File

@ -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 (
<div className="flex min-h-105 items-center justify-center">
<Loading />
</div>
)
if (error)
return (
<div className="flex min-h-105 flex-col items-center justify-center gap-3 text-center">
<span aria-hidden className="i-ri-error-warning-line size-8 text-text-warning" />
<p role="alert" className="system-sm-medium text-text-primary">
{t(($) => $.unknownError)}
</p>
<Button onClick={onRetry}>{t(($) => $.retry)}</Button>
</div>
)
return children
}

View File

@ -0,0 +1,13 @@
import { DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
export function QualityRowMenuTrigger({ disabled, label }: { disabled?: boolean; label: string }) {
return (
<DropdownMenuTrigger
aria-label={label}
disabled={disabled}
className="ml-auto flex size-7 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled"
>
<span aria-hidden className="i-ri-more-fill size-4.5" />
</DropdownMenuTrigger>
)
}