Files
nanobot/webui/src/components/settings/shared/ModelControls.tsx
T
XiaoHuo888andXubin Ren fd2d419956 feat(providers): add OrcaRouter as a named gateway provider
Registers OrcaRouter (https://www.orcarouter.ai) as a built-in OpenAI-compatible gateway provider mirroring the OpenRouter wiring: registry spec (sk-orca- key prefix, default base URL), ProvidersConfig field, WebUI icon/brand + deferred model list, docs, and tests.
2026-08-16 00:26:51 +08:00

622 lines
21 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import {
Bot,
Brain,
Check,
ChevronDown,
CircleAlert,
Cloud,
Cpu,
Database,
Gem,
Grid3X3,
Hexagon,
Layers,
Loader2,
Moon,
Orbit,
Pencil,
Search,
Sparkles,
Triangle,
Waves,
Zap,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ComboboxOption, useComboboxNavigation } from "@/components/ui/combobox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { fetchProviderModels } from "@/lib/api";
import { providerBrand } from "@/lib/provider-brand";
import type { ProviderModelsPayload, SettingsPayload } from "@/lib/types";
import { cn } from "@/lib/utils";
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix",
"atomic_chat",
"byteplus",
"byteplus_coding_plan",
"huggingface",
"lm_studio",
"modelscope",
"novita",
"ollama",
"openrouter",
"orcarouter",
"ovms",
"siliconflow",
"vllm",
"volcengine",
"volcengine_coding_plan",
]);
const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
export function normalizeContextWindowTokens(value: number | null | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000;
}
function settingsProviderRow(
payload: SettingsPayload,
provider: string | null | undefined,
): SettingsPayload["providers"][number] | null {
if (!provider) return null;
return payload.providers.find((row) => row.name === provider) ?? null;
}
export function settingsProviderConfigured(
payload: SettingsPayload,
provider: string | null | undefined,
resolvedProvider?: string | null,
): boolean {
const row = settingsProviderRow(payload, provider);
if (row) return row.configured;
if (provider === "auto") {
const resolvedRow = settingsProviderRow(
payload,
resolvedProvider ?? payload.agent.resolved_provider ?? payload.agent.provider,
);
if (resolvedRow) return resolvedRow.configured;
}
return payload.agent.has_api_key;
}
export function ProviderPicker({
providers,
value,
emptyLabel,
showProviderLogos = false,
onChange,
}: {
providers: Array<{ name: string; label: string }>;
value: string;
emptyLabel: string;
showProviderLogos?: boolean;
onChange: (provider: string) => void;
}) {
const selectedProvider = providers.find((provider) => provider.name === value) ?? null;
const disabled = providers.length === 0;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={disabled}>
<Button
type="button"
variant="outline"
disabled={disabled}
className={cn(
"h-8 w-[210px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
disabled && "text-muted-foreground",
)}
>
<span className="flex min-w-0 items-center gap-2">
{selectedProvider && showProviderLogos ? (
<ProviderPickerIcon
provider={selectedProvider.name}
showBrandLogos={showProviderLogos}
/>
) : null}
<span className="truncate">{selectedProvider?.label ?? emptyLabel}</span>
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
>
{providers.map((provider) => {
const selected = provider.name === value;
return (
<DropdownMenuItem
key={provider.name}
onSelect={() => onChange(provider.name)}
className={cn(
"flex cursor-default items-center justify-between gap-2 text-[13px]",
selected && "bg-muted/80 text-foreground focus:bg-muted",
)}
>
<span className="flex min-w-0 items-center gap-2">
{showProviderLogos ? (
<ProviderPickerIcon
provider={provider.name}
showBrandLogos={showProviderLogos}
/>
) : null}
<span className="truncate">{provider.label}</span>
</span>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function ModelIdPicker({
token,
settings,
provider,
models,
value,
showProviderLogos,
emptyLabel,
searchPlaceholder,
emptyMessage,
onChange,
}: {
token: string;
settings: SettingsPayload;
provider: string;
models?: string[];
value: string;
showProviderLogos: boolean;
emptyLabel?: string;
searchPlaceholder?: string;
emptyMessage?: string;
onChange: (model: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const tokenRef = useRef(token);
tokenRef.current = token;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const effectiveProvider =
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
const hasStaticModels = models !== undefined;
const providerRow = settingsProviderRow(settings, effectiveProvider);
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
const providerRequiresConfiguration =
!hasStaticModels && hasConcreteProvider && !providerConfigured;
const providerHasBuiltinModels = providerRow?.model_catalog === "builtin";
const providerUsesManualModelIds =
!hasStaticModels &&
hasConcreteProvider &&
providerConfigured &&
providerRow?.auth_type === "oauth" &&
!providerHasBuiltinModels;
const canFetchModels =
!hasStaticModels &&
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
const normalizedQuery = query.trim().toLowerCase();
const providerModels: ProviderModelsPayload["models"] = useMemo(
() => hasStaticModels
? (models?.map((id) => ({ id })) ?? [])
: (payload?.models ?? []),
[hasStaticModels, models, payload?.models],
);
const visibleModels = useMemo(
() => providerModels
.filter((model) => {
if (!normalizedQuery) return true;
return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""]
.some((field) => field.toLowerCase().includes(normalizedQuery));
})
.slice(0, 80),
[normalizedQuery, providerModels],
);
const isCatalog = payload?.catalog_kind === "catalog";
const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
const hasDeferredSearchQuery =
normalizedQuery.length >= DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH;
const shouldFetchModels =
canFetchModels && (!defersModelList || hasDeferredSearchQuery);
const waitingForModelSearch =
open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
const hasModelList = hasStaticModels || payload?.status === "available";
const showModels = Boolean(
hasModelList && (hasStaticModels || (payload && (!isCatalog || normalizedQuery))),
);
const customCandidate = query.trim();
const allowCustomModel = !providerRequiresConfiguration;
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
const showCustomModel = Boolean(
allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value,
);
const providerModelCount = payload?.model_count ?? providerModels.length;
const modelUnconfigured = !value.trim() || !providerConfigured;
useEffect(() => {
if (!open) return;
setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
}, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
useEffect(() => {
if (!open || !shouldFetchModels) {
setPayload(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
setPayload(null);
setError(null);
setLoading(true);
fetchProviderModels(tokenRef.current, effectiveProvider)
.then((nextPayload) => {
if (!cancelled) setPayload(nextPayload);
})
.catch((err) => {
if (!cancelled) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [effectiveProvider, open, shouldFetchModels]);
const selectModel = (model: string) => {
onChange(model);
setOpen(false);
};
const navigationValues = useMemo(
() => [
...(showModels ? visibleModels.map((model) => model.id) : []),
...(showCustomModel ? [customCandidate] : []),
],
[customCandidate, showCustomModel, showModels, visibleModels],
);
const navigation = useComboboxNavigation({
open,
values: navigationValues,
selectedValue: value,
onSelect: selectModel,
onClose: () => setOpen(false),
});
const renderModelRow = (
model: ProviderModelsPayload["models"][number],
options: { selected?: boolean } = {},
) => (
<ComboboxOption
key={model.id}
{...navigation.getOptionProps(model.id)}
className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-control px-2 py-1.5 text-[12px]",
options.selected && "text-foreground",
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0">
<span className="block truncate font-medium text-foreground">
{model.label ?? model.id}
</span>
{model.description || (model.label && model.label !== model.id) ? (
<span className="mt-0.5 block truncate text-[10.5px] text-muted-foreground">
{[model.label && model.label !== model.id ? model.id : null, model.description]
.filter(Boolean)
.join(" · ")}
</span>
) : null}
</span>
</span>
<span className="ml-2 flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
{model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null}
{options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
</span>
</ComboboxOption>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"h-9 w-[min(360px,70vw)] justify-between rounded-full border-input bg-background px-3 text-[12px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={modelUnconfigured}
/>
<span
className={cn(
"min-w-0 truncate font-medium",
value ? "text-foreground" : "text-muted-foreground",
)}
>
{value || emptyLabel || tx("settings.models.selectModel", "Select model")}
</span>
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
>
<div className="p-1 pb-1.5">
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
{...navigation.inputProps}
placeholder={
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
}
aria-label={
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
}
className="h-8 rounded-full pl-8 pr-3 text-[12px]"
/>
</div>
</div>
{providerRequiresConfiguration ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : hasStaticModels && !providerModels.length ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{emptyMessage || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : providerUsesManualModelIds ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : !canFetchModels ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
</div>
) : waitingForModelSearch ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
</div>
) : loading ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{tx("settings.models.loadingModels", "Loading models...")}
</div>
) : error || payload?.status === "error" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
</div>
) : payload?.status === "not_configured" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : isCatalog && !normalizedQuery ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
{providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
</div>
) : null}
{navigationValues.length ? (
<div
{...navigation.listProps}
aria-label={searchPlaceholder || tx("settings.models.selectModel", "Select model")}
className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
>
{showModels
? visibleModels.map((model) =>
renderModelRow(model, { selected: model.id === value }),
)
: null}
{showCustomModel ? (
<>
{showModels && visibleModels.length ? (
<div role="separator" className="-mx-1.5 my-1.5 h-px bg-border/50" />
) : null}
<ComboboxOption
{...navigation.getOptionProps(customCandidate)}
className="flex cursor-default items-center gap-2 rounded-control px-2 py-1.5 text-[12px]"
>
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
<Pencil className="h-3 w-3" aria-hidden />
</span>
<span className="min-w-0 truncate">
{tx("settings.models.useCustomModel", "Use")}{" "}
<span className="font-medium text-foreground">{customCandidate}</span>
</span>
</ComboboxOption>
</>
) : null}
</div>
) : showModels ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
{tx("settings.models.noModelResults", "No matching models.")}
</div>
) : null}
</PopoverContent>
</Popover>
);
}
export function formatContextWindow(tokens: number): string {
if (tokens >= 1_000_000) {
const value = tokens / 1_000_000;
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
}
if (tokens >= 1_000) {
const value = tokens / 1_000;
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`;
}
return String(tokens);
}
export function formatModelContextWindow(tokens: number): string {
if (tokens === 65_536) return "64K";
if (tokens === 262_144) return "256K";
if (tokens === 1_048_576) return "1M";
return formatContextWindow(tokens);
}
export function ProviderPickerIcon({
provider,
showBrandLogos,
unconfigured = false,
}: {
provider: string;
showBrandLogos: boolean;
unconfigured?: boolean;
}) {
const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
if (unconfigured) {
return (
<span
data-testid="provider-picker-unconfigured-icon"
className="grid h-5 w-5 shrink-0 place-items-center text-amber-700 dark:text-amber-200"
aria-hidden
>
<CircleAlert className="h-4 w-4" strokeWidth={1.8} />
</span>
);
}
if (showBrandLogos && logoUrl) {
return (
<span
data-testid={`provider-picker-logo-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background"
aria-hidden
>
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-3.5 w-3.5 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
);
}
if (showBrandLogos && brand) {
return (
<span
data-testid={`provider-picker-logo-fallback-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white"
style={{ backgroundColor: brand.color }}
aria-hidden
>
{brand.initials}
</span>
);
}
return (
<span
className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
aria-hidden
>
<Icon className="h-3 w-3" strokeWidth={2} />
</span>
);
}
export function optionRowsWithCurrent(
options: Array<{ name: string; label: string }>,
value: string,
): Array<{ name: string; label: string }> {
if (!value || options.some((option) => option.name === value)) return options;
return [{ name: value, label: value }, ...options];
}
export const PROVIDER_ICONS: Record<string, LucideIcon> = {
custom: Hexagon,
openrouter: Sparkles,
orcarouter: Sparkles,
skywork: Sparkles,
aihubmix: Triangle,
anthropic: Brain,
openai: Bot,
deepseek: Waves,
zhipu: Grid3X3,
dashscope: Cloud,
modelscope: Layers,
moonshot: Moon,
minimax: Zap,
minimax_anthropic: Brain,
groq: Cpu,
huggingface: Layers,
gemini: Gem,
mistral: Orbit,
siliconflow: Layers,
volcengine: Cloud,
volcengine_coding_plan: Cloud,
byteplus: Cloud,
byteplus_coding_plan: Cloud,
qianfan: Database,
ant_ling: Sparkles,
azure_openai: Cloud,
bedrock: Database,
bocha: Search,
brave: Search,
duckduckgo: Search,
exa: Search,
jina: Search,
kagi: Search,
olostep: Search,
searxng: Search,
tavily: Search,
vllm: Cpu,
ollama: Cpu,
lm_studio: Cpu,
atomic_chat: Cpu,
ovms: Cpu,
nvidia: Zap,
};