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
311 lines
12 KiB
TypeScript
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>
|
|
);
|
|
};
|