Merge pull request #103 from unslothai/feature/format-mapping
feat: dataset manual mapping (2-col)
This commit is contained in:
commit
dff074869c
16 changed files with 683 additions and 165 deletions
|
|
@ -205,6 +205,7 @@ export function ChatPage(): ReactElement {
|
|||
});
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const viewBeforeCompareRef = useRef<ChatView | null>(null);
|
||||
const inferenceParams = useChatRuntimeStore((state) => state.params);
|
||||
|
|
@ -239,8 +240,23 @@ export function ChatPage(): ReactElement {
|
|||
[],
|
||||
);
|
||||
|
||||
const openModelSelector = useCallback(() => setModelSelectorOpen(true), []);
|
||||
const closeModelSelector = useCallback(() => setModelSelectorOpen(false), []);
|
||||
const openModelSelector = useCallback(() => {
|
||||
setModelSelectorLocked(true);
|
||||
setModelSelectorOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeModelSelector = useCallback(() => {
|
||||
setModelSelectorLocked(false);
|
||||
setModelSelectorOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleModelSelectorOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open && modelSelectorLocked) return;
|
||||
setModelSelectorOpen(open);
|
||||
},
|
||||
[modelSelectorLocked],
|
||||
);
|
||||
const openSettings = useCallback(() => setSettingsOpen(true), []);
|
||||
const closeSettings = useCallback(() => setSettingsOpen(false), []);
|
||||
const openSidebar = useCallback(() => setSidebarOpen(true), []);
|
||||
|
|
@ -313,6 +329,13 @@ export function ChatPage(): ReactElement {
|
|||
steps: tourSteps,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (tour.open) return;
|
||||
if (!modelSelectorLocked) return;
|
||||
setModelSelectorLocked(false);
|
||||
setModelSelectorOpen(false);
|
||||
}, [modelSelectorLocked, tour.open]);
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] bg-background overflow-hidden">
|
||||
<GuidedTour {...tour.tourProps} />
|
||||
|
|
@ -355,7 +378,7 @@ export function ChatPage(): ReactElement {
|
|||
onEject={handleEject}
|
||||
variant="ghost"
|
||||
open={modelSelectorOpen}
|
||||
onOpenChange={setModelSelectorOpen}
|
||||
onOpenChange={handleModelSelectorOpenChange}
|
||||
triggerDataTour="chat-model-selector"
|
||||
contentDataTour="chat-model-selector-popover"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { CheckFormatResponse } from "@/features/training/types/datasets";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircleIcon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
export function HeaderPick({
|
||||
label,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-muted-foreground cursor-pointer select-none">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => onCheckedChange(v === true)}
|
||||
aria-label={label}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatasetMappingCard({
|
||||
leftLabel,
|
||||
rightLabel,
|
||||
mappingOk,
|
||||
input,
|
||||
output,
|
||||
}: {
|
||||
leftLabel: string;
|
||||
rightLabel: string;
|
||||
mappingOk: boolean;
|
||||
input: string | null;
|
||||
output: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl corner-squircle ring-1 px-5 py-4 mb-4",
|
||||
mappingOk
|
||||
? "ring-emerald-200/70 bg-emerald-50/70 text-emerald-950 dark:ring-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-50"
|
||||
: "ring-amber-200/70 bg-amber-50/70 text-amber-950 dark:ring-amber-900/50 dark:bg-amber-950/30 dark:text-amber-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl corner-squircle p-2 shrink-0",
|
||||
mappingOk ? "bg-emerald-500/15" : "bg-amber-500/15",
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={mappingOk ? CheckmarkCircle02Icon : AlertCircleIcon}
|
||||
className={cn(
|
||||
"size-4",
|
||||
mappingOk
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: "text-amber-700 dark:text-amber-300",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
{mappingOk ? "Mapping ready" : "Map dataset columns"}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs mt-0.5",
|
||||
mappingOk
|
||||
? "text-emerald-800/80 dark:text-emerald-200/80"
|
||||
: "text-amber-800/80 dark:text-amber-200/80",
|
||||
)}
|
||||
>
|
||||
{mappingOk
|
||||
? "Looks good. We'll convert this dataset automatically."
|
||||
: `We couldn't auto-detect the format. Pick the ${leftLabel.toLowerCase()} column and the ${rightLabel.toLowerCase()} column. We'll convert it to a supported format automatically.`}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 text-[11px] bg-white/60 dark:bg-transparent"
|
||||
>
|
||||
{leftLabel}: <span className="font-mono">{input ?? "--"}</span>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 text-[11px] bg-white/60 dark:bg-transparent"
|
||||
>
|
||||
{rightLabel}: <span className="font-mono">{output ?? "--"}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
{!mappingOk && (
|
||||
<p className="mt-2 text-xs text-amber-800/80 dark:text-amber-200/80">
|
||||
Select exactly 1 {leftLabel.toLowerCase()} and 1{" "}
|
||||
{rightLabel.toLowerCase()} column to continue.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatasetMappingFooter({
|
||||
leftLabel,
|
||||
rightLabel,
|
||||
mappingOk,
|
||||
isStarting,
|
||||
startError,
|
||||
onCancel,
|
||||
onStartTraining,
|
||||
}: {
|
||||
leftLabel: string;
|
||||
rightLabel: string;
|
||||
mappingOk: boolean;
|
||||
isStarting: boolean;
|
||||
startError: string | null;
|
||||
onCancel: () => void;
|
||||
onStartTraining: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-relaxed">
|
||||
Tip: you can click {leftLabel} / {rightLabel} in the headers.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
disabled={!mappingOk || isStarting}
|
||||
onClick={() => void onStartTraining()}
|
||||
>
|
||||
{isStarting ? "Starting..." : "Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{startError && (
|
||||
<p className="text-xs text-red-500 leading-relaxed text-center">
|
||||
{startError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveDefaultMapping(
|
||||
data: CheckFormatResponse,
|
||||
isVlm: boolean,
|
||||
): { input: string | null; output: string | null } {
|
||||
const input = isVlm
|
||||
? data.detected_image_column ?? pickRole(data.suggested_mapping, "image")
|
||||
: pickRole(data.suggested_mapping, "user");
|
||||
const output = isVlm
|
||||
? data.detected_text_column ?? pickRole(data.suggested_mapping, "text")
|
||||
: pickRole(data.suggested_mapping, "assistant");
|
||||
|
||||
if (input && output && input === output) {
|
||||
return { input, output: null };
|
||||
}
|
||||
|
||||
return { input: input ?? null, output: output ?? null };
|
||||
}
|
||||
|
||||
function pickRole(
|
||||
mapping: Record<string, string> | null | undefined,
|
||||
role: string,
|
||||
): string | null {
|
||||
if (!mapping) return null;
|
||||
for (const [col, mappedRole] of Object.entries(mapping)) {
|
||||
if (mappedRole === role) return col;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
type PreviewImagePayload = {
|
||||
type: "image";
|
||||
mime?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
data?: string;
|
||||
};
|
||||
|
||||
export function formatCell(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value) || typeof value === "object")
|
||||
return JSON.stringify(value).slice(0, 500);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function isPreviewImagePayload(value: unknown): value is PreviewImagePayload {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
record.type === "image" &&
|
||||
typeof record.data === "string" &&
|
||||
record.data.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function collectPreviewImages(value: unknown): PreviewImagePayload[] {
|
||||
const images: PreviewImagePayload[] = [];
|
||||
const stack: unknown[] = [value];
|
||||
let steps = 0;
|
||||
|
||||
while (stack.length > 0 && steps < 200) {
|
||||
steps += 1;
|
||||
const current = stack.pop();
|
||||
if (isPreviewImagePayload(current)) {
|
||||
images.push(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
for (const item of current) stack.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current && typeof current === "object") {
|
||||
for (const nested of Object.values(current as Record<string, unknown>)) {
|
||||
stack.push(nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -8,32 +9,19 @@ import {
|
|||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useTrainingActions, useTrainingConfigStore } from "@/features/training";
|
||||
import { checkDatasetFormat } from "@/features/training/api/datasets-api";
|
||||
import type { CheckFormatResponse } from "@/features/training/types/datasets";
|
||||
import { Database02Icon, AlertCircleIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types (matches CheckFormatResponse from backend)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CheckFormatResponse = {
|
||||
requires_manual_mapping: boolean;
|
||||
detected_format: string;
|
||||
columns: string[];
|
||||
suggested_mapping?: Record<string, string> | null;
|
||||
detected_image_column?: string | null;
|
||||
detected_text_column?: string | null;
|
||||
preview_samples?: Record<string, unknown>[] | null;
|
||||
total_rows?: number | null;
|
||||
};
|
||||
|
||||
type PreviewImagePayload = {
|
||||
type: "image";
|
||||
mime?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
data?: string;
|
||||
};
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { collectPreviewImages, formatCell } from "./dataset-preview-dialog-utils";
|
||||
import {
|
||||
DatasetMappingCard,
|
||||
DatasetMappingFooter,
|
||||
HeaderPick,
|
||||
deriveDefaultMapping,
|
||||
} from "./dataset-preview-dialog-mapping";
|
||||
|
||||
type DatasetPreviewDialogProps = {
|
||||
open: boolean;
|
||||
|
|
@ -42,41 +30,11 @@ type DatasetPreviewDialogProps = {
|
|||
hfToken: string | null;
|
||||
datasetSubset?: string | null;
|
||||
datasetSplit?: string | null;
|
||||
mode?: "preview" | "mapping";
|
||||
initialData?: CheckFormatResponse | null;
|
||||
isVlm?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API -- uses existing /check-format endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TODO(backend): Needs to accept `config` and `split` fields (see #37).
|
||||
// The frontend already sends them in the request below.
|
||||
async function fetchCheckFormat(
|
||||
datasetName: string,
|
||||
hfToken: string | null,
|
||||
subset?: string | null,
|
||||
split?: string | null,
|
||||
): Promise<CheckFormatResponse> {
|
||||
const res = await fetch("/api/datasets/check-format", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dataset_name: datasetName,
|
||||
hf_token: hfToken || undefined,
|
||||
config: subset || undefined,
|
||||
split: split || "train",
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.detail || `Request failed (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DatasetPreviewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -84,22 +42,59 @@ export function DatasetPreviewDialog({
|
|||
hfToken,
|
||||
datasetSubset,
|
||||
datasetSplit,
|
||||
mode = "preview",
|
||||
initialData,
|
||||
isVlm = false,
|
||||
}: DatasetPreviewDialogProps) {
|
||||
const [data, setData] = useState<CheckFormatResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { manualMapping, setManualMapping } = useTrainingConfigStore(
|
||||
useShallow((s) => ({
|
||||
manualMapping: s.datasetManualMapping,
|
||||
setManualMapping: s.setDatasetManualMapping,
|
||||
})),
|
||||
);
|
||||
const { isStarting, startError, startTrainingRun } = useTrainingActions();
|
||||
|
||||
const mappingEnabled = !!data?.requires_manual_mapping;
|
||||
const showMappingFooter = mode === "mapping" && mappingEnabled;
|
||||
const mappingOk = !!manualMapping.input && !!manualMapping.output;
|
||||
const leftLabel = isVlm ? "Image" : "Input";
|
||||
const rightLabel = isVlm ? "Text" : "Output";
|
||||
|
||||
useEffect(() => {
|
||||
if (!manualMapping.input || !manualMapping.output) return;
|
||||
if (manualMapping.input !== manualMapping.output) return;
|
||||
setManualMapping({ input: manualMapping.input, output: null });
|
||||
}, [manualMapping.input, manualMapping.output, setManualMapping]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !datasetName) {
|
||||
setData(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialData) {
|
||||
setData(initialData);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchCheckFormat(datasetName, hfToken, datasetSubset, datasetSplit)
|
||||
checkDatasetFormat({
|
||||
datasetName,
|
||||
hfToken,
|
||||
subset: datasetSubset,
|
||||
split: datasetSplit,
|
||||
isVlm,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setData(res);
|
||||
|
|
@ -116,7 +111,16 @@ export function DatasetPreviewDialog({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, datasetName, hfToken, datasetSubset, datasetSplit]);
|
||||
}, [open, datasetName, hfToken, datasetSubset, datasetSplit, isVlm, initialData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !datasetName) return;
|
||||
if (!data?.requires_manual_mapping) return;
|
||||
if (manualMapping.input || manualMapping.output) return;
|
||||
const derived = deriveDefaultMapping(data, isVlm);
|
||||
if (!derived.input && !derived.output) return;
|
||||
setManualMapping(derived);
|
||||
}, [open, datasetName, data, isVlm, manualMapping.input, manualMapping.output, setManualMapping]);
|
||||
|
||||
const rows = data?.preview_samples ?? [];
|
||||
const columns = data?.columns ?? [];
|
||||
|
|
@ -140,9 +144,39 @@ export function DatasetPreviewDialog({
|
|||
return columns.map((colName) => ({
|
||||
accessorKey: colName,
|
||||
header: () => (
|
||||
<span className="font-heading text-[13px] font-semibold tracking-tight text-foreground">
|
||||
{colName}
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="font-heading text-[13px] font-semibold tracking-tight text-foreground">
|
||||
{colName}
|
||||
</span>
|
||||
{mappingEnabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
{canShowInputPicker(colName, manualMapping) && (
|
||||
<HeaderPick
|
||||
label={leftLabel}
|
||||
checked={manualMapping.input === colName}
|
||||
onCheckedChange={(checked) => {
|
||||
setManualMapping({
|
||||
input: checked ? colName : null,
|
||||
output: manualMapping.output,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canShowOutputPicker(colName, manualMapping) && (
|
||||
<HeaderPick
|
||||
label={rightLabel}
|
||||
checked={manualMapping.output === colName}
|
||||
onCheckedChange={(checked) => {
|
||||
setManualMapping({
|
||||
input: manualMapping.input,
|
||||
output: checked ? colName : null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: ({ getValue }: { getValue: () => unknown }) => {
|
||||
const value = getValue();
|
||||
|
|
@ -184,8 +218,7 @@ export function DatasetPreviewDialog({
|
|||
</span>
|
||||
);
|
||||
}
|
||||
const full =
|
||||
typeof value === "string" ? value : JSON.stringify(value);
|
||||
const full = typeof value === "string" ? value : JSON.stringify(value);
|
||||
return (
|
||||
<p
|
||||
className="text-[13px] leading-relaxed line-clamp-6"
|
||||
|
|
@ -196,7 +229,15 @@ export function DatasetPreviewDialog({
|
|||
);
|
||||
},
|
||||
}));
|
||||
}, [columns]);
|
||||
}, [
|
||||
columns,
|
||||
manualMapping.input,
|
||||
manualMapping.output,
|
||||
setManualMapping,
|
||||
mappingEnabled,
|
||||
leftLabel,
|
||||
rightLabel,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -284,18 +325,51 @@ export function DatasetPreviewDialog({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{mappingEnabled && (
|
||||
<DatasetMappingCard
|
||||
leftLabel={leftLabel}
|
||||
rightLabel={rightLabel}
|
||||
mappingOk={mappingOk}
|
||||
input={manualMapping.input}
|
||||
output={manualMapping.output}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Data table */}
|
||||
<div className="flex-1 min-h-0 rounded-xl corner-squircle ring-1 ring-border/60 overflow-auto">
|
||||
<DataTable columns={tableColumns} data={rows} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-3 text-center tabular-nums">
|
||||
Showing {rows.length}
|
||||
{data.total_rows != null &&
|
||||
` of ${data.total_rows.toLocaleString()}`}{" "}
|
||||
rows
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<p className="text-[11px] text-muted-foreground/60 text-center tabular-nums">
|
||||
Showing {rows.length}
|
||||
{data.total_rows != null &&
|
||||
` of ${data.total_rows.toLocaleString()}`}{" "}
|
||||
rows
|
||||
</p>
|
||||
|
||||
{mode === "preview" && mappingEnabled && (
|
||||
<p className="mt-2 text-[11px] text-muted-foreground/70 text-center">
|
||||
Mapping is saved automatically. You can start training anytime.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showMappingFooter && (
|
||||
<DatasetMappingFooter
|
||||
leftLabel={leftLabel}
|
||||
rightLabel={rightLabel}
|
||||
mappingOk={mappingOk}
|
||||
isStarting={isStarting}
|
||||
startError={startError}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onStartTraining={async () => {
|
||||
const ok = await startTrainingRun();
|
||||
if (ok) onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -325,54 +399,18 @@ function MetaRow({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatCell(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean")
|
||||
return String(value);
|
||||
if (Array.isArray(value) || typeof value === "object")
|
||||
return JSON.stringify(value).slice(0, 500);
|
||||
return String(value);
|
||||
function canShowInputPicker(
|
||||
colName: string,
|
||||
mapping: { input: string | null; output: string | null },
|
||||
): boolean {
|
||||
if (mapping.output === colName) return false;
|
||||
return mapping.input == null || mapping.input === colName;
|
||||
}
|
||||
|
||||
function isPreviewImagePayload(value: unknown): value is PreviewImagePayload {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
record.type === "image" &&
|
||||
typeof record.data === "string" &&
|
||||
record.data.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function collectPreviewImages(value: unknown): PreviewImagePayload[] {
|
||||
const images: PreviewImagePayload[] = [];
|
||||
const stack: unknown[] = [value];
|
||||
let steps = 0;
|
||||
|
||||
while (stack.length > 0 && steps < 200) {
|
||||
steps += 1;
|
||||
const current = stack.pop();
|
||||
if (isPreviewImagePayload(current)) {
|
||||
images.push(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
for (const item of current) stack.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current && typeof current === "object") {
|
||||
for (const nested of Object.values(current as Record<string, unknown>)) {
|
||||
stack.push(nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
function canShowOutputPicker(
|
||||
colName: string,
|
||||
mapping: { input: string | null; output: string | null },
|
||||
): boolean {
|
||||
if (mapping.input === colName) return false;
|
||||
return mapping.output == null || mapping.output === colName;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import { formatCompact } from "@/lib/utils";
|
||||
import {
|
||||
HfDatasetSubsetSplitSelectors,
|
||||
useDatasetPreviewDialogStore,
|
||||
useTrainingConfigStore,
|
||||
} from "@/features/training";
|
||||
import {
|
||||
|
|
@ -43,7 +44,6 @@ import {
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { DatasetPreviewDialog } from "./dataset-preview-dialog";
|
||||
|
||||
function isLikelyLocalDatasetRef(value: string) {
|
||||
return (
|
||||
|
|
@ -81,7 +81,7 @@ export function DatasetSection() {
|
|||
);
|
||||
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const openPreview = useDatasetPreviewDialogStore((s) => s.openPreview);
|
||||
const selectingRef = useRef(false);
|
||||
const debouncedQuery = useDebouncedValue(inputValue);
|
||||
|
||||
|
|
@ -190,20 +190,21 @@ export function DatasetSection() {
|
|||
ref={scrollRef}
|
||||
className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]"
|
||||
>
|
||||
<ComboboxList className="p-1 !max-h-none !overflow-visible">
|
||||
{(id: string) => {
|
||||
const r = hfResults.find((ds) => ds.id === id);
|
||||
const detail = r?.totalExamples
|
||||
? `${formatCompact(r.totalExamples)} rows`
|
||||
: r?.sizeCategory
|
||||
? r.sizeCategory
|
||||
: r?.downloads != null
|
||||
? `↓${formatCompact(r.downloads)}`
|
||||
: null;
|
||||
return (
|
||||
<ComboboxItem
|
||||
key={id}
|
||||
value={id}
|
||||
<ComboboxList className="p-1 !max-h-none !overflow-visible">
|
||||
{(id: string) => {
|
||||
const r = hfResults.find((ds) => ds.id === id);
|
||||
let detail: string | null = null;
|
||||
if (r?.totalExamples) {
|
||||
detail = `${formatCompact(r.totalExamples)} rows`;
|
||||
} else if (r?.sizeCategory) {
|
||||
detail = r.sizeCategory;
|
||||
} else if (r?.downloads != null) {
|
||||
detail = `↓${formatCompact(r.downloads)}`;
|
||||
}
|
||||
return (
|
||||
<ComboboxItem
|
||||
key={id}
|
||||
value={id}
|
||||
className="justify-between"
|
||||
>
|
||||
<Tooltip>
|
||||
|
|
@ -341,7 +342,7 @@ export function DatasetSection() {
|
|||
size="sm"
|
||||
className="cursor-pointer gap-1.5"
|
||||
disabled={!dataset}
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
onClick={() => openPreview()}
|
||||
>
|
||||
<HugeiconsIcon icon={ViewIcon} className="size-3.5" />
|
||||
View dataset
|
||||
|
|
@ -349,14 +350,6 @@ export function DatasetSection() {
|
|||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<DatasetPreviewDialog
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
datasetName={dataset}
|
||||
hfToken={hfToken}
|
||||
datasetSubset={datasetSubset}
|
||||
datasetSplit={datasetSplit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
shouldShowTrainingView,
|
||||
useDatasetPreviewDialogStore,
|
||||
useTrainingActions,
|
||||
useTrainingConfigStore,
|
||||
useTrainingRuntimeLifecycle,
|
||||
useTrainingRuntimeStore,
|
||||
} from "@/features/training";
|
||||
|
|
@ -10,6 +12,7 @@ import { studioTourSteps, studioTrainingTourSteps } from "@/features/studio/tour
|
|||
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, useEffect } from "react";
|
||||
import { DatasetPreviewDialog } from "./sections/dataset-preview-dialog";
|
||||
import { DatasetSection } from "./sections/dataset-section";
|
||||
import { ModelSection } from "./sections/model-section";
|
||||
import { ParamsSection } from "./sections/params-section";
|
||||
|
|
@ -27,6 +30,12 @@ export function StudioPage(): ReactElement {
|
|||
const hasHydratedRuntime = useTrainingRuntimeStore((state) => state.hasHydrated);
|
||||
const { dismissTrainingRun } = useTrainingActions();
|
||||
|
||||
const config = useTrainingConfigStore();
|
||||
const dialogOpen = useDatasetPreviewDialogStore((s) => s.open);
|
||||
const dialogMode = useDatasetPreviewDialogStore((s) => s.mode);
|
||||
const dialogInitial = useDatasetPreviewDialogStore((s) => s.initialData);
|
||||
const closeDialog = useDatasetPreviewDialogStore((s) => s.close);
|
||||
|
||||
const canGoBack = runtimePhase === "stopped" || runtimePhase === "error";
|
||||
const tourEnabled = hasHydratedRuntime && !isHydratingRuntime;
|
||||
const isConfigTour = !showTrainingView;
|
||||
|
|
@ -46,7 +55,23 @@ export function StudioPage(): ReactElement {
|
|||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<main className="mx-auto max-w-7xl px-6 py-4">
|
||||
<GuidedTour {...tour.tourProps} celebrate={true} />
|
||||
<GuidedTour {...tour.tourProps} celebrate={isConfigTour} />
|
||||
|
||||
<DatasetPreviewDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeDialog();
|
||||
}}
|
||||
datasetName={
|
||||
config.datasetSource === "huggingface" ? config.dataset : config.uploadedFile
|
||||
}
|
||||
hfToken={config.hfToken.trim() || null}
|
||||
datasetSubset={config.datasetSubset}
|
||||
datasetSplit={config.datasetSplit}
|
||||
mode={dialogMode}
|
||||
initialData={dialogInitial}
|
||||
isVlm={config.modelType === "vision"}
|
||||
/>
|
||||
|
||||
{canGoBack && (
|
||||
<Button
|
||||
|
|
@ -60,7 +85,6 @@ export function StudioPage(): ReactElement {
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col gap-0.5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Fine-tuning Studio
|
||||
|
|
|
|||
37
studio/frontend/src/features/training/api/datasets-api.ts
Normal file
37
studio/frontend/src/features/training/api/datasets-api.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { CheckFormatResponse } from "../types/datasets";
|
||||
|
||||
type CheckDatasetFormatArgs = {
|
||||
datasetName: string;
|
||||
hfToken: string | null;
|
||||
subset?: string | null;
|
||||
split?: string | null;
|
||||
isVlm?: boolean;
|
||||
};
|
||||
|
||||
export async function checkDatasetFormat({
|
||||
datasetName,
|
||||
hfToken,
|
||||
subset,
|
||||
split,
|
||||
isVlm,
|
||||
}: CheckDatasetFormatArgs): Promise<CheckFormatResponse> {
|
||||
const res = await fetch("/api/datasets/check-format", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dataset_name: datasetName,
|
||||
hf_token: hfToken || undefined,
|
||||
config: subset || undefined, // backend currently ignores, safe to send
|
||||
split: split || "train",
|
||||
is_vlm: !!isVlm,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.detail || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ export function buildTrainingStartPayload(
|
|||
const adapterMethod = config.trainingMethod !== "full";
|
||||
const isQlorMethod = config.trainingMethod === "qlora";
|
||||
const hfDataset = config.datasetSource === "huggingface" ? config.dataset : null;
|
||||
const customFormatMapping = buildCustomFormatMapping(config);
|
||||
|
||||
return {
|
||||
model_name: config.selectedModel ?? "",
|
||||
|
|
@ -26,6 +27,7 @@ export function buildTrainingStartPayload(
|
|||
hf_dataset_split: hfDataset ? config.datasetSplit : null,
|
||||
local_datasets: [],
|
||||
format_type: config.datasetFormat,
|
||||
custom_format_mapping: customFormatMapping,
|
||||
num_epochs: config.epochs,
|
||||
learning_rate: String(config.learningRate),
|
||||
batch_size: config.batchSize,
|
||||
|
|
@ -63,3 +65,16 @@ export function buildTrainingStartPayload(
|
|||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCustomFormatMapping(
|
||||
config: TrainingConfigState,
|
||||
): Record<string, string> | undefined {
|
||||
const { input, output } = config.datasetManualMapping;
|
||||
if (!input || !output) return undefined;
|
||||
|
||||
if (config.modelType === "vision") {
|
||||
return { [input]: "image", [output]: "text" };
|
||||
}
|
||||
|
||||
return { [input]: "user", [output]: "assistant" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { useCallback } from "react";
|
||||
import { useTrainingConfigStore } from "../stores/training-config-store";
|
||||
import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
|
||||
import { startTraining, stopTraining, resetTraining } from "../api/train-api";
|
||||
import { checkDatasetFormat } from "../api/datasets-api";
|
||||
import { buildTrainingStartPayload } from "../api/mappers";
|
||||
import { startTraining, stopTraining, resetTraining } from "../api/train-api";
|
||||
import { syncTrainingRuntimeFromBackend } from "../lib/sync-runtime";
|
||||
import { validateTrainingConfig } from "../lib/validation";
|
||||
import { useDatasetPreviewDialogStore } from "../stores/dataset-preview-dialog-store";
|
||||
import { useTrainingConfigStore } from "../stores/training-config-store";
|
||||
import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
|
||||
import type { TrainingConfigState } from "../types/config";
|
||||
|
||||
export function useTrainingActions() {
|
||||
const isStarting = useTrainingRuntimeStore((state) => state.isStarting);
|
||||
|
|
@ -13,6 +16,7 @@ export function useTrainingActions() {
|
|||
const startTrainingRun = useCallback(async (): Promise<boolean> => {
|
||||
const config = useTrainingConfigStore.getState();
|
||||
const runtimeStore = useTrainingRuntimeStore.getState();
|
||||
const dialogStore = useDatasetPreviewDialogStore.getState();
|
||||
|
||||
runtimeStore.setStartError(null);
|
||||
const validation = validateTrainingConfig(config);
|
||||
|
|
@ -24,6 +28,39 @@ export function useTrainingActions() {
|
|||
runtimeStore.setStarting(true);
|
||||
|
||||
try {
|
||||
const datasetName = getDatasetName(config);
|
||||
const isVlm = config.modelType === "vision";
|
||||
|
||||
if (datasetName) {
|
||||
const check = await checkDatasetFormat({
|
||||
datasetName,
|
||||
hfToken: config.hfToken.trim() || null,
|
||||
subset: config.datasetSubset,
|
||||
split: config.datasetSplit,
|
||||
isVlm,
|
||||
});
|
||||
|
||||
if (check.requires_manual_mapping && !hasManualMapping(config)) {
|
||||
const hintInput = isVlm
|
||||
? check.detected_image_column
|
||||
: pickRoleColumn(check.suggested_mapping, "user");
|
||||
const hintOutput = isVlm
|
||||
? check.detected_text_column
|
||||
: pickRoleColumn(check.suggested_mapping, "assistant");
|
||||
|
||||
if (hintInput || hintOutput) {
|
||||
useTrainingConfigStore.getState().setDatasetManualMapping({
|
||||
input: hintInput ?? null,
|
||||
output: hintOutput ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
runtimeStore.setStarting(false);
|
||||
dialogStore.openMapping(check);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = buildTrainingStartPayload(config);
|
||||
const response = await startTraining(payload);
|
||||
|
||||
|
|
@ -78,3 +115,26 @@ export function useTrainingActions() {
|
|||
dismissTrainingRun,
|
||||
};
|
||||
}
|
||||
|
||||
function getDatasetName(config: TrainingConfigState): string | null {
|
||||
return config.datasetSource === "huggingface"
|
||||
? config.dataset
|
||||
: config.uploadedFile;
|
||||
}
|
||||
|
||||
function hasManualMapping(config: TrainingConfigState): boolean {
|
||||
return (
|
||||
!!config.datasetManualMapping.input && !!config.datasetManualMapping.output
|
||||
);
|
||||
}
|
||||
|
||||
function pickRoleColumn(
|
||||
mapping: Record<string, string> | null | undefined,
|
||||
role: string,
|
||||
): string | null {
|
||||
if (!mapping) return null;
|
||||
for (const [col, mappedRole] of Object.entries(mapping)) {
|
||||
if (mappedRole === role) return col;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ export {
|
|||
export { useTrainingActions } from "./hooks/use-training-actions";
|
||||
export { useTrainingRuntimeLifecycle } from "./hooks/use-training-runtime-lifecycle";
|
||||
export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-split-selectors";
|
||||
export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-store";
|
||||
export type { TrainingPhase } from "./types/runtime";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { create } from "zustand";
|
||||
import type { CheckFormatResponse } from "../types/datasets";
|
||||
|
||||
export type DatasetPreviewDialogMode = "preview" | "mapping";
|
||||
|
||||
type DatasetPreviewDialogState = {
|
||||
open: boolean;
|
||||
mode: DatasetPreviewDialogMode;
|
||||
initialData: CheckFormatResponse | null;
|
||||
};
|
||||
|
||||
type DatasetPreviewDialogActions = {
|
||||
openPreview: () => void;
|
||||
openMapping: (data: CheckFormatResponse) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const initialState: DatasetPreviewDialogState = {
|
||||
open: false,
|
||||
mode: "preview",
|
||||
initialData: null,
|
||||
};
|
||||
|
||||
export const useDatasetPreviewDialogStore = create<
|
||||
DatasetPreviewDialogState & DatasetPreviewDialogActions
|
||||
>()((set) => ({
|
||||
...initialState,
|
||||
|
||||
openPreview: () => set({ open: true, mode: "preview", initialData: null }),
|
||||
openMapping: (data) => set({ open: true, mode: "mapping", initialData: data }),
|
||||
close: () => set({ open: false, initialData: null, mode: "preview" }),
|
||||
}));
|
||||
|
||||
|
|
@ -7,6 +7,10 @@ import type { TrainingConfigState, TrainingConfigStore } from "../types/config";
|
|||
const MIN_STEP: StepNumber = 1;
|
||||
const MAX_STEP: StepNumber = STEPS.length as StepNumber;
|
||||
|
||||
function emptyManualMapping(): TrainingConfigState["datasetManualMapping"] {
|
||||
return { input: null, output: null };
|
||||
}
|
||||
|
||||
const initialState: TrainingConfigState = {
|
||||
currentStep: MIN_STEP,
|
||||
modelType: null,
|
||||
|
|
@ -18,6 +22,7 @@ const initialState: TrainingConfigState = {
|
|||
dataset: null,
|
||||
datasetSubset: null,
|
||||
datasetSplit: null,
|
||||
datasetManualMapping: emptyManualMapping(),
|
||||
uploadedFile: null,
|
||||
...DEFAULT_HYPERPARAMS,
|
||||
};
|
||||
|
|
@ -58,10 +63,22 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
setDatasetSource: (datasetSource) => set({ datasetSource }),
|
||||
setDatasetFormat: (datasetFormat) => set({ datasetFormat }),
|
||||
setDataset: (dataset) =>
|
||||
set({ dataset, datasetSubset: null, datasetSplit: null }),
|
||||
set({
|
||||
dataset,
|
||||
datasetSubset: null,
|
||||
datasetSplit: null,
|
||||
datasetManualMapping: emptyManualMapping(),
|
||||
}),
|
||||
setDatasetSubset: (datasetSubset) =>
|
||||
set({ datasetSubset, datasetSplit: null }),
|
||||
setDatasetSplit: (datasetSplit) => set({ datasetSplit }),
|
||||
set({
|
||||
datasetSubset,
|
||||
datasetSplit: null,
|
||||
datasetManualMapping: emptyManualMapping(),
|
||||
}),
|
||||
setDatasetSplit: (datasetSplit) =>
|
||||
set({ datasetSplit, datasetManualMapping: emptyManualMapping() }),
|
||||
setDatasetManualMapping: (datasetManualMapping) =>
|
||||
set({ datasetManualMapping }),
|
||||
setUploadedFile: (uploadedFile) => set({ uploadedFile }),
|
||||
setEpochs: (epochs) => set({ epochs }),
|
||||
setContextLength: (contextLength) => set({ contextLength }),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface TrainingStartRequest {
|
|||
hf_dataset_split: string | null;
|
||||
local_datasets: string[];
|
||||
format_type: string;
|
||||
custom_format_mapping?: Record<string, string> | null;
|
||||
num_epochs: number;
|
||||
learning_rate: string;
|
||||
batch_size: number;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import type {
|
|||
|
||||
export type LoraVariant = "lora" | "rslora" | "loftq";
|
||||
|
||||
export type DatasetManualMapping = {
|
||||
input: string | null;
|
||||
output: string | null;
|
||||
};
|
||||
|
||||
export interface TrainingConfigState {
|
||||
currentStep: StepNumber;
|
||||
modelType: ModelType | null;
|
||||
|
|
@ -20,6 +25,7 @@ export interface TrainingConfigState {
|
|||
dataset: string | null;
|
||||
datasetSubset: string | null;
|
||||
datasetSplit: string | null;
|
||||
datasetManualMapping: DatasetManualMapping;
|
||||
uploadedFile: string | null;
|
||||
epochs: number;
|
||||
contextLength: number;
|
||||
|
|
@ -64,6 +70,7 @@ export interface TrainingConfigActions {
|
|||
setDataset: (dataset: string | null) => void;
|
||||
setDatasetSubset: (subset: string | null) => void;
|
||||
setDatasetSplit: (split: string | null) => void;
|
||||
setDatasetManualMapping: (mapping: DatasetManualMapping) => void;
|
||||
setUploadedFile: (file: string | null) => void;
|
||||
setEpochs: (epochs: number) => void;
|
||||
setContextLength: (length: number) => void;
|
||||
|
|
|
|||
13
studio/frontend/src/features/training/types/datasets.ts
Normal file
13
studio/frontend/src/features/training/types/datasets.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export type CheckFormatResponse = {
|
||||
requires_manual_mapping: boolean;
|
||||
detected_format: string;
|
||||
columns: string[];
|
||||
suggested_mapping?: Record<string, string> | null;
|
||||
detected_image_column?: string | null;
|
||||
detected_text_column?: string | null;
|
||||
preview_samples?: Record<string, unknown>[] | null;
|
||||
total_rows?: number | null;
|
||||
is_multimodal?: boolean;
|
||||
multimodal_columns?: string[] | null;
|
||||
};
|
||||
|
||||
|
|
@ -1,19 +1,29 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
if (!crypto.randomUUID) {
|
||||
crypto.randomUUID = () =>
|
||||
"10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
|
||||
(
|
||||
+c ^
|
||||
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))
|
||||
).toString(16),
|
||||
) as `${string}-${string}-${string}-${string}-${string}`;
|
||||
}
|
||||
|
||||
import "./index.css";
|
||||
import { App } from "./app/app";
|
||||
|
||||
const globalCrypto = globalThis.crypto as Crypto | undefined;
|
||||
|
||||
if (globalCrypto && typeof globalCrypto.randomUUID !== "function") {
|
||||
// Some envs ship `crypto` but no `randomUUID()` (or a non-function stub).
|
||||
// Provide a best-effort v4 UUID using `getRandomValues` when available.
|
||||
const cryptoRef = globalCrypto;
|
||||
|
||||
function getRandomByte(): number {
|
||||
if (typeof cryptoRef.getRandomValues === "function") {
|
||||
return cryptoRef.getRandomValues(new Uint8Array(1))[0];
|
||||
}
|
||||
return Math.floor(Math.random() * 256);
|
||||
}
|
||||
|
||||
cryptoRef.randomUUID = (() =>
|
||||
"10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
|
||||
(+c ^ (getRandomByte() & (15 >> (+c / 4)))).toString(16),
|
||||
)) as Crypto["randomUUID"];
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element not found");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue