mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
Removes the global `uppercase` + `font-mondwest` from the App.tsx root
that forced every page to opt-out, replaces stacked-alpha text colors
with semantic tokens for WCAG-AA contrast across all 7 themes, and
applies the new `text-display` utility from @nous-research/ui@0.16.0
on intentional brand chrome (page titles, sidebar headings, segmented
filters) only. Bumps every sub-12px arbitrary text size to text-xs.
Also widens the dashboard plugin routes (/api/dashboard/agent-plugins/
{name:path}/...) so category-namespaced plugins like observability/
langfuse and image_gen/openai can be enable/disabled from the dashboard
— previously the FE encodeURIComponent-ed the slash and the backend
{name} route rejected it. _validate_plugin_name still blocks .. and
backslash, and strips leading/trailing slash.
Touches sessions/env/keys page chrome and adds two new i18n keys
(`overview`, `showMore`/`showLess`) across all 18 locales.
Squashes 19 commits from PR #28832.
Co-authored-by: Hermes <noreply@nousresearch.com>
470 lines
14 KiB
TypeScript
470 lines
14 KiB
TypeScript
import { Button } from "@nous-research/ui/ui/components/button";
|
|
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
|
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
|
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import type { GatewayClient } from "@/lib/gatewayClient";
|
|
import { Check, Search, X } from "lucide-react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { cn, themedBody } from "@/lib/utils";
|
|
|
|
/**
|
|
* 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
|
|
*
|
|
* Two invocation modes:
|
|
*
|
|
* 1. Chat-session mode (ChatSidebar) — pass `gw` + `sessionId`. The picker
|
|
* loads options via `model.options` JSON-RPC and emits the result as a
|
|
* slash command string (`/model <model> --provider <slug> [--global]`)
|
|
* through `onSubmit`, which the ChatPage pipes to `slashExec`.
|
|
*
|
|
* 2. Standalone mode (ModelsPage, Config settings) — pass a `loader` and
|
|
* `onApply`. The picker fetches options via the REST endpoint and calls
|
|
* `onApply(provider, model, persistGlobal)` instead of emitting a slash
|
|
* command. This lets the Models page reuse the same UI without
|
|
* requiring an open chat PTY.
|
|
*/
|
|
|
|
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 {
|
|
/** Chat-mode: when present, picker emits a slash command via onSubmit. */
|
|
gw?: GatewayClient;
|
|
sessionId?: string;
|
|
onSubmit?(slashCommand: string): void;
|
|
|
|
/** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */
|
|
loader?(): Promise<ModelOptionsResponse>;
|
|
onApply?(args: {
|
|
provider: string;
|
|
model: string;
|
|
persistGlobal: boolean;
|
|
}): Promise<void> | void;
|
|
|
|
onClose(): void;
|
|
title?: string;
|
|
/** If true, hides "Persist globally" checkbox — always saves to config.yaml. */
|
|
alwaysGlobal?: boolean;
|
|
}
|
|
|
|
export function ModelPickerDialog(props: Props) {
|
|
const {
|
|
gw,
|
|
sessionId,
|
|
onSubmit,
|
|
loader,
|
|
onApply,
|
|
onClose,
|
|
title = "Switch Model",
|
|
alwaysGlobal = false,
|
|
} = props;
|
|
const standalone = !!loader && !!onApply;
|
|
|
|
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(alwaysGlobal);
|
|
const [applying, setApplying] = useState(false);
|
|
const closedRef = useRef(false);
|
|
|
|
// Load providers + models on open.
|
|
useEffect(() => {
|
|
closedRef.current = false;
|
|
|
|
const promise = standalone
|
|
? (loader as () => Promise<ModelOptionsResponse>)()
|
|
: (gw as GatewayClient).request<ModelOptionsResponse>(
|
|
"model.options",
|
|
sessionId ? { session_id: sessionId } : {},
|
|
);
|
|
|
|
promise
|
|
.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;
|
|
};
|
|
// Deliberately omit props from deps — stable for the dialog's lifetime.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// 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 && !applying;
|
|
|
|
const confirm = async () => {
|
|
if (!canConfirm || !selectedProvider) return;
|
|
if (standalone && onApply) {
|
|
setApplying(true);
|
|
try {
|
|
await onApply({
|
|
provider: selectedProvider.slug,
|
|
model: selectedModel,
|
|
persistGlobal,
|
|
});
|
|
onClose();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setApplying(false);
|
|
}
|
|
} else if (onSubmit) {
|
|
const global = persistGlobal ? " --global" : "";
|
|
onSubmit(
|
|
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
|
|
);
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
// Portal to document.body: the main dashboard column in App.tsx is
|
|
// `relative z-2`, which creates a stacking context that traps fixed
|
|
// descendants below the app sidebar (z-50). Without the portal this
|
|
// modal's z-[100] is scoped to z-2 and the sidebar covers its left
|
|
// edge — visible especially in the Large theme variants where the
|
|
// larger root font widens the dialog into the sidebar's column. See
|
|
// Toast.tsx for the same pattern.
|
|
return createPortal(
|
|
<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={cn(themedBody, "relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col")}>
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
onClick={onClose}
|
|
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
|
aria-label="Close"
|
|
>
|
|
<X />
|
|
</Button>
|
|
|
|
<header className="p-5 pb-3 border-b border-border">
|
|
<h2
|
|
id="model-picker-title"
|
|
className="font-mondwest text-display text-base tracking-wider"
|
|
>
|
|
{title}
|
|
</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">
|
|
{alwaysGlobal ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
Saves to config.yaml — applies to new sessions.
|
|
</span>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
checked={persistGlobal}
|
|
id="model-picker-persist-global"
|
|
onCheckedChange={(checked) =>
|
|
setPersistGlobal(checked === true)
|
|
}
|
|
/>
|
|
|
|
<Label
|
|
className="font-mondwest normal-case tracking-normal text-xs text-muted-foreground cursor-pointer"
|
|
htmlFor="model-picker-persist-global"
|
|
>
|
|
Persist globally (otherwise this session only)
|
|
</Label>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<Button outlined onClick={onClose} disabled={applying}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={confirm} disabled={!canConfirm}>
|
|
{applying ? <Spinner /> : "Switch"}
|
|
</Button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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">
|
|
<Spinner className="text-xs" /> 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 (
|
|
<ListItem
|
|
key={p.slug}
|
|
active={active}
|
|
onClick={() => onSelect(p.slug)}
|
|
className={`items-start text-xs border-l-2 ${
|
|
active ? "border-l-primary" : "border-l-transparent"
|
|
}`}
|
|
>
|
|
<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-xs text-text-secondary font-mono truncate">
|
|
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
|
|
</div>
|
|
</div>
|
|
</ListItem>
|
|
);
|
|
})}
|
|
</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 (
|
|
<ListItem
|
|
key={m}
|
|
active={active}
|
|
onClick={() => onSelect(m)}
|
|
onDoubleClick={() => onConfirm(m)}
|
|
className="px-3 py-1.5 text-xs font-mono"
|
|
>
|
|
<Check
|
|
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
|
|
/>
|
|
<span className="flex-1 truncate">{m}</span>
|
|
{isCurrent && <CurrentTag />}
|
|
</ListItem>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CurrentTag() {
|
|
return (
|
|
<span className="text-display text-xs tracking-wider text-primary shrink-0">
|
|
current
|
|
</span>
|
|
);
|
|
}
|