Files
mai-bot/dashboard/src/routes/resource/knowledge-base.tsx
A-Dawn 21b642d07d feat:同步本地非算法改动到上游基线
保留反馈纠错、WebUI 与运行时增强。\n移除不应提交的 algorithm_redesign 设计目录及其专项测试。
2026-04-16 13:57:07 +08:00

4032 lines
185 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import {
ChevronLeft,
ChevronRight,
Database,
Gauge,
Loader2,
RefreshCw,
RotateCcw,
Save,
SlidersHorizontal,
Sparkles,
Trash2,
Upload,
} from 'lucide-react'
import { CodeEditor } from '@/components'
import { MemoryDeleteDialog } from '@/components/memory/MemoryDeleteDialog'
import { MemoryConfigEditor } from '@/components/memory/MemoryConfigEditor'
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 {
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'
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
import {
cancelMemoryImportTask,
createMemoryLpmmConvertImport,
createMemoryLpmmOpenieImport,
createMemoryMaibotMigrationImport,
createMemoryRawScanImport,
createMemoryTemporalBackfillImport,
executeMemoryDelete,
getMemoryFeedbackCorrection,
getMemoryFeedbackCorrections,
getMemoryImportPathAliases,
getMemoryImportSettings,
getMemoryImportTask,
getMemoryImportTaskChunks,
applyBestMemoryTuningProfile,
createMemoryPasteImport,
createMemoryTuningTask,
createMemoryUploadImport,
getMemoryConfig,
getMemoryConfigRaw,
getMemoryConfigSchema,
getMemoryDeleteOperation,
getMemoryDeleteOperations,
getMemoryImportTasks,
getMemoryRuntimeConfig,
getMemorySources,
getMemoryTuningProfile,
getMemoryTuningTasks,
type MemoryDeleteRequestPayload,
type MemoryImportChunkListPayload,
type MemoryImportInputMode,
type MemoryImportSettings,
type MemoryImportTaskKind,
type MemoryImportTaskPayload,
previewMemoryDelete,
refreshMemoryRuntimeSelfCheck,
rollbackMemoryFeedbackCorrection,
resolveMemoryImportPath,
retryMemoryImportTask,
restoreMemoryDelete,
updateMemoryConfig,
updateMemoryConfigRaw,
type MemoryConfigSchemaPayload,
type MemoryDeleteExecutePayload,
type MemoryDeleteOperationPayload,
type MemoryFeedbackActionLogPayload,
type MemoryFeedbackCorrectionDetailTaskPayload,
type MemoryFeedbackCorrectionSummaryPayload,
type MemorySourceItemPayload,
type MemoryRuntimeConfigPayload,
type MemoryTaskPayload,
} from '@/lib/memory-api'
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'])
const QUEUED_IMPORT_STATUS = new Set(['queued'])
const IMPORT_STATUS_TEXT: Record<string, string> = {
queued: '排队中',
preparing: '准备中',
running: '运行中',
cancel_requested: '取消中',
cancelled: '已取消',
completed: '已完成',
completed_with_errors: '完成(有错误)',
failed: '失败',
}
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: '失败',
}
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 历史库迁移长期记忆数据' },
]
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
}
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
}
function parseCommaSeparatedList(input: string): string[] {
return input
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
function normalizeImportInputMode(value: string): MemoryImportInputMode {
return value === 'json' ? 'json' : 'text'
}
function getImportStatusLabel(status: string): string {
const normalized = String(status ?? '').trim()
if (!normalized) {
return '-'
}
return IMPORT_STATUS_TEXT[normalized] ?? normalized
}
function getImportStepLabel(step: string): string {
const normalized = String(step ?? '').trim()
if (!normalized) {
return '-'
}
return IMPORT_STEP_TEXT[normalized] ?? normalized
}
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'
}
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',
})
}
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 || '未知'
}
}
function formatDeleteOperationStatus(status: string): string {
switch (status) {
case 'executed':
return '已执行'
case 'restored':
return '已恢复'
default:
return status || '未知'
}
}
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',
})
}
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 {
const normalized = String(value ?? '').trim().replace(/\s+/g, ' ')
if (!normalized) {
return ''
}
if (normalized.length <= maxLength) {
return normalized
}
return `${normalized.slice(0, maxLength)}...`
}
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(' -> ')
}
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 ?? '未命名对象')
}
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.confidence
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 ''
}
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 KnowledgeBasePage() {
const navigate = useNavigate()
const { toast } = useToast()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [refreshingCheck, setRefreshingCheck] = useState(false)
const [creatingImport, setCreatingImport] = useState(false)
const [creatingTuning, setCreatingTuning] = useState(false)
const [rawMode, setRawMode] = useState(false)
const [schemaPayload, setSchemaPayload] = useState<MemoryConfigSchemaPayload | null>(null)
const [visualConfig, setVisualConfig] = useState<Record<string, unknown>>({})
const [rawConfig, setRawConfig] = useState('')
const [rawConfigExists, setRawConfigExists] = useState(true)
const [rawConfigUsingDefault, setRawConfigUsingDefault] = useState(false)
const [runtimeConfig, setRuntimeConfig] = useState<MemoryRuntimeConfigPayload | null>(null)
const [selfCheckReport, setSelfCheckReport] = useState<Record<string, unknown> | null>(null)
const [importSettings, setImportSettings] = useState<MemoryImportSettings>({})
const [importPathAliases, setImportPathAliases] = useState<Record<string, string>>({})
const [importTasks, setImportTasks] = useState<MemoryImportTaskPayload[]>([])
const [selectedImportTaskId, setSelectedImportTaskId] = useState('')
const [selectedImportTask, setSelectedImportTask] = useState<MemoryImportTaskPayload | null>(null)
const [selectedImportTaskLoading, setSelectedImportTaskLoading] = useState(false)
const [selectedImportFileId, setSelectedImportFileId] = useState('')
const [importChunkOffset, setImportChunkOffset] = useState(0)
const [importChunksPayload, setImportChunksPayload] = useState<MemoryImportChunkListPayload | null>(null)
const [importChunksLoading, setImportChunksLoading] = useState(false)
const [importCreateMode, setImportCreateMode] = useState<MemoryImportTaskKind>('upload')
const [importAutoPolling, setImportAutoPolling] = useState(true)
const [importErrorText, setImportErrorText] = useState('')
const [importCommonFileConcurrency, setImportCommonFileConcurrency] = useState('2')
const [importCommonChunkConcurrency, setImportCommonChunkConcurrency] = useState('4')
const [importCommonLlmEnabled, setImportCommonLlmEnabled] = useState(true)
const [importCommonStrategyOverride, setImportCommonStrategyOverride] = useState('auto')
const [importCommonDedupePolicy, setImportCommonDedupePolicy] = useState('content_hash')
const [importCommonChatLog, setImportCommonChatLog] = useState(false)
const [importCommonChatReferenceTime, setImportCommonChatReferenceTime] = useState('')
const [importCommonForce, setImportCommonForce] = useState(false)
const [importCommonClearManifest, setImportCommonClearManifest] = useState(false)
const [uploadInputMode, setUploadInputMode] = useState<MemoryImportInputMode>('text')
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [pasteName, setPasteName] = useState('')
const [pasteMode, setPasteMode] = useState<MemoryImportInputMode>('text')
const [pasteContent, setPasteContent] = useState('')
const [rawAlias, setRawAlias] = useState('raw')
const [rawRelativePath, setRawRelativePath] = useState('')
const [rawGlob, setRawGlob] = useState('*')
const [rawInputMode, setRawInputMode] = useState<MemoryImportInputMode>('text')
const [rawRecursive, setRawRecursive] = useState(true)
const [openieAlias, setOpenieAlias] = useState('lpmm')
const [openieRelativePath, setOpenieRelativePath] = useState('')
const [openieIncludeAllJson, setOpenieIncludeAllJson] = useState(false)
const [convertAlias, setConvertAlias] = useState('lpmm')
const [convertRelativePath, setConvertRelativePath] = useState('')
const [convertTargetAlias, setConvertTargetAlias] = useState('plugin_data')
const [convertTargetRelativePath, setConvertTargetRelativePath] = useState('')
const [convertDimension, setConvertDimension] = useState('')
const [convertBatchSize, setConvertBatchSize] = useState('1024')
const [backfillAlias, setBackfillAlias] = useState('plugin_data')
const [backfillRelativePath, setBackfillRelativePath] = useState('')
const [backfillLimit, setBackfillLimit] = useState('100000')
const [backfillDryRun, setBackfillDryRun] = useState(false)
const [backfillNoCreatedFallback, setBackfillNoCreatedFallback] = useState(false)
const [maibotSourceDb, setMaibotSourceDb] = useState('')
const [maibotTimeFrom, setMaibotTimeFrom] = useState('')
const [maibotTimeTo, setMaibotTimeTo] = useState('')
const [maibotStartId, setMaibotStartId] = useState('')
const [maibotEndId, setMaibotEndId] = useState('')
const [maibotStreamIds, setMaibotStreamIds] = useState('')
const [maibotGroupIds, setMaibotGroupIds] = useState('')
const [maibotUserIds, setMaibotUserIds] = useState('')
const [maibotReadBatchSize, setMaibotReadBatchSize] = useState('2000')
const [maibotCommitWindowRows, setMaibotCommitWindowRows] = useState('20000')
const [maibotEmbedWorkers, setMaibotEmbedWorkers] = useState('')
const [maibotNoResume, setMaibotNoResume] = useState(false)
const [maibotResetState, setMaibotResetState] = useState(false)
const [maibotDryRun, setMaibotDryRun] = useState(false)
const [maibotVerifyOnly, setMaibotVerifyOnly] = useState(false)
const [pathResolveAlias, setPathResolveAlias] = useState('raw')
const [pathResolveRelativePath, setPathResolveRelativePath] = useState('')
const [pathResolveMustExist, setPathResolveMustExist] = useState(true)
const [pathResolveOutput, setPathResolveOutput] = useState('')
const [resolvingPath, setResolvingPath] = useState(false)
const [tuningTasks, setTuningTasks] = useState<MemoryTaskPayload[]>([])
const [tuningProfile, setTuningProfile] = useState<Record<string, unknown>>({})
const [tuningProfileToml, setTuningProfileToml] = useState('')
const [memorySources, setMemorySources] = useState<MemorySourceItemPayload[]>([])
const [deleteOperations, setDeleteOperations] = useState<MemoryDeleteOperationPayload[]>([])
const [selectedOperationDetail, setSelectedOperationDetail] = useState<MemoryDeleteOperationPayload | null>(null)
const [selectedOperationDetailLoading, setSelectedOperationDetailLoading] = useState(false)
const [selectedOperationDetailError, setSelectedOperationDetailError] = useState('')
const [sourceSearch, setSourceSearch] = useState('')
const [operationSearch, setOperationSearch] = useState('')
const [operationModeFilter, setOperationModeFilter] = useState('all')
const [operationStatusFilter, setOperationStatusFilter] = useState('all')
const [operationPage, setOperationPage] = useState(1)
const [selectedOperationId, setSelectedOperationId] = useState('')
const [selectedOperationItemSearch, setSelectedOperationItemSearch] = useState('')
const [selectedOperationItemPage, setSelectedOperationItemPage] = useState(1)
const [selectedSources, setSelectedSources] = useState<string[]>([])
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteDialogTitle, setDeleteDialogTitle] = useState('删除预览')
const [deleteDialogDescription, setDeleteDialogDescription] = useState('')
const [deletePreview, setDeletePreview] = useState<Awaited<ReturnType<typeof previewMemoryDelete>> | null>(null)
const [deletePreviewError, setDeletePreviewError] = useState<string | null>(null)
const [deletePreviewLoading, setDeletePreviewLoading] = useState(false)
const [deleteExecuting, setDeleteExecuting] = useState(false)
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')
const [tuningTopKEval, setTuningTopKEval] = useState('20')
const loadPage = useCallback(async () => {
try {
setLoading(true)
const [
schema,
configPayload,
rawPayload,
runtimePayload,
importSettingsPayload,
pathAliasPayload,
importTaskPayload,
tuningProfilePayload,
tuningTaskPayload,
sourcePayload,
deleteOperationPayload,
feedbackCorrectionPayload,
] = await Promise.all([
getMemoryConfigSchema(),
getMemoryConfig(),
getMemoryConfigRaw(),
getMemoryRuntimeConfig(),
getMemoryImportSettings(),
getMemoryImportPathAliases(),
getMemoryImportTasks(20),
getMemoryTuningProfile(),
getMemoryTuningTasks(20),
getMemorySources(),
getMemoryDeleteOperations(DELETE_OPERATION_FETCH_LIMIT),
getMemoryFeedbackCorrections({ limit: FEEDBACK_CORRECTION_FETCH_LIMIT }),
])
setSchemaPayload(schema)
setVisualConfig(configPayload.config ?? {})
setRawConfig(rawPayload.config ?? '')
setRawConfigExists(rawPayload.exists ?? true)
setRawConfigUsingDefault(rawPayload.using_default ?? false)
setRuntimeConfig(runtimePayload)
setImportSettings(importSettingsPayload.settings ?? {})
setImportPathAliases(pathAliasPayload.path_aliases ?? {})
setImportTasks(importTaskPayload.items ?? [])
setTuningProfile(tuningProfilePayload.profile ?? {})
setTuningProfileToml(tuningProfilePayload.toml ?? '')
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) {
setSelectedImportTaskId(initialTaskId)
}
}
if (!maibotSourceDb && String(importSettingsPayload.settings?.maibot_source_db_default ?? '').trim()) {
setMaibotSourceDb(String(importSettingsPayload.settings?.maibot_source_db_default ?? '').trim())
}
if (!pathResolveAlias) {
const aliasKeys = Object.keys(pathAliasPayload.path_aliases ?? {})
if (aliasKeys.length > 0) {
setPathResolveAlias(aliasKeys[0])
}
}
} catch (error) {
toast({
title: '加载长期记忆控制台失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [maibotSourceDb, pathResolveAlias, selectedImportTaskId, toast])
useEffect(() => {
void loadPage()
}, [loadPage])
const configPath = schemaPayload?.path ?? 'config/a_memorix.toml'
const schema = schemaPayload?.schema
const runtimeBadges = useMemo(() => {
if (!runtimeConfig) {
return []
}
return [
{ label: '运行状态', value: runtimeConfig.runtime_ready ? '就绪' : '未就绪' },
{ label: 'Embedding 维度', value: String(runtimeConfig.embedding_dimension) },
{ label: '自动保存', value: runtimeConfig.auto_save ? '开启' : '关闭' },
{ label: '数据目录', value: runtimeConfig.data_dir },
]
}, [runtimeConfig])
const importPollInterval = useMemo(
() => Math.max(200, Number(importSettings.poll_interval_ms ?? 1000)),
[importSettings.poll_interval_ms],
)
const importAliasKeys = useMemo(
() => Object.keys(importPathAliases).sort((left, right) => left.localeCompare(right)),
[importPathAliases],
)
const runningImportTasks = useMemo(
() => importTasks.filter((task) => RUNNING_IMPORT_STATUS.has(String(task.status ?? '').trim())),
[importTasks],
)
const queuedImportTasks = useMemo(
() => importTasks.filter((task) => QUEUED_IMPORT_STATUS.has(String(task.status ?? '').trim())),
[importTasks],
)
const recentImportTasks = useMemo(
() =>
importTasks.filter((task) => {
const status = String(task.status ?? '').trim()
return !RUNNING_IMPORT_STATUS.has(status) && !QUEUED_IMPORT_STATUS.has(status)
}),
[importTasks],
)
const selectedImportTaskSummary = useMemo(() => {
if (!selectedImportTaskId) {
return null
}
return importTasks.find((task) => task.task_id === selectedImportTaskId) ?? null
}, [importTasks, selectedImportTaskId])
const selectedImportFiles = useMemo(() => {
return Array.isArray(selectedImportTask?.files) ? selectedImportTask.files : []
}, [selectedImportTask?.files])
const selectedImportChunks = useMemo(() => {
return Array.isArray(importChunksPayload?.items) ? importChunksPayload.items : []
}, [importChunksPayload?.items])
const selectedImportTaskResolved = selectedImportTask ?? selectedImportTaskSummary
const selectedImportTaskErrorText = String(selectedImportTaskResolved?.error ?? '').trim()
const selectedImportRetrySummary = selectedImportTaskResolved?.retry_summary
const importChunkTotal = Number(importChunksPayload?.total ?? 0)
const canImportChunkPrev = importChunkOffset > 0
const canImportChunkNext = importChunkOffset + IMPORT_CHUNK_PAGE_SIZE < importChunkTotal
const buildCommonImportPayload = useCallback((): Record<string, unknown> => {
const payload: Record<string, unknown> = {
llm_enabled: importCommonLlmEnabled,
strategy_override: importCommonStrategyOverride,
dedupe_policy: importCommonDedupePolicy,
chat_log: importCommonChatLog,
force: importCommonForce,
clear_manifest: importCommonClearManifest,
}
const fileConcurrency = parseOptionalPositiveInt(importCommonFileConcurrency)
const chunkConcurrency = parseOptionalPositiveInt(importCommonChunkConcurrency)
if (fileConcurrency !== undefined) {
payload.file_concurrency = fileConcurrency
}
if (chunkConcurrency !== undefined) {
payload.chunk_concurrency = chunkConcurrency
}
if (importCommonChatReferenceTime.trim()) {
payload.chat_reference_time = importCommonChatReferenceTime.trim()
}
return payload
}, [
importCommonChatLog,
importCommonChatReferenceTime,
importCommonChunkConcurrency,
importCommonClearManifest,
importCommonDedupePolicy,
importCommonFileConcurrency,
importCommonForce,
importCommonLlmEnabled,
importCommonStrategyOverride,
])
const refreshImportQueue = useCallback(async (silent: boolean = false) => {
try {
const [taskPayload, settingsPayload, pathAliasPayload] = await Promise.all([
getMemoryImportTasks(20),
getMemoryImportSettings(),
getMemoryImportPathAliases(),
])
const nextTasks = taskPayload.items ?? []
setImportTasks(nextTasks)
setImportSettings(settingsPayload.settings ?? {})
setImportPathAliases(pathAliasPayload.path_aliases ?? {})
setImportErrorText('')
if (nextTasks.length <= 0) {
setSelectedImportTaskId('')
setSelectedImportTask(null)
setSelectedImportFileId('')
setImportChunksPayload(null)
return
}
if (!selectedImportTaskId || !nextTasks.some((item) => item.task_id === selectedImportTaskId)) {
setSelectedImportTaskId(nextTasks[0].task_id)
}
} catch (error) {
const message = error instanceof Error ? error.message : '刷新导入任务失败'
setImportErrorText(message)
if (!silent) {
toast({
title: '刷新导入任务失败',
description: message,
variant: 'destructive',
})
}
}
}, [selectedImportTaskId, toast])
const loadImportChunks = useCallback(
async (
taskId: string,
fileId: string,
offset: number = 0,
silent: boolean = false,
) => {
if (!taskId || !fileId) {
setImportChunksPayload(null)
return
}
try {
setImportChunksLoading(true)
const payload = await getMemoryImportTaskChunks(taskId, fileId, offset, IMPORT_CHUNK_PAGE_SIZE)
if (!payload.success) {
throw new Error(payload.error || '加载分块详情失败')
}
setImportChunksPayload(payload)
setImportErrorText('')
} catch (error) {
const message = error instanceof Error ? error.message : '加载分块详情失败'
setImportChunksPayload(null)
setImportErrorText(message)
if (!silent) {
toast({
title: '加载分块详情失败',
description: message,
variant: 'destructive',
})
}
} finally {
setImportChunksLoading(false)
}
},
[toast],
)
const loadImportTaskDetail = useCallback(
async (taskId: string, silent: boolean = false) => {
if (!taskId) {
setSelectedImportTask(null)
setSelectedImportFileId('')
setImportChunksPayload(null)
return
}
try {
if (!silent) {
setSelectedImportTaskLoading(true)
}
const payload = await getMemoryImportTask(taskId, false)
if (!payload.success || !payload.task) {
throw new Error(payload.error || '任务不存在')
}
const task = payload.task
setSelectedImportTask(task)
setImportErrorText('')
const files = Array.isArray(task.files) ? task.files : []
const keepCurrentFile = files.some((file) => file.file_id === selectedImportFileId)
const nextFileId = keepCurrentFile ? selectedImportFileId : String(files[0]?.file_id ?? '')
const nextOffset = keepCurrentFile ? importChunkOffset : 0
if (!keepCurrentFile) {
setImportChunkOffset(0)
}
setSelectedImportFileId(nextFileId)
if (nextFileId) {
await loadImportChunks(taskId, nextFileId, nextOffset, silent)
} else {
setImportChunksPayload(null)
}
} catch (error) {
const message = error instanceof Error ? error.message : '加载导入任务详情失败'
setSelectedImportTask(null)
setSelectedImportFileId('')
setImportChunksPayload(null)
setImportErrorText(message)
if (!silent) {
toast({
title: '加载导入任务详情失败',
description: message,
variant: 'destructive',
})
}
} finally {
if (!silent) {
setSelectedImportTaskLoading(false)
}
}
},
[importChunkOffset, loadImportChunks, selectedImportFileId, toast],
)
const afterImportTaskCreated = useCallback(
async (taskId: string, successTitle: string) => {
await refreshImportQueue(true)
if (taskId) {
setSelectedImportTaskId(taskId)
await loadImportTaskDetail(taskId, true)
}
toast({
title: successTitle,
description: taskId ? `任务 ${taskId.slice(0, 12)} 已加入导入队列` : '导入任务已加入队列',
})
},
[loadImportTaskDetail, refreshImportQueue, toast],
)
const submitUploadImport = useCallback(async () => {
if (uploadFiles.length <= 0) {
toast({
title: '请选择上传文件',
description: '至少选择一个 txt/md/json 文件后再提交',
variant: 'destructive',
})
return
}
try {
setCreatingImport(true)
const payload = {
...buildCommonImportPayload(),
input_mode: uploadInputMode,
}
const result = await createMemoryUploadImport(uploadFiles, payload)
if (!result.success) {
throw new Error(result.error || '创建上传导入任务失败')
}
const taskId = String(result.task?.task_id ?? '')
setUploadFiles([])
await afterImportTaskCreated(taskId, '上传导入任务已创建')
} catch (error) {
const message = error instanceof Error ? error.message : '创建上传导入任务失败'
setImportErrorText(message)
toast({
title: '创建上传导入任务失败',
description: message,
variant: 'destructive',
})
} finally {
setCreatingImport(false)
}
}, [afterImportTaskCreated, buildCommonImportPayload, toast, uploadFiles, uploadInputMode])
const submitPasteImport = useCallback(async () => {
if (!pasteContent.trim()) {
toast({
title: '粘贴内容不能为空',
description: '请填写导入内容后再提交',
variant: 'destructive',
})
return
}
try {
setCreatingImport(true)
const result = await createMemoryPasteImport({
...buildCommonImportPayload(),
name: pasteName || undefined,
content: pasteContent,
input_mode: pasteMode,
})
if (!result.success) {
throw new Error(result.error || '创建粘贴导入任务失败')
}
const taskId = String(result.task?.task_id ?? '')
setPasteContent('')
setPasteName('')
await afterImportTaskCreated(taskId, '粘贴导入任务已创建')
} catch (error) {
const message = error instanceof Error ? error.message : '创建粘贴导入任务失败'
setImportErrorText(message)
toast({
title: '创建粘贴导入任务失败',
description: message,
variant: 'destructive',
})
} finally {
setCreatingImport(false)
}
}, [afterImportTaskCreated, buildCommonImportPayload, pasteContent, pasteMode, pasteName, toast])
const submitRawScanImport = useCallback(async () => {
try {
setCreatingImport(true)
const result = await createMemoryRawScanImport({
...buildCommonImportPayload(),
alias: rawAlias,
relative_path: rawRelativePath,
glob: rawGlob,
recursive: rawRecursive,
input_mode: rawInputMode,
})
if (!result.success) {
throw new Error(result.error || '创建本地扫描任务失败')
}
await afterImportTaskCreated(String(result.task?.task_id ?? ''), '本地扫描任务已创建')
} catch (error) {
const message = error instanceof Error ? error.message : '创建本地扫描任务失败'
setImportErrorText(message)
toast({
title: '创建本地扫描任务失败',
description: message,
variant: 'destructive',
})
} finally {
setCreatingImport(false)
}
}, [
afterImportTaskCreated,
buildCommonImportPayload,
rawAlias,
rawGlob,
rawInputMode,
rawRecursive,
rawRelativePath,
toast,
])
const submitOpenieImport = useCallback(async () => {
try {
setCreatingImport(true)
const result = await createMemoryLpmmOpenieImport({
...buildCommonImportPayload(),
alias: openieAlias,
relative_path: openieRelativePath,
include_all_json: openieIncludeAllJson,
})
if (!result.success) {
throw new Error(result.error || '创建 LPMM OpenIE 任务失败')
}
await afterImportTaskCreated(String(result.task?.task_id ?? ''), 'LPMM OpenIE 任务已创建')
} catch (error) {
const message = error instanceof Error ? error.message : '创建 LPMM OpenIE 任务失败'
setImportErrorText(message)
toast({
title: '创建 LPMM OpenIE 任务失败',
description: message,
variant: 'destructive',
})
} finally {
setCreatingImport(false)
}
}, [
afterImportTaskCreated,
buildCommonImportPayload,
openieAlias,
openieIncludeAllJson,
openieRelativePath,
toast,
])
const submitConvertImport = useCallback(async () => {
try {
setCreatingImport(true)
const result = await createMemoryLpmmConvertImport({
alias: convertAlias,
relative_path: convertRelativePath,
target_alias: convertTargetAlias,
target_relative_path: convertTargetRelativePath,
dimension: parseOptionalPositiveInt(convertDimension),
batch_size: parseOptionalPositiveInt(convertBatchSize),
})
if (!result.success) {
throw new Error(result.error || '创建 LPMM 转换任务失败')
}
await afterImportTaskCreated(String(result.task?.task_id ?? ''), 'LPMM 转换任务已创建')
} catch (error) {
const message = error instanceof Error ? error.message : '创建 LPMM 转换任务失败'
setImportErrorText(message)
toast({
title: '创建 LPMM 转换任务失败',
description: message,
variant: 'destructive',
})
} finally {
setCreatingImport(false)
}
}, [
afterImportTaskCreated,
convertAlias,
convertBatchSize,
convertDimension,
convertRelativePath,
convertTargetAlias,
convertTargetRelativePath,
toast,
])
const submitBackfillImport = useCallback(async () => {
try {
setCreatingImport(true)
const result = await createMemoryTemporalBackfillImport({
alias: backfillAlias,
relative_path: backfillRelativePath,
limit: parseOptionalPositiveInt(backfillLimit),
dry_run: backfillDryRun,
no_created_fallback: backfillNoCreatedFallback,
})
if (!result.success) {
throw new Error(result.error || '创建时序回填任务失败')
}
await afterImportTaskCreated(String(result.task?.task_id ?? ''), '时序回填任务已创建')
} catch (error) {
const message = error instanceof Error ? error.message : '创建时序回填任务失败'
setImportErrorText(message)
toast({
title: '创建时序回填任务失败',
description: message,
variant: 'destructive',
})
} finally {
setCreatingImport(false)
}
}, [
afterImportTaskCreated,
backfillAlias,
backfillDryRun,
backfillLimit,
backfillNoCreatedFallback,
backfillRelativePath,
toast,
])
const submitMaibotMigrationImport = useCallback(async () => {
try {
setCreatingImport(true)
const result = await createMemoryMaibotMigrationImport({
source_db: maibotSourceDb || undefined,
time_from: maibotTimeFrom || undefined,
time_to: maibotTimeTo || undefined,
start_id: parseOptionalPositiveInt(maibotStartId),
end_id: parseOptionalPositiveInt(maibotEndId),
stream_ids: parseCommaSeparatedList(maibotStreamIds),
group_ids: parseCommaSeparatedList(maibotGroupIds),
user_ids: parseCommaSeparatedList(maibotUserIds),
read_batch_size: parseOptionalPositiveInt(maibotReadBatchSize),
commit_window_rows: parseOptionalPositiveInt(maibotCommitWindowRows),
embed_workers: parseOptionalPositiveInt(maibotEmbedWorkers),
no_resume: maibotNoResume,
reset_state: maibotResetState,
dry_run: maibotDryRun,
verify_only: maibotVerifyOnly,
})
if (!result.success) {
throw new Error(result.error || '创建 MaiBot 迁移任务失败')
}
await afterImportTaskCreated(String(result.task?.task_id ?? ''), 'MaiBot 迁移任务已创建')
} catch (error) {
const message = error instanceof Error ? error.message : '创建 MaiBot 迁移任务失败'
setImportErrorText(message)
toast({
title: '创建 MaiBot 迁移任务失败',
description: message,
variant: 'destructive',
})
} finally {
setCreatingImport(false)
}
}, [
afterImportTaskCreated,
maibotCommitWindowRows,
maibotDryRun,
maibotEmbedWorkers,
maibotEndId,
maibotGroupIds,
maibotNoResume,
maibotReadBatchSize,
maibotResetState,
maibotSourceDb,
maibotStartId,
maibotStreamIds,
maibotTimeFrom,
maibotTimeTo,
maibotUserIds,
maibotVerifyOnly,
toast,
])
const cancelSelectedImportTask = useCallback(async () => {
if (!selectedImportTaskId) {
return
}
try {
const payload = await cancelMemoryImportTask(selectedImportTaskId)
if (!payload.success) {
throw new Error(payload.error || '取消导入任务失败')
}
await refreshImportQueue(true)
await loadImportTaskDetail(selectedImportTaskId, true)
toast({
title: '已请求取消任务',
description: `任务 ${selectedImportTaskId.slice(0, 12)} 正在取消`,
})
} catch (error) {
const message = error instanceof Error ? error.message : '取消导入任务失败'
setImportErrorText(message)
toast({
title: '取消导入任务失败',
description: message,
variant: 'destructive',
})
}
}, [loadImportTaskDetail, refreshImportQueue, selectedImportTaskId, toast])
const retrySelectedImportTask = useCallback(async () => {
if (!selectedImportTaskId) {
return
}
try {
const payload = await retryMemoryImportTask(selectedImportTaskId, {
overrides: buildCommonImportPayload(),
})
if (!payload.success) {
throw new Error(payload.error || '重试失败项失败')
}
const nextTaskId = String(payload.task?.task_id ?? '')
await refreshImportQueue(true)
if (nextTaskId) {
setSelectedImportTaskId(nextTaskId)
await loadImportTaskDetail(nextTaskId, true)
} else {
await loadImportTaskDetail(selectedImportTaskId, true)
}
toast({
title: '重试任务已创建',
description: nextTaskId ? `重试任务 ${nextTaskId.slice(0, 12)} 已进入队列` : '失败项已提交重试',
})
} catch (error) {
const message = error instanceof Error ? error.message : '重试失败项失败'
setImportErrorText(message)
toast({
title: '重试失败项失败',
description: message,
variant: 'destructive',
})
}
}, [buildCommonImportPayload, loadImportTaskDetail, refreshImportQueue, selectedImportTaskId, toast])
const resolveImportPath = useCallback(async () => {
if (!pathResolveAlias.trim()) {
return
}
try {
setResolvingPath(true)
const payload = await resolveMemoryImportPath({
alias: pathResolveAlias,
relative_path: pathResolveRelativePath,
must_exist: pathResolveMustExist,
})
const lines = [
`路径别名: ${payload.alias}`,
`相对路径: ${payload.relative_path || '(空)'}`,
`解析结果: ${payload.resolved_path}`,
`是否存在: ${String(payload.exists)}`,
`是否文件: ${String(payload.is_file)}`,
`是否目录: ${String(payload.is_dir)}`,
]
setPathResolveOutput(lines.join('\n'))
} catch (error) {
const message = error instanceof Error ? error.message : '路径解析失败'
setPathResolveOutput(`解析失败:${message}`)
} finally {
setResolvingPath(false)
}
}, [pathResolveAlias, pathResolveMustExist, pathResolveRelativePath])
const selectImportTask = useCallback(
async (taskId: string) => {
setSelectedImportTaskId(taskId)
setImportChunkOffset(0)
await loadImportTaskDetail(taskId)
},
[loadImportTaskDetail],
)
const selectImportFile = useCallback(
async (fileId: string) => {
if (!selectedImportTaskId) {
return
}
setSelectedImportFileId(fileId)
setImportChunkOffset(0)
await loadImportChunks(selectedImportTaskId, fileId, 0)
},
[loadImportChunks, selectedImportTaskId],
)
const moveImportChunkPage = useCallback(
async (direction: -1 | 1) => {
if (!selectedImportTaskId || !selectedImportFileId) {
return
}
const nextOffset =
direction < 0
? Math.max(0, importChunkOffset - IMPORT_CHUNK_PAGE_SIZE)
: importChunkOffset + IMPORT_CHUNK_PAGE_SIZE
if (nextOffset === importChunkOffset) {
return
}
setImportChunkOffset(nextOffset)
await loadImportChunks(selectedImportTaskId, selectedImportFileId, nextOffset)
},
[importChunkOffset, loadImportChunks, selectedImportFileId, selectedImportTaskId],
)
useEffect(() => {
if (importAliasKeys.length <= 0) {
return
}
const pickAlias = (current: string, preferred: string): string => {
if (current && importAliasKeys.includes(current)) {
return current
}
if (importAliasKeys.includes(preferred)) {
return preferred
}
return importAliasKeys[0]
}
setRawAlias((current) => pickAlias(current, 'raw'))
setOpenieAlias((current) => pickAlias(current, 'lpmm'))
setConvertAlias((current) => pickAlias(current, 'lpmm'))
setConvertTargetAlias((current) => pickAlias(current, 'plugin_data'))
setBackfillAlias((current) => pickAlias(current, 'plugin_data'))
setPathResolveAlias((current) => pickAlias(current, 'raw'))
}, [importAliasKeys])
useEffect(() => {
const defaultFileConcurrency = String(importSettings.default_file_concurrency ?? '').trim()
const defaultChunkConcurrency = String(importSettings.default_chunk_concurrency ?? '').trim()
if (defaultFileConcurrency && importCommonFileConcurrency === '2') {
setImportCommonFileConcurrency(defaultFileConcurrency)
}
if (defaultChunkConcurrency && importCommonChunkConcurrency === '4') {
setImportCommonChunkConcurrency(defaultChunkConcurrency)
}
const defaultSourceDb = String(importSettings.maibot_source_db_default ?? '').trim()
if (defaultSourceDb && !maibotSourceDb.trim()) {
setMaibotSourceDb(defaultSourceDb)
}
}, [
importCommonChunkConcurrency,
importCommonFileConcurrency,
importSettings.default_chunk_concurrency,
importSettings.default_file_concurrency,
importSettings.maibot_source_db_default,
maibotSourceDb,
])
useEffect(() => {
if (!selectedImportTaskId && importTasks.length > 0) {
void selectImportTask(importTasks[0].task_id)
}
}, [importTasks, selectImportTask, selectedImportTaskId])
useEffect(() => {
if (!selectedImportTaskId) {
setSelectedImportTask(null)
setSelectedImportFileId('')
setImportChunksPayload(null)
return
}
if (!importTasks.some((task) => task.task_id === selectedImportTaskId) && importTasks.length > 0) {
void selectImportTask(importTasks[0].task_id)
return
}
void loadImportTaskDetail(selectedImportTaskId, true)
}, [importTasks, loadImportTaskDetail, selectImportTask, selectedImportTaskId])
useEffect(() => {
if (!importAutoPolling) {
return
}
const timerId = window.setInterval(() => {
void refreshImportQueue(true)
if (selectedImportTaskId) {
void loadImportTaskDetail(selectedImportTaskId, true)
}
}, importPollInterval)
return () => {
window.clearInterval(timerId)
}
}, [importAutoPolling, importPollInterval, loadImportTaskDetail, refreshImportQueue, selectedImportTaskId])
const filteredSources = useMemo(() => {
const keyword = sourceSearch.trim().toLowerCase()
if (!keyword) {
return memorySources
}
return memorySources.filter((item) => String(item.source ?? '').toLowerCase().includes(keyword))
}, [memorySources, sourceSearch])
const filteredDeleteOperations = useMemo(() => {
const keyword = operationSearch.trim().toLowerCase()
return deleteOperations.filter((operation) => {
const mode = String(operation.mode ?? '').trim()
const status = String(operation.status ?? '').trim()
const summary = operation.summary ?? {}
const sources = Array.isArray(summary.sources) ? summary.sources : []
if (operationModeFilter !== 'all' && mode !== operationModeFilter) {
return false
}
if (operationStatusFilter !== 'all' && status !== operationStatusFilter) {
return false
}
if (!keyword) {
return true
}
return [
operation.operation_id,
operation.reason,
operation.requested_by,
mode,
status,
...sources.map((item) => String(item)),
]
.map((item) => String(item ?? '').toLowerCase())
.some((item) => item.includes(keyword))
})
}, [deleteOperations, operationModeFilter, operationSearch, operationStatusFilter])
const deleteOperationPageCount = Math.max(1, Math.ceil(filteredDeleteOperations.length / DELETE_OPERATION_PAGE_SIZE))
const pagedDeleteOperations = useMemo(() => {
const start = (operationPage - 1) * DELETE_OPERATION_PAGE_SIZE
return filteredDeleteOperations.slice(start, start + DELETE_OPERATION_PAGE_SIZE)
}, [filteredDeleteOperations, operationPage])
const selectedDeleteOperation = useMemo(
() => filteredDeleteOperations.find((operation) => operation.operation_id === selectedOperationId) ?? pagedDeleteOperations[0] ?? null,
[filteredDeleteOperations, pagedDeleteOperations, selectedOperationId],
)
useEffect(() => {
setOperationPage(1)
}, [operationSearch, operationModeFilter, operationStatusFilter])
useEffect(() => {
if (operationPage > deleteOperationPageCount) {
setOperationPage(deleteOperationPageCount)
}
}, [deleteOperationPageCount, operationPage])
useEffect(() => {
if (!selectedDeleteOperation) {
if (selectedOperationId) {
setSelectedOperationId('')
}
setSelectedOperationDetail(null)
setSelectedOperationDetailError('')
return
}
if (selectedDeleteOperation.operation_id !== selectedOperationId) {
setSelectedOperationId(selectedDeleteOperation.operation_id)
}
}, [selectedDeleteOperation, selectedOperationId])
useEffect(() => {
const operationId = selectedDeleteOperation?.operation_id
if (!operationId) {
setSelectedOperationDetail(null)
setSelectedOperationDetailError('')
return
}
let cancelled = false
setSelectedOperationDetailLoading(true)
setSelectedOperationDetailError('')
void getMemoryDeleteOperation(operationId)
.then((payload) => {
if (cancelled) {
return
}
if (!payload.success || !payload.operation) {
setSelectedOperationDetail(null)
setSelectedOperationDetailError(payload.error || '未能加载删除操作详情')
return
}
setSelectedOperationDetail(payload.operation)
})
.catch((error) => {
if (cancelled) {
return
}
setSelectedOperationDetail(null)
setSelectedOperationDetailError(error instanceof Error ? error.message : '未能加载删除操作详情')
})
.finally(() => {
if (!cancelled) {
setSelectedOperationDetailLoading(false)
}
})
return () => {
cancelled = true
}
}, [selectedDeleteOperation?.operation_id])
const toggleSourceSelection = useCallback((source: string, checked: boolean) => {
setSelectedSources((current) => {
if (checked) {
return current.includes(source) ? current : [...current, source]
}
return current.filter((item) => item !== source)
})
}, [])
const openSourceDeletePreview = useCallback(async () => {
if (selectedSources.length <= 0) {
toast({
title: '请选择来源',
description: '至少选择一个来源后再进行删除预览',
variant: 'destructive',
})
return
}
const request: MemoryDeleteRequestPayload = {
mode: 'source',
selector: { sources: selectedSources },
reason: 'knowledge_base_source_delete',
requested_by: 'knowledge_base',
}
setDeleteDialogTitle('批量删除来源')
setDeleteDialogDescription('删除来源只会删除该来源下的段落,以及失去全部证据的关系,不会自动删除实体')
setPendingDeleteRequest(request)
setDeletePreview(null)
setDeleteResult(null)
setDeletePreviewError(null)
setDeleteDialogOpen(true)
setDeletePreviewLoading(true)
try {
const preview = await previewMemoryDelete(request)
setDeletePreview(preview)
} catch (error) {
setDeletePreviewError(error instanceof Error ? error.message : '删除预览失败')
} finally {
setDeletePreviewLoading(false)
}
}, [selectedSources, toast])
const executePendingDelete = useCallback(async () => {
if (!pendingDeleteRequest) {
return
}
try {
setDeleteExecuting(true)
const result = await executeMemoryDelete(pendingDeleteRequest)
setDeleteResult(result)
toast({
title: result.success ? '删除成功' : '删除失败',
description: result.success ? `操作 ${result.operation_id} 已完成` : result.error || '未能执行删除',
variant: result.success ? 'default' : 'destructive',
})
if (result.success) {
const [sourcePayload, deleteOperationPayload] = await Promise.all([
getMemorySources(),
getMemoryDeleteOperations(DELETE_OPERATION_FETCH_LIMIT),
])
setMemorySources(sourcePayload.items ?? [])
setDeleteOperations(deleteOperationPayload.items ?? [])
setSelectedSources([])
}
} catch (error) {
setDeletePreviewError(error instanceof Error ? error.message : '删除失败')
toast({
title: '删除失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setDeleteExecuting(false)
}
}, [pendingDeleteRequest, toast])
const restoreDeleteOperation = useCallback(async (operationId: string) => {
try {
setDeleteRestoring(true)
await restoreMemoryDelete({ operation_id: operationId, requested_by: 'knowledge_base' })
toast({
title: '恢复成功',
description: `删除操作 ${operationId} 已恢复`,
})
setDeleteDialogOpen(false)
const [sourcePayload, deleteOperationPayload] = await Promise.all([
getMemorySources(),
getMemoryDeleteOperations(DELETE_OPERATION_FETCH_LIMIT),
])
setMemorySources(sourcePayload.items ?? [])
setDeleteOperations(deleteOperationPayload.items ?? [])
} catch (error) {
toast({
title: '恢复失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setDeleteRestoring(false)
}
}, [toast])
const closeDeleteDialog = useCallback((open: boolean) => {
if (!open) {
setDeleteDialogOpen(false)
setDeletePreview(null)
setDeleteResult(null)
setDeletePreviewError(null)
setPendingDeleteRequest(null)
return
}
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
}
if (selectedOperationDetail?.operation_id === selectedDeleteOperation.operation_id) {
return {
...selectedDeleteOperation,
...selectedOperationDetail,
} satisfies MemoryDeleteOperationPayload
}
return selectedDeleteOperation
}, [selectedDeleteOperation, selectedOperationDetail])
const selectedOperationSummaryResolved = ((selectedOperationResolved?.summary ?? {}) as Record<string, unknown>)
const selectedOperationCounts = ((selectedOperationSummaryResolved.counts as Record<string, number> | undefined) ?? {})
const selectedOperationSources = Array.isArray(selectedOperationSummaryResolved.sources)
? selectedOperationSummaryResolved.sources.map((item) => String(item)).filter(Boolean)
: []
const selectedOperationItems = Array.isArray(selectedOperationResolved?.items)
? selectedOperationResolved.items
: []
const filteredSelectedOperationItems = useMemo(() => {
const keyword = selectedOperationItemSearch.trim().toLowerCase()
if (!keyword) {
return selectedOperationItems
}
return selectedOperationItems.filter((item) => {
const payload = item.payload ?? {}
const source = String(payload.source ?? '').trim()
return [
item.item_type,
item.item_hash,
item.item_key,
source,
]
.map((value) => String(value ?? '').toLowerCase())
.some((value) => value.includes(keyword))
})
}, [selectedOperationItemSearch, selectedOperationItems])
const selectedOperationItemPageCount = Math.max(
1,
Math.ceil(filteredSelectedOperationItems.length / DELETE_OPERATION_ITEM_PAGE_SIZE),
)
const pagedSelectedOperationItems = useMemo(() => {
const start = (selectedOperationItemPage - 1) * DELETE_OPERATION_ITEM_PAGE_SIZE
return filteredSelectedOperationItems.slice(start, start + DELETE_OPERATION_ITEM_PAGE_SIZE)
}, [filteredSelectedOperationItems, selectedOperationItemPage])
useEffect(() => {
setSelectedOperationItemPage(1)
}, [selectedOperationId, selectedOperationItemSearch])
useEffect(() => {
if (selectedOperationItemPage > selectedOperationItemPageCount) {
setSelectedOperationItemPage(selectedOperationItemPageCount)
}
}, [selectedOperationItemPage, selectedOperationItemPageCount])
const saveVisualConfig = useCallback(async () => {
try {
setSaving(true)
await updateMemoryConfig(visualConfig)
const [nextConfig, nextRaw, nextRuntime] = await Promise.all([
getMemoryConfig(),
getMemoryConfigRaw(),
getMemoryRuntimeConfig(),
])
setVisualConfig(nextConfig.config)
setRawConfig(nextRaw.config)
setRawConfigExists(nextRaw.exists ?? true)
setRawConfigUsingDefault(nextRaw.using_default ?? false)
setRuntimeConfig(nextRuntime)
toast({ title: '配置已保存', description: '长期记忆配置已经应用到运行时' })
} catch (error) {
toast({
title: '保存配置失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setSaving(false)
}
}, [toast, visualConfig])
const saveRaw = useCallback(async () => {
try {
setSaving(true)
await updateMemoryConfigRaw(rawConfig)
const [nextConfig, nextRaw, nextRuntime] = await Promise.all([
getMemoryConfig(),
getMemoryConfigRaw(),
getMemoryRuntimeConfig(),
])
setVisualConfig(nextConfig.config)
setRawConfig(nextRaw.config ?? '')
setRawConfigExists(nextRaw.exists ?? true)
setRawConfigUsingDefault(nextRaw.using_default ?? false)
setRuntimeConfig(nextRuntime)
toast({ title: '原始 TOML 已保存', description: '长期记忆配置已经重新加载' })
} catch (error) {
toast({
title: '保存原始配置失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setSaving(false)
}
}, [rawConfig, toast])
const refreshSelfCheck = useCallback(async () => {
try {
setRefreshingCheck(true)
const payload = await refreshMemoryRuntimeSelfCheck()
setSelfCheckReport((payload.report ?? null) as Record<string, unknown> | null)
const nextRuntime = await getMemoryRuntimeConfig()
setRuntimeConfig(nextRuntime)
toast({
title: payload.success ? '自检通过' : '自检未通过',
description: payload.success ? '运行时状态正常' : '请检查 embedding 配置和外部服务连通性',
variant: payload.success ? 'default' : 'destructive',
})
} catch (error) {
toast({
title: '运行时自检失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setRefreshingCheck(false)
}
}, [toast])
const submitImportByMode = useCallback(async () => {
if (creatingImport) {
return
}
switch (importCreateMode) {
case 'upload':
await submitUploadImport()
break
case 'paste':
await submitPasteImport()
break
case 'raw_scan':
await submitRawScanImport()
break
case 'lpmm_openie':
await submitOpenieImport()
break
case 'lpmm_convert':
await submitConvertImport()
break
case 'temporal_backfill':
await submitBackfillImport()
break
case 'maibot_migration':
await submitMaibotMigrationImport()
break
default:
break
}
}, [
creatingImport,
importCreateMode,
submitBackfillImport,
submitConvertImport,
submitMaibotMigrationImport,
submitOpenieImport,
submitPasteImport,
submitRawScanImport,
submitUploadImport,
])
const submitTuningTask = useCallback(async () => {
try {
setCreatingTuning(true)
await createMemoryTuningTask({
objective: tuningObjective,
intensity: tuningIntensity,
sample_size: Number(tuningSampleSize),
top_k_eval: Number(tuningTopKEval),
})
const tasks = await getMemoryTuningTasks(20)
setTuningTasks(tasks.items ?? [])
toast({ title: '调优任务已创建', description: '新的检索调优任务已经进入队列' })
} catch (error) {
toast({
title: '创建调优任务失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setCreatingTuning(false)
}
}, [toast, tuningIntensity, tuningObjective, tuningSampleSize, tuningTopKEval])
const applyBestTask = useCallback(async (taskId: string) => {
try {
await applyBestMemoryTuningProfile(taskId)
const [profilePayload, runtimePayload, tuningTaskPayload] = await Promise.all([
getMemoryTuningProfile(),
getMemoryRuntimeConfig(),
getMemoryTuningTasks(20),
])
setTuningProfile(profilePayload.profile ?? {})
setTuningProfileToml(profilePayload.toml ?? '')
setRuntimeConfig(runtimePayload)
setTuningTasks(tuningTaskPayload.items ?? [])
toast({ title: '最佳参数已应用', description: `任务 ${taskId} 的最佳轮次已经写入运行时` })
} catch (error) {
toast({
title: '应用最佳参数失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
}
}, [toast])
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="rounded-xl border bg-background px-6 py-5 text-sm text-muted-foreground shadow-sm">
...
</div>
</div>
)
}
return (
<div className="flex h-full flex-col">
<div className="flex-none border-b bg-card/60 px-6 py-4 backdrop-blur">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">
A_Memorix
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate({ to: '/resource/knowledge-graph' })}>
<Database className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={() => void loadPage()}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-6">
<div className="mx-auto flex w-full max-w-[1500px] flex-col gap-6">
<div className="grid gap-4 xl:grid-cols-4">
{runtimeBadges.map((item) => (
<Card key={item.label}>
<CardHeader className="pb-2">
<CardDescription>{item.label}</CardDescription>
<CardTitle className="break-all text-base">{item.value}</CardTitle>
</CardHeader>
</Card>
))}
</div>
<Tabs defaultValue="overview" className="space-y-5">
<TabsList className="h-auto flex-wrap justify-start gap-1 rounded-xl border bg-muted/30 p-1">
<TabsTrigger value="overview" className="rounded-lg px-4 py-1.5">
</TabsTrigger>
<TabsTrigger value="config" className="rounded-lg px-4 py-1.5">
</TabsTrigger>
<TabsTrigger value="import" className="rounded-lg px-4 py-1.5">
</TabsTrigger>
<TabsTrigger value="tuning" className="rounded-lg px-4 py-1.5">
</TabsTrigger>
<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">
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<Card>
<CardHeader className="flex flex-row items-start justify-between space-y-0">
<div>
<CardTitle className="flex items-center gap-2">
<Gauge className="h-4 w-4" />
</CardTitle>
<CardDescription> embedding</CardDescription>
</div>
<Button size="sm" onClick={() => void refreshSelfCheck()} disabled={refreshingCheck}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshingCheck ? 'animate-spin' : ''}`} />
</Button>
</CardHeader>
<CardContent className="space-y-3">
<Alert>
<AlertDescription>
<code>{configPath}</code>
</AlertDescription>
</Alert>
<CodeEditor
value={JSON.stringify(selfCheckReport ?? runtimeConfig ?? {}, null, 2)}
language="json"
readOnly
height="320px"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
</CardTitle>
<CardDescription>便</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="flex flex-wrap gap-2">
<Badge variant={runtimeConfig?.runtime_ready ? 'default' : 'destructive'}>
{runtimeConfig?.runtime_ready ? '运行就绪' : '运行未就绪'}
</Badge>
<Badge variant={runtimeConfig?.embedding_degraded ? 'destructive' : 'secondary'}>
{runtimeConfig?.embedding_degraded ? 'Embedding 已退化' : 'Embedding 正常'}
</Badge>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-lg border bg-muted/30 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-2xl font-semibold">{runtimeConfig?.paragraph_vector_backfill_pending ?? 0}</div>
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-2xl font-semibold">{runtimeConfig?.paragraph_vector_backfill_failed ?? 0}</div>
</div>
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<div className="text-xs text-muted-foreground"></div>
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-words text-xs">
{JSON.stringify(tuningProfile, null, 2)}
</pre>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="config" className="space-y-4">
<Card>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4" />
</CardTitle>
<CardDescription>
TOML
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button variant={rawMode ? 'outline' : 'default'} onClick={() => setRawMode(false)}>
</Button>
<Button variant={rawMode ? 'default' : 'outline'} onClick={() => setRawMode(true)}>
TOML
</Button>
<Button onClick={() => void (rawMode ? saveRaw() : saveVisualConfig())} disabled={saving}>
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertDescription>
<code>{configPath}</code>
{schema?._note ? `${schema._note}` : ''}
</AlertDescription>
</Alert>
{!rawConfigExists || rawConfigUsingDefault ? (
<Alert>
<AlertDescription>
{' '}
<code>{configPath}</code>
</AlertDescription>
</Alert>
) : null}
{rawMode ? (
<CodeEditor
value={rawConfig}
onChange={setRawConfig}
language="toml"
height="620px"
/>
) : schema ? (
<MemoryConfigEditor
schema={schema}
config={visualConfig}
onChange={setVisualConfig}
disabled={saving}
/>
) : (
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">
schema
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent
value="import"
className="space-y-7 [&_input]:h-10 [&_[role=combobox]]:h-10 [&_textarea]:min-h-[96px]"
>
<div className="mx-auto w-full max-w-5xl space-y-6">
<div className="space-y-6">
<Card className="rounded-2xl border-border/70 shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-4 w-4" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Tabs
value={importCreateMode}
onValueChange={(value) => setImportCreateMode(value as MemoryImportTaskKind)}
className="space-y-4"
>
<div className="space-y-2">
<Label></Label>
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 rounded-xl border bg-muted/20 p-1">
{IMPORT_KIND_OPTIONS.map((item) => (
<TabsTrigger
key={item.value}
value={item.value}
className="rounded-lg px-3 py-1.5 text-xs"
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</div>
<div className="space-y-3 rounded-lg border bg-muted/30 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-3">
<div className="space-y-1">
<Label></Label>
<div className="text-xs text-muted-foreground"></div>
<Input
type="number"
min={1}
max={Number(importSettings.max_file_concurrency ?? 128)}
value={importCommonFileConcurrency}
onChange={(event) => setImportCommonFileConcurrency(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label></Label>
<div className="text-xs text-muted-foreground"></div>
<Input
type="number"
min={1}
max={Number(importSettings.max_chunk_concurrency ?? 256)}
value={importCommonChunkConcurrency}
onChange={(event) => setImportCommonChunkConcurrency(event.target.value)}
/>
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox
checked={importCommonLlmEnabled}
onCheckedChange={(value) => setImportCommonLlmEnabled(Boolean(value))}
/>
LLM
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox
checked={importCommonChatLog}
onCheckedChange={(value) => setImportCommonChatLog(Boolean(value))}
/>
</div>
</div>
<details className="rounded-md border bg-background/70 p-3 text-sm">
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
</summary>
<div className="mt-3 grid gap-3">
<div className="space-y-1">
<Label></Label>
<Input
value={importCommonStrategyOverride}
onChange={(event) => setImportCommonStrategyOverride(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label></Label>
<Input
value={importCommonDedupePolicy}
onChange={(event) => setImportCommonDedupePolicy(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label></Label>
<Input
value={importCommonChatReferenceTime}
onChange={(event) => setImportCommonChatReferenceTime(event.target.value)}
/>
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox
checked={importCommonForce}
onCheckedChange={(value) => setImportCommonForce(Boolean(value))}
/>
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox
checked={importCommonClearManifest}
onCheckedChange={(value) => setImportCommonClearManifest(Boolean(value))}
/>
</div>
</div>
</details>
</div>
<TabsContent value="upload" className="mt-0">
<div className="space-y-3 rounded-xl border bg-background/70 p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="grid gap-3">
<div className="space-y-1">
<Label></Label>
<Select
value={uploadInputMode}
onValueChange={(value) => setUploadInputMode(normalizeImportInputMode(value))}
>
<SelectTrigger aria-label="upload-input-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="json"> JSON</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label></Label>
<Input
type="file"
multiple
accept=".txt,.md,.json,.jsonl,.csv,.log,.html,.htm,.xml"
onChange={(event) => setUploadFiles(Array.from(event.target.files ?? []))}
/>
</div>
</div>
<div className="text-xs text-muted-foreground"> {uploadFiles.length} </div>
</div>
</TabsContent>
<TabsContent value="paste" className="mt-0">
<div className="space-y-3 rounded-xl border bg-background/70 p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="grid gap-3">
<div className="space-y-1">
<Label></Label>
<Input value={pasteName} onChange={(event) => setPasteName(event.target.value)} />
</div>
<div className="space-y-1">
<Label></Label>
<Select
value={pasteMode}
onValueChange={(value) => setPasteMode(normalizeImportInputMode(value))}
>
<SelectTrigger aria-label="paste-input-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="json"> JSON</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label></Label>
<Textarea
value={pasteContent}
onChange={(event) => setPasteContent(event.target.value)}
rows={8}
/>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="raw_scan" className="mt-0">
<div className="space-y-3 rounded-xl border bg-background/70 p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="grid gap-3">
<div className="space-y-1">
<Label></Label>
<Input value={rawAlias} onChange={(event) => setRawAlias(event.target.value)} />
</div>
<div className="space-y-1">
<Label></Label>
<Select
value={rawInputMode}
onValueChange={(value) => setRawInputMode(normalizeImportInputMode(value))}
>
<SelectTrigger aria-label="raw-input-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="json"> JSON</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label></Label>
<Input value={rawRelativePath} onChange={(event) => setRawRelativePath(event.target.value)} />
</div>
<div className="space-y-1">
<Label>Glob</Label>
<Input value={rawGlob} onChange={(event) => setRawGlob(event.target.value)} />
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox checked={rawRecursive} onCheckedChange={(value) => setRawRecursive(Boolean(value))} />
</div>
</div>
</TabsContent>
<TabsContent value="lpmm_openie" className="mt-0">
<div className="space-y-3 rounded-xl border bg-background/70 p-4">
<div className="text-xs text-muted-foreground"> LPMM </div>
<div className="grid gap-3">
<div className="space-y-1">
<Label></Label>
<Input value={openieAlias} onChange={(event) => setOpenieAlias(event.target.value)} />
</div>
<div className="space-y-1">
<Label></Label>
<Input value={openieRelativePath} onChange={(event) => setOpenieRelativePath(event.target.value)} />
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox
checked={openieIncludeAllJson}
onCheckedChange={(value) => setOpenieIncludeAllJson(Boolean(value))}
/>
JSON
</div>
</div>
</TabsContent>
<TabsContent value="lpmm_convert" className="mt-0">
<div className="space-y-3 rounded-xl border bg-background/70 p-4">
<div className="text-xs text-muted-foreground"> LPMM </div>
<div className="grid gap-3">
<div className="space-y-1">
<Label></Label>
<Input value={convertAlias} onChange={(event) => setConvertAlias(event.target.value)} />
</div>
<div className="space-y-1">
<Label></Label>
<Input value={convertTargetAlias} onChange={(event) => setConvertTargetAlias(event.target.value)} />
</div>
<div className="space-y-1">
<Label></Label>
<Input value={convertRelativePath} onChange={(event) => setConvertRelativePath(event.target.value)} />
</div>
<div className="space-y-1">
<Label></Label>
<Input
value={convertTargetRelativePath}
onChange={(event) => setConvertTargetRelativePath(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label></Label>
<Input
type="number"
min={1}
value={convertDimension}
onChange={(event) => setConvertDimension(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label></Label>
<Input
type="number"
min={1}
value={convertBatchSize}
onChange={(event) => setConvertBatchSize(event.target.value)}
/>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="temporal_backfill" className="mt-0">
<div className="space-y-3 rounded-xl border bg-background/70 p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="grid gap-3">
<div className="space-y-1">
<Label></Label>
<Input value={backfillAlias} onChange={(event) => setBackfillAlias(event.target.value)} />
</div>
<div className="space-y-1">
<Label></Label>
<Input type="number" min={1} value={backfillLimit} onChange={(event) => setBackfillLimit(event.target.value)} />
</div>
<div className="space-y-1">
<Label></Label>
<Input value={backfillRelativePath} onChange={(event) => setBackfillRelativePath(event.target.value)} />
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center gap-2 text-sm">
<Checkbox checked={backfillDryRun} onCheckedChange={(value) => setBackfillDryRun(Boolean(value))} />
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox
checked={backfillNoCreatedFallback}
onCheckedChange={(value) => setBackfillNoCreatedFallback(Boolean(value))}
/>
退
</div>
</div>
</div>
</TabsContent>
<TabsContent value="maibot_migration" className="mt-0">
<div className="space-y-3 rounded-xl border bg-background/70 p-4">
<div className="text-xs text-muted-foreground"> MaiBot </div>
<div className="grid gap-3">
<div className="space-y-1">
<Label></Label>
<Input value={maibotSourceDb} onChange={(event) => setMaibotSourceDb(event.target.value)} />
</div>
<div className="space-y-1">
<Label></Label>
<Input value={maibotTimeFrom} onChange={(event) => setMaibotTimeFrom(event.target.value)} />
</div>
<div className="space-y-1">
<Label></Label>
<Input value={maibotTimeTo} onChange={(event) => setMaibotTimeTo(event.target.value)} />
</div>
<div className="space-y-1">
<Label> ID</Label>
<Input type="number" min={1} value={maibotStartId} onChange={(event) => setMaibotStartId(event.target.value)} />
</div>
<div className="space-y-1">
<Label> ID</Label>
<Input type="number" min={1} value={maibotEndId} onChange={(event) => setMaibotEndId(event.target.value)} />
</div>
<div className="space-y-1">
<Label> ID </Label>
<Input value={maibotStreamIds} onChange={(event) => setMaibotStreamIds(event.target.value)} />
</div>
<div className="space-y-1">
<Label> ID </Label>
<Input value={maibotGroupIds} onChange={(event) => setMaibotGroupIds(event.target.value)} />
</div>
<div className="space-y-1">
<Label> ID </Label>
<Input value={maibotUserIds} onChange={(event) => setMaibotUserIds(event.target.value)} />
</div>
<div className="space-y-1">
<Label></Label>
<Input
type="number"
min={1}
value={maibotReadBatchSize}
onChange={(event) => setMaibotReadBatchSize(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label></Label>
<Input
type="number"
min={1}
value={maibotCommitWindowRows}
onChange={(event) => setMaibotCommitWindowRows(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label>线</Label>
<Input
type="number"
min={1}
value={maibotEmbedWorkers}
onChange={(event) => setMaibotEmbedWorkers(event.target.value)}
/>
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center gap-2 text-sm">
<Checkbox checked={maibotNoResume} onCheckedChange={(value) => setMaibotNoResume(Boolean(value))} />
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox checked={maibotResetState} onCheckedChange={(value) => setMaibotResetState(Boolean(value))} />
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox checked={maibotDryRun} onCheckedChange={(value) => setMaibotDryRun(Boolean(value))} />
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox checked={maibotVerifyOnly} onCheckedChange={(value) => setMaibotVerifyOnly(Boolean(value))} />
</div>
</div>
</div>
</TabsContent>
</Tabs>
<Button onClick={() => void submitImportByMode()} disabled={creatingImport}>
{creatingImport ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
</Button>
</CardContent>
</Card>
<Card className="rounded-2xl border-border/70 bg-card/85 shadow-sm">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3">
<div className="space-y-1">
<Label></Label>
<div className="text-xs text-muted-foreground"></div>
<Select value={pathResolveAlias} onValueChange={setPathResolveAlias}>
<SelectTrigger aria-label="import-path-alias">
<SelectValue />
</SelectTrigger>
<SelectContent>
{importAliasKeys.length > 0 ? importAliasKeys.map((alias) => (
<SelectItem key={alias} value={alias}>{alias}</SelectItem>
)) : (
<SelectItem value="raw">raw</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label></Label>
<div className="text-xs text-muted-foreground"></div>
<Input
value={pathResolveRelativePath}
onChange={(event) => setPathResolveRelativePath(event.target.value)}
placeholder="例如 exports/weekly"
/>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox checked={pathResolveMustExist} onCheckedChange={(value) => setPathResolveMustExist(Boolean(value))} />
</div>
<Button
variant="outline"
onClick={() => void resolveImportPath()}
disabled={resolvingPath || !pathResolveAlias.trim()}
>
{resolvingPath ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
</Button>
<Textarea value={pathResolveOutput} readOnly rows={6} placeholder="解析结果会显示在这里" />
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card className="rounded-2xl border-border/70 bg-card/90 shadow-sm">
<CardHeader className="space-y-4 pb-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle></CardTitle>
<Button variant="outline" size="sm" onClick={() => void refreshImportQueue()}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<CardDescription className="text-sm">
{runningImportTasks.length} {queuedImportTasks.length} {recentImportTasks.length}
</CardDescription>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Checkbox checked={importAutoPolling} onCheckedChange={(value) => setImportAutoPolling(Boolean(value))} />
{importPollInterval}ms
</label>
</div>
</CardHeader>
<CardContent className="space-y-6">
{importErrorText ? (
<Alert variant="destructive">
<AlertDescription>{importErrorText}</AlertDescription>
</Alert>
) : null}
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium"></div>
<Badge variant="outline">{runningImportTasks.length}</Badge>
</div>
{runningImportTasks.length > 0 ? (
<ScrollArea className="h-[208px] rounded-xl border bg-muted/10">
<div className="space-y-2.5 p-2.5">
{runningImportTasks.map((task) => {
const isSelected = task.task_id === selectedImportTaskId
return (
<button
key={task.task_id}
type="button"
onClick={() => void selectImportTask(task.task_id)}
className={cn(
'w-full rounded-xl border p-4 text-left transition-all',
isSelected
? 'border-primary/70 bg-primary/5 shadow-sm'
: 'bg-background/80 hover:border-muted-foreground/40 hover:bg-muted/20',
)}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="break-all font-mono text-[11px] leading-relaxed text-muted-foreground">
{task.task_id}
</div>
<div className="text-sm font-medium">{String(task.task_kind ?? task.mode ?? '-')}</div>
</div>
<Badge variant={getImportStatusVariant(String(task.status ?? ''))}>
{getImportStatusLabel(String(task.status ?? ''))}
</Badge>
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{getImportStepLabel(String(task.current_step ?? 'running'))}</span>
<span>{Number(task.progress ?? 0).toFixed(1)}%</span>
</div>
<Progress value={normalizeProgress(task.progress)} className="mt-2 h-1.5" />
</button>
)
})}
</div>
</ScrollArea>
) : (
<div className="rounded-xl border bg-muted/20 p-4 text-sm text-muted-foreground"></div>
)}
</div>
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium"></div>
<Badge variant="outline">{queuedImportTasks.length}</Badge>
</div>
{queuedImportTasks.length > 0 ? (
<ScrollArea className="h-[188px] rounded-xl border bg-muted/10">
<div className="space-y-2.5 p-2.5">
{queuedImportTasks.map((task) => {
const isSelected = task.task_id === selectedImportTaskId
return (
<button
key={task.task_id}
type="button"
onClick={() => void selectImportTask(task.task_id)}
className={cn(
'w-full rounded-xl border p-4 text-left transition-all',
isSelected
? 'border-primary/70 bg-primary/5 shadow-sm'
: 'bg-background/80 hover:border-muted-foreground/40 hover:bg-muted/20',
)}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="break-all font-mono text-[11px] leading-relaxed text-muted-foreground">
{task.task_id}
</div>
<div className="text-sm font-medium">{String(task.task_kind ?? task.mode ?? '-')}</div>
</div>
<Badge variant={getImportStatusVariant(String(task.status ?? ''))}>
{getImportStatusLabel(String(task.status ?? ''))}
</Badge>
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span></span>
<span>{formatImportTime(task.created_at)}</span>
</div>
</button>
)
})}
</div>
</ScrollArea>
) : (
<div className="rounded-xl border bg-muted/20 p-4 text-sm text-muted-foreground"></div>
)}
</div>
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium"></div>
<Badge variant="secondary">{recentImportTasks.length}</Badge>
</div>
{recentImportTasks.length > 0 ? (
<ScrollArea className="h-[260px] rounded-xl border bg-muted/10">
<div className="space-y-2.5 p-2.5">
{recentImportTasks.map((task) => {
const isSelected = task.task_id === selectedImportTaskId
return (
<button
key={task.task_id}
type="button"
onClick={() => void selectImportTask(task.task_id)}
className={cn(
'w-full rounded-xl border p-4 text-left transition-all',
isSelected
? 'border-primary/70 bg-primary/5 shadow-sm'
: 'bg-background/80 hover:border-muted-foreground/40 hover:bg-muted/20',
)}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="break-all font-mono text-[11px] leading-relaxed text-muted-foreground">
{task.task_id}
</div>
<div className="text-sm font-medium">{String(task.task_kind ?? task.mode ?? '-')}</div>
</div>
<Badge variant={getImportStatusVariant(String(task.status ?? ''))}>
{getImportStatusLabel(String(task.status ?? ''))}
</Badge>
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span></span>
<span>{Number(task.progress ?? 0).toFixed(1)}%</span>
</div>
<Progress value={normalizeProgress(task.progress)} className="mt-2 h-1.5" />
</button>
)
})}
</div>
</ScrollArea>
) : (
<div className="rounded-xl border bg-muted/20 p-4 text-sm text-muted-foreground"></div>
)}
</div>
</CardContent>
</Card>
</div>
<Card className="rounded-2xl border-border/70 bg-card/90 shadow-sm">
<CardHeader className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle></CardTitle>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
aria-label="取消选中导入任务"
onClick={() => void cancelSelectedImportTask()}
disabled={!selectedImportTaskId}
>
</Button>
<Button
size="sm"
aria-label="重试选中导入任务"
onClick={() => void retrySelectedImportTask()}
disabled={!selectedImportTaskId}
>
</Button>
</div>
</div>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{selectedImportTaskLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : null}
{!selectedImportTaskResolved ? (
<div className="rounded-xl border bg-muted/20 p-4 text-sm text-muted-foreground">
</div>
) : (
<>
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="overflow-auto rounded-xl border bg-muted/10">
<Table className="min-w-[680px]">
<TableBody>
<TableRow>
<TableCell className="w-[140px] text-muted-foreground"> ID</TableCell>
<TableCell className="break-all font-mono text-xs leading-relaxed">
{selectedImportTaskResolved.task_id}
</TableCell>
</TableRow>
<TableRow>
<TableCell className="text-muted-foreground"></TableCell>
<TableCell>{String(selectedImportTaskResolved.task_kind ?? selectedImportTaskResolved.mode ?? '-')}</TableCell>
</TableRow>
<TableRow>
<TableCell className="text-muted-foreground"> / </TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={getImportStatusVariant(String(selectedImportTaskResolved.status ?? ''))}>
{getImportStatusLabel(String(selectedImportTaskResolved.status ?? ''))}
</Badge>
<span className="text-xs text-muted-foreground">
{getImportStepLabel(String(selectedImportTaskResolved.current_step ?? ''))}
</span>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="text-muted-foreground"></TableCell>
<TableCell>
<div className="space-y-2">
<div className="text-sm">
{Number(selectedImportTaskResolved.progress ?? 0).toFixed(1)}% ·
{' '}
{Number(selectedImportTaskResolved.done_chunks ?? 0)}
{' / '}
{Number(selectedImportTaskResolved.total_chunks ?? 0)}
</div>
<Progress value={normalizeProgress(selectedImportTaskResolved.progress)} className="h-1.5" />
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="text-muted-foreground"></TableCell>
<TableCell>{formatImportTime(selectedImportTaskResolved.created_at)}</TableCell>
</TableRow>
<TableRow>
<TableCell className="text-muted-foreground"></TableCell>
<TableCell>{formatImportTime(selectedImportTaskResolved.updated_at)}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
{selectedImportRetrySummary ? (
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="overflow-auto rounded-xl border bg-muted/10">
<Table>
<TableBody>
<TableRow>
<TableCell className="w-[220px] text-muted-foreground"></TableCell>
<TableCell>{Number(selectedImportRetrySummary.chunk_retry_files ?? 0)}</TableCell>
</TableRow>
<TableRow>
<TableCell className="text-muted-foreground"></TableCell>
<TableCell>{Number(selectedImportRetrySummary.chunk_retry_chunks ?? 0)}</TableCell>
</TableRow>
<TableRow>
<TableCell className="text-muted-foreground">退</TableCell>
<TableCell>{Number(selectedImportRetrySummary.file_fallback_files ?? 0)}</TableCell>
</TableRow>
<TableRow>
<TableCell className="text-muted-foreground"></TableCell>
<TableCell>{Number(selectedImportRetrySummary.skipped_files ?? 0)}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
) : null}
{selectedImportTaskErrorText ? (
<Alert variant="destructive">
<AlertDescription>{selectedImportTaskErrorText}</AlertDescription>
</Alert>
) : null}
<div className="space-y-2.5">
<div className="text-sm font-medium"></div>
{selectedImportFiles.length > 0 ? (
<ScrollArea className="h-[260px] rounded-xl border bg-muted/10">
<div className="space-y-2.5 p-2.5">
{selectedImportFiles.map((file) => {
const isSelected = file.file_id === selectedImportFileId
return (
<button
key={file.file_id}
type="button"
onClick={() => void selectImportFile(file.file_id)}
className={cn(
'w-full rounded-xl border p-4 text-left transition-all',
isSelected
? 'border-primary/70 bg-primary/5 shadow-sm'
: 'bg-background/80 hover:border-muted-foreground/40 hover:bg-muted/20',
)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{file.name || file.file_id}</span>
<Badge variant={getImportStatusVariant(String(file.status ?? ''))}>
{getImportStatusLabel(String(file.status ?? ''))}
</Badge>
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{getImportStepLabel(String(file.current_step ?? ''))}</span>
<span>{Number(file.progress ?? 0).toFixed(1)}%</span>
</div>
<Progress value={normalizeProgress(file.progress)} className="mt-2 h-1.5" />
<div className="mt-2 text-xs text-muted-foreground">
{Number(file.progress ?? 0).toFixed(1)}% · {Number(file.done_chunks ?? 0)} / {Number(file.total_chunks ?? 0)}
</div>
{file.error ? (
<div className="mt-2 truncate text-xs text-destructive">{file.error}</div>
) : null}
</button>
)
})}
</div>
</ScrollArea>
) : (
<div className="rounded-xl border bg-muted/20 p-4 text-sm text-muted-foreground"></div>
)}
</div>
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium"></div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Button
size="icon"
variant="outline"
aria-label="上一页分块"
onClick={() => void moveImportChunkPage(-1)}
disabled={!canImportChunkPrev}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span>
{importChunkTotal > 0
? `${importChunkOffset + 1}-${Math.min(importChunkOffset + IMPORT_CHUNK_PAGE_SIZE, importChunkTotal)}`
: '0-0'}
{' / '}
{importChunkTotal}
</span>
<Button
size="icon"
variant="outline"
aria-label="下一页分块"
onClick={() => void moveImportChunkPage(1)}
disabled={!canImportChunkNext}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="overflow-auto rounded-xl border bg-background/80">
<Table className="min-w-[700px]">
<TableHeader>
<TableRow>
<TableHead className="w-[72px]"></TableHead>
<TableHead className="w-[108px]"></TableHead>
<TableHead className="w-[108px]"></TableHead>
<TableHead className="w-[84px]"></TableHead>
<TableHead> / </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{importChunksLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
...
</TableCell>
</TableRow>
) : selectedImportChunks.length > 0 ? (
selectedImportChunks.map((chunk) => (
<TableRow key={chunk.chunk_id}>
<TableCell>{chunk.index}</TableCell>
<TableCell>{getImportStatusLabel(String(chunk.status ?? ''))}</TableCell>
<TableCell>{getImportStepLabel(String(chunk.step ?? ''))}</TableCell>
<TableCell>{Number(chunk.progress ?? 0).toFixed(1)}%</TableCell>
<TableCell className="max-w-[360px]">
<div className="truncate text-sm">{String(chunk.error ?? '') || String(chunk.content_preview ?? '-')}</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<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="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<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>
<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 className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Input type="number" value={tuningSampleSize} onChange={(event) => setTuningSampleSize(event.target.value)} />
</div>
<div className="space-y-2">
<Label> Top-K</Label>
<Input type="number" value={tuningTopKEval} onChange={(event) => setTuningTopKEval(event.target.value)} />
</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>
</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>
</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>{String(task.status ?? '-')}</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>
<TabsContent value="delete" className="space-y-4">
<div className="space-y-4">
<Card>
<CardHeader className="space-y-3">
<div>
<CardTitle className="flex items-center gap-2">
<Trash2 className="h-4 w-4" />
</CardTitle>
<CardDescription>
</CardDescription>
</div>
<Alert>
<AlertDescription>
operation
</AlertDescription>
</Alert>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 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"> {filteredSources.length} </Badge>
<Badge variant="secondary"> {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>
<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 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">
operation source
</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="搜索类型 / hash / item_key / source"
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">
hashitem_keysource
</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>
<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>
<MemoryDeleteDialog
open={deleteDialogOpen}
onOpenChange={closeDeleteDialog}
title={deleteDialogTitle}
description={deleteDialogDescription}
preview={deletePreview}
result={deleteResult}
loadingPreview={deletePreviewLoading}
executing={deleteExecuting}
restoring={deleteRestoring}
error={deletePreviewError}
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>
)
}