mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 02:41:48 +00:00
Merge resolved conflicts in web/src/{i18n/{en,zh,types}.ts,lib/api.ts}
by keeping both this branch's `profiles` additions and upstream's new
`models` page additions.
Copilot review feedback:
- Implement POST /api/profiles/{name}/open-terminal endpoint (already
present); align Windows branch to `cmd.exe /c start "" <cmd>` so it
matches the new test and spawns a fresh window instead of /k reusing
the parent console.
- Move backslash escaping out of the macOS AppleScript f-string
expression (Python <3.12 disallows backslashes inside f-string
expression parts).
- Patch `_get_wrapper_dir` via monkeypatch in
test_profiles_create_creates_wrapper_alias_when_safe so the test no
longer writes to the real `~/.local/bin`.
- Extend test_dashboard_browser_safe_imports to scan `.ts` files in
addition to `.tsx`.
- Switch upstream's new ModelsPage.tsx away from the `@nous-research/ui`
root barrel onto per-component subpaths to satisfy the stricter scan.
- Fix NouiTypography `leading-1.4` -> `leading-[1.4]` so Tailwind
actually emits the line-height for the `sm` variant.
- Guard ProfilesPage.openSoulEditor against out-of-order responses by
tracking the latest requested profile via a ref.
- Replace ProfilesPage's hand-rolled setup command with a fetch to
`/api/profiles/{name}/setup-command` so the copied command always
matches what the backend would actually run (handles wrapper-alias
collisions and reserved names correctly).
- Wire SOUL.md textarea label `htmlFor` -> textarea `id` so screen
readers and clicking the label work as expected.
451 lines
13 KiB
TypeScript
451 lines
13 KiB
TypeScript
import { Button } from "@nous-research/ui/ui/components/button";
|
|
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
|
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
|
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";
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
};
|
|
|
|
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
|
|
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-display text-base tracking-wider uppercase"
|
|
>
|
|
{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>
|
|
) : (
|
|
<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 outlined onClick={onClose} disabled={applying}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={confirm} disabled={!canConfirm}>
|
|
{applying ? <Spinner /> : "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">
|
|
<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-[0.65rem] text-muted-foreground/80 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-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0">
|
|
current
|
|
</span>
|
|
);
|
|
}
|