feat:同步本地非算法改动到上游基线

保留反馈纠错、WebUI 与运行时增强。\n移除不应提交的 algorithm_redesign 设计目录及其专项测试。
This commit is contained in:
A-Dawn
2026-04-16 13:57:07 +08:00
parent 6c22fdfdf9
commit 21b642d07d
10 changed files with 2244 additions and 34 deletions

View File

@@ -496,6 +496,77 @@ export interface MemoryDeleteOperationDetailPayload {
error?: string
}
export interface MemoryFeedbackAffectedCountsPayload {
relations?: number
stale_paragraphs?: number
episode_sources?: number
profile_person_ids?: number
correction_paragraphs?: number
corrected_relations?: number
}
export interface MemoryFeedbackActionLogPayload {
id: number
task_id: number
query_tool_id: string
action_type: string
target_hash: string
reason?: string
before_payload?: Record<string, unknown>
after_payload?: Record<string, unknown>
created_at?: number
}
export interface MemoryFeedbackCorrectionSummaryPayload {
task_id: number
query_tool_id: string
session_id: string
query_text: string
query_timestamp?: number
task_status: string
decision: string
decision_confidence: number
feedback_message_count: number
rollback_status: string
affected_counts: MemoryFeedbackAffectedCountsPayload
created_at?: number
updated_at?: number
}
export interface MemoryFeedbackCorrectionDetailTaskPayload extends MemoryFeedbackCorrectionSummaryPayload {
query_snapshot?: Record<string, unknown>
decision_payload?: Record<string, unknown>
rollback_plan_summary?: Record<string, unknown>
rollback_result?: Record<string, unknown>
rollback_error?: string
rollback_requested_by?: string
rollback_reason?: string
rollback_requested_at?: number
rolled_back_at?: number
action_logs?: MemoryFeedbackActionLogPayload[]
}
export interface MemoryFeedbackCorrectionListPayload {
success: boolean
items: MemoryFeedbackCorrectionSummaryPayload[]
count?: number
error?: string
}
export interface MemoryFeedbackCorrectionDetailPayload {
success: boolean
task?: MemoryFeedbackCorrectionDetailTaskPayload | null
error?: string
}
export interface MemoryFeedbackCorrectionRollbackPayload {
success: boolean
already_rolled_back?: boolean
result?: Record<string, unknown>
task?: MemoryFeedbackCorrectionDetailTaskPayload | null
error?: string
}
export interface MemorySourceItemPayload {
source: string
paragraph_count?: number
@@ -610,6 +681,49 @@ export async function getMemoryDeleteOperation(
return requestJson<MemoryDeleteOperationDetailPayload>(`/delete/operations/${encodeURIComponent(operationId)}`)
}
export async function getMemoryFeedbackCorrections(
options?: {
limit?: number
status?: string
rollbackStatus?: string
query?: string
},
): Promise<MemoryFeedbackCorrectionListPayload> {
const params = new URLSearchParams({
limit: String(options?.limit ?? 50),
})
if (options?.status?.trim()) {
params.set('status', options.status.trim())
}
if (options?.rollbackStatus?.trim()) {
params.set('rollback_status', options.rollbackStatus.trim())
}
if (options?.query?.trim()) {
params.set('query', options.query.trim())
}
return requestJson<MemoryFeedbackCorrectionListPayload>(`/feedback-corrections?${params.toString()}`)
}
export async function getMemoryFeedbackCorrection(
taskId: number,
): Promise<MemoryFeedbackCorrectionDetailPayload> {
return requestJson<MemoryFeedbackCorrectionDetailPayload>(`/feedback-corrections/${taskId}`)
}
export async function rollbackMemoryFeedbackCorrection(
taskId: number,
payload: {
requested_by?: string
reason?: string
},
): Promise<MemoryFeedbackCorrectionRollbackPayload> {
return requestJson<MemoryFeedbackCorrectionRollbackPayload>(`/feedback-corrections/${taskId}/rollback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function getMemorySources(): Promise<MemorySourceListPayload> {
return requestJson<MemorySourceListPayload>('/sources')
}

View File

@@ -81,9 +81,12 @@ vi.mock('@/lib/memory-api', () => ({
getMemorySources: vi.fn(),
getMemoryDeleteOperations: vi.fn(),
getMemoryDeleteOperation: vi.fn(),
getMemoryFeedbackCorrections: vi.fn(),
getMemoryFeedbackCorrection: vi.fn(),
previewMemoryDelete: vi.fn(),
executeMemoryDelete: vi.fn(),
restoreMemoryDelete: vi.fn(),
rollbackMemoryFeedbackCorrection: vi.fn(),
}))
function mockImportTask(taskId: string, status: string = 'running'): memoryApi.MemoryImportTaskPayload {
@@ -357,6 +360,82 @@ describe('KnowledgeBasePage import workflow', () => {
items: [],
},
})
vi.mocked(memoryApi.getMemoryFeedbackCorrections).mockResolvedValue({
success: true,
items: [
{
task_id: 11,
query_tool_id: 'tool-query-11',
session_id: 'session-1',
query_text: '测试用户最喜欢的颜色是什么',
query_timestamp: 1_710_000_010,
task_status: 'applied',
decision: 'correct',
decision_confidence: 0.97,
feedback_message_count: 1,
rollback_status: 'none',
affected_counts: {
relations: 1,
stale_paragraphs: 1,
episode_sources: 2,
profile_person_ids: 1,
correction_paragraphs: 1,
corrected_relations: 1,
},
created_at: 1_710_000_011,
updated_at: 1_710_000_012,
},
],
count: 1,
})
vi.mocked(memoryApi.getMemoryFeedbackCorrection).mockResolvedValue({
success: true,
task: {
task_id: 11,
query_tool_id: 'tool-query-11',
session_id: 'session-1',
query_text: '测试用户最喜欢的颜色是什么',
query_timestamp: 1_710_000_010,
task_status: 'applied',
decision: 'correct',
decision_confidence: 0.97,
feedback_message_count: 1,
rollback_status: 'none',
affected_counts: {
relations: 1,
stale_paragraphs: 1,
episode_sources: 2,
profile_person_ids: 1,
correction_paragraphs: 1,
corrected_relations: 1,
},
query_snapshot: { query: '测试用户最喜欢的颜色是什么', hits: [{ hash: 'paragraph-1' }] },
decision_payload: { decision: 'correct', confidence: 0.97 },
rollback_plan_summary: {
forgotten_relations: [{ hash: 'rel-old', subject: '测试用户', predicate: '最喜欢的颜色是', object: '蓝色' }],
corrected_write: {
paragraph_hashes: ['paragraph-new'],
corrected_relations: [{ hash: 'rel-new', subject: '测试用户', predicate: '最喜欢的颜色是', object: '绿色' }],
},
},
rollback_result: {},
action_logs: [
{
id: 1,
task_id: 11,
query_tool_id: 'tool-query-11',
action_type: 'forget_relation',
target_hash: 'rel-old',
reason: '用户明确纠正为绿色',
before_payload: { hash: 'rel-old', subject: '测试用户', predicate: '最喜欢的颜色是', object: '蓝色' },
after_payload: { is_inactive: true },
created_at: 1_710_000_013,
},
],
created_at: 1_710_000_011,
updated_at: 1_710_000_012,
},
})
vi.mocked(memoryApi.previewMemoryDelete).mockResolvedValue({
success: true,
mode: 'source',
@@ -380,6 +459,37 @@ describe('KnowledgeBasePage import workflow', () => {
deleted_source_count: 1,
} as never)
vi.mocked(memoryApi.restoreMemoryDelete).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.rollbackMemoryFeedbackCorrection).mockResolvedValue({
success: true,
result: { restored_relation_hashes: ['rel-old'] },
task: {
task_id: 11,
query_tool_id: 'tool-query-11',
session_id: 'session-1',
query_text: '测试用户最喜欢的颜色是什么',
query_timestamp: 1_710_000_010,
task_status: 'applied',
decision: 'correct',
decision_confidence: 0.97,
feedback_message_count: 1,
rollback_status: 'rolled_back',
affected_counts: {
relations: 1,
stale_paragraphs: 1,
episode_sources: 2,
profile_person_ids: 1,
correction_paragraphs: 1,
corrected_relations: 1,
},
query_snapshot: { query: '测试用户最喜欢的颜色是什么', hits: [{ hash: 'paragraph-1' }] },
decision_payload: { decision: 'correct', confidence: 0.97 },
rollback_plan_summary: {},
rollback_result: { restored_relation_hashes: ['rel-old'] },
action_logs: [],
created_at: 1_710_000_011,
updated_at: 1_710_000_012,
},
})
vi.mocked(memoryApi.refreshMemoryRuntimeSelfCheck).mockResolvedValue({
success: true,
report: { ok: true },
@@ -619,4 +729,27 @@ describe('KnowledgeBasePage import workflow', () => {
}),
)
}, 20_000)
it('shows feedback correction history and supports rollback', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '纠错历史' }))
await screen.findByText('反馈纠错历史')
await screen.findByText('测试用户最喜欢的颜色是什么')
await waitFor(() => expect(memoryApi.getMemoryFeedbackCorrection).toHaveBeenCalledWith(11))
await user.click(screen.getByRole('button', { name: '回退本次纠错' }))
const rollbackReason = await screen.findByLabelText('回退原因')
await user.type(rollbackReason, '人工确认回退')
await user.click(screen.getByRole('button', { name: '确认回退' }))
await waitFor(() =>
expect(memoryApi.rollbackMemoryFeedbackCorrection).toHaveBeenCalledWith(11, {
requested_by: 'knowledge_base',
reason: '人工确认回退',
}),
)
}, 20_000)
})

View File

@@ -24,6 +24,14 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
@@ -48,6 +56,8 @@ import {
createMemoryRawScanImport,
createMemoryTemporalBackfillImport,
executeMemoryDelete,
getMemoryFeedbackCorrection,
getMemoryFeedbackCorrections,
getMemoryImportPathAliases,
getMemoryImportSettings,
getMemoryImportTask,
@@ -74,6 +84,7 @@ import {
type MemoryImportTaskPayload,
previewMemoryDelete,
refreshMemoryRuntimeSelfCheck,
rollbackMemoryFeedbackCorrection,
resolveMemoryImportPath,
retryMemoryImportTask,
restoreMemoryDelete,
@@ -82,6 +93,9 @@ import {
type MemoryConfigSchemaPayload,
type MemoryDeleteExecutePayload,
type MemoryDeleteOperationPayload,
type MemoryFeedbackActionLogPayload,
type MemoryFeedbackCorrectionDetailTaskPayload,
type MemoryFeedbackCorrectionSummaryPayload,
type MemorySourceItemPayload,
type MemoryRuntimeConfigPayload,
type MemoryTaskPayload,
@@ -90,6 +104,9 @@ import {
const DELETE_OPERATION_FETCH_LIMIT = 100
const DELETE_OPERATION_PAGE_SIZE = 6
const DELETE_OPERATION_ITEM_PAGE_SIZE = 8
const FEEDBACK_CORRECTION_FETCH_LIMIT = 100
const FEEDBACK_CORRECTION_PAGE_SIZE = 6
const FEEDBACK_ACTION_LOG_PAGE_SIZE = 8
const IMPORT_CHUNK_PAGE_SIZE = 50
const RUNNING_IMPORT_STATUS = new Set(['preparing', 'running', 'cancel_requested'])
@@ -270,6 +287,90 @@ function formatDeleteOperationTime(timestamp?: number | null): string {
})
}
function formatFeedbackDecision(decision: string): string {
switch (decision) {
case 'correct':
return '纠正'
case 'reject':
return '否定'
case 'confirm':
return '确认'
case 'supplement':
return '补充'
case 'none':
return '无动作'
default:
return decision || '未知'
}
}
function formatFeedbackTaskStatus(status: string): string {
switch (status) {
case 'pending':
return '待处理'
case 'running':
return '处理中'
case 'applied':
return '已应用'
case 'skipped':
return '已跳过'
case 'error':
return '失败'
default:
return status || '未知'
}
}
function formatFeedbackRollbackStatus(status: string): string {
switch (status) {
case 'none':
return '未回退'
case 'running':
return '回退中'
case 'rolled_back':
return '已回退'
case 'error':
return '回退失败'
default:
return status || '未知'
}
}
function getFeedbackStatusVariant(
status: string,
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (status === 'applied' || status === 'rolled_back') {
return 'default'
}
if (status === 'error') {
return 'destructive'
}
if (status === 'running' || status === 'pending') {
return 'outline'
}
return 'secondary'
}
function summarizeFeedbackActionPayload(value: Record<string, unknown> | undefined): string {
if (!value) {
return ''
}
const hash = String(value.hash ?? '').trim()
const subject = String(value.subject ?? '').trim()
const predicate = String(value.predicate ?? '').trim()
const object = String(value.object ?? '').trim()
if (subject && predicate && object) {
return formatDeleteRelationText(subject, predicate, object)
}
if (hash) {
return hash
}
if (Array.isArray(value.target_hashes) && value.target_hashes.length > 0) {
return `targets ${value.target_hashes.length}`
}
return trimDeleteItemText(JSON.stringify(value, null, 2), 120)
}
type DeleteOperationItem = NonNullable<MemoryDeleteOperationPayload['items']>[number]
function trimDeleteItemText(value: string, maxLength: number = 140): string {
@@ -471,6 +572,20 @@ export function KnowledgeBasePage() {
const [deleteRestoring, setDeleteRestoring] = useState(false)
const [deleteResult, setDeleteResult] = useState<MemoryDeleteExecutePayload | null>(null)
const [pendingDeleteRequest, setPendingDeleteRequest] = useState<MemoryDeleteRequestPayload | null>(null)
const [feedbackCorrections, setFeedbackCorrections] = useState<MemoryFeedbackCorrectionSummaryPayload[]>([])
const [feedbackSearch, setFeedbackSearch] = useState('')
const [feedbackStatusFilter, setFeedbackStatusFilter] = useState('all')
const [feedbackRollbackFilter, setFeedbackRollbackFilter] = useState('all')
const [feedbackPage, setFeedbackPage] = useState(1)
const [selectedFeedbackTaskId, setSelectedFeedbackTaskId] = useState(0)
const [selectedFeedbackTaskDetail, setSelectedFeedbackTaskDetail] = useState<MemoryFeedbackCorrectionDetailTaskPayload | null>(null)
const [selectedFeedbackTaskLoading, setSelectedFeedbackTaskLoading] = useState(false)
const [selectedFeedbackTaskError, setSelectedFeedbackTaskError] = useState('')
const [feedbackActionLogSearch, setFeedbackActionLogSearch] = useState('')
const [feedbackActionLogPage, setFeedbackActionLogPage] = useState(1)
const [feedbackRollbackDialogOpen, setFeedbackRollbackDialogOpen] = useState(false)
const [feedbackRollbackReason, setFeedbackRollbackReason] = useState('')
const [feedbackRollingBack, setFeedbackRollingBack] = useState(false)
const [tuningObjective, setTuningObjective] = useState('precision_priority')
const [tuningIntensity, setTuningIntensity] = useState('standard')
const [tuningSampleSize, setTuningSampleSize] = useState('24')
@@ -491,6 +606,7 @@ export function KnowledgeBasePage() {
tuningTaskPayload,
sourcePayload,
deleteOperationPayload,
feedbackCorrectionPayload,
] = await Promise.all([
getMemoryConfigSchema(),
getMemoryConfig(),
@@ -503,6 +619,7 @@ export function KnowledgeBasePage() {
getMemoryTuningTasks(20),
getMemorySources(),
getMemoryDeleteOperations(DELETE_OPERATION_FETCH_LIMIT),
getMemoryFeedbackCorrections({ limit: FEEDBACK_CORRECTION_FETCH_LIMIT }),
])
setSchemaPayload(schema)
@@ -519,6 +636,7 @@ export function KnowledgeBasePage() {
setTuningTasks(tuningTaskPayload.items ?? [])
setMemorySources(sourcePayload.items ?? [])
setDeleteOperations(deleteOperationPayload.items ?? [])
setFeedbackCorrections(feedbackCorrectionPayload.items ?? [])
if (!selectedImportTaskId && (importTaskPayload.items ?? []).length > 0) {
const initialTaskId = String(importTaskPayload.items?.[0]?.task_id ?? '')
if (initialTaskId) {
@@ -1494,6 +1612,212 @@ export function KnowledgeBasePage() {
setDeleteDialogOpen(true)
}, [])
const filteredFeedbackCorrections = useMemo(() => {
const keyword = feedbackSearch.trim().toLowerCase()
return feedbackCorrections.filter((item) => {
const taskStatus = String(item.task_status ?? '').trim().toLowerCase()
const rollbackStatus = String(item.rollback_status ?? '').trim().toLowerCase()
if (feedbackStatusFilter !== 'all' && taskStatus !== feedbackStatusFilter) {
return false
}
if (feedbackRollbackFilter !== 'all' && rollbackStatus !== feedbackRollbackFilter) {
return false
}
if (!keyword) {
return true
}
return [
item.query_tool_id,
item.session_id,
item.query_text,
item.decision,
item.task_status,
item.rollback_status,
]
.map((value) => String(value ?? '').toLowerCase())
.some((value) => value.includes(keyword))
})
}, [feedbackCorrections, feedbackRollbackFilter, feedbackSearch, feedbackStatusFilter])
const feedbackPageCount = Math.max(1, Math.ceil(filteredFeedbackCorrections.length / FEEDBACK_CORRECTION_PAGE_SIZE))
const pagedFeedbackCorrections = useMemo(() => {
const start = (feedbackPage - 1) * FEEDBACK_CORRECTION_PAGE_SIZE
return filteredFeedbackCorrections.slice(start, start + FEEDBACK_CORRECTION_PAGE_SIZE)
}, [feedbackPage, filteredFeedbackCorrections])
const selectedFeedbackCorrection = useMemo(
() =>
filteredFeedbackCorrections.find((item) => item.task_id === selectedFeedbackTaskId)
?? pagedFeedbackCorrections[0]
?? null,
[filteredFeedbackCorrections, pagedFeedbackCorrections, selectedFeedbackTaskId],
)
useEffect(() => {
setFeedbackPage(1)
}, [feedbackSearch, feedbackStatusFilter, feedbackRollbackFilter])
useEffect(() => {
if (feedbackPage > feedbackPageCount) {
setFeedbackPage(feedbackPageCount)
}
}, [feedbackPage, feedbackPageCount])
useEffect(() => {
if (!selectedFeedbackCorrection) {
if (selectedFeedbackTaskId) {
setSelectedFeedbackTaskId(0)
}
setSelectedFeedbackTaskDetail(null)
setSelectedFeedbackTaskError('')
return
}
if (selectedFeedbackCorrection.task_id !== selectedFeedbackTaskId) {
setSelectedFeedbackTaskId(selectedFeedbackCorrection.task_id)
}
}, [selectedFeedbackCorrection, selectedFeedbackTaskId])
useEffect(() => {
const taskId = selectedFeedbackCorrection?.task_id
if (!taskId) {
setSelectedFeedbackTaskDetail(null)
setSelectedFeedbackTaskError('')
return
}
let cancelled = false
setSelectedFeedbackTaskLoading(true)
setSelectedFeedbackTaskError('')
void getMemoryFeedbackCorrection(taskId)
.then((payload) => {
if (cancelled) {
return
}
if (!payload.success || !payload.task) {
setSelectedFeedbackTaskDetail(null)
setSelectedFeedbackTaskError(payload.error || '未能加载纠错任务详情')
return
}
setSelectedFeedbackTaskDetail(payload.task)
})
.catch((error) => {
if (cancelled) {
return
}
setSelectedFeedbackTaskDetail(null)
setSelectedFeedbackTaskError(error instanceof Error ? error.message : '未能加载纠错任务详情')
})
.finally(() => {
if (!cancelled) {
setSelectedFeedbackTaskLoading(false)
}
})
return () => {
cancelled = true
}
}, [selectedFeedbackCorrection?.task_id])
const selectedFeedbackResolved = useMemo(() => {
if (!selectedFeedbackCorrection) {
return null
}
if (selectedFeedbackTaskDetail?.task_id === selectedFeedbackCorrection.task_id) {
return {
...selectedFeedbackCorrection,
...selectedFeedbackTaskDetail,
} satisfies MemoryFeedbackCorrectionDetailTaskPayload
}
return selectedFeedbackTaskDetail ?? selectedFeedbackCorrection
}, [selectedFeedbackCorrection, selectedFeedbackTaskDetail])
const selectedFeedbackActionLogs = Array.isArray(selectedFeedbackResolved?.action_logs)
? selectedFeedbackResolved.action_logs
: []
const filteredFeedbackActionLogs = useMemo(() => {
const keyword = feedbackActionLogSearch.trim().toLowerCase()
if (!keyword) {
return selectedFeedbackActionLogs
}
return selectedFeedbackActionLogs.filter((item) =>
[
item.action_type,
item.target_hash,
item.reason,
summarizeFeedbackActionPayload(item.before_payload),
summarizeFeedbackActionPayload(item.after_payload),
]
.map((value) => String(value ?? '').toLowerCase())
.some((value) => value.includes(keyword)),
)
}, [feedbackActionLogSearch, selectedFeedbackActionLogs])
const feedbackActionLogPageCount = Math.max(
1,
Math.ceil(filteredFeedbackActionLogs.length / FEEDBACK_ACTION_LOG_PAGE_SIZE),
)
const pagedFeedbackActionLogs = useMemo(() => {
const start = (feedbackActionLogPage - 1) * FEEDBACK_ACTION_LOG_PAGE_SIZE
return filteredFeedbackActionLogs.slice(start, start + FEEDBACK_ACTION_LOG_PAGE_SIZE)
}, [feedbackActionLogPage, filteredFeedbackActionLogs])
useEffect(() => {
setFeedbackActionLogPage(1)
}, [selectedFeedbackTaskId, feedbackActionLogSearch])
useEffect(() => {
if (feedbackActionLogPage > feedbackActionLogPageCount) {
setFeedbackActionLogPage(feedbackActionLogPageCount)
}
}, [feedbackActionLogPage, feedbackActionLogPageCount])
const openFeedbackRollbackDialog = useCallback(() => {
setFeedbackRollbackReason('')
setFeedbackRollbackDialogOpen(true)
}, [])
const executeFeedbackRollback = useCallback(async () => {
const taskId = selectedFeedbackResolved?.task_id
if (!taskId) {
return
}
try {
setFeedbackRollingBack(true)
const payload = await rollbackMemoryFeedbackCorrection(taskId, {
requested_by: 'knowledge_base',
reason: feedbackRollbackReason.trim(),
})
if (!payload.success) {
throw new Error(payload.error || '回退失败')
}
toast({
title: payload.already_rolled_back ? '该纠错已回退' : '纠错回退成功',
description: `任务 ${taskId} 的回退结果已写入日志`,
})
setFeedbackRollbackDialogOpen(false)
const [listPayload, detailPayload] = await Promise.all([
getMemoryFeedbackCorrections({ limit: FEEDBACK_CORRECTION_FETCH_LIMIT }),
getMemoryFeedbackCorrection(taskId),
])
setFeedbackCorrections(listPayload.items ?? [])
setSelectedFeedbackTaskDetail(detailPayload.task ?? null)
const [sourcePayload, runtimePayload] = await Promise.all([
getMemorySources(),
getMemoryRuntimeConfig(),
])
setMemorySources(sourcePayload.items ?? [])
setRuntimeConfig(runtimePayload)
} catch (error) {
toast({
title: '纠错回退失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setFeedbackRollingBack(false)
}
}, [feedbackRollbackReason, selectedFeedbackResolved?.task_id, toast])
const selectedOperationResolved = useMemo(() => {
if (!selectedDeleteOperation) {
return null
@@ -1776,6 +2100,9 @@ export function KnowledgeBasePage() {
<TabsTrigger value="delete" className="rounded-lg px-4 py-1.5">
</TabsTrigger>
<TabsTrigger value="feedback" className="rounded-lg px-4 py-1.5">
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
@@ -3314,6 +3641,327 @@ export function KnowledgeBasePage() {
</Card>
</div>
</TabsContent>
<TabsContent value="feedback" className="space-y-4">
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RotateCcw className="h-4 w-4" />
</CardTitle>
<CardDescription>
feedback correction 退
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px_180px]">
<Input
value={feedbackSearch}
onChange={(event) => setFeedbackSearch(event.target.value)}
placeholder="搜索 query_tool_id / session / query / reason"
/>
<Select value={feedbackStatusFilter} onValueChange={setFeedbackStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="按任务状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="applied"></SelectItem>
<SelectItem value="skipped"></SelectItem>
<SelectItem value="error"></SelectItem>
<SelectItem value="running"></SelectItem>
<SelectItem value="pending"></SelectItem>
</SelectContent>
</Select>
<Select value={feedbackRollbackFilter} onValueChange={setFeedbackRollbackFilter}>
<SelectTrigger>
<SelectValue placeholder="按回退状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">退</SelectItem>
<SelectItem value="none">退</SelectItem>
<SelectItem value="rolled_back">退</SelectItem>
<SelectItem value="error">退</SelectItem>
<SelectItem value="running">退</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span> {filteredFeedbackCorrections.length} {feedbackCorrections.length} </span>
<span> {feedbackPage} / {feedbackPageCount} {FEEDBACK_CORRECTION_PAGE_SIZE} </span>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<ScrollArea className="h-[720px] rounded-lg border">
<div className="space-y-3 p-3">
{pagedFeedbackCorrections.length > 0 ? pagedFeedbackCorrections.map((item) => {
const isSelected = selectedFeedbackCorrection?.task_id === item.task_id
return (
<button
key={item.task_id}
type="button"
onClick={() => setSelectedFeedbackTaskId(item.task_id)}
className={cn(
'w-full rounded-xl border p-4 text-left transition-colors',
isSelected
? 'border-primary bg-primary/5 shadow-sm'
: 'bg-muted/20 hover:border-primary/40 hover:bg-muted/40',
)}
>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={getFeedbackStatusVariant(item.task_status)}>
{formatFeedbackTaskStatus(item.task_status)}
</Badge>
<Badge variant={getFeedbackStatusVariant(item.rollback_status)}>
{formatFeedbackRollbackStatus(item.rollback_status)}
</Badge>
<Badge variant="outline">
{formatFeedbackDecision(item.decision)}
</Badge>
</div>
<div className="text-sm font-medium break-words">
{item.query_text || '无查询文本'}
</div>
<div className="font-mono text-[11px] break-all text-muted-foreground">
{item.query_tool_id}
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span> {Number(item.affected_counts?.relations ?? 0)}</span>
<span> {Number(item.affected_counts?.stale_paragraphs ?? 0)}</span>
<span>Episode {Number(item.affected_counts?.episode_sources ?? 0)}</span>
<span>Profile {Number(item.affected_counts?.profile_person_ids ?? 0)}</span>
</div>
<div className="text-xs text-muted-foreground">
{formatDeleteOperationTime(item.query_timestamp ?? item.created_at)}
</div>
</div>
</button>
)
}) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</ScrollArea>
<div className="rounded-xl border bg-muted/20 p-4">
{selectedFeedbackCorrection ? (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={getFeedbackStatusVariant(String(selectedFeedbackResolved?.task_status ?? ''))}>
{formatFeedbackTaskStatus(String(selectedFeedbackResolved?.task_status ?? ''))}
</Badge>
<Badge variant={getFeedbackStatusVariant(String(selectedFeedbackResolved?.rollback_status ?? 'none'))}>
{formatFeedbackRollbackStatus(String(selectedFeedbackResolved?.rollback_status ?? 'none'))}
</Badge>
<Badge variant="outline">
{formatFeedbackDecision(String(selectedFeedbackResolved?.decision ?? ''))}
</Badge>
</div>
<div className="text-sm font-medium break-words">
{selectedFeedbackResolved?.query_text || '无查询文本'}
</div>
<div className="font-mono text-xs break-all">
{selectedFeedbackResolved?.query_tool_id}
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={openFeedbackRollbackDialog}
disabled={
String(selectedFeedbackResolved?.task_status ?? '') !== 'applied'
|| String(selectedFeedbackResolved?.rollback_status ?? 'none') === 'rolled_back'
|| feedbackRollingBack
}
>
<RotateCcw className="mr-2 h-4 w-4" />
{String(selectedFeedbackResolved?.rollback_status ?? 'none') === 'rolled_back'
? '已回退'
: '回退本次纠错'}
</Button>
</div>
<div className="grid gap-3 lg:grid-cols-4">
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm break-all">{selectedFeedbackResolved?.session_id || '-'}</div>
</div>
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm">{Number(selectedFeedbackResolved?.feedback_message_count ?? 0)}</div>
</div>
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm">{Number(selectedFeedbackResolved?.decision_confidence ?? 0).toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground">退</div>
<div className="mt-1 text-sm">{formatDeleteOperationTime(selectedFeedbackResolved?.rolled_back_at)}</div>
</div>
</div>
{selectedFeedbackTaskLoading ? (
<div className="rounded-lg border bg-background/60 p-4 text-sm text-muted-foreground">
...
</div>
) : null}
{selectedFeedbackTaskError ? (
<Alert variant="destructive">
<AlertDescription>{selectedFeedbackTaskError}</AlertDescription>
</Alert>
) : null}
{selectedFeedbackResolved?.rollback_error ? (
<Alert variant="destructive">
<AlertDescription>{selectedFeedbackResolved.rollback_error}</AlertDescription>
</Alert>
) : null}
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<div className="space-y-2">
<div className="text-sm font-semibold"></div>
<pre className="max-h-56 overflow-auto rounded-lg border bg-background/70 p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedFeedbackResolved?.query_snapshot ?? {}, null, 2)}
</pre>
</div>
<div className="space-y-2">
<div className="text-sm font-semibold"></div>
<pre className="max-h-56 overflow-auto rounded-lg border bg-background/70 p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedFeedbackResolved?.decision_payload ?? {}, null, 2)}
</pre>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<div className="space-y-2">
<div className="text-sm font-semibold">退</div>
<pre className="max-h-64 overflow-auto rounded-lg border bg-background/70 p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedFeedbackResolved?.rollback_plan_summary ?? {}, null, 2)}
</pre>
</div>
<div className="space-y-2">
<div className="text-sm font-semibold">退</div>
<pre className="max-h-64 overflow-auto rounded-lg border bg-background/70 p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedFeedbackResolved?.rollback_result ?? {}, null, 2)}
</pre>
</div>
</div>
<div className="space-y-2">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="text-sm font-semibold">线</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input
value={feedbackActionLogSearch}
onChange={(event) => setFeedbackActionLogSearch(event.target.value)}
placeholder="搜索动作 / hash / 预览内容"
className="lg:w-80"
/>
<div className="text-xs text-muted-foreground">
{feedbackActionLogPage} / {feedbackActionLogPageCount} {FEEDBACK_ACTION_LOG_PAGE_SIZE}
</div>
</div>
</div>
<ScrollArea className="h-[280px] rounded-lg border bg-background/60">
<div className="space-y-2 p-3">
{pagedFeedbackActionLogs.length > 0 ? pagedFeedbackActionLogs.map((item: MemoryFeedbackActionLogPayload) => (
<div key={`${item.id}:${item.action_type}`} className="rounded-lg border bg-muted/20 p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{item.action_type}</Badge>
{item.target_hash ? (
<span className="font-mono text-[11px] break-all text-muted-foreground">{item.target_hash}</span>
) : null}
</div>
{item.reason ? (
<div className="mt-2 text-xs text-muted-foreground break-words">
{item.reason}
</div>
) : null}
{item.before_payload && Object.keys(item.before_payload).length > 0 ? (
<div className="mt-2 text-xs break-words">
<span className="font-medium">Before</span>
<span className="text-muted-foreground">{summarizeFeedbackActionPayload(item.before_payload)}</span>
</div>
) : null}
{item.after_payload && Object.keys(item.after_payload).length > 0 ? (
<div className="mt-1 text-xs break-words">
<span className="font-medium">After</span>
<span className="text-muted-foreground">{summarizeFeedbackActionPayload(item.after_payload)}</span>
</div>
) : null}
<div className="mt-2 text-[11px] text-muted-foreground">
{formatDeleteOperationTime(item.created_at)}
</div>
</div>
)) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground">
{selectedFeedbackActionLogs.length > 0 ? '当前筛选条件下没有动作日志' : '当前任务没有动作日志'}
</div>
)}
</div>
</ScrollArea>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setFeedbackActionLogPage((current) => Math.max(1, current - 1))}
disabled={feedbackActionLogPage <= 1}
>
</Button>
<div className="text-xs text-muted-foreground">hash </div>
<Button
variant="outline"
size="sm"
onClick={() => setFeedbackActionLogPage((current) => Math.min(feedbackActionLogPageCount, current + 1))}
disabled={feedbackActionLogPage >= feedbackActionLogPageCount}
>
</Button>
</div>
</div>
</div>
) : (
<div className="flex min-h-[360px] items-center justify-center rounded-lg border border-dashed bg-background/40 p-6 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setFeedbackPage((current) => Math.max(1, current - 1))}
disabled={feedbackPage <= 1}
>
</Button>
<div className="text-xs text-muted-foreground">
query退
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFeedbackPage((current) => Math.min(feedbackPageCount, current + 1))}
disabled={feedbackPage >= feedbackPageCount}
>
</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
</div>
@@ -3332,6 +3980,52 @@ export function KnowledgeBasePage() {
onExecute={() => void executePendingDelete()}
onRestore={() => void (deleteResult?.operation_id ? restoreDeleteOperation(deleteResult.operation_id) : Promise.resolve())}
/>
<Dialog open={feedbackRollbackDialogOpen} onOpenChange={setFeedbackRollbackDialogOpen}>
<DialogContent className="max-w-lg" confirmOnEnter>
<DialogHeader>
<DialogTitle>退</DialogTitle>
<DialogDescription>
relation episode/profile
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-lg border bg-muted/20 p-3 text-sm">
<div className="font-medium break-words">{selectedFeedbackResolved?.query_text || '无查询文本'}</div>
<div className="mt-1 font-mono text-[11px] break-all text-muted-foreground">
{selectedFeedbackResolved?.query_tool_id}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="feedback-rollback-reason">退</Label>
<Textarea
id="feedback-rollback-reason"
value={feedbackRollbackReason}
onChange={(event) => setFeedbackRollbackReason(event.target.value)}
placeholder="可选,建议填写本次人工回退原因"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFeedbackRollbackDialogOpen(false)} disabled={feedbackRollingBack}>
</Button>
<Button onClick={() => void executeFeedbackRollback()} disabled={feedbackRollingBack}>
{feedbackRollingBack ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
退
</>
) : (
<>
<RotateCcw className="mr-2 h-4 w-4" />
退
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}