Merge upstream/dev into dev

This commit is contained in:
DawnARC
2026-05-01 20:36:20 +08:00
10 changed files with 3451 additions and 79 deletions

View File

@@ -0,0 +1,54 @@
import { TabsList, TabsTrigger } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
export interface MemoryMiniTabItem<TValue extends string> {
value: TValue
label: string
description?: string
}
export interface MemoryMiniTabsProps<TValue extends string> {
items: ReadonlyArray<MemoryMiniTabItem<TValue>>
className?: string
/** 触发器额外样式 */
triggerClassName?: string
}
/**
* 长期记忆控制台统一的迷你标签页样式。
*
* - 复用 shadcn `Tabs` 原语,仅替换样式以保留无障碍能力(`role="tab"` 与文案不变)。
* - 胶囊形外观,激活态使用主色渐变,便于在密集表单上快速定位当前页签。
*/
export function MemoryMiniTabs<TValue extends string>({
items,
className,
triggerClassName,
}: MemoryMiniTabsProps<TValue>) {
return (
<TabsList
className={cn(
'h-auto w-full flex-wrap justify-start gap-1.5 rounded-full border border-border/60',
'bg-gradient-to-r from-muted/40 via-background to-muted/30 p-1.5 shadow-inner',
className,
)}
>
{items.map((item) => (
<TabsTrigger
key={item.value}
value={item.value}
title={item.description}
className={cn(
'rounded-full px-3.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors',
'hover:bg-background/80 hover:text-foreground',
'data-[state=active]:bg-gradient-to-r data-[state=active]:from-primary data-[state=active]:to-primary/80',
'data-[state=active]:text-primary-foreground data-[state=active]:shadow-sm',
triggerClassName,
)}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
)
}

View File

@@ -0,0 +1,130 @@
import { Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { cn } from '@/lib/utils'
export interface MemoryProgressIndicatorProps {
/** 0-100 之间的进度百分比 */
value: number
/** 任务状态文本(如 “运行中”、“已完成”) */
statusLabel?: string
/** 当前步骤文本(如 “分块中”) */
stepLabel?: string
/** 状态对应的语义色(用于左侧圆环和徽标) */
tone?: 'default' | 'success' | 'warning' | 'destructive' | 'muted'
/** 是否显示加载动画(运行中/取消中场景) */
busy?: boolean
/** 紧凑模式:用于队列列表项 */
compact?: boolean
/** 额外说明(如 “已完成 36 / 120 分块”) */
detail?: string
className?: string
}
const TONE_RING_CLASS: Record<NonNullable<MemoryProgressIndicatorProps['tone']>, string> = {
default: 'text-primary',
success: 'text-emerald-500',
warning: 'text-amber-500',
destructive: 'text-rose-500',
muted: 'text-muted-foreground',
}
const TONE_BADGE_VARIANT: Record<
NonNullable<MemoryProgressIndicatorProps['tone']>,
'default' | 'secondary' | 'destructive' | 'outline'
> = {
default: 'default',
success: 'secondary',
warning: 'outline',
destructive: 'destructive',
muted: 'outline',
}
/**
* 长期记忆控制台统一的任务进度展示组件。
*
* 设计目标:
* - 让用户一眼看清「整体百分比 + 语义状态 + 当前步骤」。
* - 复用 shadcn `Progress` 与 `Badge`,避免引入额外样式来源。
* - 在紧凑模式下保留可读性,可放进队列卡片;非紧凑模式带圆环用于详情区。
*/
export function MemoryProgressIndicator({
value,
statusLabel,
stepLabel,
tone = 'default',
busy = false,
compact = false,
detail,
className,
}: MemoryProgressIndicatorProps) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
const ringSize = compact ? 36 : 56
const ringStroke = compact ? 4 : 5
const radius = (ringSize - ringStroke) / 2
const circumference = 2 * Math.PI * radius
const dashOffset = circumference * (1 - safeValue / 100)
return (
<div className={cn('flex items-center gap-3', className)}>
<div
className={cn('relative shrink-0', TONE_RING_CLASS[tone])}
style={{ width: ringSize, height: ringSize }}
aria-hidden="true"
>
<svg width={ringSize} height={ringSize} className="-rotate-90">
<circle
cx={ringSize / 2}
cy={ringSize / 2}
r={radius}
strokeWidth={ringStroke}
className="stroke-muted/40"
fill="none"
/>
<circle
cx={ringSize / 2}
cy={ringSize / 2}
r={radius}
strokeWidth={ringStroke}
strokeLinecap="round"
stroke="currentColor"
fill="none"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
className="transition-[stroke-dashoffset] duration-500 ease-out"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
{busy ? (
<Loader2 className={cn('animate-spin', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
) : (
<span className={cn('font-medium tabular-nums', compact ? 'text-[10px]' : 'text-xs')}>
{Math.round(safeValue)}%
</span>
)}
</div>
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
{statusLabel ? (
<Badge variant={TONE_BADGE_VARIANT[tone]} className="shrink-0">
{statusLabel}
</Badge>
) : null}
{stepLabel ? (
<span className="truncate text-xs text-muted-foreground">{stepLabel}</span>
) : null}
{!compact ? (
<span className="ml-auto text-xs tabular-nums text-muted-foreground">
{safeValue.toFixed(1)}%
</span>
) : null}
</div>
<Progress value={safeValue} className={cn(compact ? 'h-1' : 'h-1.5')} />
{detail ? <div className="truncate text-xs text-muted-foreground">{detail}</div> : null}
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
import { unifiedWsClient, type WsEventEnvelope } from './unified-ws'
export type MemoryProgressTopic = 'import_progress' | 'delete_progress' | 'feedback_progress'
export interface MemoryProgressEvent {
topic: MemoryProgressTopic
event: string
data: Record<string, unknown>
}
type ProgressListener = (event: MemoryProgressEvent) => void
const DOMAIN = 'memory'
const KNOWN_TOPICS: MemoryProgressTopic[] = ['import_progress', 'delete_progress', 'feedback_progress']
/**
* 长期记忆控制台的统一 WebSocket 桥接客户端。
*
* 负责:
* 1. 订阅 `memory` 域下的若干 topic导入/删除/反馈进度)。
* 2. 把后端推送的事件分发给所有已注册的监听器。
* 3. 即使后端尚未广播也保持安全:监听器为空时不抛错,订阅幂等。
*
* 与 `pluginProgressClient` 保持一致的形状,便于复用。
*/
class MemoryProgressClient {
private initialized = false
private listeners: Set<ProgressListener> = new Set()
private activeTopics: Set<MemoryProgressTopic> = new Set()
private initialize(): void {
if (this.initialized) {
return
}
unifiedWsClient.addEventListener((message: WsEventEnvelope) => {
if (message.domain !== DOMAIN) {
return
}
const topic = (message.topic ?? '') as MemoryProgressTopic
if (!KNOWN_TOPICS.includes(topic)) {
return
}
const payload: MemoryProgressEvent = {
topic,
event: message.event,
data: message.data ?? {},
}
this.listeners.forEach((listener) => {
try {
listener(payload)
} catch (error) {
console.error('长期记忆进度监听器执行失败:', error)
}
})
})
this.initialized = true
}
async subscribe(
listener: ProgressListener,
topics: MemoryProgressTopic[] = KNOWN_TOPICS,
): Promise<() => Promise<void>> {
this.initialize()
this.listeners.add(listener)
// 仅订阅尚未激活的 topic避免重复 subscribe
for (const topic of topics) {
if (this.activeTopics.has(topic)) {
continue
}
try {
await unifiedWsClient.subscribe(DOMAIN, topic)
this.activeTopics.add(topic)
} catch (error) {
// 后端可能尚未实现该 topic订阅失败时只记录不抛出确保 polling 仍可作为兜底
console.warn(`订阅长期记忆 topic 失败(将退化到轮询兜底): ${topic}`, error)
}
}
return async () => {
this.listeners.delete(listener)
if (this.listeners.size === 0) {
const topicsToRelease = Array.from(this.activeTopics)
this.activeTopics.clear()
for (const topic of topicsToRelease) {
try {
await unifiedWsClient.unsubscribe(DOMAIN, topic)
} catch (error) {
console.warn(`取消订阅长期记忆 topic 失败: ${topic}`, error)
}
}
}
}
}
}
export const memoryProgressClient = new MemoryProgressClient()

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
@@ -63,23 +63,58 @@ export function AuthPage() {
const { t } = useTranslation()
const { enableWavesBackground, setEnableWavesBackground } = useAnimation()
const { theme, setTheme } = useTheme()
// 避免 React StrictMode 下重复触发 URL token 自动登录。
const urlTokenHandledRef = useRef(false)
// 如果已经认证,直接跳转到首页
useEffect(() => {
const verifyAuth = async () => {
try {
const isAuth = await checkAuthStatus()
if (isAuth) {
navigate({ to: '/' })
}
} catch {
// 忽略错误,保持在登录页
} finally {
setCheckingAuth(false)
// 从 URL 中提取 token支持 query 与 hash 两种位置)。
// 允许 ?token=xxx 、&token=xxxquery以及 #/foo?token=xxx、#token=xxxhash
const extractUrlToken = useCallback((): string => {
if (typeof window === 'undefined') return ''
const fromQuery = new URLSearchParams(window.location.search).get('token')
if (fromQuery && fromQuery.trim()) return fromQuery.trim()
// hash 中可能形如 "#token=xxx" 或 "#/path?token=xxx"
const rawHash = window.location.hash.replace(/^#/, '')
if (!rawHash) return ''
const queryIdx = rawHash.indexOf('?')
const hashQuery = queryIdx >= 0 ? rawHash.slice(queryIdx + 1) : rawHash
const fromHash = new URLSearchParams(hashQuery).get('token')
return fromHash && fromHash.trim() ? fromHash.trim() : ''
}, [])
// 从当前 URL 中移除 token 参数,避免令牌被书签/Referer/浏览器历史泄露。
const stripTokenFromUrl = useCallback(() => {
if (typeof window === 'undefined') return
try {
const url = new URL(window.location.href)
let changed = false
if (url.searchParams.has('token')) {
url.searchParams.delete('token')
changed = true
}
const rawHash = url.hash.replace(/^#/, '')
if (rawHash) {
const queryIdx = rawHash.indexOf('?')
if (queryIdx >= 0) {
const path = rawHash.slice(0, queryIdx)
const hashParams = new URLSearchParams(rawHash.slice(queryIdx + 1))
if (hashParams.has('token')) {
hashParams.delete('token')
const next = hashParams.toString()
url.hash = next ? `#${path}?${next}` : `#${path}`
changed = true
}
} else if (/^token=/.test(rawHash)) {
url.hash = ''
changed = true
}
}
if (changed) {
window.history.replaceState(null, '', url.toString())
}
} catch {
// 忽略 URL 解析异常
}
verifyAuth()
}, [navigate])
}, [])
// 获取实际应用的主题(处理 system 情况)
const getActualTheme = () => {
@@ -97,83 +132,120 @@ export function AuthPage() {
setTheme(newTheme)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
const verifyToken = useCallback(
async (rawToken: string): Promise<boolean> => {
const trimmed = rawToken.trim()
setError('')
if (!token.trim()) {
setError(t('auth.tokenRequired'))
return
}
setIsValidating(true)
console.log('开始验证 token...')
try {
// 向后端发送请求验证 token后端会设置 HttpOnly Cookie
const response = await fetch('/api/webui/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 确保接收并存储 Cookie
body: JSON.stringify({ token: token.trim() }),
})
console.log('Token 验证响应状态:', response.status)
const result = await parseResponse<{
valid: boolean
is_first_setup?: boolean
message?: string
}>(response)
if (!result.success) {
console.error('Token 验证失败:', result.error)
setError(result.error)
return
if (!trimmed) {
setError(t('auth.tokenRequired'))
return false
}
const data = result.data
console.log('Token 验证响应数据:', data)
setIsValidating(true)
console.log('开始验证 token...')
if (data.valid) {
console.log('Token 验证成功,准备跳转...')
console.log('is_first_setup:', data.is_first_setup)
try {
// 向后端发送请求验证 token后端会设置 HttpOnly Cookie
const response = await fetch('/api/webui/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 确保接收并存储 Cookie
body: JSON.stringify({ token: trimmed }),
})
// Token 验证成功Cookie 已由后端设置
// 等待一小段时间确保 Cookie 已设置
await new Promise((resolve) => setTimeout(resolve, 100))
console.log('Token 验证响应状态:', response.status)
// 再次检查认证状态
const authCheck = await checkAuthStatus()
console.log('跳转前认证状态检查:', authCheck)
const result = await parseResponse<{
valid: boolean
is_first_setup?: boolean
message?: string
}>(response)
// 直接使用验证响应中的 is_first_setup 字段,避免额外请求
if (data.is_first_setup) {
console.log('跳转到首次配置页面')
// 需要首次配置,跳转到配置向导
navigate({ to: '/setup' })
} else {
console.log('跳转到首页')
// 不需要配置或配置已完成,跳转到首页
navigate({ to: '/' })
if (!result.success) {
console.error('Token 验证失败:', result.error)
setError(result.error)
return false
}
} else {
const data = result.data
console.log('Token 验证响应数据:', data)
if (data.valid) {
console.log('Token 验证成功,准备跳转...')
console.log('is_first_setup:', data.is_first_setup)
// Token 验证成功Cookie 已由后端设置
// 等待一小段时间确保 Cookie 已设置
await new Promise((resolve) => setTimeout(resolve, 100))
// 再次检查认证状态
const authCheck = await checkAuthStatus()
console.log('跳转前认证状态检查:', authCheck)
// 直接使用验证响应中的 is_first_setup 字段,避免额外请求
if (data.is_first_setup) {
console.log('跳转到首次配置页面')
navigate({ to: '/setup' })
} else {
console.log('跳转到首页')
navigate({ to: '/' })
}
return true
}
console.error('Token 验证失败:', data.message)
setError(data.message || t('auth.verifyFailed'))
return false
} catch (err) {
console.error('Token 验证错误:', err)
setError(err instanceof Error ? err.message : t('auth.connFailed'))
return false
} finally {
setIsValidating(false)
}
} catch (err) {
console.error('Token 验证错误:', err)
setError(
err instanceof Error ? err.message : t('auth.connFailed')
)
} finally {
setIsValidating(false)
}
},
[navigate, t]
)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await verifyToken(token)
}
// 如果已经认证,直接跳转到首页;否则尝试用 URL 中的 token 自动登录。
useEffect(() => {
const verifyAuth = async () => {
try {
const isAuth = await checkAuthStatus()
if (isAuth) {
// 已登录场景下URL 中残留的 token 也清掉,避免外泄。
stripTokenFromUrl()
navigate({ to: '/' })
return
}
// 未登录:检查 URL 是否带 token带了就自动尝试登录。
const urlToken = extractUrlToken()
if (urlToken && !urlTokenHandledRef.current) {
urlTokenHandledRef.current = true
// 立即从 URL 中剥离 token防止刷新/复制链接时再次暴露。
stripTokenFromUrl()
setToken(urlToken)
// 异步触发验证失败时错误信息会显示在表单上token 也会保留在输入框中以便用户修正。
void verifyToken(urlToken)
}
} catch {
// 忽略错误,保持在登录页
} finally {
setCheckingAuth(false)
}
}
verifyAuth()
}, [navigate, extractUrlToken, stripTokenFromUrl, verifyToken])
// 正在检查认证状态时显示加载
if (checkingAuth) {
return (

View File

@@ -0,0 +1,52 @@
import type { MemoryImportTaskKind } from '@/lib/memory-api'
export const DELETE_OPERATION_FETCH_LIMIT = 100
export const DELETE_OPERATION_PAGE_SIZE = 6
export const DELETE_OPERATION_ITEM_PAGE_SIZE = 8
export const FEEDBACK_CORRECTION_FETCH_LIMIT = 100
export const FEEDBACK_CORRECTION_PAGE_SIZE = 6
export const FEEDBACK_ACTION_LOG_PAGE_SIZE = 8
export const IMPORT_CHUNK_PAGE_SIZE = 50
export const RUNNING_IMPORT_STATUS = new Set(['preparing', 'running', 'cancel_requested'])
export const QUEUED_IMPORT_STATUS = new Set(['queued'])
export const IMPORT_STATUS_TEXT: Record<string, string> = {
queued: '排队中',
preparing: '准备中',
running: '运行中',
cancel_requested: '取消中',
cancelled: '已取消',
completed: '已完成',
completed_with_errors: '完成(有错误)',
failed: '失败',
}
export const IMPORT_STEP_TEXT: Record<string, string> = {
queued: '排队中',
preparing: '准备中',
running: '运行中',
splitting: '分块中',
extracting: '抽取中',
writing: '写入中',
saving: '保存中',
backfilling: '回填中',
converting: '转换中',
verifying: '校验中',
switching: '切换中',
cancel_requested: '取消中',
cancelled: '已取消',
completed: '已完成',
completed_with_errors: '完成(有错误)',
failed: '失败',
}
export const IMPORT_KIND_OPTIONS: Array<{ value: MemoryImportTaskKind; label: string; description: string }> = [
{ value: 'upload', label: '上传文件', description: '从本地批量上传资料文件' },
{ value: 'paste', label: '粘贴导入', description: '直接粘贴文本或 JSON 内容创建任务' },
{ value: 'raw_scan', label: '本地扫描', description: '按路径别名和匹配规则批量扫描导入' },
{ value: 'lpmm_openie', label: 'LPMM OpenIE', description: '读取 LPMM 数据并抽取关系' },
{ value: 'lpmm_convert', label: 'LPMM 转换', description: '将 LPMM 数据转换到目标目录' },
{ value: 'temporal_backfill', label: '时序回填', description: '为已有数据补充时间字段' },
{ value: 'maibot_migration', label: 'MaiBot 迁移', description: '从 MaiBot 历史数据迁移长期记忆' },
]

View File

@@ -0,0 +1,492 @@
import type { Dispatch, SetStateAction } from 'react'
import { CircleAlert, RotateCcw, Trash2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { TabsContent } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
import type { MemoryDeleteOperationPayload, MemorySourceItemPayload } from '@/lib/memory-api'
import { DELETE_OPERATION_ITEM_PAGE_SIZE, DELETE_OPERATION_PAGE_SIZE } from '../constants'
import {
formatDeleteOperationMode,
formatDeleteOperationStatus,
formatDeleteOperationTime,
getDeleteOperationItemLabel,
getDeleteOperationItemPreview,
getDeleteOperationItemSource,
type DeleteOperationItem,
} from '../utils'
export interface DeleteTabProps {
sourceSearch: string
setSourceSearch: Dispatch<SetStateAction<string>>
selectedSources: string[]
setSelectedSources: Dispatch<SetStateAction<string[]>>
filteredSources: MemorySourceItemPayload[]
openSourceDeletePreview: () => Promise<void>
toggleSourceSelection: (source: string, checked: boolean) => void
operationSearch: string
setOperationSearch: Dispatch<SetStateAction<string>>
operationModeFilter: string
setOperationModeFilter: Dispatch<SetStateAction<string>>
operationStatusFilter: string
setOperationStatusFilter: Dispatch<SetStateAction<string>>
filteredDeleteOperations: MemoryDeleteOperationPayload[]
deleteOperations: MemoryDeleteOperationPayload[]
operationPage: number
setOperationPage: Dispatch<SetStateAction<number>>
deleteOperationPageCount: number
pagedDeleteOperations: MemoryDeleteOperationPayload[]
selectedDeleteOperation: MemoryDeleteOperationPayload | null
setSelectedOperationId: Dispatch<SetStateAction<string>>
restoreDeleteOperation: (operationId: string) => Promise<void>
deleteRestoring: boolean
selectedOperationCounts: Record<string, number>
selectedOperationDetailLoading: boolean
selectedOperationDetailError: string
selectedOperationSources: string[]
selectedOperationItems: DeleteOperationItem[]
filteredSelectedOperationItems: DeleteOperationItem[]
selectedOperationItemSearch: string
setSelectedOperationItemSearch: Dispatch<SetStateAction<string>>
selectedOperationItemPage: number
setSelectedOperationItemPage: Dispatch<SetStateAction<number>>
selectedOperationItemPageCount: number
pagedSelectedOperationItems: DeleteOperationItem[]
}
export function DeleteTab(props: DeleteTabProps) {
const {
sourceSearch,
setSourceSearch,
selectedSources,
setSelectedSources,
filteredSources,
openSourceDeletePreview,
toggleSourceSelection,
operationSearch,
setOperationSearch,
operationModeFilter,
setOperationModeFilter,
operationStatusFilter,
setOperationStatusFilter,
filteredDeleteOperations,
deleteOperations,
operationPage,
setOperationPage,
deleteOperationPageCount,
pagedDeleteOperations,
selectedDeleteOperation,
setSelectedOperationId,
restoreDeleteOperation,
deleteRestoring,
selectedOperationCounts,
selectedOperationDetailLoading,
selectedOperationDetailError,
selectedOperationSources,
selectedOperationItems,
filteredSelectedOperationItems,
selectedOperationItemSearch,
setSelectedOperationItemSearch,
selectedOperationItemPage,
setSelectedOperationItemPage,
selectedOperationItemPageCount,
pagedSelectedOperationItems,
} = props
return (
<TabsContent value="delete" className="space-y-4">
<div className="flex flex-col gap-4">
<Card className="order-2">
<CardHeader className="space-y-3">
<div>
<CardTitle className="flex items-center gap-2">
<Trash2 className="h-4 w-4" />
</CardTitle>
<CardDescription>
</CardDescription>
</div>
<Alert className="border-amber-500/30 bg-amber-500/5 text-amber-950 dark:text-amber-200">
<CircleAlert className="h-4 w-4 text-amber-500" />
<AlertDescription>
</AlertDescription>
</Alert>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 rounded-xl border bg-muted/20 p-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="space-y-2">
<Label></Label>
<Input
value={sourceSearch}
onChange={(event) => setSourceSearch(event.target.value)}
placeholder="搜索 source 名称"
/>
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
<Button
variant="outline"
onClick={() => setSelectedSources(filteredSources.map((item) => String(item.source ?? '')).filter(Boolean))}
>
</Button>
<Button onClick={() => void openSourceDeletePreview()} disabled={selectedSources.length <= 0}>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline" className="bg-background/70"> {filteredSources.length} </Badge>
<Badge variant={selectedSources.length > 0 ? 'secondary' : 'outline'} className="bg-background/70">
{selectedSources.length}
</Badge>
</div>
<ScrollArea className="h-[320px] rounded-lg border">
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSources.length > 0 ? filteredSources.map((item) => {
const source = String(item.source ?? '')
const checked = selectedSources.includes(source)
return (
<TableRow key={source}>
<TableCell>
<Checkbox checked={checked} onCheckedChange={(value) => toggleSourceSelection(source, Boolean(value))} />
</TableCell>
<TableCell className="font-mono text-xs break-all">{source}</TableCell>
<TableCell>{Number(item.paragraph_count ?? 0)}</TableCell>
<TableCell>{Number(item.relation_count ?? 0)}</TableCell>
</TableRow>
)
}) : (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
<Card className="order-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RotateCcw className="h-4 w-4" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 rounded-xl border bg-muted/20 p-4 lg:grid-cols-[minmax(0,1fr)_180px_180px]">
<Input
value={operationSearch}
onChange={(event) => setOperationSearch(event.target.value)}
placeholder="搜索 operation / reason / requested_by / source"
/>
<Select value={operationModeFilter} onValueChange={setOperationModeFilter}>
<SelectTrigger>
<SelectValue placeholder="按模式筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="source"></SelectItem>
<SelectItem value="mixed"></SelectItem>
<SelectItem value="entity"></SelectItem>
<SelectItem value="relation"></SelectItem>
<SelectItem value="paragraph"></SelectItem>
</SelectContent>
</Select>
<Select value={operationStatusFilter} onValueChange={setOperationStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="按状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="executed"></SelectItem>
<SelectItem value="restored"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span> {filteredDeleteOperations.length} {deleteOperations.length} </span>
<span> {operationPage} / {deleteOperationPageCount} {DELETE_OPERATION_PAGE_SIZE} </span>
</div>
<ScrollArea className="h-[320px] rounded-lg border">
<div className="space-y-3 p-3">
{pagedDeleteOperations.length > 0 ? pagedDeleteOperations.map((operation) => {
const summary = (operation.summary ?? {}) as Record<string, unknown>
const counts = ((summary.counts as Record<string, number> | undefined) ?? {})
const isSelected = selectedDeleteOperation?.operation_id === operation.operation_id
return (
<button
key={operation.operation_id}
type="button"
onClick={() => setSelectedOperationId(operation.operation_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 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={operation.status === 'restored' ? 'secondary' : 'default'}>
{formatDeleteOperationStatus(String(operation.status ?? ''))}
</Badge>
<Badge variant="outline">
{formatDeleteOperationMode(String(operation.mode ?? ''))}
</Badge>
</div>
<div className="font-mono text-xs break-all">{operation.operation_id}</div>
<div className="text-sm text-muted-foreground">
{operation.reason || '未填写原因'}
</div>
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground lg:max-w-[280px] lg:justify-end">
<span> {Number(counts.entities ?? 0)}</span>
<span> {Number(counts.relations ?? 0)}</span>
<span> {Number(counts.paragraphs ?? 0)}</span>
<span> {Number(counts.sources ?? 0)}</span>
</div>
</div>
<div className="mt-3 text-xs text-muted-foreground">
{formatDeleteOperationTime(operation.created_at)}
</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="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setOperationPage((current) => Math.max(1, current - 1))}
disabled={operationPage <= 1}
>
</Button>
<div className="text-xs text-muted-foreground">
</div>
<Button
variant="outline"
size="sm"
onClick={() => setOperationPage((current) => Math.min(deleteOperationPageCount, current + 1))}
disabled={operationPage >= deleteOperationPageCount}
>
</Button>
</div>
<div className="rounded-xl border bg-muted/20 p-4">
{selectedDeleteOperation ? (
<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={selectedDeleteOperation.status === 'restored' ? 'secondary' : 'default'}>
{formatDeleteOperationStatus(String(selectedDeleteOperation.status ?? ''))}
</Badge>
<Badge variant="outline">
{formatDeleteOperationMode(String(selectedDeleteOperation.mode ?? ''))}
</Badge>
</div>
<div className="font-mono text-xs break-all">{selectedDeleteOperation.operation_id}</div>
<div className="text-sm text-muted-foreground">
{selectedDeleteOperation.reason || '未填写删除原因'}
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => void restoreDeleteOperation(selectedDeleteOperation.operation_id)}
disabled={selectedDeleteOperation.status === 'restored' || deleteRestoring}
>
<RotateCcw className="mr-2 h-4 w-4" />
{selectedDeleteOperation.status === 'restored' ? '已恢复' : '恢复这次删除'}
</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">{selectedDeleteOperation.requested_by || '-'}</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(selectedDeleteOperation.created_at)}</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(selectedDeleteOperation.restored_at)}</div>
</div>
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 flex flex-wrap gap-2">
<Badge variant="outline"> {Number(selectedOperationCounts.entities ?? 0)}</Badge>
<Badge variant="outline"> {Number(selectedOperationCounts.relations ?? 0)}</Badge>
<Badge variant="outline"> {Number(selectedOperationCounts.paragraphs ?? 0)}</Badge>
<Badge variant="outline"> {Number(selectedOperationCounts.sources ?? 0)}</Badge>
</div>
</div>
</div>
{selectedOperationDetailLoading ? (
<div className="rounded-lg border bg-background/60 p-4 text-sm text-muted-foreground">
...
</div>
) : null}
{selectedOperationDetailError ? (
<Alert variant="destructive">
<AlertDescription>{selectedOperationDetailError}</AlertDescription>
</Alert>
) : null}
{selectedOperationSources.length > 0 ? (
<div className="space-y-2">
<div className="text-sm font-semibold"></div>
<div className="flex flex-wrap gap-2">
{selectedOperationSources.map((source) => (
<Badge key={source} variant="secondary" className="max-w-full break-all">
{source}
</Badge>
))}
</div>
</div>
) : null}
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<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(selectedDeleteOperation.selector ?? {}, null, 2)}
</pre>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold"></div>
<div className="text-xs text-muted-foreground">
{filteredSelectedOperationItems.length} / {selectedOperationItems.length}
</div>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<Input
value={selectedOperationItemSearch}
onChange={(event) => setSelectedOperationItemSearch(event.target.value)}
placeholder="搜索对象类型 / 哈希 / 对象键 / 来源"
className="lg:max-w-sm"
/>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground lg:min-w-[180px] lg:justify-end">
<span> {selectedOperationItemPage} / {selectedOperationItemPageCount} </span>
<span> {DELETE_OPERATION_ITEM_PAGE_SIZE} </span>
</div>
</div>
<ScrollArea className="h-[280px] rounded-lg border bg-background/60">
<div className="space-y-2 p-3">
{pagedSelectedOperationItems.length > 0 ? pagedSelectedOperationItems.map((item) => {
const source = getDeleteOperationItemSource(item)
const label = getDeleteOperationItemLabel(item)
const preview = getDeleteOperationItemPreview(item)
return (
<div key={`${item.item_type}:${item.item_hash}:${item.item_key ?? ''}`} className="rounded-lg border bg-muted/20 p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{item.item_type}</Badge>
{source ? <Badge variant="secondary">{source}</Badge> : null}
{item.item_key && item.item_key !== item.item_hash ? (
<span className="text-xs text-muted-foreground break-all">{item.item_key}</span>
) : null}
</div>
<div className="mt-2 text-sm font-medium break-words">
{label}
</div>
{preview ? (
<div className="mt-1 text-xs text-muted-foreground break-words">
{preview}
</div>
) : null}
<div className="mt-2 font-mono text-[11px] break-all text-muted-foreground">
{item.item_hash}
</div>
</div>
)
}) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground">
{selectedOperationItems.length > 0 ? '当前筛选条件下没有明细项' : '当前操作没有记录明细项'}
</div>
)}
</div>
</ScrollArea>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedOperationItemPage((current) => Math.max(1, current - 1))}
disabled={selectedOperationItemPage <= 1}
>
</Button>
<div className="text-xs text-muted-foreground">
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedOperationItemPage((current) => Math.min(selectedOperationItemPageCount, current + 1))}
disabled={selectedOperationItemPage >= selectedOperationItemPageCount}
>
</Button>
</div>
</div>
</div>
</div>
) : (
<div className="flex min-h-[320px] items-center justify-center rounded-lg border border-dashed bg-background/40 p-6 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
)
}

View File

@@ -0,0 +1,512 @@
import type { Dispatch, SetStateAction } from 'react'
import { RotateCcw } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { TabsContent } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
import type {
MemoryFeedbackActionLogPayload,
MemoryFeedbackCorrectionDetailTaskPayload,
MemoryFeedbackCorrectionSummaryPayload,
} from '@/lib/memory-api'
import { FEEDBACK_ACTION_LOG_PAGE_SIZE, FEEDBACK_CORRECTION_PAGE_SIZE } from '../constants'
import {
buildFeedbackImpactSummary,
describeFeedbackActionLog,
formatDeleteOperationTime,
formatFeedbackActionType,
formatFeedbackDecision,
formatFeedbackRollbackStatus,
formatFeedbackTaskStatus,
getFeedbackCorrectionPreview,
getFeedbackStatusVariant,
summarizeFeedbackActionPayload,
} from '../utils'
export interface FeedbackTabProps {
feedbackSearch: string
setFeedbackSearch: Dispatch<SetStateAction<string>>
feedbackStatusFilter: string
setFeedbackStatusFilter: Dispatch<SetStateAction<string>>
feedbackRollbackFilter: string
setFeedbackRollbackFilter: Dispatch<SetStateAction<string>>
filteredFeedbackCorrections: MemoryFeedbackCorrectionSummaryPayload[]
feedbackCorrections: MemoryFeedbackCorrectionSummaryPayload[]
pagedFeedbackCorrections: MemoryFeedbackCorrectionSummaryPayload[]
feedbackPage: number
setFeedbackPage: Dispatch<SetStateAction<number>>
feedbackPageCount: number
selectedFeedbackCorrection: MemoryFeedbackCorrectionSummaryPayload | null
setSelectedFeedbackTaskId: Dispatch<SetStateAction<number>>
selectedFeedbackResolved: MemoryFeedbackCorrectionDetailTaskPayload | null
selectedFeedbackPreview: ReturnType<typeof getFeedbackCorrectionPreview>
selectedFeedbackImpactSummary: string[]
openFeedbackRollbackDialog: () => void
feedbackRollingBack: boolean
selectedFeedbackTaskLoading: boolean
selectedFeedbackTaskError: string | null
feedbackActionLogPage: number
setFeedbackActionLogPage: Dispatch<SetStateAction<number>>
feedbackActionLogPageCount: number
feedbackActionLogSearch: string
setFeedbackActionLogSearch: Dispatch<SetStateAction<string>>
pagedFeedbackActionLogs: MemoryFeedbackActionLogPayload[]
selectedFeedbackActionLogs: MemoryFeedbackActionLogPayload[]
}
export function FeedbackTab(props: FeedbackTabProps) {
const {
feedbackSearch,
setFeedbackSearch,
feedbackStatusFilter,
setFeedbackStatusFilter,
feedbackRollbackFilter,
setFeedbackRollbackFilter,
filteredFeedbackCorrections,
feedbackCorrections,
pagedFeedbackCorrections,
feedbackPage,
setFeedbackPage,
feedbackPageCount,
selectedFeedbackCorrection,
setSelectedFeedbackTaskId,
selectedFeedbackResolved,
selectedFeedbackPreview,
selectedFeedbackImpactSummary,
openFeedbackRollbackDialog,
feedbackRollingBack,
selectedFeedbackTaskLoading,
selectedFeedbackTaskError,
feedbackActionLogPage,
setFeedbackActionLogPage,
feedbackActionLogPageCount,
feedbackActionLogSearch,
setFeedbackActionLogSearch,
pagedFeedbackActionLogs,
selectedFeedbackActionLogs,
} = props
return (
<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="搜索查询编号 / 会话 / 查询内容 / 原因"
/>
<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 rounded-xl border bg-background/70 px-3 py-2 text-sm text-muted-foreground">
<span> {filteredFeedbackCorrections.length} {feedbackCorrections.length} </span>
<span> {feedbackPage} / {feedbackPageCount} {FEEDBACK_CORRECTION_PAGE_SIZE} </span>
</div>
<div className="grid items-start 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
const preview = getFeedbackCorrectionPreview(item)
const impactSummary = buildFeedbackImpactSummary(item)
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-start justify-between gap-2">
<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-[11px] text-muted-foreground">
{formatDeleteOperationTime(item.query_timestamp ?? item.created_at)}
</div>
</div>
<div className="space-y-1">
<div className="text-sm font-semibold break-words">
{preview.headline}
</div>
<div className="text-xs text-muted-foreground break-words">
{item.query_text || '无查询文本'}
</div>
</div>
{(preview.oldRelation || preview.newRelation) ? (
<div className="grid gap-2 rounded-lg border bg-background/70 p-3 text-xs shadow-sm">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] sm:items-stretch">
<div className="rounded-md border border-amber-500/20 bg-amber-500/5 p-2">
<div className="text-[11px] font-medium text-amber-700 dark:text-amber-300"></div>
<div className="mt-1 break-words">{preview.oldRelation || '无'}</div>
</div>
<div className="hidden items-center text-muted-foreground sm:flex"></div>
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 p-2">
<div className="text-[11px] font-medium text-emerald-700 dark:text-emerald-300"></div>
<div className="mt-1 break-words">{preview.newRelation || '无'}</div>
</div>
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
{impactSummary.length > 0 ? impactSummary.slice(0, 3).map((summary) => (
<Badge key={`${item.task_id}:${summary}`} variant="secondary" className="font-normal">
{summary}
</Badge>
)) : (
<Badge variant="secondary" className="font-normal">
</Badge>
)}
</div>
<div className="font-mono text-[11px] break-all text-muted-foreground">
{item.query_tool_id}
</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="self-start 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-base font-semibold break-words">
{selectedFeedbackPreview.headline}
</div>
<div className="text-sm text-muted-foreground break-words">
{selectedFeedbackResolved?.query_text || '无查询文本'}
</div>
<div className="font-mono text-xs break-all text-muted-foreground">
{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-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<div className="rounded-xl border bg-background/70 p-4 shadow-sm">
<div className="text-sm font-semibold"></div>
<div className="mt-3 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-stretch">
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-3">
<div className="text-xs font-medium text-amber-700 dark:text-amber-300"></div>
<div className="mt-2 text-sm break-words">
{selectedFeedbackPreview.oldRelation || '当前详情没有记录旧结论'}
</div>
</div>
<div className="hidden items-center justify-center text-muted-foreground md:flex"></div>
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="text-xs font-medium text-emerald-700 dark:text-emerald-300"></div>
<div className="mt-2 text-sm break-words">
{selectedFeedbackPreview.newRelation || '当前详情没有记录新结论'}
</div>
</div>
</div>
</div>
<div className="rounded-xl border bg-background/70 p-4 shadow-sm">
<div className="text-sm font-semibold"></div>
<div className="mt-3 flex flex-wrap gap-2">
{selectedFeedbackImpactSummary.length > 0 ? selectedFeedbackImpactSummary.map((summary) => (
<Badge key={summary} variant="secondary" className="bg-primary/10 font-normal text-primary hover:bg-primary/15">
{summary}
</Badge>
)) : (
<div className="text-sm text-muted-foreground"></div>
)}
</div>
</div>
</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,1.05fr)_minmax(0,0.95fr)]">
<div className="rounded-xl border bg-background/70 p-4">
<div className="text-sm font-semibold">退</div>
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
<div></div>
<div> Episode / Profile </div>
<div>退</div>
</div>
</div>
<div className="rounded-xl border bg-background/70 p-4">
<div className="text-sm font-semibold"></div>
<div className="mt-3 grid gap-2 text-sm text-muted-foreground">
<div>{formatFeedbackDecision(String(selectedFeedbackResolved?.decision ?? ''))}</div>
<div>{formatFeedbackTaskStatus(String(selectedFeedbackResolved?.task_status ?? ''))}</div>
<div>退{formatFeedbackRollbackStatus(String(selectedFeedbackResolved?.rollback_status ?? 'none'))}</div>
<div>{Number(selectedFeedbackResolved?.feedback_message_count ?? 0)}</div>
</div>
</div>
</div>
<div className="space-y-3">
<div className="text-sm font-semibold"></div>
<div className="grid gap-3 xl:grid-cols-2">
<details className="rounded-lg border bg-background/70 p-3">
<summary className="cursor-pointer text-sm font-medium"> JSON</summary>
<pre className="mt-3 max-h-56 overflow-auto text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedFeedbackResolved?.query_snapshot ?? {}, null, 2)}
</pre>
</details>
<details className="rounded-lg border bg-background/70 p-3">
<summary className="cursor-pointer text-sm font-medium"> JSON</summary>
<pre className="mt-3 max-h-56 overflow-auto text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedFeedbackResolved?.decision_payload ?? {}, null, 2)}
</pre>
</details>
<details className="rounded-lg border bg-background/70 p-3">
<summary className="cursor-pointer text-sm font-medium">退 JSON</summary>
<pre className="mt-3 max-h-64 overflow-auto text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedFeedbackResolved?.rollback_plan_summary ?? {}, null, 2)}
</pre>
</details>
<details className="rounded-lg border bg-background/70 p-3">
<summary className="cursor-pointer text-sm font-medium">退 JSON</summary>
<pre className="mt-3 max-h-64 overflow-auto text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedFeedbackResolved?.rollback_result ?? {}, null, 2)}
</pre>
</details>
</div>
</div>
<details className="rounded-xl border bg-background/70 p-4">
<summary className="cursor-pointer text-sm font-semibold">
线
</summary>
<div className="mt-4 space-y-2">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="text-xs text-muted-foreground">
{feedbackActionLogPage} / {feedbackActionLogPageCount} {FEEDBACK_ACTION_LOG_PAGE_SIZE}
</div>
<Input
value={feedbackActionLogSearch}
onChange={(event) => setFeedbackActionLogSearch(event.target.value)}
placeholder="搜索动作 / 目标哈希 / 预览内容"
className="lg:w-80"
/>
</div>
<ScrollArea className="h-[240px] 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 justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{formatFeedbackActionType(item.action_type)}</Badge>
{item.target_hash ? (
<span className="font-mono text-[11px] break-all text-muted-foreground">{item.target_hash}</span>
) : null}
</div>
<div className="text-[11px] text-muted-foreground">
{formatDeleteOperationTime(item.created_at)}
</div>
</div>
<div className="mt-2 text-sm break-words">
{describeFeedbackActionLog(item)}
</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-3 rounded-md border bg-background/70 p-2 text-xs break-words">
<span className="font-medium"></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-2 rounded-md border bg-background/70 p-2 text-xs break-words">
<span className="font-medium"></span>
<span className="text-muted-foreground">{summarizeFeedbackActionPayload(item.after_payload)}</span>
</div>
) : null}
</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"></div>
<Button
variant="outline"
size="sm"
onClick={() => setFeedbackActionLogPage((current) => Math.min(feedbackActionLogPageCount, current + 1))}
disabled={feedbackActionLogPage >= feedbackActionLogPageCount}
>
</Button>
</div>
</div>
</details>
</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">
退
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFeedbackPage((current) => Math.min(feedbackPageCount, current + 1))}
disabled={feedbackPage >= feedbackPageCount}
>
</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
import type { Dispatch, SetStateAction } from 'react'
import { Sparkles } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { CodeEditor } from '@/components/CodeEditor'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { TabsContent } from '@/components/ui/tabs'
import type { MemoryTaskPayload } from '@/lib/memory-api'
import { getImportStatusVariant } from '../utils'
export interface TuningTabProps {
tuningObjective: string
setTuningObjective: Dispatch<SetStateAction<string>>
tuningIntensity: string
setTuningIntensity: Dispatch<SetStateAction<string>>
tuningSampleSize: string
setTuningSampleSize: Dispatch<SetStateAction<string>>
tuningTopKEval: string
setTuningTopKEval: Dispatch<SetStateAction<string>>
submitTuningTask: () => Promise<void>
creatingTuning: boolean
tuningProfile: Record<string, unknown>
tuningProfileToml: string
tuningTasks: MemoryTaskPayload[]
applyBestTask: (taskId: string) => Promise<void>
}
export function TuningTab(props: TuningTabProps) {
const {
tuningObjective,
setTuningObjective,
tuningIntensity,
setTuningIntensity,
tuningSampleSize,
setTuningSampleSize,
tuningTopKEval,
setTuningTopKEval,
submitTuningTask,
creatingTuning,
tuningProfile,
tuningProfileToml,
tuningTasks,
applyBestTask,
} = props
return (
<TabsContent value="tuning" className="space-y-4">
<div className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 rounded-lg border bg-muted/20 p-4">
<div className="space-y-1">
<div className="text-sm font-medium"></div>
<div className="text-xs text-muted-foreground"> balanced / standard </div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<div className="text-xs text-muted-foreground"></div>
<Select value={tuningObjective} onValueChange={setTuningObjective}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="precision_priority">precision_priority</SelectItem>
<SelectItem value="balanced">balanced</SelectItem>
<SelectItem value="recall_priority">recall_priority</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<div className="text-xs text-muted-foreground"></div>
<Select value={tuningIntensity} onValueChange={setTuningIntensity}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="quick">quick</SelectItem>
<SelectItem value="standard">standard</SelectItem>
<SelectItem value="deep">deep</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="space-y-3 rounded-lg border bg-muted/20 p-4">
<div className="space-y-1">
<div className="text-sm font-medium"></div>
<div className="text-xs text-muted-foreground">使</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<div className="text-xs text-muted-foreground"></div>
<Input type="number" value={tuningSampleSize} onChange={(event) => setTuningSampleSize(event.target.value)} />
</div>
<div className="space-y-2">
<Label> Top-K</Label>
<div className="text-xs text-muted-foreground"></div>
<Input type="number" value={tuningTopKEval} onChange={(event) => setTuningTopKEval(event.target.value)} />
</div>
</div>
</div>
<Button onClick={() => void submitTuningTask()} disabled={creatingTuning}>
<Sparkles className="mr-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>便</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CodeEditor
value={JSON.stringify(tuningProfile, null, 2)}
language="json"
readOnly
height="220px"
/>
<CodeEditor
value={tuningProfileToml}
language="toml"
readOnly
height="180px"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tuningTasks.length > 0 ? tuningTasks.map((task) => (
<TableRow key={String(task.task_id ?? Math.random())}>
<TableCell className="font-mono text-xs">{String(task.task_id ?? '-')}</TableCell>
<TableCell>
<Badge variant={getImportStatusVariant(String(task.status ?? ''))}>
{String(task.status ?? '-')}
</Badge>
</TableCell>
<TableCell>
<Button
size="sm"
variant="outline"
onClick={() => void applyBestTask(String(task.task_id ?? ''))}
disabled={!task.task_id}
>
</Button>
</TableCell>
</TableRow>
)) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
使
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
)
}

View File

@@ -0,0 +1,498 @@
import type {
MemoryDeleteOperationPayload,
MemoryFeedbackActionLogPayload,
MemoryFeedbackCorrectionDetailTaskPayload,
MemoryFeedbackCorrectionSummaryPayload,
MemoryImportInputMode,
} from '@/lib/memory-api'
import {
IMPORT_STATUS_TEXT,
IMPORT_STEP_TEXT,
QUEUED_IMPORT_STATUS,
RUNNING_IMPORT_STATUS,
} from './constants'
export type DeleteOperationItem = NonNullable<MemoryDeleteOperationPayload['items']>[number]
export function normalizeProgress(value: number | string | null | undefined): number {
const numeric = Number(value ?? 0)
if (!Number.isFinite(numeric)) {
return 0
}
if (numeric < 0) {
return 0
}
if (numeric > 100) {
return 100
}
return numeric
}
export function parseOptionalPositiveInt(input: string): number | undefined {
const value = input.trim()
if (!value) {
return undefined
}
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
return undefined
}
return parsed
}
export function parseCommaSeparatedList(input: string): string[] {
return input
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
export function normalizeImportInputMode(value: string): MemoryImportInputMode {
return value === 'json' ? 'json' : 'text'
}
export function getImportStatusLabel(status: string): string {
const normalized = String(status ?? '').trim()
if (!normalized) {
return '-'
}
return IMPORT_STATUS_TEXT[normalized] ?? normalized
}
export function getImportStepLabel(step: string): string {
const normalized = String(step ?? '').trim()
if (!normalized) {
return '-'
}
return IMPORT_STEP_TEXT[normalized] ?? normalized
}
export function getImportStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (status === 'failed') {
return 'destructive'
}
if (status === 'completed') {
return 'default'
}
if (status === 'completed_with_errors' || status === 'cancelled') {
return 'secondary'
}
if (RUNNING_IMPORT_STATUS.has(status) || QUEUED_IMPORT_STATUS.has(status)) {
return 'outline'
}
return 'secondary'
}
export function formatImportTime(timestamp?: number | null): string {
if (!timestamp) {
return '-'
}
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
const value = new Date(normalized)
if (Number.isNaN(value.getTime())) {
return '-'
}
return value.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export function formatDeleteOperationMode(mode: string): string {
switch (mode) {
case 'entity':
return '实体'
case 'relation':
return '关系'
case 'paragraph':
return '段落'
case 'source':
return '来源'
case 'mixed':
return '混合'
default:
return mode || '未知'
}
}
export function formatDeleteOperationStatus(status: string): string {
switch (status) {
case 'executed':
return '已执行'
case 'restored':
return '已恢复'
default:
return status || '未知'
}
}
export function formatDeleteOperationTime(timestamp?: number | null): string {
if (!timestamp) {
return '未知时间'
}
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
const value = new Date(normalized)
if (Number.isNaN(value.getTime())) {
return '未知时间'
}
return value.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export function trimDeleteItemText(value: string, maxLength: number = 140): string {
const normalized = String(value ?? '').trim().replace(/\s+/g, ' ')
if (!normalized) {
return ''
}
if (normalized.length <= maxLength) {
return normalized
}
return `${normalized.slice(0, maxLength)}...`
}
export function formatDeleteRelationText(subject: string, predicate: string, object: string): string {
const left = String(subject ?? '').trim()
const middle = String(predicate ?? '').trim()
const right = String(object ?? '').trim()
return [left, middle, right].filter(Boolean).join(' -> ')
}
export function getDeleteOperationItemLabel(item: DeleteOperationItem): string {
const payload = item.payload ?? {}
if (item.item_type === 'entity') {
const entity = (payload.entity ?? {}) as Record<string, unknown>
return String(entity.name ?? item.item_key ?? item.item_hash ?? '未命名实体')
}
if (item.item_type === 'relation') {
const relation = (payload.relation ?? {}) as Record<string, unknown>
return (
formatDeleteRelationText(
String(relation.subject ?? ''),
String(relation.predicate ?? ''),
String(relation.object ?? ''),
) || String(item.item_key ?? item.item_hash ?? '未命名关系')
)
}
if (item.item_type === 'paragraph') {
const paragraph = (payload.paragraph ?? {}) as Record<string, unknown>
const source = String(paragraph.source ?? '').trim()
return source || String(item.item_key ?? item.item_hash ?? '未命名段落')
}
return String(item.item_key ?? item.item_hash ?? '未命名对象')
}
export function getDeleteOperationItemPreview(item: DeleteOperationItem): string {
const payload = item.payload ?? {}
if (item.item_type === 'entity') {
const paragraphLinks = Array.isArray(payload.paragraph_links) ? payload.paragraph_links : []
if (paragraphLinks.length > 0) {
return `关联段落 ${paragraphLinks.length}`
}
return '实体快照'
}
if (item.item_type === 'relation') {
const relation = (payload.relation ?? {}) as Record<string, unknown>
const paragraphHashes = Array.isArray(payload.paragraph_hashes) ? payload.paragraph_hashes : []
const { confidence } = relation
const parts = []
if (paragraphHashes.length > 0) {
parts.push(`证据段落 ${paragraphHashes.length}`)
}
if (typeof confidence === 'number') {
parts.push(`置信度 ${confidence.toFixed(2)}`)
}
return parts.join('') || '关系快照'
}
if (item.item_type === 'paragraph') {
const paragraph = (payload.paragraph ?? {}) as Record<string, unknown>
return trimDeleteItemText(String(paragraph.content ?? ''))
}
return ''
}
export function getDeleteOperationItemSource(item: DeleteOperationItem): string {
const payload = item.payload ?? {}
if (item.item_type === 'paragraph') {
const paragraph = (payload.paragraph ?? {}) as Record<string, unknown>
return String(paragraph.source ?? '').trim()
}
return String(payload.source ?? '').trim()
}
export 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 || '未知'
}
}
export 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 || '未知'
}
}
export function formatFeedbackRollbackStatus(status: string): string {
switch (status) {
case 'none':
return '未回退'
case 'running':
return '回退中'
case 'rolled_back':
return '已回退'
case 'error':
return '回退失败'
default:
return status || '未知'
}
}
export 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'
}
export 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)
}
export function pickFeedbackRelationTriplet(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as Record<string, unknown>
const subject = String(record.subject ?? '').trim()
const predicate = String(record.predicate ?? '').trim()
const object = String(record.object ?? '').trim()
if (!subject || !predicate || !object) {
return null
}
return record
}
export function formatFeedbackRelationTriplet(value: unknown): string {
const triplet = pickFeedbackRelationTriplet(value)
if (!triplet) {
return ''
}
return formatDeleteRelationText(
String(triplet.subject ?? ''),
String(triplet.predicate ?? ''),
String(triplet.object ?? ''),
)
}
export function getFeedbackCorrectionPreview(
task: MemoryFeedbackCorrectionDetailTaskPayload | MemoryFeedbackCorrectionSummaryPayload | null,
): {
headline: string
oldRelation: string
newRelation: string
} {
if (!task) {
return {
headline: '当前没有纠错摘要',
oldRelation: '',
newRelation: '',
}
}
const detailTask = task as MemoryFeedbackCorrectionDetailTaskPayload
const rollbackPlanSummary = detailTask.rollback_plan_summary ?? {}
const forgottenRelations = Array.isArray(rollbackPlanSummary.forgotten_relations)
? rollbackPlanSummary.forgotten_relations
: []
const correctedWrite = rollbackPlanSummary.corrected_write && typeof rollbackPlanSummary.corrected_write === 'object'
? rollbackPlanSummary.corrected_write
: {}
const correctedRelations = Array.isArray((correctedWrite as Record<string, unknown>).corrected_relations)
? ((correctedWrite as Record<string, unknown>).corrected_relations as unknown[])
: []
const oldRelation = formatFeedbackRelationTriplet(forgottenRelations[0])
const newRelation = formatFeedbackRelationTriplet(correctedRelations[0])
if (oldRelation && newRelation) {
return {
headline: `将“${oldRelation}”纠正为“${newRelation}`,
oldRelation,
newRelation,
}
}
if (newRelation) {
return {
headline: `补充了新的纠错结论:“${newRelation}`,
oldRelation: '',
newRelation,
}
}
if (oldRelation) {
return {
headline: `撤销了旧记忆关系:“${oldRelation}`,
oldRelation,
newRelation: '',
}
}
return {
headline: task.query_text || '当前纠错没有可读摘要',
oldRelation: '',
newRelation: '',
}
}
export function buildFeedbackImpactSummary(
task: MemoryFeedbackCorrectionDetailTaskPayload | MemoryFeedbackCorrectionSummaryPayload | null,
): string[] {
if (!task) {
return []
}
const counts = task.affected_counts ?? {}
const items: string[] = []
if (Number(counts.relations ?? 0) > 0) {
items.push(`影响关系 ${Number(counts.relations ?? 0)}`)
}
if (Number(counts.corrected_relations ?? 0) > 0) {
items.push(`新增纠正关系 ${Number(counts.corrected_relations ?? 0)}`)
}
if (Number(counts.correction_paragraphs ?? 0) > 0) {
items.push(`写入纠错段落 ${Number(counts.correction_paragraphs ?? 0)}`)
}
if (Number(counts.stale_paragraphs ?? 0) > 0) {
items.push(`标记旧段落 ${Number(counts.stale_paragraphs ?? 0)}`)
}
if (Number(counts.episode_sources ?? 0) > 0) {
items.push(`触发 Episode 修复 ${Number(counts.episode_sources ?? 0)} 个来源`)
}
if (Number(counts.profile_person_ids ?? 0) > 0) {
items.push(`触发 Profile 刷新 ${Number(counts.profile_person_ids ?? 0)} 个对象`)
}
return items
}
export function formatFeedbackActionType(actionType: string): string {
switch (actionType) {
case 'classification':
return '判定纠错'
case 'forget_relation':
return '撤销旧关系'
case 'mark_stale_paragraph':
return '标记旧段落'
case 'write_correction':
return '写入纠错'
case 'rollback_restore_relation':
return '恢复旧关系'
case 'rollback_delete_correction_paragraph':
return '隐藏纠错段落'
case 'rollback_revert_corrected_relation':
return '撤销纠正关系'
case 'rollback_clear_stale_mark':
return '清除脏段落标记'
case 'rollback_enqueue_episode_rebuild':
return '加入 Episode 修复队列'
case 'rollback_enqueue_profile_refresh':
return '加入 Profile 刷新队列'
case 'rollback_error':
return '回退失败'
case 'error':
return '处理失败'
case 'skip':
return '跳过处理'
default:
return actionType || '未知动作'
}
}
export function describeFeedbackActionLog(item: MemoryFeedbackActionLogPayload): string {
const beforeSummary = summarizeFeedbackActionPayload(item.before_payload)
const afterSummary = summarizeFeedbackActionPayload(item.after_payload)
switch (item.action_type) {
case 'classification':
return afterSummary ? `系统完成判定:${afterSummary}` : '系统完成纠错判定'
case 'forget_relation':
return beforeSummary ? `旧关系已失效:${beforeSummary}` : '旧关系已被标记为失效'
case 'mark_stale_paragraph':
return '旧段落已标记为待复核,后续检索会更谨慎地使用它'
case 'write_correction':
return afterSummary ? `已写入新的纠错结果:${afterSummary}` : '已写入新的纠错段落和关系'
case 'rollback_restore_relation':
return afterSummary ? `已恢复旧关系状态:${afterSummary}` : '已恢复旧关系状态'
case 'rollback_delete_correction_paragraph':
return '已隐藏这次纠错写入的段落'
case 'rollback_revert_corrected_relation':
return '已撤销纠错阶段新增的关系'
case 'rollback_clear_stale_mark':
return '已清除旧段落的待复核标记'
case 'rollback_enqueue_episode_rebuild':
return '已重新加入 Episode 修复队列'
case 'rollback_enqueue_profile_refresh':
return '已重新加入 Profile 刷新队列'
case 'rollback_error':
return item.reason || '这次回退执行失败'
case 'error':
return item.reason || '这次纠错处理失败'
case 'skip':
return item.reason || '这次纠错被跳过'
default:
return afterSummary || beforeSummary || item.reason || '记录了一条动作日志'
}
}