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