mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import type { GatewayClient } from "@/lib/gatewayClient";
|
|
import { Check, Loader2, Search, X } from "lucide-react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
/**
|
|
* Two-stage model picker modal.
|
|
*
|
|
* Mirrors ui-tui/src/components/modelPicker.tsx:
|
|
* Stage 1: pick provider (authenticated providers only)
|
|
* Stage 2: pick model within that provider
|
|
*
|
|
* On confirm, emits `/model <model> --provider <slug> [--global]` through
|
|
* the parent callback so ChatPage can dispatch it via the existing slash
|
|
* pipeline. That keeps persistence + actual switch logic in one place.
|
|
*/
|
|
|
|
interface ModelOptionProvider {
|
|
name: string;
|
|
slug: string;
|
|
models?: string[];
|
|
total_models?: number;
|
|
is_current?: boolean;
|
|
warning?: string;
|
|
}
|
|
|
|
interface ModelOptionsResponse {
|
|
model?: string;
|
|
provider?: string;
|
|
providers?: ModelOptionProvider[];
|
|
}
|
|
|
|
interface Props {
|
|
gw: GatewayClient;
|
|
sessionId: string;
|
|
onClose(): void;
|
|
/** Parent runs the resulting slash command through slashExec. */
|
|
onSubmit(slashCommand: string): void;
|
|
}
|
|
|
|
export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
|
|
const [providers, setProviders] = useState<ModelOptionProvider[]>([]);
|
|
const [currentModel, setCurrentModel] = useState("");
|
|
const [currentProviderSlug, setCurrentProviderSlug] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedSlug, setSelectedSlug] = useState("");
|
|
const [selectedModel, setSelectedModel] = useState("");
|
|
const [query, setQuery] = useState("");
|
|
const [persistGlobal, setPersistGlobal] = useState(false);
|
|
const closedRef = useRef(false);
|
|
|
|
// Load providers + models on open.
|
|
useEffect(() => {
|
|
closedRef.current = false;
|
|
|
|
gw.request<ModelOptionsResponse>(
|
|
"model.options",
|
|
sessionId ? { session_id: sessionId } : {},
|
|
)
|
|
.then((r) => {
|
|
if (closedRef.current) return;
|
|
const next = r?.providers ?? [];
|
|
setProviders(next);
|
|
setCurrentModel(String(r?.model ?? ""));
|
|
setCurrentProviderSlug(String(r?.provider ?? ""));
|
|
setSelectedSlug(
|
|
(next.find((p) => p.is_current) ?? next[0])?.slug ?? "",
|
|
);
|
|
setSelectedModel("");
|
|
setLoading(false);
|
|
})
|
|
.catch((e) => {
|
|
if (closedRef.current) return;
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
setLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
closedRef.current = true;
|
|
};
|
|
}, [gw, sessionId]);
|
|
|
|
// Esc closes.
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
onClose();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [onClose]);
|
|
|
|
const selectedProvider = useMemo(
|
|
() => providers.find((p) => p.slug === selectedSlug) ?? null,
|
|
[providers, selectedSlug],
|
|
);
|
|
|
|
const models = useMemo(
|
|
() => selectedProvider?.models ?? [],
|
|
[selectedProvider],
|
|
);
|
|
|
|
const needle = query.trim().toLowerCase();
|
|
|
|
const filteredProviders = useMemo(
|
|
() =>
|
|
!needle
|
|
? providers
|
|
: providers.filter(
|
|
(p) =>
|
|
p.name.toLowerCase().includes(needle) ||
|
|
p.slug.toLowerCase().includes(needle) ||
|
|
(p.models ?? []).some((m) => m.toLowerCase().includes(needle)),
|
|
),
|
|
[providers, needle],
|
|
);
|
|
|
|
const filteredModels = useMemo(
|
|
() =>
|
|
!needle ? models : models.filter((m) => m.toLowerCase().includes(needle)),
|
|
[models, needle],
|
|
);
|
|
|
|
const canConfirm = !!selectedProvider && !!selectedModel;
|
|
|
|
const confirm = () => {
|
|
if (!canConfirm) return;
|
|
const global = persistGlobal ? " --global" : "";
|
|
onSubmit(
|
|
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
|
|
);
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="model-picker-title"
|
|
>
|
|
<div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
|
aria-label="Close"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
|
|
<header className="p-5 pb-3 border-b border-border">
|
|
<h2
|
|
id="model-picker-title"
|
|
className="font-display text-base tracking-wider uppercase"
|
|
>
|
|
Switch Model
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground mt-1 font-mono">
|
|
current: {currentModel || "(unknown)"}
|
|
{currentProviderSlug && ` · ${currentProviderSlug}`}
|
|
</p>
|
|
</header>
|
|
|
|
<div className="px-5 pt-3 pb-2 border-b border-border">
|
|
<div className="relative">
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
autoFocus
|
|
placeholder="Filter providers and models…"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
className="pl-7 h-8 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden">
|
|
<ProviderColumn
|
|
loading={loading}
|
|
error={error}
|
|
providers={filteredProviders}
|
|
total={providers.length}
|
|
selectedSlug={selectedSlug}
|
|
query={needle}
|
|
onSelect={(slug) => {
|
|
setSelectedSlug(slug);
|
|
setSelectedModel("");
|
|
}}
|
|
/>
|
|
|
|
<ModelColumn
|
|
provider={selectedProvider}
|
|
models={filteredModels}
|
|
allModels={models}
|
|
selectedModel={selectedModel}
|
|
currentModel={currentModel}
|
|
currentProviderSlug={currentProviderSlug}
|
|
onSelect={setSelectedModel}
|
|
onConfirm={(m) => {
|
|
setSelectedModel(m);
|
|
// Confirm on next tick so state settles.
|
|
window.setTimeout(confirm, 0);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
|
|
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={persistGlobal}
|
|
onChange={(e) => setPersistGlobal(e.target.checked)}
|
|
className="cursor-pointer"
|
|
/>
|
|
Persist globally (otherwise this session only)
|
|
</label>
|
|
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button size="sm" onClick={confirm} disabled={!canConfirm}>
|
|
Switch
|
|
</Button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Provider column */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function ProviderColumn({
|
|
loading,
|
|
error,
|
|
providers,
|
|
total,
|
|
selectedSlug,
|
|
query,
|
|
onSelect,
|
|
}: {
|
|
loading: boolean;
|
|
error: string | null;
|
|
providers: ModelOptionProvider[];
|
|
total: number;
|
|
selectedSlug: string;
|
|
query: string;
|
|
onSelect(slug: string): void;
|
|
}) {
|
|
return (
|
|
<div className="border-r border-border overflow-y-auto">
|
|
{loading && (
|
|
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" /> loading…
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="p-4 text-xs text-destructive">{error}</div>}
|
|
|
|
{!loading && !error && providers.length === 0 && (
|
|
<div className="p-4 text-xs text-muted-foreground italic">
|
|
{query
|
|
? "no matches"
|
|
: total === 0
|
|
? "no authenticated providers"
|
|
: "no matches"}
|
|
</div>
|
|
)}
|
|
|
|
{providers.map((p) => {
|
|
const active = p.slug === selectedSlug;
|
|
return (
|
|
<button
|
|
key={p.slug}
|
|
type="button"
|
|
onClick={() => onSelect(p.slug)}
|
|
className={`w-full text-left px-3 py-2 text-xs border-l-2 transition-colors cursor-pointer flex items-start gap-2 ${
|
|
active
|
|
? "bg-primary/10 border-l-primary text-foreground"
|
|
: "border-l-transparent text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
|
}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="font-medium truncate">{p.name}</span>
|
|
{p.is_current && <CurrentTag />}
|
|
</div>
|
|
<div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate">
|
|
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Model column */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function ModelColumn({
|
|
provider,
|
|
models,
|
|
allModels,
|
|
selectedModel,
|
|
currentModel,
|
|
currentProviderSlug,
|
|
onSelect,
|
|
onConfirm,
|
|
}: {
|
|
provider: ModelOptionProvider | null;
|
|
models: string[];
|
|
allModels: string[];
|
|
selectedModel: string;
|
|
currentModel: string;
|
|
currentProviderSlug: string;
|
|
onSelect(model: string): void;
|
|
onConfirm(model: string): void;
|
|
}) {
|
|
if (!provider) {
|
|
return (
|
|
<div className="overflow-y-auto">
|
|
<div className="p-4 text-xs text-muted-foreground italic">
|
|
pick a provider →
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-y-auto">
|
|
{provider.warning && (
|
|
<div className="p-3 text-xs text-destructive border-b border-border">
|
|
{provider.warning}
|
|
</div>
|
|
)}
|
|
|
|
{models.length === 0 ? (
|
|
<div className="p-4 text-xs text-muted-foreground italic">
|
|
{allModels.length
|
|
? "no models match your filter"
|
|
: "no models listed for this provider"}
|
|
</div>
|
|
) : (
|
|
models.map((m) => {
|
|
const active = m === selectedModel;
|
|
const isCurrent =
|
|
m === currentModel && provider.slug === currentProviderSlug;
|
|
|
|
return (
|
|
<button
|
|
key={m}
|
|
type="button"
|
|
onClick={() => onSelect(m)}
|
|
onDoubleClick={() => onConfirm(m)}
|
|
className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors cursor-pointer flex items-center gap-2 ${
|
|
active
|
|
? "bg-primary/15 text-foreground"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
|
}`}
|
|
>
|
|
<Check
|
|
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
|
|
/>
|
|
<span className="flex-1 truncate">{m}</span>
|
|
{isCurrent && <CurrentTag />}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CurrentTag() {
|
|
return (
|
|
<span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0">
|
|
current
|
|
</span>
|
|
);
|
|
}
|