tokenize-it / src /components /ModelPicker.tsx
Orion-zhen's picture
:boom: use web stack
21ab33c unverified
Raw History Blame Contribute Delete
7.79 kB
import {
Check,
ChevronDown,
CloudDownload,
LoaderCircle,
Search,
Waypoints,
} from "lucide-react";
import { useEffect, useRef } from "react";
import { formatBytes, shortSha } from "../lib/format";
import type { LoadProgress, TokenizerMetadata } from "../tokenizer/types";
interface ModelPickerProps {
open: boolean;
reference: string;
metadata: TokenizerMetadata | null;
progress: LoadProgress | null;
loading: boolean;
onOpenChange: (open: boolean) => void;
onReferenceChange: (value: string) => void;
onLoad: () => void;
}
const SUGGESTIONS = [
{ name: "Qwen 3.8", repo: "Qwen/Qwen3.8-2.4T-A95B", format: "Tokenizers" },
{ name: "GLM 5.3 Flash", repo: "zai-org/GLM-5.3-Flash", format: "Tokenizers" },
{ name: "Kimi K3", repo: "moonshotai/Kimi-K3", format: "Tiktoken" },
{
name: "DeepSeek V4 Pro",
repo: "deepseek-ai/DeepSeek-V4-Pro-0813",
format: "Tokenizers",
},
{ name: "Gemma 4", repo: "google/gemma-4-31B-it", format: "Tokenizers" },
{
name: "Muse Glimmer",
repo: "meta-models/Muse-Glimmer-30B",
format: "Tokenizers",
},
{
name: "Mistral Small 4",
repo: "mistralai/Mistral-Small-4-119B-2603",
format: "Tokenizers",
},
{ name: "GPT-OSS", repo: "openai/gpt-oss-20b", format: "Tokenizers" },
];
function modelName(reference: string, metadata: TokenizerMetadata | null): string {
const repo = metadata?.repoId ?? reference.split("@")[0] ?? reference;
return repo.split("/").at(-1) || "选择模型";
}
export function ModelPicker({
open,
reference,
metadata,
progress,
loading,
onOpenChange,
onReferenceChange,
onLoad,
}: ModelPickerProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const closeOnOutsideClick = (event: PointerEvent) => {
if (!containerRef.current?.contains(event.target as Node)) onOpenChange(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onOpenChange(false);
};
document.addEventListener("pointerdown", closeOnOutsideClick);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeOnOutsideClick);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open, onOpenChange]);
const percent =
progress?.total && progress.total > 0
? Math.min(100, (progress.loaded / progress.total) * 100)
: null;
return (
<div className="relative min-w-0" ref={containerRef}>
<span className="toolbar-label">Model</span>
<button
className="toolbar-control w-[min(19rem,72vw)] justify-between"
type="button"
aria-expanded={open}
onClick={() => onOpenChange(!open)}
>
<span className="flex min-w-0 items-center gap-2.5">
{loading ? (
<LoaderCircle className="shrink-0 animate-spin text-[var(--accent)]" size={17} />
) : (
<Waypoints className="shrink-0 text-[var(--accent)]" size={17} />
)}
<span className="truncate">{modelName(reference, metadata)}</span>
</span>
<ChevronDown
className={`shrink-0 text-[var(--muted)] transition-transform ${open ? "rotate-180" : ""}`}
size={16}
/>
</button>
{open ? (
<div className="popover-panel absolute right-0 top-[calc(100%+10px)] z-30 w-[min(27rem,calc(100vw-2rem))] p-3">
<form
className="border-b border-[var(--line)] p-2 pb-4"
onSubmit={(event) => {
event.preventDefault();
onLoad();
}}
>
<label className="field-label" htmlFor="model-reference">
Hugging Face 仓库
</label>
<div className="relative mt-2">
<Search
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted)]"
size={15}
aria-hidden="true"
/>
<input
id="model-reference"
className="text-field py-3 pl-9 pr-24 font-mono text-[12px]"
value={reference}
onChange={(event) => onReferenceChange(event.target.value)}
placeholder="owner/model@revision"
spellCheck={false}
autoComplete="off"
/>
<button
className="primary-button absolute bottom-1.5 right-1.5 top-1.5 px-3"
type="submit"
disabled={loading || !reference.trim()}
>
{loading ? <LoaderCircle className="animate-spin" size={14} /> : <CloudDownload size={14} />}
加载
</button>
</div>
<p className="mt-2 text-[10px] leading-4 text-[var(--muted)]">
使用 <code>@revision</code> 可以固定模型版本。
</p>
</form>
{loading && progress ? (
<div className="m-2 rounded-xl bg-[var(--soft)] p-3" role="status">
<div className="flex items-center justify-between gap-3 text-[11px]">
<span className="text-[var(--muted-strong)]">{progress.message}</span>
<span className="font-mono text-[var(--muted)]">
{progress.total ? `${Math.round(percent ?? 0)}%` : "•••"}
</span>
</div>
<div className="mt-2 h-1 overflow-hidden rounded-full bg-[var(--line-strong)]">
<div
className={`h-full rounded-full bg-[var(--accent)] ${percent === null ? "w-1/3 animate-[loading_1.4s_ease-in-out_infinite]" : ""}`}
style={percent === null ? undefined : { width: `${percent}%` }}
/>
</div>
</div>
) : null}
<div className="max-h-[min(26rem,50vh)] space-y-1 overflow-y-auto p-2 pt-3">
<p className="mb-2 px-2 text-[9px] font-bold uppercase tracking-[0.18em] text-[var(--muted)]">
Quick access
</p>
{SUGGESTIONS.map((suggestion) => {
const selected = metadata?.repoId === suggestion.repo;
return (
<button
className="model-option"
type="button"
key={suggestion.repo}
onClick={() => onReferenceChange(suggestion.repo)}
>
<span className="grid size-8 shrink-0 place-items-center rounded-lg border border-[var(--line)] bg-[var(--soft)] text-[var(--accent)]">
<Waypoints size={15} />
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-xs font-semibold text-[var(--ink)]">
{suggestion.name}
</span>
<span className="mt-0.5 block truncate font-mono text-[9px] text-[var(--muted)]">
{suggestion.format} · {suggestion.repo}
</span>
</span>
{selected ? <Check className="text-[var(--accent)]" size={15} /> : null}
</button>
);
})}
</div>
{metadata ? (
<div className="mx-2 mb-2 flex items-center justify-between gap-3 rounded-xl border border-[var(--line)] bg-[var(--soft)] px-3 py-2 text-[9px] text-[var(--muted)]">
<span>{metadata.format} · {formatBytes(metadata.fileSize)}</span>
<span className="font-mono">{shortSha(metadata.commitSha)}</span>
</div>
) : null}
</div>
) : null}
</div>
);
}