refactor: create utilities for dataset manual mapping and improve UI logic consistency in dataset preview dialog

This commit is contained in:
Shine1i 2026-02-16 01:54:09 +01:00
commit be745d1637
7 changed files with 177 additions and 162 deletions

View file

@ -1,9 +1,10 @@
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";
import type { CheckFormatResponse } from "@/features/training/types/datasets";
export function HeaderPick({
label,
@ -40,30 +41,30 @@ export function DatasetMappingCard({
input: string | null;
output: string | null;
}) {
const tone = mappingOk ? "ok" : "warn";
return (
<div
className={
tone === "ok"
? "rounded-xl corner-squircle ring-1 ring-emerald-200/70 bg-emerald-50/70 px-5 py-4 mb-4 text-emerald-950 dark:ring-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-50"
: "rounded-xl corner-squircle ring-1 ring-amber-200/70 bg-amber-50/70 px-5 py-4 mb-4 text-amber-950 dark:ring-amber-900/50 dark:bg-amber-950/30 dark:text-amber-50"
}
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={
tone === "ok"
? "rounded-xl corner-squircle bg-emerald-500/15 p-2 shrink-0"
: "rounded-xl corner-squircle bg-amber-500/15 p-2 shrink-0"
}
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={
tone === "ok"
? "size-4 text-emerald-700 dark:text-emerald-300"
: "size-4 text-amber-700 dark:text-amber-300"
}
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">
@ -71,11 +72,12 @@ export function DatasetMappingCard({
{mappingOk ? "Mapping ready" : "Map dataset columns"}
</p>
<p
className={
tone === "ok"
? "text-xs text-emerald-800/80 dark:text-emerald-200/80 mt-0.5"
: "text-xs text-amber-800/80 dark:text-amber-200/80 mt-0.5"
}
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."
@ -163,17 +165,17 @@ export function deriveDefaultMapping(
data: CheckFormatResponse,
isVlm: boolean,
): { input: string | null; output: string | null } {
if (isVlm) {
const input =
data.detected_image_column ?? pickRole(data.suggested_mapping, "image");
const output =
data.detected_text_column ?? pickRole(data.suggested_mapping, "text");
if (input && output && input === output) return { input, output: null };
return { input: input ?? null, output: output ?? 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 };
}
const input = pickRole(data.suggested_mapping, "user");
const output = pickRole(data.suggested_mapping, "assistant");
if (input && output && input === output) return { input, output: null };
return { input: input ?? null, output: output ?? null };
}

View file

@ -1,3 +1,4 @@
import { type ReactNode, useEffect, useMemo, useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import {
Dialog,
@ -8,12 +9,12 @@ import {
import { DataTable } from "@/components/ui/data-table";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Database02Icon, AlertCircleIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactNode, useEffect, useMemo, useState } from "react";
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 { useShallow } from "zustand/react/shallow";
import { collectPreviewImages, formatCell } from "./dataset-preview-dialog-utils";
import {
DatasetMappingCard,
@ -22,10 +23,6 @@ import {
deriveDefaultMapping,
} from "./dataset-preview-dialog-mapping";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type DatasetPreviewDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -38,10 +35,6 @@ type DatasetPreviewDialogProps = {
isVlm?: boolean;
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function DatasetPreviewDialog({
open,
onOpenChange,
@ -57,8 +50,12 @@ export function DatasetPreviewDialog({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const manualMapping = useTrainingConfigStore((s) => s.datasetManualMapping);
const setManualMapping = useTrainingConfigStore((s) => s.setDatasetManualMapping);
const { manualMapping, setManualMapping } = useTrainingConfigStore(
useShallow((s) => ({
manualMapping: s.datasetManualMapping,
setManualMapping: s.setDatasetManualMapping,
})),
);
const { isStarting, startError, startTrainingRun } = useTrainingActions();
const mappingEnabled = !!data?.requires_manual_mapping;
@ -79,12 +76,14 @@ export function DatasetPreviewDialog({
setError(null);
return;
}
if (initialData) {
setData(initialData);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
@ -115,21 +114,13 @@ export function DatasetPreviewDialog({
}, [open, datasetName, hfToken, datasetSubset, datasetSplit, isVlm, initialData]);
useEffect(() => {
if (!open || !datasetName || !data?.requires_manual_mapping) return;
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?.requires_manual_mapping,
isVlm,
manualMapping.input,
manualMapping.output,
setManualMapping,
data,
]);
}, [open, datasetName, data, isVlm, manualMapping.input, manualMapping.output, setManualMapping]);
const rows = data?.preview_samples ?? [];
const columns = data?.columns ?? [];
@ -159,38 +150,30 @@ export function DatasetPreviewDialog({
</span>
{mappingEnabled && (
<div className="flex items-center gap-3">
{(manualMapping.output == null || manualMapping.output !== colName) &&
(manualMapping.input == null || manualMapping.input === colName) && (
<HeaderPick
label={leftLabel}
checked={manualMapping.input === colName}
onCheckedChange={(checked) => {
setManualMapping({
input: checked ? colName : null,
output:
checked && manualMapping.output === colName
? null
: manualMapping.output,
});
}}
/>
)}
{(manualMapping.input == null || manualMapping.input !== colName) &&
(manualMapping.output == null || manualMapping.output === colName) && (
<HeaderPick
label={rightLabel}
checked={manualMapping.output === colName}
onCheckedChange={(checked) => {
setManualMapping({
input:
checked && manualMapping.input === colName
? null
: manualMapping.input,
output: checked ? colName : null,
});
}}
/>
)}
{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>
@ -235,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"
@ -325,10 +307,10 @@ export function DatasetPreviewDialog({
: "--"
}
/>
<MetaRow
label="Columns"
value={
<span className="flex items-center gap-1.5 flex-wrap">
<MetaRow
label="Columns"
value={
<span className="flex items-center gap-1.5 flex-wrap">
{columns.map((col) => (
<Badge
key={col}
@ -417,4 +399,18 @@ function MetaRow({
);
}
// mapping UI extracted to ./dataset-preview-dialog-mapping.tsx
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 canShowOutputPicker(
colName: string,
mapping: { input: string | null; output: string | null },
): boolean {
if (mapping.input === colName) return false;
return mapping.output == null || mapping.output === colName;
}

View file

@ -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>

View file

@ -14,15 +14,7 @@ export function buildTrainingStartPayload(
const adapterMethod = config.trainingMethod !== "full";
const isQlorMethod = config.trainingMethod === "qlora";
const hfDataset = config.datasetSource === "huggingface" ? config.dataset : null;
const manual = config.datasetManualMapping;
const isVlm = config.modelType === "vision";
const customFormatMapping =
manual.input && manual.output
? {
[manual.input]: isVlm ? "image" : "user",
[manual.output]: isVlm ? "text" : "assistant",
}
: undefined;
const customFormatMapping = buildCustomFormatMapping(config);
return {
model_name: config.selectedModel ?? "",
@ -73,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" };
}

View file

@ -1,12 +1,13 @@
import { useCallback } from "react";
import { useTrainingConfigStore } from "../stores/training-config-store";
import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
import { useDatasetPreviewDialogStore } from "../stores/dataset-preview-dialog-store";
import { startTraining, stopTraining, resetTraining } from "../api/train-api";
import { buildTrainingStartPayload } from "../api/mappers";
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);
@ -27,8 +28,7 @@ export function useTrainingActions() {
runtimeStore.setStarting(true);
try {
const datasetName =
config.datasetSource === "huggingface" ? config.dataset : config.uploadedFile;
const datasetName = getDatasetName(config);
const isVlm = config.modelType === "vision";
if (datasetName) {
@ -40,29 +40,24 @@ export function useTrainingActions() {
isVlm,
});
if (check.requires_manual_mapping) {
const existing = useTrainingConfigStore.getState().datasetManualMapping;
const hasMapping = !!existing.input && !!existing.output;
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 (!hasMapping) {
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;
if (hintInput || hintOutput) {
useTrainingConfigStore.getState().setDatasetManualMapping({
input: hintInput ?? null,
output: hintOutput ?? null,
});
}
runtimeStore.setStarting(false);
dialogStore.openMapping(check);
return false;
}
}
@ -121,6 +116,18 @@ export function useTrainingActions() {
};
}
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,

View file

@ -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,7 +22,7 @@ const initialState: TrainingConfigState = {
dataset: null,
datasetSubset: null,
datasetSplit: null,
datasetManualMapping: { input: null, output: null },
datasetManualMapping: emptyManualMapping(),
uploadedFile: null,
...DEFAULT_HYPERPARAMS,
};
@ -63,16 +67,16 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
dataset,
datasetSubset: null,
datasetSplit: null,
datasetManualMapping: { input: null, output: null },
datasetManualMapping: emptyManualMapping(),
}),
setDatasetSubset: (datasetSubset) =>
set({
datasetSubset,
datasetSplit: null,
datasetManualMapping: { input: null, output: null },
datasetManualMapping: emptyManualMapping(),
}),
setDatasetSplit: (datasetSplit) =>
set({ datasetSplit, datasetManualMapping: { input: null, output: null } }),
set({ datasetSplit, datasetManualMapping: emptyManualMapping() }),
setDatasetManualMapping: (datasetManualMapping) =>
set({ datasetManualMapping }),
setUploadedFile: (uploadedFile) => set({ uploadedFile }),

View file

@ -1,29 +1,29 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
const globalCrypto = globalThis.crypto as Crypto | undefined;
const hasUuid =
globalCrypto && typeof (globalCrypto as Crypto).randomUUID === "function";
import "./index.css";
import { App } from "./app/app";
if (globalCrypto && !hasUuid) {
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 getRandomByte = () => {
if (typeof globalCrypto.getRandomValues === "function") {
return globalCrypto.getRandomValues(new Uint8Array(1))[0];
const cryptoRef = globalCrypto;
function getRandomByte(): number {
if (typeof cryptoRef.getRandomValues === "function") {
return cryptoRef.getRandomValues(new Uint8Array(1))[0];
}
return Math.floor(Math.random() * 256);
};
}
(globalCrypto as Crypto).randomUUID = (() =>
cryptoRef.randomUUID = (() =>
"10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
(+c ^ (getRandomByte() & (15 >> (+c / 4)))).toString(16),
)) as Crypto["randomUUID"];
}
import "./index.css";
import { App } from "./app/app";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");