mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
refactor(knowledge-fs): split quality page state ownership
This commit is contained in:
parent
3983f979b7
commit
11d184ddae
@ -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 () => {
|
||||
|
||||
399
web/features/new-rag/quality/bad-cases-panel.tsx
Normal file
399
web/features/new-rag/quality/bad-cases-panel.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
475
web/features/new-rag/quality/golden-questions-panel.tsx
Normal file
475
web/features/new-rag/quality/golden-questions-panel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
38
web/features/new-rag/quality/quality-model.ts
Normal file
38
web/features/new-rag/quality/quality-model.ts
Normal 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)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
38
web/features/new-rag/quality/quality-query-state.tsx
Normal file
38
web/features/new-rag/quality/quality-query-state.tsx
Normal 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
|
||||
}
|
||||
13
web/features/new-rag/quality/quality-row-menu-trigger.tsx
Normal file
13
web/features/new-rag/quality/quality-row-menu-trigger.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user