"use client"; import { DatabaseZap, Loader2Icon, RefreshCwIcon } from "lucide-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { apiFetch } from "@/core/api"; import { getBackendBaseURL } from "@/core/config"; interface CheckpointMigrationSources { source_paths: string[]; } interface CheckpointMigrationTask { task_id: string; status: "queued" | "running" | "completed" | "failed"; start_at: string; end_at: string; source_paths: string[]; total_threads: number; copied_threads: number; skipped_threads: number; failed_threads: number; skipped_ranges: number; message: string; error?: string | null; created_at: string; updated_at: string; } async function loadSources(): Promise { const response = await apiFetch( `${getBackendBaseURL()}/api/admin/checkpoint-migration/sources`, ); if (!response.ok) throw new Error("加载 SQLite 来源失败"); return (await response.json()) as CheckpointMigrationSources; } async function startMigration(body: { start_at: string; end_at: string; }): Promise { const response = await apiFetch( `${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, ); if (!response.ok) { const detail = await response.json().catch(() => ({ detail: "启动迁移失败" })); throw new Error(detail.detail ?? "启动迁移失败"); } return (await response.json()) as CheckpointMigrationTask; } async function loadTask(taskId: string): Promise { const response = await apiFetch( `${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks/${taskId}`, ); if (!response.ok) throw new Error("加载迁移任务失败"); return (await response.json()) as CheckpointMigrationTask; } function toLocalInputValue(date: Date): string { const pad = (n: number) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } function statusVariant(status?: string) { if (status === "failed") return "destructive" as const; if (status === "completed") return "default" as const; if (status === "queued" || status === "running") return "secondary" as const; return "outline" as const; } export default function CheckpointMigrationPage() { const now = useMemo(() => new Date(), []); const weekAgo = useMemo( () => new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), [now], ); const [startAt, setStartAt] = useState(toLocalInputValue(weekAgo)); const [endAt, setEndAt] = useState(toLocalInputValue(now)); const [taskId, setTaskId] = useState(null); const { data: sources, isLoading: sourcesLoading, refetch } = useQuery({ queryKey: ["checkpointMigration", "sources"], queryFn: loadSources, staleTime: 60_000, refetchOnWindowFocus: false, }); const startMutation = useMutation({ mutationFn: startMigration }); const { data: task } = useQuery({ queryKey: ["checkpointMigration", "task", taskId], queryFn: () => loadTask(taskId as string), enabled: !!taskId, refetchInterval: (query) => { const status = query.state.data?.status; return status === "queued" || status === "running" ? 2000 : false; }, refetchOnWindowFocus: false, }); const running = startMutation.isPending || task?.status === "queued" || task?.status === "running"; async function handleStart() { const start = new Date(startAt); const end = new Date(endAt); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { toast.error("请选择有效的开始和结束时间"); return; } if (end <= start) { toast.error("结束时间必须晚于开始时间"); return; } try { const created = await startMutation.mutateAsync({ start_at: start.toISOString(), end_at: end.toISOString(), }); setTaskId(created.task_id); toast.success("迁移任务已启动"); } catch (err) { toast.error(err instanceof Error ? err.message : "启动迁移失败"); } } return (

Checkpoint 迁移

按时间段把 SQLite 旧聊天 checkpoint 同步到 Postgres。同一来源、同一时间段完成后再次执行会跳过。

SQLite 来源
{sourcesLoading ? (
加载中
) : (sources?.source_paths.length ?? 0) > 0 ? (
{sources?.source_paths.map((path) => (
{path}
))}
) : (
未配置 SQLite 来源路径
)}
{task && (
任务状态
{task.status}
总线程
{task.total_threads}
已复制
{task.copied_threads}
已跳过
{task.skipped_threads}
跳过时间段
{task.skipped_ranges}
{(task.message || task.error) && (
{task.error || task.message}
)}
)}
); }