Files
NetMesh/components/settings/tabs/ai/ModelSelector.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

311 lines
12 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, RefreshCw } from "lucide-react";
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "../../../../infrastructure/ai/modelDiscoveryHeaders";
import { buildProviderProbeUrl } from "../../../../infrastructure/ai/providerConnectionProbe";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { cn } from "../../../../lib/utils";
import type { FetchedModel } from "./types";
import { getFetchBridge } from "./types";
import { parseFetchedModels } from "./modelMetadata";
export function buildModelSuggestions({
presetModels,
fetchedModels,
hasFetched,
value,
}: {
presetModels?: readonly string[];
fetchedModels: FetchedModel[];
hasFetched: boolean;
value: string;
}): FetchedModel[] {
const byId = new Map<string, FetchedModel>();
for (const modelId of presetModels ?? []) {
const id = modelId.trim();
if (id) byId.set(id, { id });
}
if (hasFetched) {
for (const model of fetchedModels) {
byId.set(model.id, model);
}
}
const allSuggestions = Array.from(byId.values());
if (!value.trim()) return allSuggestions;
const q = value.toLowerCase();
return allSuggestions.filter((m) =>
m.id.toLowerCase().includes(q) || (m.name && m.name.toLowerCase().includes(q)),
);
}
export function getModelSuggestionsPresentation({
suggestionsLength,
isLoading,
error,
hasFetched,
hasPresetModels,
}: {
suggestionsLength: number;
isLoading: boolean;
error: string | null;
hasFetched: boolean;
hasPresetModels: boolean;
}): {
showSuggestions: boolean;
emptyState: "loading" | "error" | "noMatches" | "loadPrompt" | null;
footerState: "loading" | "error" | null;
} {
if (suggestionsLength > 0) {
return {
showSuggestions: true,
emptyState: null,
footerState: isLoading ? "loading" : error ? "error" : null,
};
}
if (isLoading) {
return { showSuggestions: false, emptyState: "loading", footerState: null };
}
if (error) {
return { showSuggestions: false, emptyState: "error", footerState: null };
}
return {
showSuggestions: false,
emptyState: hasFetched || hasPresetModels ? "noMatches" : "loadPrompt",
footerState: null,
};
}
export function getModelSuggestionClassName(isSelected: boolean): string {
return cn(
"w-full text-left px-3 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition-colors flex items-center justify-between gap-2",
isSelected && "bg-accent text-accent-foreground",
);
}
export const ModelSelector: React.FC<{
value: string;
onChange: (value: string) => void;
baseURL: string;
modelsEndpoint?: string;
presetModels?: readonly string[];
placeholder?: string;
apiKey?: string;
providerId?: AIProviderId;
/** Optional protocol-family override; falls back to `providerId` via {@link resolveProviderStyle}. */
style?: ProviderStyle;
skipTLSVerify?: boolean;
onModelMetadata?: (model: FetchedModel) => void;
}> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, providerId, style, skipTLSVerify, onModelMetadata }) => {
const { t } = useI18n();
const [models, setModels] = useState<FetchedModel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [hasFetched, setHasFetched] = useState(false);
// Resolve the wire-protocol family: prefer an explicit style override (set in
// the form), then fall back to the providerId-derived default.
const resolvedStyle: ProviderStyle = style
?? (providerId ? resolveProviderStyle({ providerId }) : "openai");
// Endpoint follows the resolved style so a providerId+style mismatch (e.g.
// Anthropic providerId switched to OpenAI style) still hits the right path.
const effectiveModelsEndpoint = resolveModelsDiscoveryEndpoint(resolvedStyle, modelsEndpoint);
// Ollama runs locally without auth; all other providers need an API key to list models
const needsApiKey = providerId !== "ollama";
const canFetch = !!effectiveModelsEndpoint && (!needsApiKey || !!apiKey);
const hasPresetModels = (presetModels?.length ?? 0) > 0;
const canSuggest = canFetch || hasPresetModels;
const discoveryKey = JSON.stringify({
baseURL,
effectiveModelsEndpoint,
apiKey,
resolvedStyle,
skipTLSVerify,
});
const discoveryKeyRef = useRef(discoveryKey);
useEffect(() => {
discoveryKeyRef.current = discoveryKey;
setModels([]);
setHasFetched(false);
setError(null);
setIsLoading(false);
}, [discoveryKey]);
const fetchModels = useCallback(async () => {
if (!effectiveModelsEndpoint) return;
const bridge = getFetchBridge();
if (!bridge?.aiFetch) return;
const requestKey = discoveryKey;
setIsLoading(true);
setError(null);
try {
// Temporarily allow the provider's host in the backend fetch allowlist
// so model listing works for URLs not yet synced from the main window.
if (bridge.aiAllowlistAddHost && baseURL) {
await bridge.aiAllowlistAddHost(baseURL);
}
const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint);
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
if (!result.ok) {
if (discoveryKeyRef.current !== requestKey) return;
setError(`Failed to fetch models (${result.error || "unknown error"})`);
return;
}
const parsed = JSON.parse(result.data);
const list = parseFetchedModels(parsed);
list.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
if (discoveryKeyRef.current !== requestKey) return;
setModels(list);
setHasFetched(true);
} catch (err) {
if (discoveryKeyRef.current !== requestKey) return;
setError(err instanceof Error ? err.message : "Failed to parse response");
} finally {
if (discoveryKeyRef.current === requestKey) setIsLoading(false);
}
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify, discoveryKey]);
// Auto-fetch when dropdown first opens
useEffect(() => {
if (isOpen && canFetch && !hasFetched && !isLoading) {
void fetchModels();
}
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
// Filter preset and discovered models by current input value (inline autocomplete).
const suggestions = useMemo(() => {
return buildModelSuggestions({
presetModels,
fetchedModels: models,
hasFetched,
value,
});
}, [models, presetModels, value, hasFetched]);
const showSuggestions = isOpen && canSuggest;
const presentation = getModelSuggestionsPresentation({
suggestionsLength: suggestions.length,
isLoading,
error,
hasFetched,
hasPresetModels,
});
return (
<div className="relative">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
if (canSuggest && !isOpen) setIsOpen(true);
}}
onFocus={() => { if (canSuggest) setIsOpen(true); }}
onBlur={() => { setIsOpen(false); }}
placeholder={placeholder ?? (canSuggest ? t('ai.providers.searchModel') : t('ai.providers.defaultModel.placeholder'))}
className={cn(
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
canSuggest && "pr-8",
)}
/>
{canSuggest && (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); setIsOpen(!isOpen); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<ChevronDown size={14} className={cn("transition-transform", isOpen && "rotate-180")} />
</button>
)}
</div>
{canFetch && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => { setHasFetched(false); void fetchModels(); }}
disabled={isLoading}
className="shrink-0 px-2"
>
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.providers.refreshModels')}</TooltipContent>
</Tooltip>
)}
</div>
{/* Suggestions dropdown */}
{showSuggestions && (
<div className="absolute top-full left-0 right-0 mt-1 z-[101] rounded-md border border-border bg-popover shadow-md">
<div className="max-h-60 overflow-y-auto">
{!presentation.showSuggestions ? (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
{presentation.emptyState === "loading" ? (
<>
<RefreshCw size={14} className="animate-spin inline mr-1.5" />
{t('ai.providers.loadingModels')}
</>
) : presentation.emptyState === "error" ? (
<span className="text-destructive">{error}</span>
) : presentation.emptyState === "noMatches" ? (
t('ai.providers.noMatchingModels')
) : (
t('ai.providers.clickToLoadModels')
)}
</div>
) : (
suggestions.slice(0, 100).map((m) => (
<button
key={m.id}
onMouseDown={(e) => {
e.preventDefault();
onChange(m.id);
onModelMetadata?.(m);
setIsOpen(false);
}}
className={getModelSuggestionClassName(m.id === value)}
>
<span className="font-mono truncate">{m.id}</span>
{m.id === value && <Check size={12} className="text-accent-foreground shrink-0" />}
</button>
))
)}
{presentation.footerState && (
<div className={cn(
"px-3 py-2 text-center text-[10px] border-t border-border/40",
presentation.footerState === "error" ? "text-destructive" : "text-muted-foreground",
)}>
{presentation.footerState === "loading" ? (
<>
<RefreshCw size={12} className="animate-spin inline mr-1" />
{t('ai.providers.loadingModels')}
</>
) : (
error
)}
</div>
)}
{suggestions.length > 100 && (
<div className="px-3 py-2 text-center text-[10px] text-muted-foreground border-t border-border/40">
{t('ai.providers.showingModels').replace('{count}', String(suggestions.length))}
</div>
)}
</div>
</div>
)}
</div>
);
};