Files
infinite-canvas/web/src/components/model-picker.tsx
T

119 lines
5.2 KiB
TypeScript

"use client";
import { useEffect, useId, useMemo, useState } from "react";
import { Cpu } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { filterModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
type ModelPickerProps = {
config: AiConfig;
value?: string;
onChange: (model: string) => void;
capability?: ModelCapability;
className?: string;
fullWidth?: boolean;
placeholder?: string;
onMissingConfig?: () => void;
};
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) {
const pickerId = useId();
const [open, setOpen] = useState(false);
const options = useMemo(() => filterModelsByCapability(Array.from(new Set([...(config.channelMode === "local" ? [value] : []), ...config.models].filter((model): model is string => Boolean(model)))), capability), [capability, config.channelMode, config.models, value]);
const current = value || "";
useEffect(() => {
const closeOtherPicker = (event: Event) => {
if ((event as CustomEvent<string>).detail !== pickerId) setOpen(false);
};
window.addEventListener("model-picker-open", closeOtherPicker);
return () => window.removeEventListener("model-picker-open", closeOtherPicker);
}, [pickerId]);
return (
<Select
open={open}
value={current}
onOpenChange={(nextOpen) => {
if (nextOpen && !options.length && config.channelMode === "local") {
onMissingConfig?.();
return;
}
if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId }));
setOpen(nextOpen);
}}
onValueChange={onChange}
>
<SelectTrigger
className={cn(
"canvas-composer-model-picker h-8 w-fit max-w-full gap-2 rounded-full border border-input bg-transparent px-3 text-sm font-normal shadow-sm transition-colors",
fullWidth ? "w-full min-w-0 justify-start" : "min-w-[9rem] justify-start",
"data-[state=open]:border-ring data-[state=open]:ring-2 data-[state=open]:ring-ring/20",
className,
)}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
title={current || placeholder}
>
<ModelIcon model={current} />
<span className="canvas-model-picker-text min-w-0 flex-1 truncate text-left">{current || placeholder}</span>
</SelectTrigger>
<SelectContent
data-canvas-no-zoom
className="z-[1200] w-80 max-w-[calc(100vw-24px)] rounded-xl border border-border/70 bg-popover p-1 shadow-xl"
position="popper"
align="start"
side="bottom"
sideOffset={6}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
{options.length ? (
options.map((model) => (
<SelectItem key={model} value={model} textValue={model}>
<ModelLabel model={model} />
</SelectItem>
))
) : (
<SelectItem value="__empty__" disabled>
{emptyModelLabel(config, capability)}
</SelectItem>
)}
</SelectContent>
</Select>
);
}
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : "";
if (config.channelMode === "remote") return `暂无可用${label}模型`;
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里拉取模型列表";
}
function ModelLabel({ model }: { model: string }) {
return (
<span className="flex min-w-0 items-center gap-2">
<ModelIcon model={model} />
<span className="truncate">{model}</span>
</span>
);
}
function ModelIcon({ model }: { model: string }) {
const icon = resolveModelIcon(model);
return icon ? <img src={icon} alt="" className="size-4 shrink-0 dark:invert" /> : <Cpu className="size-4 shrink-0 opacity-70" />;
}
function resolveModelIcon(model: string) {
const name = model.toLowerCase();
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg";
if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg";
if (name.includes("grok") || name.includes("grok")) return "/icons/grok.svg";
if (name.includes("deepseek") || name.includes("deepseek")) return "/icons/deepseek.svg";
if (name.includes("glm") || name.includes("glm")) return "/icons/glm.svg";
return "";
}