|
|
@@ -1,542 +0,0 @@
|
|
|
-"use client";
|
|
|
-import {
|
|
|
- Check,
|
|
|
- ChevronsUpDown,
|
|
|
- Cloud,
|
|
|
- Database,
|
|
|
- Loader2,
|
|
|
- Pencil,
|
|
|
- Plus,
|
|
|
- RefreshCw,
|
|
|
- X,
|
|
|
-} from "lucide-react";
|
|
|
-import { useTranslations } from "next-intl";
|
|
|
-import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
-import { getAvailableModelsByProviderType } from "@/actions/model-prices";
|
|
|
-import { fetchUpstreamModels, getUnmaskedProviderKey } from "@/actions/providers";
|
|
|
-import { Badge } from "@/components/ui/badge";
|
|
|
-import { Button } from "@/components/ui/button";
|
|
|
-import { Checkbox } from "@/components/ui/checkbox";
|
|
|
-import {
|
|
|
- Command,
|
|
|
- CommandEmpty,
|
|
|
- CommandGroup,
|
|
|
- CommandInput,
|
|
|
- CommandItem,
|
|
|
- CommandList,
|
|
|
-} from "@/components/ui/command";
|
|
|
-import { Input } from "@/components/ui/input";
|
|
|
-import { Label } from "@/components/ui/label";
|
|
|
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
|
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
|
-import type { ProviderType } from "@/types/provider";
|
|
|
-
|
|
|
-type ModelSource = "upstream" | "fallback" | "loading";
|
|
|
-
|
|
|
-interface ModelMultiSelectProps {
|
|
|
- providerType: ProviderType;
|
|
|
- selectedModels: string[];
|
|
|
- onChange: (models: string[]) => void;
|
|
|
- disabled?: boolean;
|
|
|
- /** 供应商 URL(用于获取上游模型列表) */
|
|
|
- providerUrl?: string;
|
|
|
- /** API Key(用于获取上游模型列表) */
|
|
|
- apiKey?: string;
|
|
|
- /** 代理 URL */
|
|
|
- proxyUrl?: string | null;
|
|
|
- /** 代理失败时是否回退到直连 */
|
|
|
- proxyFallbackToDirect?: boolean;
|
|
|
- /** 供应商 ID(编辑模式下用于获取未脱敏的 API Key) */
|
|
|
- providerId?: number;
|
|
|
-}
|
|
|
-
|
|
|
-function ModelSourceIndicator({
|
|
|
- loading,
|
|
|
- isUpstream,
|
|
|
- label,
|
|
|
- description,
|
|
|
-}: {
|
|
|
- loading: boolean;
|
|
|
- isUpstream: boolean;
|
|
|
- label: string;
|
|
|
- description: string;
|
|
|
-}) {
|
|
|
- if (loading) return null;
|
|
|
-
|
|
|
- const Icon = isUpstream ? Cloud : Database;
|
|
|
-
|
|
|
- return (
|
|
|
- <TooltipProvider>
|
|
|
- <Tooltip>
|
|
|
- <TooltipTrigger asChild>
|
|
|
- <div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 text-xs text-muted-foreground">
|
|
|
- <Icon className="h-3 w-3" />
|
|
|
- <span>{label}</span>
|
|
|
- </div>
|
|
|
- </TooltipTrigger>
|
|
|
- <TooltipContent side="top" className="max-w-[200px]">
|
|
|
- <p className="text-xs">{description}</p>
|
|
|
- </TooltipContent>
|
|
|
- </Tooltip>
|
|
|
- </TooltipProvider>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-export function ModelMultiSelect({
|
|
|
- providerType,
|
|
|
- selectedModels,
|
|
|
- onChange,
|
|
|
- disabled = false,
|
|
|
- providerUrl,
|
|
|
- apiKey,
|
|
|
- proxyUrl,
|
|
|
- proxyFallbackToDirect,
|
|
|
- providerId,
|
|
|
-}: ModelMultiSelectProps) {
|
|
|
- const t = useTranslations("settings.providers.form.modelSelect");
|
|
|
- const [open, setOpen] = useState(false);
|
|
|
- const [availableModels, setAvailableModels] = useState<string[]>([]);
|
|
|
- const [loading, setLoading] = useState(true);
|
|
|
- const [modelSource, setModelSource] = useState<ModelSource>("loading");
|
|
|
- const [customModel, setCustomModel] = useState("");
|
|
|
- const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
|
|
- const [editValue, setEditValue] = useState("");
|
|
|
- const [managementError, setManagementError] = useState<string | null>(null);
|
|
|
-
|
|
|
- const displayedModels = useMemo(() => {
|
|
|
- const seen = new Set<string>();
|
|
|
- const merged: string[] = [];
|
|
|
-
|
|
|
- for (const model of availableModels) {
|
|
|
- if (seen.has(model)) continue;
|
|
|
- seen.add(model);
|
|
|
- merged.push(model);
|
|
|
- }
|
|
|
-
|
|
|
- // 关键:把已选中但不在远端列表的自定义模型也渲染出来,保证可取消选中
|
|
|
- for (const model of selectedModels) {
|
|
|
- if (seen.has(model)) continue;
|
|
|
- seen.add(model);
|
|
|
- merged.push(model);
|
|
|
- }
|
|
|
-
|
|
|
- return merged;
|
|
|
- }, [availableModels, selectedModels]);
|
|
|
-
|
|
|
- const availableDisplayedModels = useMemo(
|
|
|
- () => displayedModels.filter((model) => !selectedModels.includes(model)),
|
|
|
- [displayedModels, selectedModels]
|
|
|
- );
|
|
|
-
|
|
|
- // 供应商类型到显示名称的映射
|
|
|
- const getProviderTypeLabel = (type: string): string => {
|
|
|
- const typeMap: Record<string, string> = {
|
|
|
- claude: t("claude"),
|
|
|
- "claude-auth": t("claude"),
|
|
|
- codex: t("openai"),
|
|
|
- gemini: t("gemini"),
|
|
|
- "gemini-cli": t("gemini"),
|
|
|
- "openai-compatible": t("openai"),
|
|
|
- };
|
|
|
- return typeMap[type] || t("openai");
|
|
|
- };
|
|
|
-
|
|
|
- // 加载模型列表(优先上游,失败则回退)
|
|
|
- const loadModels = useCallback(async () => {
|
|
|
- setLoading(true);
|
|
|
- setModelSource("loading");
|
|
|
-
|
|
|
- // 尝试从上游获取模型列表
|
|
|
- if (providerUrl) {
|
|
|
- // 解析 API Key:优先使用表单中的 key,否则从数据库获取
|
|
|
- let resolvedKey = apiKey?.trim() || "";
|
|
|
-
|
|
|
- if (!resolvedKey && providerId) {
|
|
|
- const keyResult = await getUnmaskedProviderKey(providerId);
|
|
|
- if (keyResult.ok && keyResult.data?.key) {
|
|
|
- resolvedKey = keyResult.data.key;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (resolvedKey) {
|
|
|
- const upstreamResult = await fetchUpstreamModels({
|
|
|
- providerUrl,
|
|
|
- apiKey: resolvedKey,
|
|
|
- providerType,
|
|
|
- proxyUrl,
|
|
|
- proxyFallbackToDirect,
|
|
|
- });
|
|
|
-
|
|
|
- if (upstreamResult.ok && upstreamResult.data) {
|
|
|
- setAvailableModels(upstreamResult.data.models);
|
|
|
- setModelSource("upstream");
|
|
|
- setLoading(false);
|
|
|
- return;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 回退到全量模型列表
|
|
|
- const fallbackModels = await getAvailableModelsByProviderType();
|
|
|
- setAvailableModels(fallbackModels);
|
|
|
- setModelSource("fallback");
|
|
|
- setLoading(false);
|
|
|
- }, [providerUrl, apiKey, providerId, providerType, proxyUrl, proxyFallbackToDirect]);
|
|
|
-
|
|
|
- // 组件挂载时加载模型
|
|
|
- useEffect(() => {
|
|
|
- loadModels();
|
|
|
- }, [loadModels]);
|
|
|
-
|
|
|
- const toggleModel = (model: string) => {
|
|
|
- setManagementError(null);
|
|
|
- if (selectedModels.includes(model)) {
|
|
|
- onChange(selectedModels.filter((m) => m !== model));
|
|
|
- } else {
|
|
|
- onChange([...selectedModels, model]);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- const selectAll = () => onChange(availableModels);
|
|
|
- const clearAll = () => {
|
|
|
- setManagementError(null);
|
|
|
- setEditingIndex(null);
|
|
|
- setEditValue("");
|
|
|
- onChange([]);
|
|
|
- };
|
|
|
-
|
|
|
- const handleAddCustomModel = () => {
|
|
|
- const trimmed = customModel.trim();
|
|
|
- if (!trimmed) return;
|
|
|
- setManagementError(null);
|
|
|
-
|
|
|
- if (selectedModels.includes(trimmed)) {
|
|
|
- setCustomModel("");
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- onChange([...selectedModels, trimmed]);
|
|
|
- setCustomModel("");
|
|
|
- };
|
|
|
-
|
|
|
- const isUpstream = modelSource === "upstream";
|
|
|
- const sourceLabel = isUpstream ? t("sourceUpstream") : t("sourceFallback");
|
|
|
- const sourceDescription = isUpstream ? t("sourceUpstreamDesc") : t("sourceFallbackDesc");
|
|
|
-
|
|
|
- const handleRemoveSelectedModel = (index: number) => {
|
|
|
- setManagementError(null);
|
|
|
- if (editingIndex === index) {
|
|
|
- setEditingIndex(null);
|
|
|
- setEditValue("");
|
|
|
- }
|
|
|
- onChange(selectedModels.filter((_, currentIndex) => currentIndex !== index));
|
|
|
- };
|
|
|
-
|
|
|
- const handleStartEditSelectedModel = (index: number, model: string) => {
|
|
|
- setEditingIndex(index);
|
|
|
- setEditValue(model);
|
|
|
- setManagementError(null);
|
|
|
- };
|
|
|
-
|
|
|
- const handleCancelEditSelectedModel = () => {
|
|
|
- setEditingIndex(null);
|
|
|
- setEditValue("");
|
|
|
- setManagementError(null);
|
|
|
- };
|
|
|
-
|
|
|
- const handleSaveEditSelectedModel = (index: number) => {
|
|
|
- const trimmed = editValue.trim();
|
|
|
- if (!trimmed) {
|
|
|
- setManagementError(t("selectedEditEmpty"));
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (selectedModels.some((model, currentIndex) => currentIndex !== index && model === trimmed)) {
|
|
|
- setManagementError(t("selectedEditExists", { model: trimmed }));
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- setManagementError(null);
|
|
|
- onChange(
|
|
|
- selectedModels.map((model, currentIndex) => (currentIndex === index ? trimmed : model))
|
|
|
- );
|
|
|
- setEditingIndex(null);
|
|
|
- setEditValue("");
|
|
|
- };
|
|
|
-
|
|
|
- return (
|
|
|
- <div className="space-y-3">
|
|
|
- <Popover open={open} onOpenChange={setOpen}>
|
|
|
- <PopoverTrigger asChild>
|
|
|
- <Button
|
|
|
- variant="outline"
|
|
|
- role="combobox"
|
|
|
- aria-expanded={open}
|
|
|
- disabled={disabled}
|
|
|
- className="w-full justify-between"
|
|
|
- >
|
|
|
- {selectedModels.length === 0 ? (
|
|
|
- <span className="text-muted-foreground">
|
|
|
- {t("allowAllModels", {
|
|
|
- type: getProviderTypeLabel(providerType),
|
|
|
- })}
|
|
|
- </span>
|
|
|
- ) : (
|
|
|
- <div className="flex gap-2 items-center">
|
|
|
- <span className="truncate">
|
|
|
- {t("selectedCount", { count: selectedModels.length })}
|
|
|
- </span>
|
|
|
- <Badge variant="secondary" className="ml-auto">
|
|
|
- {selectedModels.length}
|
|
|
- </Badge>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- {loading ? (
|
|
|
- <Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin opacity-50" />
|
|
|
- ) : (
|
|
|
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
- )}
|
|
|
- </Button>
|
|
|
- </PopoverTrigger>
|
|
|
- <PopoverContent
|
|
|
- className="w-[400px] max-w-[calc(100vw-2rem)] p-0 flex flex-col"
|
|
|
- align="start"
|
|
|
- onWheel={(e) => e.stopPropagation()}
|
|
|
- onTouchMove={(e) => e.stopPropagation()}
|
|
|
- >
|
|
|
- <Command shouldFilter={true}>
|
|
|
- <CommandInput placeholder={t("searchPlaceholder")} />
|
|
|
- <CommandList className="max-h-[250px] overflow-y-auto">
|
|
|
- <CommandEmpty>{loading ? t("loading") : t("notFound")}</CommandEmpty>
|
|
|
-
|
|
|
- {!loading && (
|
|
|
- <>
|
|
|
- <CommandGroup>
|
|
|
- <div className="flex items-center justify-between gap-2 p-2">
|
|
|
- <div className="flex items-center gap-2">
|
|
|
- <ModelSourceIndicator
|
|
|
- loading={loading}
|
|
|
- isUpstream={isUpstream}
|
|
|
- label={sourceLabel}
|
|
|
- description={sourceDescription}
|
|
|
- />
|
|
|
- <TooltipProvider>
|
|
|
- <Tooltip>
|
|
|
- <TooltipTrigger asChild>
|
|
|
- <Button
|
|
|
- size="icon"
|
|
|
- variant="ghost"
|
|
|
- className="h-6 w-6"
|
|
|
- onClick={(e) => {
|
|
|
- e.stopPropagation();
|
|
|
- loadModels();
|
|
|
- }}
|
|
|
- type="button"
|
|
|
- >
|
|
|
- <RefreshCw className="h-3 w-3" />
|
|
|
- </Button>
|
|
|
- </TooltipTrigger>
|
|
|
- <TooltipContent side="top">
|
|
|
- <p className="text-xs">{t("refresh")}</p>
|
|
|
- </TooltipContent>
|
|
|
- </Tooltip>
|
|
|
- </TooltipProvider>
|
|
|
- </div>
|
|
|
- <div className="flex gap-2">
|
|
|
- <Button
|
|
|
- size="sm"
|
|
|
- variant="outline"
|
|
|
- onClick={selectAll}
|
|
|
- className="h-7 text-xs"
|
|
|
- type="button"
|
|
|
- >
|
|
|
- {t("selectAll", { count: availableModels.length })}
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- size="sm"
|
|
|
- variant="outline"
|
|
|
- onClick={clearAll}
|
|
|
- disabled={selectedModels.length === 0}
|
|
|
- className="h-7 text-xs"
|
|
|
- type="button"
|
|
|
- >
|
|
|
- {t("clear")}
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </CommandGroup>
|
|
|
-
|
|
|
- {selectedModels.length > 0 && (
|
|
|
- <div data-model-group="selected">
|
|
|
- <CommandGroup heading={t("selectedGroupLabel")}>
|
|
|
- {selectedModels.map((model, index) => (
|
|
|
- <CommandItem
|
|
|
- key={`selected:${index}:${model}`}
|
|
|
- value={model}
|
|
|
- onSelect={() => handleRemoveSelectedModel(index)}
|
|
|
- className="cursor-pointer"
|
|
|
- >
|
|
|
- <Checkbox
|
|
|
- checked={true}
|
|
|
- className="mr-2"
|
|
|
- onCheckedChange={() => handleRemoveSelectedModel(index)}
|
|
|
- />
|
|
|
- <span className="font-mono text-sm flex-1">{model}</span>
|
|
|
- <Check className="h-4 w-4 text-primary" />
|
|
|
- </CommandItem>
|
|
|
- ))}
|
|
|
- </CommandGroup>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- <div data-model-group="available">
|
|
|
- <CommandGroup heading={t("availableGroupLabel")}>
|
|
|
- {availableDisplayedModels.map((model) => (
|
|
|
- <CommandItem
|
|
|
- key={model}
|
|
|
- value={model}
|
|
|
- onSelect={() => toggleModel(model)}
|
|
|
- className="cursor-pointer"
|
|
|
- >
|
|
|
- <Checkbox
|
|
|
- checked={false}
|
|
|
- className="mr-2"
|
|
|
- onCheckedChange={() => toggleModel(model)}
|
|
|
- />
|
|
|
- <span className="font-mono text-sm flex-1">{model}</span>
|
|
|
- </CommandItem>
|
|
|
- ))}
|
|
|
- </CommandGroup>
|
|
|
- </div>
|
|
|
- </>
|
|
|
- )}
|
|
|
- </CommandList>
|
|
|
- </Command>
|
|
|
-
|
|
|
- <div className="border-t p-3 space-y-2">
|
|
|
- <Label className="text-xs font-medium">{t("manualAdd")}</Label>
|
|
|
- <div className="flex gap-2">
|
|
|
- <Input
|
|
|
- placeholder={t("manualPlaceholder")}
|
|
|
- value={customModel}
|
|
|
- onChange={(e) => setCustomModel(e.target.value)}
|
|
|
- onKeyDown={(e) => {
|
|
|
- if (e.key === "Enter") {
|
|
|
- e.preventDefault();
|
|
|
- handleAddCustomModel();
|
|
|
- }
|
|
|
- }}
|
|
|
- disabled={disabled}
|
|
|
- className="font-mono text-sm flex-1"
|
|
|
- />
|
|
|
- <Button
|
|
|
- size="sm"
|
|
|
- onClick={handleAddCustomModel}
|
|
|
- disabled={disabled || !customModel.trim()}
|
|
|
- type="button"
|
|
|
- >
|
|
|
- <Plus className="h-4 w-4" />
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
- <p className="text-xs text-muted-foreground">{t("manualDesc")}</p>
|
|
|
- </div>
|
|
|
- </PopoverContent>
|
|
|
- </Popover>
|
|
|
-
|
|
|
- {selectedModels.length > 0 && (
|
|
|
- <div className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2">
|
|
|
- <div className="flex items-center justify-between gap-2">
|
|
|
- <Label className="text-xs font-medium">
|
|
|
- {t("selectedListLabel", { count: selectedModels.length })}
|
|
|
- </Label>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-1">
|
|
|
- {selectedModels.map((model, index) => {
|
|
|
- const isEditing = editingIndex === index;
|
|
|
-
|
|
|
- return (
|
|
|
- <div
|
|
|
- key={`${index}:${model}`}
|
|
|
- data-model-row={`${index}:${model}`}
|
|
|
- className="flex flex-wrap items-center gap-2 rounded-md border border-border/50 bg-background px-3 py-2"
|
|
|
- >
|
|
|
- {isEditing ? (
|
|
|
- <>
|
|
|
- <Input
|
|
|
- value={editValue}
|
|
|
- data-model-edit-input={model}
|
|
|
- onChange={(e) => setEditValue(e.target.value)}
|
|
|
- onInput={(e) => setEditValue((e.target as HTMLInputElement).value)}
|
|
|
- onKeyDown={(e) => {
|
|
|
- if (e.key === "Enter") {
|
|
|
- e.preventDefault();
|
|
|
- handleSaveEditSelectedModel(index);
|
|
|
- } else if (e.key === "Escape") {
|
|
|
- e.preventDefault();
|
|
|
- handleCancelEditSelectedModel();
|
|
|
- }
|
|
|
- }}
|
|
|
- className="font-mono text-sm h-8 flex-1"
|
|
|
- autoFocus
|
|
|
- />
|
|
|
- <Button
|
|
|
- type="button"
|
|
|
- variant="ghost"
|
|
|
- size="sm"
|
|
|
- data-model-edit-save={model}
|
|
|
- onClick={() => handleSaveEditSelectedModel(index)}
|
|
|
- disabled={disabled}
|
|
|
- className="h-8 w-8 p-0"
|
|
|
- >
|
|
|
- <Check className="h-3.5 w-3.5 text-green-600" />
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- type="button"
|
|
|
- variant="ghost"
|
|
|
- size="sm"
|
|
|
- onClick={handleCancelEditSelectedModel}
|
|
|
- disabled={disabled}
|
|
|
- className="h-8 w-8 p-0"
|
|
|
- >
|
|
|
- <X className="h-3.5 w-3.5 text-muted-foreground" />
|
|
|
- </Button>
|
|
|
- </>
|
|
|
- ) : (
|
|
|
- <>
|
|
|
- <span className="font-mono text-sm flex-1 break-all">{model}</span>
|
|
|
- <Button
|
|
|
- type="button"
|
|
|
- variant="ghost"
|
|
|
- size="sm"
|
|
|
- data-model-edit={model}
|
|
|
- onClick={() => handleStartEditSelectedModel(index, model)}
|
|
|
- disabled={disabled}
|
|
|
- className="h-8 w-8 p-0"
|
|
|
- >
|
|
|
- <Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- type="button"
|
|
|
- variant="ghost"
|
|
|
- size="sm"
|
|
|
- data-model-remove={model}
|
|
|
- onClick={() => handleRemoveSelectedModel(index)}
|
|
|
- disabled={disabled}
|
|
|
- className="h-8 w-8 p-0"
|
|
|
- >
|
|
|
- <X className="h-3.5 w-3.5 text-muted-foreground" />
|
|
|
- </Button>
|
|
|
- </>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- );
|
|
|
- })}
|
|
|
- </div>
|
|
|
-
|
|
|
- {managementError && <div className="text-xs text-destructive">{managementError}</div>}
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- );
|
|
|
-}
|