返回 DeepSeek-Reasonix
ModelSwitcher.tsx
根目录 / desktop / frontend / src / components / ModelSwitcher.tsx
1 import { formatTokens } from "../lib/format";
2 import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3 import { Check, ChevronDown, ChevronRight, Cpu, Image, List, Plus, Search, Star } from "lucide-react";
4 import { asArray } from "../lib/array";
5 import { app } from "../lib/bridge";
6 import { useT } from "../lib/i18n";
7 import { readModelFavorites, writeModelFavorites } from "../lib/modelFavorites";
8 import { providerBrandIcons } from "../lib/providerBrandIcons";
9 import type { ModelInfo } from "../lib/types";
10 import { AnchoredPopover } from "./AnchoredPopover";
11 import { Tooltip } from "./Tooltip";
12
13 // ModelSwitcher opens an upward popover listing configured providers. Selecting
14 // one switches the active model while the current conversation continues.
15 export function ModelSwitcher({
16 label,
17 tabId,
18 draftId,
19 ready = true,
20 sessionKey,
21 onPick,
22 onManage,
23 detailLabel,
24 details,
25 composerMenu = false,
26 disabled = false,
27 dismissSignal,
28 }: {
29 label: string;
30 detailLabel?: string;
31 details?: ReactNode;
32 composerMenu?: boolean;
33 disabled?: boolean;
34 dismissSignal?: number;
35 tabId?: string;
36 draftId?: string;
37 ready?: boolean;
38 sessionKey?: string;
39 onPick: (name: string) => boolean | Promise<boolean>;
40 onManage?: () => void;
41 }) {
42 const t = useT();
43 const [open, setOpen] = useState(false);
44 const [models, setModels] = useState<ModelInfo[]>([]);
45 const [query, setQuery] = useState("");
46 const [activeFilter, setActiveFilter] = useState("all");
47 const [favorites, setFavorites] = useState<Set<string>>(() => readModelFavorites());
48 const [triggerWidth, setTriggerWidth] = useState<number | undefined>(undefined);
49 const triggerRef = useRef<HTMLButtonElement>(null);
50 const inputRef = useRef<HTMLInputElement>(null);
51 const loadSeqRef = useRef(0);
52 const currentTabKeyRef = useRef(draftId ? `draft:${draftId}` : tabId ?? "");
53 const pendingPickCountByTabRef = useRef(new Map<string, number>());
54 const pickSeqByTabRef = useRef(new Map<string, number>());
55 currentTabKeyRef.current = draftId ? `draft:${draftId}` : tabId ?? "";
56
57 useEffect(() => {
58 setOpen(false);
59 }, [disabled, dismissSignal, draftId, sessionKey, tabId]);
60
61 // Measure trigger width off the render path to avoid forced layout
62 useEffect(() => {
63 const el = triggerRef.current;
64 if (!el) return;
65 const measure = () => setTriggerWidth(el.getBoundingClientRect().width);
66 measure();
67 const observer = new ResizeObserver(() => measure());
68 observer.observe(el);
69 return () => observer.disconnect();
70 }, []);
71
72 const loadModelsForTab = useCallback((targetTabId?: string, targetDraftId?: string) => {
73 const targetKey = targetDraftId ? `draft:${targetDraftId}` : targetTabId ?? "";
74 const seq = ++loadSeqRef.current;
75 return (targetDraftId ? app.ModelsForDraft(targetDraftId) : targetTabId ? app.ModelsForTab(targetTabId) : app.Models())
76 .then((next) => {
77 if (seq === loadSeqRef.current && currentTabKeyRef.current === targetKey) {
78 setModels(asArray(next).map(normalizeModelInfo));
79 }
80 })
81 .catch(() => {});
82 }, []);
83
84 const loadModels = useCallback(
85 () => loadModelsForTab(tabId, draftId),
86 [draftId, loadModelsForTab, tabId],
87 );
88
89 useEffect(() => {
90 void loadModels();
91 }, [loadModels, ready, sessionKey, label]);
92
93 useEffect(() => {
94 const refresh = () => void loadModels();
95 window.addEventListener("reasonix:model-catalog-changed", refresh);
96 return () => window.removeEventListener("reasonix:model-catalog-changed", refresh);
97 }, [loadModels]);
98
99 useEffect(() => {
100 if (open) {
101 setQuery("");
102 void loadModels();
103 window.requestAnimationFrame(() => inputRef.current?.focus());
104 }
105 }, [loadModels, open]);
106
107 const providers = useMemo(() => {
108 const seen = new Set<string>();
109 return models.flatMap((model) => {
110 if (seen.has(model.provider)) return [];
111 seen.add(model.provider);
112 return [{
113 id: model.provider,
114 label: model.displayName?.trim() || providerLabel(model.provider, t),
115 }];
116 });
117 }, [models, t]);
118
119 useEffect(() => {
120 if (activeFilter !== "all" && activeFilter !== "favorites" && !providers.some((provider) => provider.id === activeFilter)) {
121 setActiveFilter("all");
122 }
123 }, [activeFilter, providers]);
124
125 const keyword = query.trim().toLowerCase();
126 const filtered = useMemo(() => models.filter((model) => {
127 if (activeFilter === "favorites" && !favorites.has(model.ref)) return false;
128 if (activeFilter !== "all" && activeFilter !== "favorites" && model.provider !== activeFilter) return false;
129 return !keyword
130 || model.model.toLowerCase().includes(keyword)
131 || model.provider.toLowerCase().includes(keyword)
132 || (model.displayName ?? "").toLowerCase().includes(keyword);
133 }), [activeFilter, favorites, keyword, models]);
134
135 // Preserve catalog/configuration order, including when the current model changes.
136 const groups = useMemo(() => {
137 if (activeFilter === "favorites") {
138 return [{ id: "favorites", label: t("modelSwitcher.favorites"), items: filtered }];
139 }
140 if (activeFilter !== "all") {
141 const provider = providers.find((item) => item.id === activeFilter);
142 return [{ id: activeFilter, label: provider?.label || activeFilter, items: filtered }];
143 }
144 const favoriteItems = filtered.filter((model) => favorites.has(model.ref));
145 const otherItems = filtered.filter((model) => !favorites.has(model.ref));
146 return [
147 { id: "favorites", label: t("modelSwitcher.favorites"), items: favoriteItems },
148 { id: "all", label: t("modelSwitcher.allModels"), items: otherItems },
149 ].filter((group) => group.items.length > 0);
150 }, [activeFilter, favorites, filtered, providers, t]);
151
152 const currentProvider = useMemo(() => {
153 const cur = models.find((m) => m.current) ?? models.find((m) => m.model === label || m.ref === label);
154 return cur ? (cur.displayName?.trim() || providerLabel(cur.provider, t)) : null;
155 }, [label, models, t]);
156 const triggerLabel = [label, currentProvider, detailLabel].filter(Boolean).join(" · ");
157
158 const toggleFavorite = (ref: string) => {
159 setFavorites((current) => {
160 const next = new Set(current);
161 if (next.has(ref)) next.delete(ref);
162 else next.add(ref);
163 writeModelFavorites(next);
164 return next;
165 });
166 };
167
168 const pick = (model: ModelInfo) => {
169 setOpen(false);
170 const pendingKey = draftId ? `draft:${draftId}` : tabId ?? "";
171 const pendingPickCount = pendingPickCountByTabRef.current.get(pendingKey) ?? 0;
172 // A catalog refresh can still report the outgoing model as current while
173 // an earlier switch is rebuilding. In that window, selecting it again is
174 // an intentional last-click-wins rollback rather than a no-op.
175 if (model.current && pendingPickCount === 0) return;
176 const previousModels = models;
177 const pickSeq = (pickSeqByTabRef.current.get(pendingKey) ?? 0) + 1;
178 pickSeqByTabRef.current.set(pendingKey, pickSeq);
179 // Catalog requests started before this click describe the outgoing model
180 // and must not overwrite the optimistic last-click choice.
181 loadSeqRef.current += 1;
182 setModels((prev) => prev.map((m) => ({ ...m, current: m.ref === model.ref })));
183 pendingPickCountByTabRef.current.set(pendingKey, pendingPickCount + 1);
184 const settlePick = (switched: boolean) => {
185 const nextCount = Math.max(
186 0,
187 (pendingPickCountByTabRef.current.get(pendingKey) ?? 0) - 1,
188 );
189 if (nextCount === 0) pendingPickCountByTabRef.current.delete(pendingKey);
190 else pendingPickCountByTabRef.current.set(pendingKey, nextCount);
191 // A superseded completion no longer owns the visible selection. Only the
192 // latest failed click may roll back and reconcile with the backend.
193 if (
194 switched ||
195 pickSeqByTabRef.current.get(pendingKey) !== pickSeq ||
196 currentTabKeyRef.current !== pendingKey
197 ) {
198 return;
199 }
200 setModels(previousModels);
201 void loadModelsForTab(tabId, draftId);
202 };
203 try {
204 void Promise.resolve(onPick(model.ref)).then(
205 (switched) => settlePick(switched),
206 () => settlePick(false),
207 );
208 } catch (err) {
209 settlePick(false);
210 throw err;
211 }
212 };
213
214 return (
215 <div className="modelsw">
216 <Tooltip label={triggerLabel} fill disabled={open}>
217 <button
218 ref={triggerRef}
219 type="button"
220 className="modelsw__trigger"
221 disabled={disabled}
222 aria-label={triggerLabel}
223 aria-expanded={open && !disabled}
224 onClick={() => setOpen((v) => !v)}
225 >
226 <Cpu size={14} className="modelsw__kind" />
227 <span className="modelsw__label">{label}{detailLabel && <span className="modelsw__detail"> · {detailLabel}</span>}</span>
228 <ChevronDown size={12} />
229 </button>
230 </Tooltip>
231 <AnchoredPopover
232 open={open && !disabled}
233 anchorRef={triggerRef}
234 onClose={() => setOpen(false)}
235 className={`modelsw__menu modelsw__menu--portal${composerMenu ? " composer-menu-surface" : ""}`}
236 style={composerMenu ? undefined : { minWidth: Math.max(triggerWidth || 200, 200), maxWidth: "min(90vw, 480px)" }}
237 >
238 <div className="modelsw__search" role="presentation">
239 <Search size={17} />
240 <input
241 ref={inputRef}
242 type="text"
243 className="modelsw__search-input"
244 placeholder={t("modelSwitcher.searchPlaceholder")}
245 aria-label={t("modelSwitcher.searchPlaceholder")}
246 value={query}
247 onChange={(e) => {
248 const nextQuery = e.target.value;
249 setQuery(nextQuery);
250 // The search field belongs to the whole catalog. Typing while
251 // a provider or Favorites is selected must still find models
252 // from every configured connection.
253 if (nextQuery.trim()) setActiveFilter("all");
254 }}
255 onKeyDown={(e) => {
256 if (e.key === "Escape") setOpen(false);
257 if (e.key === "Enter" && filtered.length === 1) pick(filtered[0]);
258 }}
259 />
260 </div>
261 <div className="modelsw__body">
262 <nav className="modelsw__rail" aria-label={t("modelSwitcher.filters")}>
263 <button type="button" className="modelsw__rail-item" aria-label={t("modelSwitcher.favorites")} title={t("modelSwitcher.favorites")} aria-pressed={activeFilter === "favorites"} onClick={() => setActiveFilter("favorites")}>
264 <Star size={18} />
265 </button>
266 <button type="button" className="modelsw__rail-item" aria-label={t("modelSwitcher.allModels")} title={t("modelSwitcher.allModels")} aria-pressed={activeFilter === "all"} onClick={() => setActiveFilter("all")}>
267 <List size={19} />
268 </button>
269 {providers.length > 0 && <span className="modelsw__rail-divider" aria-hidden="true" />}
270 {providers.map((provider) => (
271 <button key={provider.id} type="button" className="modelsw__rail-item" aria-label={provider.label} title={provider.label} aria-pressed={activeFilter === provider.id} onClick={() => setActiveFilter(provider.id)}>
272 <ProviderMark provider={provider.id} label={provider.label} />
273 </button>
274 ))}
275 </nav>
276 <div className="modelsw__catalog" role="listbox" aria-label={t("modelSwitcher.modelList")}>
277 {models.length === 0 && <div className="modelsw__empty">{t("status.noModels")}</div>}
278 {models.length > 0 && filtered.length === 0 && <div className="modelsw__empty">{activeFilter === "favorites" && !query ? t("modelSwitcher.noFavorites") : t("modelSwitcher.noMatches")}</div>}
279 {groups.map((g) => (
280 <div key={g.id} role="group" aria-label={g.label} className="modelsw__group">
281 <div className="modelsw__group-label" role="presentation">{g.label}</div>
282 {g.items.map((m) => {
283 const favorite = favorites.has(m.ref);
284 const favoriteLabel = t(favorite ? "modelSwitcher.removeFavorite" : "modelSwitcher.addFavorite", { model: m.model });
285 return (
286 <div className="modelsw__row" key={m.ref}>
287 <button
288 type="button"
289 role="option"
290 aria-selected={m.current}
291 className={`modelsw__item ${m.current ? "modelsw__item--current" : ""}`}
292 onClick={() => pick(m)}
293 >
294 <ProviderMark provider={m.provider} label={m.displayName?.trim() || providerLabel(m.provider, t)} />
295 <span className="modelsw__copy">
296 <span className="modelsw__model">{m.model}</span>
297 <span className="modelsw__meta">{modelMeta(m, t)}</span>
298 </span>
299 {m.contextWindow ? <span className="badge badge--neutral">{formatTokens(m.contextWindow)}</span> : null}
300 {m.vision && <span className="modelsw__capability" title={t("providerUI.image")}><Image size={13} aria-hidden="true" /><span>{t("providerUI.image")}</span></span>}
301 {m.current && <Check size={13} className="modelsw__check" />}
302 </button>
303 <button type="button" className={`modelsw__favorite${favorite ? " modelsw__favorite--active" : ""}`} aria-label={favoriteLabel} title={favoriteLabel} aria-pressed={favorite} onClick={() => toggleFavorite(m.ref)}>
304 <Star size={16} fill={favorite ? "currentColor" : "none"} />
305 </button>
306 </div>
307 );})}
308 </div>
309 ))}
310 </div>
311 </div>
312 {details && <div className="modelsw__details">{details}</div>}
313 {onManage && <button className="modelsw__manage" type="button" onClick={() => { setOpen(false); onManage(); }}><Plus size={16} />{t("modelSwitcher.configureModels")}<ChevronRight size={15} /></button>}
314 </AnchoredPopover>
315 </div>
316 );
317 }
318
319 export function normalizeModelInfo(model: ModelInfo): ModelInfo {
320 return {
321 ...model,
322 provider: String(model.provider ?? ""),
323 model: String(model.model ?? ""),
324 };
325 }
326
327 function providerLabel(provider: string, t: ReturnType<typeof useT>): string {
328 switch (provider) {
329 case "deepseek":
330 case "deepseek-flash":
331 case "deepseek-pro":
332 return t("settings.providerLabel.deepseek");
333 default:
334 return provider;
335 }
336 }
337
338 function modelMeta(model: ModelInfo, t: ReturnType<typeof useT>): string {
339 const provider = model.displayName?.trim() || providerLabel(model.provider, t);
340 return model.current ? `${provider} · ${t("modelSwitcher.currentModel")}` : provider;
341 }
342
343 function providerBrandID(provider: string): string {
344 const normalized = provider.trim().toLowerCase();
345 if (providerBrandIcons.has(normalized)) return normalized;
346 if (normalized.includes("deepseek")) return "deepseek";
347 if (normalized.includes("openai") || normalized.includes("gpt")) return "openai";
348 if (normalized.includes("anthropic") || normalized.includes("claude")) return "anthropic";
349 if (normalized.includes("google") || normalized.includes("gemini")) return "gemini";
350 if (normalized.includes("glm") || normalized.includes("zhipu") || normalized.includes("zai")) return "zai";
351 if (normalized.includes("minimax")) return "minimax";
352 if (normalized.includes("qwen") || normalized.includes("dashscope")) return "qwen";
353 if (normalized.includes("kimi") || normalized.includes("moonshot")) return "kimi";
354 if (normalized.includes("xai") || normalized.includes("grok")) return "xai";
355 return "";
356 }
357
358 function ProviderMark({ provider, label }: { provider: string; label: string }) {
359 const brandID = providerBrandID(provider);
360 if (brandID) {
361 const icon = `url(/provider-icons/${brandID}.svg)`;
362 return <span className="modelsw__provider-icon" aria-hidden="true" style={{ maskImage: icon, WebkitMaskImage: icon }} />;
363 }
364 const monogram = label.trim().match(/[\p{L}\p{N}]/u)?.[0]?.toUpperCase() || "•";
365 return <span className="modelsw__provider-monogram" aria-hidden="true">{monogram}</span>;
366 }
367
367 lines Plain Text