feat: implement guided tours and refactor model selector components
This commit is contained in:
parent
047899ae0f
commit
c7ff1687e6
24 changed files with 778 additions and 454 deletions
|
|
@ -1,39 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks";
|
||||
import { cn, formatCompact } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
Logout01Icon,
|
||||
Search01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type {
|
||||
LoraModelOption,
|
||||
ModelOption,
|
||||
ModelSelectorChangeMeta,
|
||||
} from "./model-selector/types";
|
||||
import { HubModelPicker, LoraModelPicker } from "./model-selector/pickers";
|
||||
|
||||
export interface ModelOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export interface LoraModelOption extends ModelOption {
|
||||
baseModel?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface ModelSelectorChangeMeta {
|
||||
source: "hub" | "lora";
|
||||
isLora: boolean;
|
||||
}
|
||||
export type { LoraModelOption, ModelOption, ModelSelectorChangeMeta } from "./model-selector/types";
|
||||
|
||||
interface ModelSelectorProps {
|
||||
models: ModelOption[];
|
||||
|
|
@ -46,10 +33,10 @@ interface ModelSelectorProps {
|
|||
size?: "sm" | "default" | "lg";
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
function dedupe(values: string[]): string[] {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
triggerDataTour?: string;
|
||||
contentDataTour?: string;
|
||||
}
|
||||
|
||||
function ModelSelectorTrigger({
|
||||
|
|
@ -58,17 +45,20 @@ function ModelSelectorTrigger({
|
|||
variant = "outline",
|
||||
size = "default",
|
||||
className,
|
||||
dataTour,
|
||||
}: {
|
||||
currentModel?: ModelOption;
|
||||
isLoaded: boolean;
|
||||
variant?: "outline" | "ghost" | "muted";
|
||||
size?: "sm" | "default" | "lg";
|
||||
className?: string;
|
||||
dataTour?: string;
|
||||
}) {
|
||||
return (
|
||||
<PopoverTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
data-tour={dataTour}
|
||||
className={cn(
|
||||
"flex items-center gap-2 transition-colors",
|
||||
variant === "outline" &&
|
||||
|
|
@ -99,268 +89,6 @@ function ModelSelectorTrigger({
|
|||
);
|
||||
}
|
||||
|
||||
function ListLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
label,
|
||||
meta,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
meta?: string;
|
||||
selected?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent",
|
||||
selected && "bg-accent/60",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{meta ? (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{meta}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HubModelPicker({
|
||||
models,
|
||||
value,
|
||||
onSelect,
|
||||
}: {
|
||||
models: ModelOption[];
|
||||
value?: string;
|
||||
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebouncedValue(query);
|
||||
const { results, isLoading, isLoadingMore, fetchMore } = useHfModelSearch(
|
||||
debouncedQuery,
|
||||
);
|
||||
|
||||
const recommendedIds = useMemo(
|
||||
() => dedupe([...models.map((model) => model.id), value ?? ""]),
|
||||
[models, value],
|
||||
);
|
||||
|
||||
const showHfSection = debouncedQuery.trim().length > 0;
|
||||
const recommendedSet = useMemo(
|
||||
() => new Set(recommendedIds),
|
||||
[recommendedIds],
|
||||
);
|
||||
|
||||
const hfIds = useMemo(() => {
|
||||
if (!showHfSection) {
|
||||
return [];
|
||||
}
|
||||
return results
|
||||
.map((result) => result.id)
|
||||
.filter((id) => !recommendedSet.has(id));
|
||||
}, [recommendedSet, results, showHfSection]);
|
||||
|
||||
const metricsById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
results.map((result) => [
|
||||
result.id,
|
||||
result.totalParams
|
||||
? formatCompact(result.totalParams)
|
||||
: `↓${formatCompact(result.downloads)}`,
|
||||
]),
|
||||
),
|
||||
[results],
|
||||
);
|
||||
|
||||
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore, results.length);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<HugeiconsIcon
|
||||
icon={Search01Icon}
|
||||
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search Hugging Face models"
|
||||
className="h-9 pl-8 pr-8"
|
||||
/>
|
||||
{isLoading && (
|
||||
<Spinner className="pointer-events-none absolute right-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-64 overflow-y-auto"
|
||||
>
|
||||
<div className="p-1">
|
||||
{!showHfSection ? (
|
||||
<>
|
||||
<ListLabel>Recommended</ListLabel>
|
||||
{recommendedIds.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-xs text-muted-foreground">
|
||||
No default models.
|
||||
</div>
|
||||
) : (
|
||||
recommendedIds.map((id) => (
|
||||
<ModelRow
|
||||
key={id}
|
||||
label={id}
|
||||
selected={value === id}
|
||||
onClick={() => onSelect(id, { source: "hub", isLora: false })}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{showHfSection ? (
|
||||
<>
|
||||
<ListLabel>Hugging Face</ListLabel>
|
||||
{hfIds.length === 0 && !isLoading ? (
|
||||
<div className="px-2.5 py-2 text-xs text-muted-foreground">
|
||||
No matching models.
|
||||
</div>
|
||||
) : (
|
||||
hfIds.map((id) => (
|
||||
<ModelRow
|
||||
key={id}
|
||||
label={id}
|
||||
meta={metricsById.get(id)}
|
||||
selected={value === id}
|
||||
onClick={() => onSelect(id, { source: "hub", isLora: false })}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div ref={sentinelRef} className="h-px" />
|
||||
{isLoadingMore ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Spinner className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoraModelPicker({
|
||||
loraModels,
|
||||
value,
|
||||
onSelect,
|
||||
}: {
|
||||
loraModels: LoraModelOption[];
|
||||
value?: string;
|
||||
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const normalized = useMemo(
|
||||
() =>
|
||||
loraModels
|
||||
.map((model) => ({
|
||||
...model,
|
||||
baseModel: model.baseModel || model.description || "Unknown base model",
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const aTime = a.updatedAt ?? -1;
|
||||
const bTime = b.updatedAt ?? -1;
|
||||
if (aTime !== bTime) {
|
||||
return bTime - aTime;
|
||||
}
|
||||
const baseCmp = a.baseModel.localeCompare(b.baseModel);
|
||||
if (baseCmp !== 0) {
|
||||
return baseCmp;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
}),
|
||||
[loraModels],
|
||||
);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const out = new Map<string, LoraModelOption[]>();
|
||||
|
||||
for (const model of normalized) {
|
||||
const searchText = `${model.name} ${model.baseModel} ${model.id}`.toLowerCase();
|
||||
if (needle && !searchText.includes(needle)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = model.baseModel || "Unknown base model";
|
||||
const prev = out.get(key) ?? [];
|
||||
prev.push(model);
|
||||
out.set(key, prev);
|
||||
}
|
||||
|
||||
return [...out.entries()].sort((a, b) => {
|
||||
const aLatest = Math.max(...a[1].map((model) => model.updatedAt ?? -1));
|
||||
const bLatest = Math.max(...b[1].map((model) => model.updatedAt ?? -1));
|
||||
if (aLatest !== bLatest) {
|
||||
return bLatest - aLatest;
|
||||
}
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
}, [normalized, query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<HugeiconsIcon
|
||||
icon={Search01Icon}
|
||||
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search local adapters"
|
||||
className="h-9 pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
<div className="p-1">
|
||||
{grouped.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-xs text-muted-foreground">No adapters found.</div>
|
||||
) : (
|
||||
grouped.map(([baseModel, adapters], index) => (
|
||||
<div key={baseModel}>
|
||||
{index > 0 ? <div className="my-1" /> : null}
|
||||
<ListLabel>{baseModel}</ListLabel>
|
||||
{adapters.map((adapter) => (
|
||||
<ModelRow
|
||||
key={adapter.id}
|
||||
label={adapter.name}
|
||||
meta="LoRA"
|
||||
selected={value === adapter.id}
|
||||
onClick={() => onSelect(adapter.id, { source: "lora", isLora: true })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelSelectorContent({
|
||||
models,
|
||||
loraModels,
|
||||
|
|
@ -368,6 +96,7 @@ function ModelSelectorContent({
|
|||
onSelect,
|
||||
onEject,
|
||||
className,
|
||||
dataTour,
|
||||
}: {
|
||||
models: ModelOption[];
|
||||
loraModels: LoraModelOption[];
|
||||
|
|
@ -375,12 +104,14 @@ function ModelSelectorContent({
|
|||
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
|
||||
onEject?: () => void;
|
||||
className?: string;
|
||||
dataTour?: string;
|
||||
}) {
|
||||
const hasSelection = Boolean(value);
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
align="start"
|
||||
data-tour={dataTour}
|
||||
className={cn("w-[440px] min-w-[440px] gap-0 p-2", className)}
|
||||
>
|
||||
<Tabs defaultValue="hub" className="w-full">
|
||||
|
|
@ -430,8 +161,14 @@ export function ModelSelector({
|
|||
size = "default",
|
||||
className,
|
||||
contentClassName,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
triggerDataTour,
|
||||
contentDataTour,
|
||||
}: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
||||
const open = controlledOpen ?? uncontrolledOpen;
|
||||
const setOpen = onOpenChange ?? setUncontrolledOpen;
|
||||
const [uncontrolled, setUncontrolled] = useState(defaultValue ?? "");
|
||||
|
||||
const selected = value ?? uncontrolled;
|
||||
|
|
@ -477,6 +214,7 @@ export function ModelSelector({
|
|||
variant={variant}
|
||||
size={size}
|
||||
className={className}
|
||||
dataTour={triggerDataTour}
|
||||
/>
|
||||
<ModelSelectorContent
|
||||
models={models}
|
||||
|
|
@ -485,6 +223,7 @@ export function ModelSelector({
|
|||
onSelect={handleSelect}
|
||||
onEject={onEject ? handleEject : undefined}
|
||||
className={contentClassName}
|
||||
dataTour={contentDataTour}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks";
|
||||
import { cn, formatCompact } from "@/lib/utils";
|
||||
import { Search01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import type {
|
||||
LoraModelOption,
|
||||
ModelOption,
|
||||
ModelSelectorChangeMeta,
|
||||
} from "./types";
|
||||
|
||||
function dedupe(values: string[]): string[] {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
function ListLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
label,
|
||||
meta,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
meta?: string;
|
||||
selected?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent",
|
||||
selected && "bg-accent/60",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{meta ? (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{meta}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function HubModelPicker({
|
||||
models,
|
||||
value,
|
||||
onSelect,
|
||||
}: {
|
||||
models: ModelOption[];
|
||||
value?: string;
|
||||
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebouncedValue(query);
|
||||
const { results, isLoading, isLoadingMore, fetchMore } = useHfModelSearch(
|
||||
debouncedQuery,
|
||||
);
|
||||
|
||||
const recommendedIds = useMemo(
|
||||
() => dedupe([...models.map((model) => model.id), value ?? ""]),
|
||||
[models, value],
|
||||
);
|
||||
|
||||
const showHfSection = debouncedQuery.trim().length > 0;
|
||||
const recommendedSet = useMemo(() => new Set(recommendedIds), [recommendedIds]);
|
||||
|
||||
const hfIds = useMemo(() => {
|
||||
if (!showHfSection) return [];
|
||||
return results
|
||||
.map((result) => result.id)
|
||||
.filter((id) => !recommendedSet.has(id));
|
||||
}, [recommendedSet, results, showHfSection]);
|
||||
|
||||
const metricsById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
results.map((result) => [
|
||||
result.id,
|
||||
result.totalParams
|
||||
? formatCompact(result.totalParams)
|
||||
: `↓${formatCompact(result.downloads)}`,
|
||||
]),
|
||||
),
|
||||
[results],
|
||||
);
|
||||
|
||||
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore, results.length);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<HugeiconsIcon
|
||||
icon={Search01Icon}
|
||||
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search Hugging Face models"
|
||||
className="h-9 pl-8 pr-8"
|
||||
/>
|
||||
{isLoading && (
|
||||
<Spinner className="pointer-events-none absolute right-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="max-h-64 overflow-y-auto">
|
||||
<div className="p-1">
|
||||
{!showHfSection ? (
|
||||
<>
|
||||
<ListLabel>Recommended</ListLabel>
|
||||
{recommendedIds.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-xs text-muted-foreground">
|
||||
No default models.
|
||||
</div>
|
||||
) : (
|
||||
recommendedIds.map((id) => (
|
||||
<ModelRow
|
||||
key={id}
|
||||
label={id}
|
||||
selected={value === id}
|
||||
onClick={() => onSelect(id, { source: "hub", isLora: false })}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{showHfSection ? (
|
||||
<>
|
||||
<ListLabel>Hugging Face</ListLabel>
|
||||
{hfIds.length === 0 && !isLoading ? (
|
||||
<div className="px-2.5 py-2 text-xs text-muted-foreground">
|
||||
No matching models.
|
||||
</div>
|
||||
) : (
|
||||
hfIds.map((id) => (
|
||||
<ModelRow
|
||||
key={id}
|
||||
label={id}
|
||||
meta={metricsById.get(id)}
|
||||
selected={value === id}
|
||||
onClick={() => onSelect(id, { source: "hub", isLora: false })}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div ref={sentinelRef} className="h-px" />
|
||||
{isLoadingMore ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Spinner className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoraModelPicker({
|
||||
loraModels,
|
||||
value,
|
||||
onSelect,
|
||||
}: {
|
||||
loraModels: LoraModelOption[];
|
||||
value?: string;
|
||||
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const normalized = useMemo(
|
||||
() =>
|
||||
loraModels
|
||||
.map((model) => ({
|
||||
...model,
|
||||
baseModel: model.baseModel || model.description || "Unknown base model",
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const aTime = a.updatedAt ?? -1;
|
||||
const bTime = b.updatedAt ?? -1;
|
||||
if (aTime !== bTime) return bTime - aTime;
|
||||
const baseCmp = a.baseModel.localeCompare(b.baseModel);
|
||||
if (baseCmp !== 0) return baseCmp;
|
||||
return a.name.localeCompare(b.name);
|
||||
}),
|
||||
[loraModels],
|
||||
);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const out = new Map<string, LoraModelOption[]>();
|
||||
|
||||
for (const model of normalized) {
|
||||
const searchText = `${model.name} ${model.baseModel} ${model.id}`.toLowerCase();
|
||||
if (needle && !searchText.includes(needle)) continue;
|
||||
|
||||
const key = model.baseModel || "Unknown base model";
|
||||
const prev = out.get(key) ?? [];
|
||||
prev.push(model);
|
||||
out.set(key, prev);
|
||||
}
|
||||
|
||||
return [...out.entries()].sort((a, b) => {
|
||||
const aLatest = Math.max(...a[1].map((model) => model.updatedAt ?? -1));
|
||||
const bLatest = Math.max(...b[1].map((model) => model.updatedAt ?? -1));
|
||||
if (aLatest !== bLatest) return bLatest - aLatest;
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
}, [normalized, query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<HugeiconsIcon
|
||||
icon={Search01Icon}
|
||||
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search local adapters"
|
||||
className="h-9 pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
<div className="p-1">
|
||||
{grouped.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-xs text-muted-foreground">
|
||||
No adapters found.
|
||||
</div>
|
||||
) : (
|
||||
grouped.map(([baseModel, adapters], index) => (
|
||||
<div key={baseModel}>
|
||||
{index > 0 ? <div className="my-1" /> : null}
|
||||
<ListLabel>{baseModel}</ListLabel>
|
||||
{adapters.map((adapter) => (
|
||||
<ModelRow
|
||||
key={adapter.id}
|
||||
label={adapter.name}
|
||||
meta="LoRA"
|
||||
selected={value === adapter.id}
|
||||
onClick={() => onSelect(adapter.id, { source: "lora", isLora: true })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ModelOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export interface LoraModelOption extends ModelOption {
|
||||
baseModel?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface ModelSelectorChangeMeta {
|
||||
source: "hub" | "lora";
|
||||
isLora: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Analytics01Icon,
|
||||
ArrowRight01Icon,
|
||||
Book03Icon,
|
||||
InformationCircleIcon,
|
||||
PackageIcon,
|
||||
ZapIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
|
|
@ -16,6 +17,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
|||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { TOUR_OPEN_EVENT } from "@/features/tour";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: "Studio", href: "/studio", icon: ZapIcon, enabled: true },
|
||||
|
|
@ -28,6 +30,15 @@ export function Navbar() {
|
|||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const [logoHovered, setLogoHovered] = useState(false);
|
||||
|
||||
const tourId =
|
||||
pathname === "/studio"
|
||||
? "studio"
|
||||
: pathname === "/chat"
|
||||
? "chat"
|
||||
: pathname === "/export"
|
||||
? "export"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<header className="top-0 z-40 h-16 w-full">
|
||||
<div className="mx-auto flex h-full max-w-7xl items-center justify-between px-6">
|
||||
|
|
@ -128,39 +139,56 @@ export function Navbar() {
|
|||
</nav>
|
||||
|
||||
{/* Right: docs link */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 hover:text-emerald-700 transition-colors"
|
||||
<div className="flex items-center gap-2">
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 hover:text-emerald-700 transition-colors"
|
||||
>
|
||||
<HugeiconsIcon icon={Book03Icon} className="size-4" />
|
||||
Learn more
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="w-80 p-0">
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group/card flex flex-col gap-1 p-4 no-underline"
|
||||
>
|
||||
<p className="text-sm font-semibold font-heading">
|
||||
Unsloth Documentation
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Guides on fine-tuning LLMs 2x faster with 70% less memory.
|
||||
Covers LoRA, QLoRA, data formatting, and deployment.
|
||||
</p>
|
||||
<span className="mt-1 flex items-center gap-1 text-xs font-medium text-emerald-600 group-hover/card:underline">
|
||||
Visit docs
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
|
||||
</span>
|
||||
</a>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
{tourId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(TOUR_OPEN_EVENT, { detail: { id: tourId } }),
|
||||
);
|
||||
}}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Tour"
|
||||
>
|
||||
<HugeiconsIcon icon={Book03Icon} className="size-4" />
|
||||
Learn more
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="w-80 p-0">
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group/card flex flex-col gap-1 p-4 no-underline"
|
||||
>
|
||||
<p className="text-sm font-semibold font-heading">
|
||||
Unsloth Documentation
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Guides on fine-tuning LLMs 2x faster with 70% less memory.
|
||||
Covers LoRA, QLoRA, data formatting, and deployment.
|
||||
</p>
|
||||
<span className="mt-1 flex items-center gap-1 text-xs font-medium text-emerald-600 group-hover/card:underline">
|
||||
Visit docs
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
|
||||
</span>
|
||||
</a>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<HugeiconsIcon icon={InformationCircleIcon} className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,16 +5,8 @@ import {
|
|||
} from "@/components/assistant-ui/model-selector";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { SidebarProvider, SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ColumnInsertIcon,
|
||||
|
|
@ -33,6 +25,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { ChatSettingsPanel } from "./chat-settings-sheet";
|
||||
import { db } from "./db";
|
||||
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
|
||||
|
|
@ -46,6 +39,7 @@ import {
|
|||
} from "./shared-composer";
|
||||
import { ThreadSidebar } from "./thread-sidebar";
|
||||
import type { ChatView } from "./types";
|
||||
import { buildChatTourSteps } from "./tour";
|
||||
|
||||
const SingleContent = memo(function SingleContent({
|
||||
threadId,
|
||||
|
|
@ -92,7 +86,7 @@ const CompareContent = memo(function CompareContent({
|
|||
return (
|
||||
<CompareHandlesProvider handlesRef={handlesRef}>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 px-0">
|
||||
<div data-tour="chat-compare-view" className="grid min-h-0 flex-1 grid-cols-2 px-0">
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="px-3 py-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
|
|
@ -210,6 +204,8 @@ export function ChatPage(): ReactElement {
|
|||
newThreadNonce: crypto.randomUUID(),
|
||||
});
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||
const viewBeforeCompareRef = useRef<ChatView | null>(null);
|
||||
const inferenceParams = useChatRuntimeStore((state) => state.params);
|
||||
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
|
||||
const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
|
||||
|
|
@ -242,6 +238,25 @@ export function ChatPage(): ReactElement {
|
|||
[],
|
||||
);
|
||||
|
||||
const openModelSelector = useCallback(() => setModelSelectorOpen(true), []);
|
||||
const closeModelSelector = useCallback(() => setModelSelectorOpen(false), []);
|
||||
const openSettings = useCallback(() => setSettingsOpen(true), []);
|
||||
const closeSettings = useCallback(() => setSettingsOpen(false), []);
|
||||
|
||||
const enterCompare = useCallback(() => {
|
||||
if (viewBeforeCompareRef.current == null) {
|
||||
viewBeforeCompareRef.current = view;
|
||||
}
|
||||
setView({ mode: "compare", pairId: crypto.randomUUID() });
|
||||
}, [view]);
|
||||
|
||||
const exitCompare = useCallback(() => {
|
||||
const prev = viewBeforeCompareRef.current;
|
||||
if (!prev) return;
|
||||
viewBeforeCompareRef.current = null;
|
||||
setView(prev);
|
||||
}, []);
|
||||
|
||||
const models = useMemo<ModelOption[]>(
|
||||
() =>
|
||||
modelsFromStore.map((model) => ({
|
||||
|
|
@ -267,8 +282,36 @@ export function ChatPage(): ReactElement {
|
|||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const tourSteps = useMemo(
|
||||
() =>
|
||||
buildChatTourSteps({
|
||||
canCompare,
|
||||
openModelSelector,
|
||||
closeModelSelector,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
enterCompare,
|
||||
exitCompare,
|
||||
}),
|
||||
[
|
||||
canCompare,
|
||||
closeModelSelector,
|
||||
closeSettings,
|
||||
enterCompare,
|
||||
exitCompare,
|
||||
openModelSelector,
|
||||
openSettings,
|
||||
],
|
||||
);
|
||||
|
||||
const tour = useGuidedTourController({
|
||||
id: "chat",
|
||||
steps: tourSteps,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] bg-background overflow-hidden">
|
||||
<GuidedTour {...tour.tourProps} />
|
||||
<SidebarProvider
|
||||
defaultOpen={true}
|
||||
className="!min-h-0 h-full max-w-7xl mx-auto px-4"
|
||||
|
|
@ -305,6 +348,10 @@ export function ChatPage(): ReactElement {
|
|||
onValueChange={handleCheckpointChange}
|
||||
onEject={handleEject}
|
||||
variant="ghost"
|
||||
open={modelSelectorOpen}
|
||||
onOpenChange={setModelSelectorOpen}
|
||||
triggerDataTour="chat-model-selector"
|
||||
contentDataTour="chat-model-selector-popover"
|
||||
/>
|
||||
</div>
|
||||
{modelsError && (
|
||||
|
|
@ -318,6 +365,7 @@ export function ChatPage(): ReactElement {
|
|||
onClick={() => setSettingsOpen((o) => !o)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Inference settings"
|
||||
data-tour="chat-settings"
|
||||
>
|
||||
<HugeiconsIcon icon={Settings04Icon} className="size-5" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export function ThreadSidebar({
|
|||
</SidebarMenuItem>
|
||||
{showCompare ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={onNewCompare}>
|
||||
<SidebarMenuButton data-tour="chat-compare" onClick={onNewCompare}>
|
||||
<HugeiconsIcon icon={ColumnInsertIcon} />
|
||||
<span>Compare</span>
|
||||
</SidebarMenuButton>
|
||||
|
|
|
|||
2
studio/frontend/src/features/chat/tour/index.ts
Normal file
2
studio/frontend/src/features/chat/tour/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { buildChatTourSteps } from "./steps";
|
||||
|
||||
66
studio/frontend/src/features/chat/tour/steps.tsx
Normal file
66
studio/frontend/src/features/chat/tour/steps.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { TourStep } from "@/features/tour";
|
||||
|
||||
export function buildChatTourSteps({
|
||||
canCompare,
|
||||
openModelSelector,
|
||||
closeModelSelector,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
enterCompare,
|
||||
exitCompare,
|
||||
}: {
|
||||
canCompare: boolean;
|
||||
openModelSelector: () => void;
|
||||
closeModelSelector: () => void;
|
||||
openSettings: () => void;
|
||||
closeSettings: () => void;
|
||||
enterCompare: () => void;
|
||||
exitCompare: () => void;
|
||||
}): TourStep[] {
|
||||
const steps: TourStep[] = [
|
||||
{
|
||||
id: "model",
|
||||
target: "chat-model-selector",
|
||||
title: "Pick a model",
|
||||
body: <>Hub models vs fine-tuned adapters live here.</>,
|
||||
},
|
||||
{
|
||||
id: "model-tabs",
|
||||
target: "chat-model-selector-popover",
|
||||
title: "Two tabs",
|
||||
body: <>Hub: search HF. Fine-tuned: your local LoRA adapters.</>,
|
||||
onEnter: openModelSelector,
|
||||
onExit: closeModelSelector,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
target: "chat-settings",
|
||||
title: "Settings sidebar",
|
||||
body: <>Sampling + system prompt live in the right sidebar.</>,
|
||||
onEnter: openSettings,
|
||||
onExit: closeSettings,
|
||||
},
|
||||
];
|
||||
|
||||
if (canCompare) {
|
||||
steps.push(
|
||||
{
|
||||
id: "compare-btn",
|
||||
target: "chat-compare",
|
||||
title: "Compare mode",
|
||||
body: <>When a LoRA is selected, you can compare base vs fine-tuned.</>,
|
||||
},
|
||||
{
|
||||
id: "compare-view",
|
||||
target: "chat-compare-view",
|
||||
title: "Side-by-side threads",
|
||||
body: <>Same prompt, 2 threads. Compose at bottom.</>,
|
||||
onEnter: enterCompare,
|
||||
onExit: exitCompare,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ interface MethodPickerProps {
|
|||
|
||||
export function MethodPicker({ value, onChange }: MethodPickerProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div data-tour="export-method" className="flex flex-col gap-3">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Export Method
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ import {
|
|||
METHOD_LABELS,
|
||||
getEstimatedSize,
|
||||
} from "./constants";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { exportTourSteps } from "./tour";
|
||||
|
||||
export function ExportPage() {
|
||||
const {
|
||||
|
|
@ -89,6 +91,11 @@ export function ExportPage() {
|
|||
const [modelName, setModelName] = useState("");
|
||||
const [privateRepo, setPrivateRepo] = useState(false);
|
||||
|
||||
const tour = useGuidedTourController({
|
||||
id: "export",
|
||||
steps: exportTourSteps,
|
||||
});
|
||||
|
||||
const handleMethodChange = (method: ExportMethod) => {
|
||||
setExportMethod(method);
|
||||
if (method !== "gguf") {
|
||||
|
|
@ -106,6 +113,8 @@ export function ExportPage() {
|
|||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<main className="mx-auto max-w-7xl px-6 py-4">
|
||||
<GuidedTour {...tour.tourProps} />
|
||||
|
||||
<div className="mb-8 flex flex-col gap-0.5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Export Model
|
||||
|
|
@ -156,7 +165,7 @@ export function ExportPage() {
|
|||
</Tooltip>
|
||||
</label>
|
||||
<Select value={checkpoint ?? ""} onValueChange={setCheckpoint}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger data-tour="export-checkpoint" className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isAdapter ? "Select a checkpoint…" : "Select model…"
|
||||
|
|
@ -250,7 +259,11 @@ export function ExportPage() {
|
|||
/>
|
||||
<span>Est. size: {estimatedSize} · Free disk space: 120 GB</span>
|
||||
</div>
|
||||
<Button disabled={!canExport} onClick={() => setDialogOpen(true)}>
|
||||
<Button
|
||||
data-tour="export-cta"
|
||||
disabled={!canExport}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
Export Model
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
2
studio/frontend/src/features/export/tour/index.ts
Normal file
2
studio/frontend/src/features/export/tour/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { exportTourSteps } from "./steps";
|
||||
|
||||
23
studio/frontend/src/features/export/tour/steps.tsx
Normal file
23
studio/frontend/src/features/export/tour/steps.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { TourStep } from "@/features/tour";
|
||||
|
||||
export const exportTourSteps: TourStep[] = [
|
||||
{
|
||||
id: "checkpoint",
|
||||
target: "export-checkpoint",
|
||||
title: "Pick checkpoint",
|
||||
body: <>Choose which checkpoint (or final model) to export.</>,
|
||||
},
|
||||
{
|
||||
id: "method",
|
||||
target: "export-method",
|
||||
title: "Export method",
|
||||
body: <>Select GGUF vs safetensors, then quant if needed.</>,
|
||||
},
|
||||
{
|
||||
id: "cta",
|
||||
target: "export-cta",
|
||||
title: "Export",
|
||||
body: <>When ready, export to local or HF Hub.</>,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { TrainingPhase } from "@/features/training";
|
||||
|
||||
export const phaseLabel: Record<TrainingPhase, string> = {
|
||||
idle: "Idle",
|
||||
loading_model: "Loading model",
|
||||
loading_dataset: "Loading dataset",
|
||||
configuring: "Configuring",
|
||||
training: "Training",
|
||||
completed: "Completed",
|
||||
error: "Error",
|
||||
stopped: "Stopped",
|
||||
};
|
||||
|
||||
export const phaseColors: Record<TrainingPhase, string> = {
|
||||
idle: "bg-muted text-muted-foreground",
|
||||
loading_model:
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
loading_dataset:
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
configuring: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
||||
training:
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
||||
completed:
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
||||
error: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
stopped: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
export function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null || seconds < 0) return "--";
|
||||
const total = Math.floor(seconds);
|
||||
const min = Math.floor(total / 60);
|
||||
const sec = total % 60;
|
||||
return `${min}m ${sec}s`;
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | null | undefined, digits: number): string {
|
||||
if (value == null || !Number.isFinite(value)) return "--";
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
|
||||
|
|
@ -19,7 +19,6 @@ import {
|
|||
useTrainingConfigStore,
|
||||
useTrainingActions,
|
||||
useTrainingRuntimeStore,
|
||||
type TrainingPhase,
|
||||
} from "@/features/training";
|
||||
import {
|
||||
ChartAverageIcon,
|
||||
|
|
@ -33,48 +32,7 @@ import {
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
const phaseLabel: Record<TrainingPhase, string> = {
|
||||
idle: "Idle",
|
||||
loading_model: "Loading model",
|
||||
loading_dataset: "Loading dataset",
|
||||
configuring: "Configuring",
|
||||
training: "Training",
|
||||
completed: "Completed",
|
||||
error: "Error",
|
||||
stopped: "Stopped",
|
||||
};
|
||||
|
||||
const phaseColors: Record<TrainingPhase, string> = {
|
||||
idle: "bg-muted text-muted-foreground",
|
||||
loading_model: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
loading_dataset:
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
configuring: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
||||
training:
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
||||
completed:
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
||||
error: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
stopped: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null || seconds < 0) {
|
||||
return "--";
|
||||
}
|
||||
const total = Math.floor(seconds);
|
||||
const min = Math.floor(total / 60);
|
||||
const sec = total % 60;
|
||||
return `${min}m ${sec}s`;
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined, digits: number): string {
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
return "--";
|
||||
}
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
import { formatDuration, formatNumber, phaseColors, phaseLabel } from "./progress-section-lib";
|
||||
|
||||
export function ProgressSection(): ReactElement {
|
||||
const runtime = useTrainingRuntimeStore(
|
||||
|
|
@ -238,6 +196,7 @@ export function ProgressSection(): ReactElement {
|
|||
</Popover>
|
||||
<AlertDialog open={stopDialogOpen} onOpenChange={setStopDialogOpen}>
|
||||
<Button
|
||||
data-tour="studio-training-stop"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 cursor-pointer px-3 text-xs"
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import {
|
|||
useTrainingRuntimeLifecycle,
|
||||
useTrainingRuntimeStore,
|
||||
} from "@/features/training";
|
||||
import { GuidedTour } from "@/features/tour";
|
||||
import { studioTourSteps } from "@/features/studio/tour";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { studioTourSteps, studioTrainingTourSteps } from "@/features/studio/tour";
|
||||
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import { type ReactElement, useEffect } from "react";
|
||||
import { DatasetSection } from "./sections/dataset-section";
|
||||
import { ModelSection } from "./sections/model-section";
|
||||
import { ParamsSection } from "./sections/params-section";
|
||||
|
|
@ -28,25 +28,25 @@ export function StudioPage(): ReactElement {
|
|||
const { dismissTrainingRun } = useTrainingActions();
|
||||
|
||||
const canGoBack = runtimePhase === "stopped" || runtimePhase === "error";
|
||||
const tourEnabled = hasHydratedRuntime && !isHydratingRuntime && !showTrainingView;
|
||||
const [tourOpen, setTourOpen] = useState(false);
|
||||
const tourEnabled = hasHydratedRuntime && !isHydratingRuntime;
|
||||
const isConfigTour = !showTrainingView;
|
||||
const tourSteps = showTrainingView ? studioTrainingTourSteps : studioTourSteps;
|
||||
const tour = useGuidedTourController({
|
||||
id: "studio",
|
||||
steps: tourSteps,
|
||||
enabled: tourEnabled,
|
||||
autoKey: isConfigTour ? STUDIO_TOUR_KEY : undefined,
|
||||
autoWhen: isConfigTour,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!tourEnabled) return;
|
||||
if (localStorage.getItem(STUDIO_TOUR_KEY)) return;
|
||||
setTourOpen(true);
|
||||
}, [tourEnabled]);
|
||||
tour.setOpen(false);
|
||||
}, [showTrainingView, tour.setOpen]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<main className="mx-auto max-w-7xl px-6 py-4">
|
||||
<GuidedTour
|
||||
open={tourOpen}
|
||||
onOpenChange={setTourOpen}
|
||||
steps={studioTourSteps}
|
||||
onSkip={() => localStorage.setItem(STUDIO_TOUR_KEY, "skipped")}
|
||||
onComplete={() => localStorage.setItem(STUDIO_TOUR_KEY, "done")}
|
||||
/>
|
||||
<GuidedTour {...tour.tourProps} />
|
||||
|
||||
{canGoBack && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { studioTourSteps } from "./steps";
|
||||
|
||||
export { studioTrainingTourSteps } from "./training";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export { studioTrainingTourSteps } from "./steps";
|
||||
|
||||
23
studio/frontend/src/features/studio/tour/training/steps.tsx
Normal file
23
studio/frontend/src/features/studio/tour/training/steps.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { TourStep } from "@/features/tour";
|
||||
|
||||
export const studioTrainingTourSteps: TourStep[] = [
|
||||
{
|
||||
id: "nav",
|
||||
target: "navbar",
|
||||
title: "Training view",
|
||||
body: <>Live run status + metrics. You can stop anytime.</>,
|
||||
},
|
||||
{
|
||||
id: "progress",
|
||||
target: "studio-training-progress",
|
||||
title: "Progress + ETA",
|
||||
body: <>Phase, steps, loss, speed, ETA. This card updates live.</>,
|
||||
},
|
||||
{
|
||||
id: "stop",
|
||||
target: "studio-training-stop",
|
||||
title: "Stop / save",
|
||||
body: <>Stop training, optionally save adapters/checkpoints.</>,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -33,7 +33,9 @@ export function TrainingView(): ReactElement {
|
|||
<div
|
||||
className={cn("flex flex-col gap-6 transition-[filter]", showOverlay && "blur")}
|
||||
>
|
||||
<ProgressSection />
|
||||
<div data-tour="studio-training-progress">
|
||||
<ProgressSection />
|
||||
</div>
|
||||
<ChartsSection />
|
||||
</div>
|
||||
{showOverlay ? (
|
||||
|
|
|
|||
|
|
@ -20,64 +20,9 @@ import {
|
|||
import { cssEscape, toRect } from "../lib/dom";
|
||||
import { fireConfettiFireworks } from "../lib/confetti-fireworks";
|
||||
import { computeCardPos, padded, pickPlacement } from "../lib/layout";
|
||||
import { SpotlightOverlay } from "./spotlight-overlay";
|
||||
import type { Placement, Rect, TourStep } from "../types";
|
||||
|
||||
// (types + layout/dom helpers live in ../types and ../lib)
|
||||
|
||||
type SpotlightOverlayProps = {
|
||||
rect: Rect | null;
|
||||
vw: number;
|
||||
vh: number;
|
||||
maskId: string;
|
||||
};
|
||||
|
||||
function SpotlightOverlay({
|
||||
rect,
|
||||
vw,
|
||||
vh,
|
||||
maskId,
|
||||
}: SpotlightOverlayProps) {
|
||||
const hole = rect ?? { x: vw / 2 - 140, y: vh / 2 - 90, w: 280, h: 180 };
|
||||
const r = 22;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="absolute inset-0 size-full"
|
||||
viewBox={`0 0 ${vw} ${vh}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={`${maskId}-v`} cx="50%" cy="45%" r="80%">
|
||||
<stop offset="0%" stopColor="rgba(6, 9, 15, 0.35)" />
|
||||
<stop offset="55%" stopColor="rgba(6, 9, 15, 0.65)" />
|
||||
<stop offset="100%" stopColor="rgba(6, 9, 15, 0.88)" />
|
||||
</radialGradient>
|
||||
<mask id={maskId}>
|
||||
<rect x="0" y="0" width={vw} height={vh} fill="white" />
|
||||
<motion.rect
|
||||
x={hole.x}
|
||||
y={hole.y}
|
||||
width={hole.w}
|
||||
height={hole.h}
|
||||
rx={r}
|
||||
fill="black"
|
||||
transition={{ type: "spring", stiffness: 260, damping: 30 }}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width={vw}
|
||||
height={vh}
|
||||
fill={`url(#${maskId}-v)`}
|
||||
mask={`url(#${maskId})`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type GuidedTourProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
|
@ -107,6 +52,7 @@ export function GuidedTour({
|
|||
const closeLockRef = useRef(false);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastRectRef = useRef<Rect | null>(null);
|
||||
const activeStepRef = useRef<TourStep | null>(null);
|
||||
|
||||
const step = steps[idx] ?? null;
|
||||
const total = steps.length;
|
||||
|
|
@ -118,6 +64,27 @@ export function GuidedTour({
|
|||
return padded(targetRect, pad, vw, vh);
|
||||
}, [step?.target, targetRect, vw, vh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = activeStepRef.current;
|
||||
if (prev && prev.id !== step?.id) {
|
||||
void prev.onExit?.();
|
||||
}
|
||||
activeStepRef.current = step;
|
||||
if (step) {
|
||||
void step.onEnter?.();
|
||||
}
|
||||
}, [open, step?.id]); // run before target lookup effect below
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return;
|
||||
const prev = activeStepRef.current;
|
||||
activeStepRef.current = null;
|
||||
if (prev) {
|
||||
void prev.onExit?.();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setIdx(0);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { motion } from "motion/react";
|
||||
import type { Rect } from "../types";
|
||||
|
||||
type SpotlightOverlayProps = {
|
||||
rect: Rect | null;
|
||||
vw: number;
|
||||
vh: number;
|
||||
maskId: string;
|
||||
};
|
||||
|
||||
export function SpotlightOverlay({ rect, vw, vh, maskId }: SpotlightOverlayProps) {
|
||||
const hole = rect ?? { x: vw / 2 - 140, y: vh / 2 - 90, w: 280, h: 180 };
|
||||
const r = 22;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="absolute inset-0 size-full"
|
||||
viewBox={`0 0 ${vw} ${vh}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={`${maskId}-v`} cx="50%" cy="45%" r="80%">
|
||||
<stop offset="0%" stopColor="rgba(6, 9, 15, 0.35)" />
|
||||
<stop offset="55%" stopColor="rgba(6, 9, 15, 0.65)" />
|
||||
<stop offset="100%" stopColor="rgba(6, 9, 15, 0.88)" />
|
||||
</radialGradient>
|
||||
<mask id={maskId}>
|
||||
<rect x="0" y="0" width={vw} height={vh} fill="white" />
|
||||
<motion.rect
|
||||
x={hole.x}
|
||||
y={hole.y}
|
||||
width={hole.w}
|
||||
height={hole.h}
|
||||
rx={r}
|
||||
fill="black"
|
||||
transition={{ type: "spring", stiffness: 260, damping: 30 }}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width={vw}
|
||||
height={vh}
|
||||
fill={`url(#${maskId}-v)`}
|
||||
mask={`url(#${maskId})`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { TourStep } from "../types";
|
||||
|
||||
export const TOUR_OPEN_EVENT = "omx:tour:open";
|
||||
|
||||
export type TourOpenDetail = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export function useGuidedTourController({
|
||||
id,
|
||||
steps,
|
||||
enabled = true,
|
||||
autoKey,
|
||||
autoWhen = false,
|
||||
}: {
|
||||
id: string;
|
||||
steps: TourStep[];
|
||||
enabled?: boolean;
|
||||
autoKey?: string;
|
||||
autoWhen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hasRuntime, setHasRuntime] = useState(false);
|
||||
|
||||
useEffect(() => setHasRuntime(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRuntime || !enabled) return;
|
||||
if (!autoKey || !autoWhen) return;
|
||||
if (steps.length === 0) return;
|
||||
if (localStorage.getItem(autoKey)) return;
|
||||
setOpen(true);
|
||||
}, [autoKey, autoWhen, enabled, hasRuntime, steps.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRuntime || !enabled) return;
|
||||
function onOpen(e: Event) {
|
||||
const ce = e as CustomEvent<TourOpenDetail>;
|
||||
if (ce.detail?.id && ce.detail.id !== id) return;
|
||||
if (steps.length === 0) return;
|
||||
setOpen(true);
|
||||
}
|
||||
window.addEventListener(TOUR_OPEN_EVENT, onOpen);
|
||||
return () => window.removeEventListener(TOUR_OPEN_EVENT, onOpen);
|
||||
}, [enabled, hasRuntime, id, steps.length]);
|
||||
|
||||
const onSkip = useCallback(() => {
|
||||
if (!autoKey) return;
|
||||
localStorage.setItem(autoKey, "skipped");
|
||||
}, [autoKey]);
|
||||
|
||||
const onComplete = useCallback(() => {
|
||||
if (!autoKey) return;
|
||||
localStorage.setItem(autoKey, "done");
|
||||
}, [autoKey]);
|
||||
|
||||
const tourProps = useMemo(
|
||||
() => ({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
steps,
|
||||
onSkip,
|
||||
onComplete,
|
||||
}),
|
||||
[onComplete, onSkip, open, steps],
|
||||
);
|
||||
|
||||
return { open, setOpen, onSkip, onComplete, tourProps };
|
||||
}
|
||||
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export { GuidedTour } from "./components/guided-tour";
|
||||
export { ReadMore } from "./components/read-more";
|
||||
export { TOUR_OPEN_EVENT, useGuidedTourController } from "./hooks/use-guided-tour-controller";
|
||||
export type { TourStep } from "./types";
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ export type TourStep = {
|
|||
target: string; // data-tour="<target>"
|
||||
title: string;
|
||||
body: ReactNode;
|
||||
onEnter?: () => void | Promise<void>;
|
||||
onExit?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type Rect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
export type Placement = "right" | "left" | "top" | "bottom";
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue