Merge upstream v0.4.0 into Gitea fork
This commit is contained in:
@@ -23,10 +23,12 @@ import { useI18n } from "@/components/i18n";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ModelCatalogEntry } from "@/src/core";
|
||||
import { fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||
import { CLAUDE_CODE_PROVIDER, CUSTOM_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||
import { formatTokens } from "@/components/usage-format";
|
||||
import { SearchMenuList } from "./search-menu-list";
|
||||
|
||||
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
||||
if (!cost) return "Unpriced";
|
||||
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
|
||||
return `${fmt(cost.input)}/${fmt(cost.output)}`;
|
||||
}
|
||||
@@ -40,6 +42,7 @@ export function ModelMenuList({
|
||||
effectiveProvider,
|
||||
onPick,
|
||||
footer,
|
||||
vision,
|
||||
}: {
|
||||
/** The full catalog — undefined while it loads. */
|
||||
models: ModelCatalogEntry[] | undefined;
|
||||
@@ -54,92 +57,47 @@ export function ModelMenuList({
|
||||
onPick: (entry: ModelCatalogEntry) => void;
|
||||
/** Quiet footer note under the list (context-specific). */
|
||||
footer?: string;
|
||||
/** Vision slot: only image-capable models are selectable — plus every
|
||||
* custom-endpoint model, whose capability the endpoint may not report.
|
||||
* Image-capable ones float to the top. */
|
||||
vision?: boolean;
|
||||
}) {
|
||||
const { t, language } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Keep the arrow-walked result visible in the scrolling list.
|
||||
useEffect(() => {
|
||||
if (menuIdx === null) return;
|
||||
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [menuIdx]);
|
||||
|
||||
// The filtered, ranked model list.
|
||||
const results = (models ?? [])
|
||||
.map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) }))
|
||||
.filter((r) => r.s !== null)
|
||||
.sort((a, b) => b.s! - a.s! || a.m.id.localeCompare(b.m.id))
|
||||
.map((r) => r.m);
|
||||
|
||||
const { t } = useI18n();
|
||||
// Vision slot: built-ins must report image input; custom-endpoint models
|
||||
// stay selectable (the endpoint may not report a modality). Image-capable
|
||||
// ones float to the top of the list.
|
||||
const visionItems = vision
|
||||
? models?.filter((m) => m.input.includes("image") || m.provider === CUSTOM_PROVIDER.id)
|
||||
: models;
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={searchRef}
|
||||
data-testid="model-search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setMenuIdx(null); // new query → the walk restarts on Enter/↓
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (results.length === 0) return;
|
||||
setMenuIdx((i) =>
|
||||
e.key === "ArrowDown"
|
||||
? Math.min((i ?? -1) + 1, results.length - 1)
|
||||
: Math.max((i ?? results.length) - 1, 0),
|
||||
);
|
||||
} else if (e.key === "Enter") {
|
||||
const chosen = results[menuIdx ?? 0];
|
||||
if (chosen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onPick(chosen);
|
||||
}
|
||||
}
|
||||
}}
|
||||
<SearchMenuList
|
||||
items={visionItems}
|
||||
itemKey={(m) => `${m.provider}/${m.id}`}
|
||||
searchTerms={(m) => [m.provider, m.id, m.name]}
|
||||
compare={(a, b) => a.id.localeCompare(b.id)}
|
||||
prioritize={vision ? (m) => m.input.includes("image") : undefined}
|
||||
isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)}
|
||||
onPick={onPick}
|
||||
searchTestid="model-search"
|
||||
listTestid="model-list"
|
||||
optionTestid={(m) => `model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
|
||||
placeholder={t("Search models…")}
|
||||
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
loading={t("Loading models…")}
|
||||
empty={t("No models match.")}
|
||||
renderItem={(m) => <>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-card-foreground">{m.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title={t(m.provider === CLAUDE_CODE_PROVIDER ? "Billing is determined by your Claude Code configuration; LearnHarness does not estimate it." : subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : m.cost ? "USD per 1M tokens in/out" : "Price unavailable")}>
|
||||
{t(m.provider === CLAUDE_CODE_PROVIDER ? "CLI billing" : subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost))}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? t("{v0} (subscription)", { v0: subscriptionLabel(`${m.provider}:${m.id}`) }) : m.provider} · {m.id}</span>
|
||||
<span className="shrink-0 tabular-nums">{m.contextWindow === null ? "—" : formatTokens(m.contextWindow)}</span>
|
||||
</span>
|
||||
</>}
|
||||
/>
|
||||
{!models && <div className="px-2 py-3 text-center text-muted-foreground">{t("Loading models…")}</div>}
|
||||
{models && results.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-muted-foreground">{t("No models match.")}</div>
|
||||
)}
|
||||
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
|
||||
{results.map((m, i) => (
|
||||
<li key={`${m.provider}/${m.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
|
||||
data-idx={i}
|
||||
onClick={() => onPick(m)}
|
||||
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
|
||||
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
|
||||
}${m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider) && i !== menuIdx ? " bg-muted/60" : ""}`}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-card-foreground">{m.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? t("Subscription connection") : t("USD per 1M tokens in/out")}>
|
||||
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? t("{v0} (subscription)", { v0: subscriptionLabel(`${m.provider}:${m.id}`) }) : m.provider} · {m.id}</span>
|
||||
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow, language)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{footer && (
|
||||
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user