- Implemented TuningTab component to handle tuning objectives, intensity, sample size, and evaluation settings. - Added UI elements for creating tuning tasks and displaying current configurations and recent tasks. - Introduced utility functions for normalizing and formatting memory operation data, including feedback actions and delete operations.
1271 lines
60 KiB
TypeScript
1271 lines
60 KiB
TypeScript
import type { Dispatch, SetStateAction } from 'react'
|
||
|
||
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Upload } from 'lucide-react'
|
||
|
||
import { MemoryMiniTabs } from '@/components/memory/MemoryMiniTabs'
|
||
import { MemoryProgressIndicator } from '@/components/memory/MemoryProgressIndicator'
|
||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Checkbox } from '@/components/ui/checkbox'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { 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 } from '@/components/ui/tabs'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import { cn } from '@/lib/utils'
|
||
import type {
|
||
MemoryImportChunkPayload,
|
||
MemoryImportFilePayload,
|
||
MemoryImportInputMode,
|
||
MemoryImportRetrySummary,
|
||
MemoryImportSettings,
|
||
MemoryImportTaskKind,
|
||
MemoryImportTaskPayload,
|
||
} from '@/lib/memory-api'
|
||
|
||
import { IMPORT_CHUNK_PAGE_SIZE, IMPORT_KIND_OPTIONS, RUNNING_IMPORT_STATUS } from '../constants'
|
||
import {
|
||
formatImportTime,
|
||
getImportStatusLabel,
|
||
getImportStatusVariant,
|
||
getImportStepLabel,
|
||
normalizeImportInputMode,
|
||
normalizeProgress,
|
||
} from '../utils'
|
||
|
||
export interface ImportTabProps {
|
||
importCreateMode: MemoryImportTaskKind
|
||
setImportCreateMode: Dispatch<SetStateAction<MemoryImportTaskKind>>
|
||
importSettings: MemoryImportSettings
|
||
importCommonFileConcurrency: string
|
||
setImportCommonFileConcurrency: Dispatch<SetStateAction<string>>
|
||
importCommonChunkConcurrency: string
|
||
setImportCommonChunkConcurrency: Dispatch<SetStateAction<string>>
|
||
importCommonLlmEnabled: boolean
|
||
setImportCommonLlmEnabled: Dispatch<SetStateAction<boolean>>
|
||
importCommonChatLog: boolean
|
||
setImportCommonChatLog: Dispatch<SetStateAction<boolean>>
|
||
importCommonStrategyOverride: string
|
||
setImportCommonStrategyOverride: Dispatch<SetStateAction<string>>
|
||
importCommonDedupePolicy: string
|
||
setImportCommonDedupePolicy: Dispatch<SetStateAction<string>>
|
||
importCommonChatReferenceTime: string
|
||
setImportCommonChatReferenceTime: Dispatch<SetStateAction<string>>
|
||
importCommonForce: boolean
|
||
setImportCommonForce: Dispatch<SetStateAction<boolean>>
|
||
importCommonClearManifest: boolean
|
||
setImportCommonClearManifest: Dispatch<SetStateAction<boolean>>
|
||
|
||
uploadInputMode: MemoryImportInputMode
|
||
setUploadInputMode: Dispatch<SetStateAction<MemoryImportInputMode>>
|
||
uploadFiles: File[]
|
||
setUploadFiles: Dispatch<SetStateAction<File[]>>
|
||
|
||
pasteName: string
|
||
setPasteName: Dispatch<SetStateAction<string>>
|
||
pasteMode: MemoryImportInputMode
|
||
setPasteMode: Dispatch<SetStateAction<MemoryImportInputMode>>
|
||
pasteContent: string
|
||
setPasteContent: Dispatch<SetStateAction<string>>
|
||
|
||
rawAlias: string
|
||
setRawAlias: Dispatch<SetStateAction<string>>
|
||
rawInputMode: MemoryImportInputMode
|
||
setRawInputMode: Dispatch<SetStateAction<MemoryImportInputMode>>
|
||
rawRelativePath: string
|
||
setRawRelativePath: Dispatch<SetStateAction<string>>
|
||
rawGlob: string
|
||
setRawGlob: Dispatch<SetStateAction<string>>
|
||
rawRecursive: boolean
|
||
setRawRecursive: Dispatch<SetStateAction<boolean>>
|
||
|
||
openieAlias: string
|
||
setOpenieAlias: Dispatch<SetStateAction<string>>
|
||
openieRelativePath: string
|
||
setOpenieRelativePath: Dispatch<SetStateAction<string>>
|
||
openieIncludeAllJson: boolean
|
||
setOpenieIncludeAllJson: Dispatch<SetStateAction<boolean>>
|
||
|
||
convertAlias: string
|
||
setConvertAlias: Dispatch<SetStateAction<string>>
|
||
convertTargetAlias: string
|
||
setConvertTargetAlias: Dispatch<SetStateAction<string>>
|
||
convertRelativePath: string
|
||
setConvertRelativePath: Dispatch<SetStateAction<string>>
|
||
convertTargetRelativePath: string
|
||
setConvertTargetRelativePath: Dispatch<SetStateAction<string>>
|
||
convertDimension: string
|
||
setConvertDimension: Dispatch<SetStateAction<string>>
|
||
convertBatchSize: string
|
||
setConvertBatchSize: Dispatch<SetStateAction<string>>
|
||
|
||
backfillAlias: string
|
||
setBackfillAlias: Dispatch<SetStateAction<string>>
|
||
backfillLimit: string
|
||
setBackfillLimit: Dispatch<SetStateAction<string>>
|
||
backfillRelativePath: string
|
||
setBackfillRelativePath: Dispatch<SetStateAction<string>>
|
||
backfillDryRun: boolean
|
||
setBackfillDryRun: Dispatch<SetStateAction<boolean>>
|
||
backfillNoCreatedFallback: boolean
|
||
setBackfillNoCreatedFallback: Dispatch<SetStateAction<boolean>>
|
||
|
||
maibotSourceDb: string
|
||
setMaibotSourceDb: Dispatch<SetStateAction<string>>
|
||
maibotTimeFrom: string
|
||
setMaibotTimeFrom: Dispatch<SetStateAction<string>>
|
||
maibotTimeTo: string
|
||
setMaibotTimeTo: Dispatch<SetStateAction<string>>
|
||
maibotStartId: string
|
||
setMaibotStartId: Dispatch<SetStateAction<string>>
|
||
maibotEndId: string
|
||
setMaibotEndId: Dispatch<SetStateAction<string>>
|
||
maibotStreamIds: string
|
||
setMaibotStreamIds: Dispatch<SetStateAction<string>>
|
||
maibotGroupIds: string
|
||
setMaibotGroupIds: Dispatch<SetStateAction<string>>
|
||
maibotUserIds: string
|
||
setMaibotUserIds: Dispatch<SetStateAction<string>>
|
||
maibotReadBatchSize: string
|
||
setMaibotReadBatchSize: Dispatch<SetStateAction<string>>
|
||
maibotCommitWindowRows: string
|
||
setMaibotCommitWindowRows: Dispatch<SetStateAction<string>>
|
||
maibotEmbedWorkers: string
|
||
setMaibotEmbedWorkers: Dispatch<SetStateAction<string>>
|
||
maibotNoResume: boolean
|
||
setMaibotNoResume: Dispatch<SetStateAction<boolean>>
|
||
maibotResetState: boolean
|
||
setMaibotResetState: Dispatch<SetStateAction<boolean>>
|
||
maibotDryRun: boolean
|
||
setMaibotDryRun: Dispatch<SetStateAction<boolean>>
|
||
maibotVerifyOnly: boolean
|
||
setMaibotVerifyOnly: Dispatch<SetStateAction<boolean>>
|
||
|
||
submitImportByMode: () => Promise<void>
|
||
creatingImport: boolean
|
||
|
||
pathResolveAlias: string
|
||
setPathResolveAlias: Dispatch<SetStateAction<string>>
|
||
importAliasKeys: string[]
|
||
pathResolveRelativePath: string
|
||
setPathResolveRelativePath: Dispatch<SetStateAction<string>>
|
||
pathResolveMustExist: boolean
|
||
setPathResolveMustExist: Dispatch<SetStateAction<boolean>>
|
||
resolveImportPath: () => Promise<void>
|
||
resolvingPath: boolean
|
||
pathResolveOutput: string
|
||
|
||
refreshImportQueue: () => Promise<void>
|
||
runningImportTasks: MemoryImportTaskPayload[]
|
||
queuedImportTasks: MemoryImportTaskPayload[]
|
||
recentImportTasks: MemoryImportTaskPayload[]
|
||
selectedImportTaskId: string
|
||
selectImportTask: (taskId: string) => Promise<void>
|
||
importAutoPolling: boolean
|
||
setImportAutoPolling: Dispatch<SetStateAction<boolean>>
|
||
importPollInterval: number
|
||
importErrorText: string
|
||
|
||
cancelSelectedImportTask: () => Promise<void>
|
||
retrySelectedImportTask: () => Promise<void>
|
||
selectedImportTaskLoading: boolean
|
||
selectedImportTaskResolved: MemoryImportTaskPayload | null | undefined
|
||
selectedImportRetrySummary: MemoryImportRetrySummary | null | undefined
|
||
selectedImportTaskErrorText: string
|
||
|
||
selectedImportFiles: MemoryImportFilePayload[]
|
||
selectedImportFileId: string
|
||
selectImportFile: (fileId: string) => Promise<void>
|
||
|
||
importChunkTotal: number
|
||
importChunkOffset: number
|
||
moveImportChunkPage: (direction: -1 | 1) => Promise<void>
|
||
canImportChunkPrev: boolean
|
||
canImportChunkNext: boolean
|
||
importChunksLoading: boolean
|
||
selectedImportChunks: MemoryImportChunkPayload[]
|
||
}
|
||
|
||
export function ImportTab(props: ImportTabProps) {
|
||
const {
|
||
importCreateMode,
|
||
setImportCreateMode,
|
||
importSettings,
|
||
importCommonFileConcurrency,
|
||
setImportCommonFileConcurrency,
|
||
importCommonChunkConcurrency,
|
||
setImportCommonChunkConcurrency,
|
||
importCommonLlmEnabled,
|
||
setImportCommonLlmEnabled,
|
||
importCommonChatLog,
|
||
setImportCommonChatLog,
|
||
importCommonStrategyOverride,
|
||
setImportCommonStrategyOverride,
|
||
importCommonDedupePolicy,
|
||
setImportCommonDedupePolicy,
|
||
importCommonChatReferenceTime,
|
||
setImportCommonChatReferenceTime,
|
||
importCommonForce,
|
||
setImportCommonForce,
|
||
importCommonClearManifest,
|
||
setImportCommonClearManifest,
|
||
uploadInputMode,
|
||
setUploadInputMode,
|
||
uploadFiles,
|
||
setUploadFiles,
|
||
pasteName,
|
||
setPasteName,
|
||
pasteMode,
|
||
setPasteMode,
|
||
pasteContent,
|
||
setPasteContent,
|
||
rawAlias,
|
||
setRawAlias,
|
||
rawInputMode,
|
||
setRawInputMode,
|
||
rawRelativePath,
|
||
setRawRelativePath,
|
||
rawGlob,
|
||
setRawGlob,
|
||
rawRecursive,
|
||
setRawRecursive,
|
||
openieAlias,
|
||
setOpenieAlias,
|
||
openieRelativePath,
|
||
setOpenieRelativePath,
|
||
openieIncludeAllJson,
|
||
setOpenieIncludeAllJson,
|
||
convertAlias,
|
||
setConvertAlias,
|
||
convertTargetAlias,
|
||
setConvertTargetAlias,
|
||
convertRelativePath,
|
||
setConvertRelativePath,
|
||
convertTargetRelativePath,
|
||
setConvertTargetRelativePath,
|
||
convertDimension,
|
||
setConvertDimension,
|
||
convertBatchSize,
|
||
setConvertBatchSize,
|
||
backfillAlias,
|
||
setBackfillAlias,
|
||
backfillLimit,
|
||
setBackfillLimit,
|
||
backfillRelativePath,
|
||
setBackfillRelativePath,
|
||
backfillDryRun,
|
||
setBackfillDryRun,
|
||
backfillNoCreatedFallback,
|
||
setBackfillNoCreatedFallback,
|
||
maibotSourceDb,
|
||
setMaibotSourceDb,
|
||
maibotTimeFrom,
|
||
setMaibotTimeFrom,
|
||
maibotTimeTo,
|
||
setMaibotTimeTo,
|
||
maibotStartId,
|
||
setMaibotStartId,
|
||
maibotEndId,
|
||
setMaibotEndId,
|
||
maibotStreamIds,
|
||
setMaibotStreamIds,
|
||
maibotGroupIds,
|
||
setMaibotGroupIds,
|
||
maibotUserIds,
|
||
setMaibotUserIds,
|
||
maibotReadBatchSize,
|
||
setMaibotReadBatchSize,
|
||
maibotCommitWindowRows,
|
||
setMaibotCommitWindowRows,
|
||
maibotEmbedWorkers,
|
||
setMaibotEmbedWorkers,
|
||
maibotNoResume,
|
||
setMaibotNoResume,
|
||
maibotResetState,
|
||
setMaibotResetState,
|
||
maibotDryRun,
|
||
setMaibotDryRun,
|
||
maibotVerifyOnly,
|
||
setMaibotVerifyOnly,
|
||
submitImportByMode,
|
||
creatingImport,
|
||
pathResolveAlias,
|
||
setPathResolveAlias,
|
||
importAliasKeys,
|
||
pathResolveRelativePath,
|
||
setPathResolveRelativePath,
|
||
pathResolveMustExist,
|
||
setPathResolveMustExist,
|
||
resolveImportPath,
|
||
resolvingPath,
|
||
pathResolveOutput,
|
||
refreshImportQueue,
|
||
runningImportTasks,
|
||
queuedImportTasks,
|
||
recentImportTasks,
|
||
selectedImportTaskId,
|
||
selectImportTask,
|
||
importAutoPolling,
|
||
setImportAutoPolling,
|
||
importPollInterval,
|
||
importErrorText,
|
||
cancelSelectedImportTask,
|
||
retrySelectedImportTask,
|
||
selectedImportTaskLoading,
|
||
selectedImportTaskResolved,
|
||
selectedImportRetrySummary,
|
||
selectedImportTaskErrorText,
|
||
selectedImportFiles,
|
||
selectedImportFileId,
|
||
selectImportFile,
|
||
importChunkTotal,
|
||
importChunkOffset,
|
||
moveImportChunkPage,
|
||
canImportChunkPrev,
|
||
canImportChunkNext,
|
||
importChunksLoading,
|
||
selectedImportChunks,
|
||
} = props
|
||
|
||
return (
|
||
<TabsContent
|
||
value="import"
|
||
className="space-y-6 [&_input]:h-10 [&_[role=combobox]]:h-10 [&_textarea]:min-h-[96px]"
|
||
>
|
||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||
<div className="order-2 space-y-6 lg:order-1">
|
||
<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>
|
||
<MemoryMiniTabs items={IMPORT_KIND_OPTIONS} />
|
||
</div>
|
||
|
||
<div className="space-y-4 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 md:grid-cols-2">
|
||
<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="rounded-md border bg-background/70 p-3">
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<Checkbox
|
||
checked={importCommonLlmEnabled}
|
||
onCheckedChange={(value) => setImportCommonLlmEnabled(Boolean(value))}
|
||
/>
|
||
启用 LLM 抽取
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">需要模型参与抽取,质量更高但耗时更长。</div>
|
||
</div>
|
||
<div className="rounded-md border bg-background/70 p-3">
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<Checkbox
|
||
checked={importCommonChatLog}
|
||
onCheckedChange={(value) => setImportCommonChatLog(Boolean(value))}
|
||
/>
|
||
按聊天日志解析
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">适合导入聊天记录,会尽量保留时间和对话上下文。</div>
|
||
</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">直接粘贴少量文本或 JSON,适合临时补充一段资料。</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="order-1 space-y-6 lg:order-2">
|
||
<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">
|
||
查看任务是否正在运行、排队等待或已经结束。点击任务卡片可查看详情。
|
||
</CardDescription>
|
||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||
<Badge variant="outline" className="bg-background/70">运行中 {runningImportTasks.length}</Badge>
|
||
<Badge variant="outline" className="bg-background/70">排队中 {queuedImportTasks.length}</Badge>
|
||
<Badge variant="outline" className="bg-background/70">最近完成 {recentImportTasks.length}</Badge>
|
||
</div>
|
||
<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>
|
||
</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="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed bg-muted/15 px-6 py-10 text-center">
|
||
<div className="rounded-full bg-muted/40 p-3">
|
||
<Loader2 className="h-5 w-5 text-muted-foreground" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<div className="text-sm font-medium">还没选中任务</div>
|
||
<div className="text-xs leading-relaxed text-muted-foreground">
|
||
在左侧/上方的导入队列里点击任意任务卡片<br />
|
||
即可在这里查看进度、文件状态和分块详情
|
||
</div>
|
||
</div>
|
||
</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>
|
||
<MemoryProgressIndicator
|
||
value={normalizeProgress(selectedImportTaskResolved.progress)}
|
||
statusLabel={getImportStatusLabel(String(selectedImportTaskResolved.status ?? ''))}
|
||
stepLabel={getImportStepLabel(String(selectedImportTaskResolved.current_step ?? ''))}
|
||
tone={
|
||
String(selectedImportTaskResolved.status ?? '') === 'completed'
|
||
? 'success'
|
||
: String(selectedImportTaskResolved.status ?? '') === 'failed'
|
||
? 'destructive'
|
||
: String(selectedImportTaskResolved.status ?? '') === 'cancelled'
|
||
? 'muted'
|
||
: 'default'
|
||
}
|
||
busy={RUNNING_IMPORT_STATUS.has(String(selectedImportTaskResolved.status ?? ''))}
|
||
detail={`已完成 ${Number(selectedImportTaskResolved.done_chunks ?? 0)} / ${Number(selectedImportTaskResolved.total_chunks ?? 0)} 分块`}
|
||
/>
|
||
</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="space-y-2">
|
||
{String(chunk.error ?? '').trim() ? (
|
||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-2.5 py-2 text-sm leading-relaxed text-destructive">
|
||
{String(chunk.error)}
|
||
</div>
|
||
) : null}
|
||
<details className="rounded-md border bg-muted/20 px-2.5 py-2 text-xs text-muted-foreground">
|
||
<summary className="cursor-pointer font-medium text-foreground">
|
||
{String(chunk.error ?? '').trim() ? '查看分块预览' : '查看内容详情'}
|
||
</summary>
|
||
<div className="mt-2 whitespace-pre-wrap break-words leading-relaxed">
|
||
{String(chunk.content_preview ?? '-') || '-'}
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
) : (
|
||
<TableRow>
|
||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||
当前页没有分块数据
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
)
|
||
}
|