diff --git a/.gitignore b/.gitignore index c660c107a9..e07aa496b8 100755 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ outputs/ *.swp *.swo +# oh-my-codex +.omx/ + # OS .DS_Store Thumbs.db diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx index 85bc1cacdb..33dea79627 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx @@ -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 ( - ); -} - -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 ( -
-
- - setQuery(event.target.value)} - placeholder="Search Hugging Face models" - className="h-9 pl-8 pr-8" - /> - {isLoading && ( - - )} -
- -
-
- {!showHfSection ? ( - <> - Recommended - {recommendedIds.length === 0 ? ( -
- No default models. -
- ) : ( - recommendedIds.map((id) => ( - onSelect(id, { source: "hub", isLora: false })} - /> - )) - )} - - ) : null} - - {showHfSection ? ( - <> - Hugging Face - {hfIds.length === 0 && !isLoading ? ( -
- No matching models. -
- ) : ( - hfIds.map((id) => ( - onSelect(id, { source: "hub", isLora: false })} - /> - )) - )} -
- {isLoadingMore ? ( -
- -
- ) : null} - - ) : null} -
-
-
- ); -} - -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(); - - 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 ( -
-
- - setQuery(event.target.value)} - placeholder="Search local adapters" - className="h-9 pl-8" - /> -
- -
-
- {grouped.length === 0 ? ( -
No adapters found.
- ) : ( - grouped.map(([baseModel, adapters], index) => ( -
- {index > 0 ?
: null} - {baseModel} - {adapters.map((adapter) => ( - onSelect(adapter.id, { source: "lora", isLora: true })} - /> - ))} -
- )) - )} -
-
-
- ); -} - 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 ( @@ -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} /> ); diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx new file mode 100644 index 0000000000..b90cdd0e3b --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -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 ( +
+ {children} +
+ ); +} + +function ModelRow({ + label, + meta, + selected, + onClick, +}: { + label: string; + meta?: string; + selected?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +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 ( +
+
+ + setQuery(event.target.value)} + placeholder="Search Hugging Face models" + className="h-9 pl-8 pr-8" + /> + {isLoading && ( + + )} +
+ +
+
+ {!showHfSection ? ( + <> + Recommended + {recommendedIds.length === 0 ? ( +
+ No default models. +
+ ) : ( + recommendedIds.map((id) => ( + onSelect(id, { source: "hub", isLora: false })} + /> + )) + )} + + ) : null} + + {showHfSection ? ( + <> + Hugging Face + {hfIds.length === 0 && !isLoading ? ( +
+ No matching models. +
+ ) : ( + hfIds.map((id) => ( + onSelect(id, { source: "hub", isLora: false })} + /> + )) + )} +
+ {isLoadingMore ? ( +
+ +
+ ) : null} + + ) : null} +
+
+
+ ); +} + +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(); + + 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 ( +
+
+ + setQuery(event.target.value)} + placeholder="Search local adapters" + className="h-9 pl-8" + /> +
+ +
+
+ {grouped.length === 0 ? ( +
+ No adapters found. +
+ ) : ( + grouped.map(([baseModel, adapters], index) => ( +
+ {index > 0 ?
: null} + {baseModel} + {adapters.map((adapter) => ( + onSelect(adapter.id, { source: "lora", isLora: true })} + /> + ))} +
+ )) + )} +
+
+
+ ); +} + diff --git a/studio/frontend/src/components/assistant-ui/model-selector/types.ts b/studio/frontend/src/components/assistant-ui/model-selector/types.ts new file mode 100644 index 0000000000..dcf110bfb7 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/types.ts @@ -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; +} + diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx index 6c02dba493..28fc80499c 100644 --- a/studio/frontend/src/components/navbar.tsx +++ b/studio/frontend/src/components/navbar.tsx @@ -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 (
@@ -128,39 +139,56 @@ export function Navbar() { {/* Right: docs link */} - - - + + + + + Learn more + + + + +

+ Unsloth Documentation +

+

+ Guides on fine-tuning LLMs 2x faster with 70% less memory. + Covers LoRA, QLoRA, data formatting, and deployment. +

+ + Visit docs + + +
+
+
+ + {tourId ? ( + + ) : null} +
); diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 84fae04080..0f57841249 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -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 (
-
+
@@ -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(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( () => 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 (
+
{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" > diff --git a/studio/frontend/src/features/chat/thread-sidebar.tsx b/studio/frontend/src/features/chat/thread-sidebar.tsx index 369dc30769..b72d48ddfd 100644 --- a/studio/frontend/src/features/chat/thread-sidebar.tsx +++ b/studio/frontend/src/features/chat/thread-sidebar.tsx @@ -116,7 +116,7 @@ export function ThreadSidebar({ {showCompare ? ( - + Compare diff --git a/studio/frontend/src/features/chat/tour/index.ts b/studio/frontend/src/features/chat/tour/index.ts new file mode 100644 index 0000000000..8db3c1d207 --- /dev/null +++ b/studio/frontend/src/features/chat/tour/index.ts @@ -0,0 +1,2 @@ +export { buildChatTourSteps } from "./steps"; + diff --git a/studio/frontend/src/features/chat/tour/steps.tsx b/studio/frontend/src/features/chat/tour/steps.tsx new file mode 100644 index 0000000000..193a6b6ad3 --- /dev/null +++ b/studio/frontend/src/features/chat/tour/steps.tsx @@ -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; +} diff --git a/studio/frontend/src/features/export/components/method-picker.tsx b/studio/frontend/src/features/export/components/method-picker.tsx index 99cabe1fe9..1096947d94 100644 --- a/studio/frontend/src/features/export/components/method-picker.tsx +++ b/studio/frontend/src/features/export/components/method-picker.tsx @@ -19,7 +19,7 @@ interface MethodPickerProps { export function MethodPicker({ value, onChange }: MethodPickerProps) { return ( -
+
Export Method diff --git a/studio/frontend/src/features/export/export-page.tsx b/studio/frontend/src/features/export/export-page.tsx index 2e6b744f2d..96ca3589eb 100644 --- a/studio/frontend/src/features/export/export-page.tsx +++ b/studio/frontend/src/features/export/export-page.tsx @@ -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 (
+ +

Export Model @@ -156,7 +165,7 @@ export function ExportPage() {