Merge pull request #102 from unslothai/feature/guided-tour-p2
feat: Guided tours p2: per-page + navbar trigger
This commit is contained in:
commit
34483f2325
34 changed files with 969 additions and 525 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -28,6 +28,9 @@ outputs/
|
|||
*.swp
|
||||
*.swo
|
||||
|
||||
# oh-my-codex
|
||||
.omx/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
CursorInfo02Icon,
|
||||
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={CursorInfo02Icon} 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,9 @@ export function ChatPage(): ReactElement {
|
|||
newThreadNonce: crypto.randomUUID(),
|
||||
});
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
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 +239,26 @@ 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 openSidebar = useCallback(() => setSidebarOpen(true), []);
|
||||
|
||||
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,10 +284,42 @@ export function ChatPage(): ReactElement {
|
|||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const tourSteps = useMemo(
|
||||
() =>
|
||||
buildChatTourSteps({
|
||||
canCompare,
|
||||
openModelSelector,
|
||||
closeModelSelector,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
openSidebar,
|
||||
enterCompare,
|
||||
exitCompare,
|
||||
}),
|
||||
[
|
||||
canCompare,
|
||||
closeModelSelector,
|
||||
closeSettings,
|
||||
enterCompare,
|
||||
exitCompare,
|
||||
openModelSelector,
|
||||
openSettings,
|
||||
openSidebar,
|
||||
],
|
||||
);
|
||||
|
||||
const tour = useGuidedTourController({
|
||||
id: "chat",
|
||||
steps: tourSteps,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] bg-background overflow-hidden">
|
||||
<GuidedTour {...tour.tourProps} />
|
||||
<SidebarProvider
|
||||
defaultOpen={true}
|
||||
open={sidebarOpen}
|
||||
onOpenChange={setSidebarOpen}
|
||||
className="!min-h-0 h-full max-w-7xl mx-auto px-4"
|
||||
style={
|
||||
{
|
||||
|
|
@ -305,6 +354,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 +371,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";
|
||||
|
||||
94
studio/frontend/src/features/chat/tour/steps.tsx
Normal file
94
studio/frontend/src/features/chat/tour/steps.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { TourStep } from "@/features/tour";
|
||||
|
||||
export function buildChatTourSteps({
|
||||
canCompare,
|
||||
openModelSelector,
|
||||
closeModelSelector,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
openSidebar,
|
||||
enterCompare,
|
||||
exitCompare,
|
||||
}: {
|
||||
canCompare: boolean;
|
||||
openModelSelector: () => void;
|
||||
closeModelSelector: () => void;
|
||||
openSettings: () => void;
|
||||
closeSettings: () => void;
|
||||
openSidebar: () => void;
|
||||
enterCompare: () => void;
|
||||
exitCompare: () => void;
|
||||
}): TourStep[] {
|
||||
const steps: TourStep[] = [
|
||||
{
|
||||
id: "model",
|
||||
target: "chat-model-selector",
|
||||
title: "Pick a model",
|
||||
body: (
|
||||
<>
|
||||
This selects what’s loaded for inference. Hub = base models. Fine-tuned
|
||||
= your LoRA adapters from Studio.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "model-tabs",
|
||||
target: "chat-model-selector-popover",
|
||||
title: "Two tabs",
|
||||
body: (
|
||||
<>
|
||||
Hub: search Hugging Face models. Fine-tuned: adapters (LoRA) you’ve
|
||||
trained locally. If results look off, compare base vs LoRA to see what
|
||||
changed.
|
||||
</>
|
||||
),
|
||||
onEnter: openModelSelector,
|
||||
onExit: closeModelSelector,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
target: "chat-settings",
|
||||
title: "Settings sidebar",
|
||||
body: (
|
||||
<>
|
||||
Sampling (temperature/top-p/top-k) + system prompt live here. If you
|
||||
want more deterministic outputs, lower temperature first.
|
||||
</>
|
||||
),
|
||||
onEnter: openSettings,
|
||||
onExit: closeSettings,
|
||||
},
|
||||
];
|
||||
|
||||
if (canCompare) {
|
||||
steps.push(
|
||||
{
|
||||
id: "compare-btn",
|
||||
target: "chat-compare",
|
||||
title: "Compare mode",
|
||||
body: (
|
||||
<>
|
||||
When a LoRA is selected, compare base vs fine-tuned side-by-side.
|
||||
This is the fastest way to sanity-check your training.
|
||||
</>
|
||||
),
|
||||
onEnter: openSidebar,
|
||||
},
|
||||
{
|
||||
id: "compare-view",
|
||||
target: "chat-compare-view",
|
||||
title: "Side-by-side threads",
|
||||
body: (
|
||||
<>
|
||||
Same prompt, 2 threads. If LoRA is worse than base, it’s usually
|
||||
data formatting, too many epochs, or a bad checkpoint choice.
|
||||
</>
|
||||
),
|
||||
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";
|
||||
|
||||
38
studio/frontend/src/features/export/tour/steps.tsx
Normal file
38
studio/frontend/src/features/export/tour/steps.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { TourStep } from "@/features/tour";
|
||||
|
||||
export const exportTourSteps: TourStep[] = [
|
||||
{
|
||||
id: "checkpoint",
|
||||
target: "export-checkpoint",
|
||||
title: "Pick checkpoint",
|
||||
body: (
|
||||
<>
|
||||
Pick which checkpoint to export. If you trained multiple checkpoints,
|
||||
it’s worth exporting 1-2 candidates and testing in Chat.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "method",
|
||||
target: "export-method",
|
||||
title: "Export method",
|
||||
body: (
|
||||
<>
|
||||
Choose the packaging. GGUF is for llama.cpp-style runtimes (pick a
|
||||
quant). Safetensors is for HF/Transformers-style usage. If you’re unsure,
|
||||
start with safetensors.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cta",
|
||||
target: "export-cta",
|
||||
title: "Export",
|
||||
body: (
|
||||
<>
|
||||
Export to local or push to HF Hub. After export, test in Chat and compare
|
||||
against base to confirm behavior is what you expect.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -264,7 +264,7 @@ export function ChartsContent({
|
|||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card size="sm">
|
||||
<Card data-tour="studio-training-loss" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm pl-2">Training Loss</CardTitle>
|
||||
<CardAction>
|
||||
|
|
@ -546,7 +546,7 @@ export function ChartsContent({
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card size="sm">
|
||||
<Card data-tour="studio-eval-loss" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground pl-2">
|
||||
Eval Loss
|
||||
|
|
|
|||
|
|
@ -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} celebrate={true} />
|
||||
|
||||
{canGoBack && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { studioTourSteps } from "./steps";
|
||||
|
||||
export { studioTrainingTourSteps } from "./training";
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ export const studioBaseModelStep: TourStep = {
|
|||
title: "Base model from Hugging Face",
|
||||
body: (
|
||||
<>
|
||||
Search Hub here. Paste <span className="font-mono">org/model</span> too.
|
||||
Pick something close to your domain to save compute. <ReadMore />
|
||||
Paste <span className="font-mono">org/model</span> or search. Pick a base
|
||||
model close to your task (chat/instruct vs base). Smaller models iterate
|
||||
faster; scale up once prompts + data look good.{" "}
|
||||
<ReadMore href="https://docs.unsloth.ai/basics/fine-tuning-llms-guide" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,8 +6,12 @@ export const studioDatasetStep: TourStep = {
|
|||
title: "Dataset",
|
||||
body: (
|
||||
<>
|
||||
Search Hub or paste <span className="font-mono">user/dataset</span>.
|
||||
Preview a few rows before you burn hours of compute. <ReadMore />
|
||||
Search Hub or paste <span className="font-mono">user/dataset</span>. Preview
|
||||
a few rows: formatting matters more than size. We’ll try to auto-convert
|
||||
your dataset into a supported training format. If we can’t infer it
|
||||
cleanly, we’ll prompt you to map the fields manually. If outputs look off
|
||||
in Chat later, dataset formatting/template is the first thing to check.{" "}
|
||||
<ReadMore href="https://docs.unsloth.ai/basics/fine-tuning-llms-guide" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ export const studioLocalModelStep: TourStep = {
|
|||
title: "Local model path",
|
||||
body: (
|
||||
<>
|
||||
Point to a local folder (<span className="font-mono">./models/...</span>)
|
||||
or a custom HF repo. Use this when you already downloaded weights.{" "}
|
||||
<ReadMore />
|
||||
Use this if you already downloaded weights locally (eg{" "}
|
||||
<span className="font-mono">./models/...</span>) to avoid re-downloading.
|
||||
Folder should look like a Hugging Face model (config + tokenizer + weights).{" "}
|
||||
<ReadMore href="https://docs.unsloth.ai/basics/fine-tuning-llms-guide" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ export const studioMethodStep: TourStep = {
|
|||
title: "Method: QLoRA vs LoRA vs Full",
|
||||
body: (
|
||||
<>
|
||||
QLoRA: lowest VRAM (4-bit). LoRA: fast + solid (16-bit adapters). Full:
|
||||
slowest, highest cost, updates all weights. <ReadMore />
|
||||
LoRA: trains small adapter weights (fast, common default). QLoRA: LoRA on
|
||||
4-bit base weights (much lower VRAM). Full: updates all weights (highest
|
||||
cost, usually needs more data to be worth it).{" "}
|
||||
<ReadMore href="https://docs.unsloth.ai/basics/lora-hyperparameters-guide" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ export const studioNavStep: TourStep = {
|
|||
title: "Quick orientation",
|
||||
body: (
|
||||
<>
|
||||
Studio is where you fine-tune. Export ships results. Chat is for poking at
|
||||
models. This tour is Studio-only (for now).
|
||||
Studio: pick base model, dataset, hyperparams, then start training. After
|
||||
you start, you’ll see a Training view with live loss/metrics. Chat is for
|
||||
testing base vs LoRA adapters. Export packages checkpoints for deployment.
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ export const studioParamsStep: TourStep = {
|
|||
title: "Dial hyperparams",
|
||||
body: (
|
||||
<>
|
||||
Epochs + context length + LR. Keep it boring: small changes, one at a
|
||||
time. <ReadMore />
|
||||
Start boring, then iterate. We usually recommend starting with 1-3 epochs
|
||||
(higher can overfit fast). If you’re unsure, change 1 knob at a time, and
|
||||
watch train vs eval loss.{" "}
|
||||
<ReadMore href="https://docs.unsloth.ai/basics/lora-hyperparameters-guide" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ export const studioSaveStep: TourStep = {
|
|||
title: "Save config",
|
||||
body: (
|
||||
<>
|
||||
Save good runs. Repeatability beats vibe. You can iterate from a known
|
||||
baseline.
|
||||
Save configs that worked. Re-running the same baseline makes it obvious
|
||||
if a change helped (or if you just got lucky).
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ export const studioStartStep: TourStep = {
|
|||
title: "Start training",
|
||||
body: (
|
||||
<>
|
||||
One click. If it fails, the error text is the first place to look (token,
|
||||
path, config).
|
||||
Kick off training. If it errors immediately, check HF token / local paths
|
||||
/ dataset access first. Start with a small run to sanity-check loss + sample
|
||||
outputs before burning hours.
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export { studioTrainingTourSteps } from "./steps";
|
||||
|
||||
63
studio/frontend/src/features/studio/tour/training/steps.tsx
Normal file
63
studio/frontend/src/features/studio/tour/training/steps.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { TourStep } from "@/features/tour";
|
||||
|
||||
export const studioTrainingTourSteps: TourStep[] = [
|
||||
{
|
||||
id: "nav",
|
||||
target: "navbar",
|
||||
title: "Training view",
|
||||
body: (
|
||||
<>
|
||||
This view updates live as training runs. Watch loss, speed, and ETA, and
|
||||
use Stop if you need to bail out or save.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "progress",
|
||||
target: "studio-training-progress",
|
||||
title: "Progress + ETA",
|
||||
body: (
|
||||
<>
|
||||
Phase shows what we’re doing (loading model/dataset, configuring,
|
||||
training). ETA is rough early on; it stabilizes after a few steps.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "train-loss",
|
||||
target: "studio-training-loss",
|
||||
title: "Training loss",
|
||||
body: (
|
||||
<>
|
||||
Training loss should generally trend down. Absolute values vary by
|
||||
dataset + tokenizer, so use it for direction more than “a magic number”.
|
||||
If loss goes very low (eg below ~0.2), that can be a sign you’re
|
||||
overfitting. If loss plateaus high, you likely need better data
|
||||
formatting, more data, or different hyperparams.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "eval-loss",
|
||||
target: "studio-eval-loss",
|
||||
title: "Eval loss (validation)",
|
||||
body: (
|
||||
<>
|
||||
Eval loss is your sanity check. If training loss keeps dropping but eval
|
||||
loss goes up, you’re likely overfitting. To track it, set an eval dataset
|
||||
and `eval_steps` (setting `eval_steps=1` can be very slow).
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "stop",
|
||||
target: "studio-training-stop",
|
||||
title: "Stop / save",
|
||||
body: (
|
||||
<>
|
||||
Stop training any time. “Stop and Save” keeps the checkpoint/adapters so
|
||||
you can export or compare later.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -1,90 +1,17 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowLeft01Icon,
|
||||
ArrowRight01Icon,
|
||||
Cancel01Icon,
|
||||
CheckmarkCircle01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { ArrowLeft01Icon, ArrowRight01Icon, Cancel01Icon, CheckmarkCircle01Icon } from "@hugeicons/core-free-icons";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
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;
|
||||
steps: TourStep[];
|
||||
onSkip: () => void;
|
||||
onComplete: () => void;
|
||||
};
|
||||
type GuidedTourProps = { open: boolean; onOpenChange: (open: boolean) => void; steps: TourStep[]; onSkip: () => void; onComplete: () => void; celebrate?: boolean }; // confetti on complete only
|
||||
|
||||
export function GuidedTour({
|
||||
open,
|
||||
|
|
@ -92,6 +19,7 @@ export function GuidedTour({
|
|||
steps,
|
||||
onSkip,
|
||||
onComplete,
|
||||
celebrate = false,
|
||||
}: GuidedTourProps) {
|
||||
const maskId = `${useId()}-tour-mask`;
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
|
@ -107,6 +35,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 +47,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);
|
||||
|
|
@ -141,24 +91,25 @@ export function GuidedTour({
|
|||
if (!open || !step) return;
|
||||
|
||||
const sel = `[data-tour="${cssEscape(step.target)}"]`;
|
||||
const found = document.querySelector(sel);
|
||||
if (!(found instanceof HTMLElement)) {
|
||||
setTargetRect(null);
|
||||
return;
|
||||
}
|
||||
const el = found;
|
||||
|
||||
if (step.target !== "navbar") {
|
||||
el.scrollIntoView({
|
||||
block: "center",
|
||||
inline: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
let el: HTMLElement | null = null;
|
||||
let ro: ResizeObserver | null = null;
|
||||
let retryTimer = 0;
|
||||
let retries = 0;
|
||||
|
||||
let raf = 0;
|
||||
let t = 0;
|
||||
|
||||
function findTarget(): HTMLElement | null {
|
||||
const found = document.querySelector(sel);
|
||||
if (!(found instanceof HTMLElement)) return null;
|
||||
return found;
|
||||
}
|
||||
|
||||
function isUsableTarget(candidate: HTMLElement): boolean {
|
||||
const r = candidate.getBoundingClientRect();
|
||||
return r.width >= 6 && r.height >= 6;
|
||||
}
|
||||
|
||||
function rectChanged(a: Rect | null, b: Rect): boolean {
|
||||
if (!a) return true;
|
||||
return (
|
||||
|
|
@ -169,8 +120,8 @@ export function GuidedTour({
|
|||
);
|
||||
}
|
||||
|
||||
function read() {
|
||||
const r = el.getBoundingClientRect();
|
||||
function read(candidate: HTMLElement) {
|
||||
const r = candidate.getBoundingClientRect();
|
||||
const next = toRect(r);
|
||||
const prev = lastRectRef.current;
|
||||
if (rectChanged(prev, next)) {
|
||||
|
|
@ -183,22 +134,53 @@ export function GuidedTour({
|
|||
if (rafRef.current != null) return;
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
read();
|
||||
if (el) read(el);
|
||||
});
|
||||
}
|
||||
|
||||
raf = window.requestAnimationFrame(read);
|
||||
t = window.setTimeout(schedule, 240);
|
||||
function attach(candidate: HTMLElement) {
|
||||
el = candidate;
|
||||
|
||||
const ro = new ResizeObserver(() => schedule());
|
||||
ro.observe(el);
|
||||
window.addEventListener("scroll", schedule, { capture: true, passive: true });
|
||||
window.addEventListener("resize", schedule, { passive: true });
|
||||
if (step.target !== "navbar") {
|
||||
el.scrollIntoView({
|
||||
block: "center",
|
||||
inline: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
raf = window.requestAnimationFrame(() => read(el!));
|
||||
t = window.setTimeout(schedule, 240);
|
||||
|
||||
ro = new ResizeObserver(() => schedule());
|
||||
ro.observe(el);
|
||||
window.addEventListener("scroll", schedule, { capture: true, passive: true });
|
||||
window.addEventListener("resize", schedule, { passive: true });
|
||||
}
|
||||
|
||||
function tryAttach(): boolean {
|
||||
const candidate = findTarget();
|
||||
if (!candidate) return false;
|
||||
if (!isUsableTarget(candidate)) return false;
|
||||
attach(candidate);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!tryAttach()) {
|
||||
setTargetRect(null);
|
||||
retryTimer = window.setInterval(() => {
|
||||
retries += 1;
|
||||
if (tryAttach() || retries > 40) {
|
||||
window.clearInterval(retryTimer);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
window.clearTimeout(t);
|
||||
ro.disconnect();
|
||||
if (retryTimer) window.clearInterval(retryTimer);
|
||||
ro?.disconnect();
|
||||
window.removeEventListener("scroll", schedule, true);
|
||||
window.removeEventListener("resize", schedule);
|
||||
if (rafRef.current != null) {
|
||||
|
|
@ -206,7 +188,7 @@ export function GuidedTour({
|
|||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, step]);
|
||||
}, [open, step?.id]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !spotlightRect || !vw || !vh) return;
|
||||
|
|
@ -231,9 +213,12 @@ export function GuidedTour({
|
|||
function requestClose(reason: "skip" | "complete") {
|
||||
if (closeLockRef.current) return;
|
||||
closeLockRef.current = true;
|
||||
void fireConfettiFireworks();
|
||||
if (reason === "skip") onSkip();
|
||||
else onComplete();
|
||||
if (reason === "skip") {
|
||||
onSkip();
|
||||
} else {
|
||||
if (celebrate) void fireConfettiFireworks();
|
||||
onComplete();
|
||||
}
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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