返回 DeepSeek-Reasonix
ModelSwitcher.tsx
根目录 / desktop / frontend / src / components / ModelSwitcher.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import { Brain, Check, ChevronsUpDown, Search } from "lucide-react";
3 import { asArray } from "../lib/array";
4 import { app } from "../lib/bridge";
5 import { useT } from "../lib/i18n";
6 import type { ModelInfo } from "../lib/types";
7 import { AnchoredPopover } from "./AnchoredPopover";
8 import { Tooltip } from "./Tooltip";
9
10 // ModelSwitcher opens an upward popover listing configured providers. Selecting
11 // one switches the active model while the current conversation continues.
12 export function ModelSwitcher({
13 label,
14 tabId,
15 onPick,
16 }: {
17 label: string;
18 tabId?: string;
19 onPick: (name: string) => boolean | Promise<boolean>;
20 }) {
21 const t = useT();
22 const [open, setOpen] = useState(false);
23 const [models, setModels] = useState<ModelInfo[]>([]);
24 const [query, setQuery] = useState("");
25 const [triggerWidth, setTriggerWidth] = useState<number | undefined>(undefined);
26 const triggerRef = useRef<HTMLButtonElement>(null);
27 const inputRef = useRef<HTMLInputElement>(null);
28 const loadSeqRef = useRef(0);
29 const currentTabKeyRef = useRef(tabId ?? "");
30 const pendingPickCountByTabRef = useRef(new Map<string, number>());
31 const pickSeqByTabRef = useRef(new Map<string, number>());
32 currentTabKeyRef.current = tabId ?? "";
33
34 // Measure trigger width off the render path to avoid forced layout
35 useEffect(() => {
36 const el = triggerRef.current;
37 if (!el) return;
38 const measure = () => setTriggerWidth(el.getBoundingClientRect().width);
39 measure();
40 const observer = new ResizeObserver(() => measure());
41 observer.observe(el);
42 return () => observer.disconnect();
43 }, []);
44
45 const loadModelsForTab = useCallback((targetTabId?: string) => {
46 const targetKey = targetTabId ?? "";
47 const seq = ++loadSeqRef.current;
48 return (targetTabId ? app.ModelsForTab(targetTabId) : app.Models())
49 .then((next) => {
50 if (seq === loadSeqRef.current && currentTabKeyRef.current === targetKey) {
51 setModels(asArray(next).map(normalizeModelInfo));
52 }
53 })
54 .catch(() => {});
55 }, []);
56
57 const loadModels = useCallback(
58 () => loadModelsForTab(tabId),
59 [loadModelsForTab, tabId],
60 );
61
62 useEffect(() => {
63 void loadModels();
64 }, [loadModels]);
65
66 useEffect(() => {
67 const refresh = () => void loadModels();
68 window.addEventListener("reasonix:model-catalog-changed", refresh);
69 return () => window.removeEventListener("reasonix:model-catalog-changed", refresh);
70 }, [loadModels]);
71
72 useEffect(() => {
73 if (open) {
74 setQuery("");
75 void loadModels();
76 window.requestAnimationFrame(() => inputRef.current?.focus());
77 }
78 }, [loadModels, open]);
79
80 const keyword = query.trim().toLowerCase();
81 const filtered = useMemo(
82 () => keyword
83 ? models.filter((m) => m.model.toLowerCase().includes(keyword) || m.provider.toLowerCase().includes(keyword))
84 : models,
85 [models, keyword],
86 );
87
88 // Group by provider, with the current model's group first
89 const groups = useMemo(() => {
90 const map = new Map<string, ModelInfo[]>();
91 let currentProvider = "";
92 for (const m of filtered) {
93 if (m.current) currentProvider = m.provider;
94 const list = map.get(m.provider);
95 if (list) list.push(m);
96 else map.set(m.provider, [m]);
97 }
98 return [...map.entries()]
99 .sort(([a], [b]) => {
100 if (a === currentProvider) return -1;
101 if (b === currentProvider) return 1;
102 return providerLabel(a, t).localeCompare(providerLabel(b, t));
103 })
104 .map(([provider, items]) => ({
105 provider,
106 label: providerLabel(provider, t),
107 items,
108 }));
109 }, [filtered, t]);
110
111 const currentProvider = useMemo(() => {
112 const cur = models.find((m) => m.current) ?? models.find((m) => m.model === label || m.ref === label);
113 return cur ? providerLabel(cur.provider, t) : null;
114 }, [label, models, t]);
115 const triggerLabel = currentProvider ? `${label} · ${currentProvider}` : label;
116
117 const pick = (model: ModelInfo) => {
118 setOpen(false);
119 const pendingKey = tabId ?? "";
120 const pendingPickCount = pendingPickCountByTabRef.current.get(pendingKey) ?? 0;
121 // A catalog refresh can still report the outgoing model as current while
122 // an earlier switch is rebuilding. In that window, selecting it again is
123 // an intentional last-click-wins rollback rather than a no-op.
124 if (model.current && pendingPickCount === 0) return;
125 const previousModels = models;
126 const pickSeq = (pickSeqByTabRef.current.get(pendingKey) ?? 0) + 1;
127 pickSeqByTabRef.current.set(pendingKey, pickSeq);
128 // Catalog requests started before this click describe the outgoing model
129 // and must not overwrite the optimistic last-click choice.
130 loadSeqRef.current += 1;
131 setModels((prev) => prev.map((m) => ({ ...m, current: m.ref === model.ref })));
132 pendingPickCountByTabRef.current.set(pendingKey, pendingPickCount + 1);
133 const settlePick = (switched: boolean) => {
134 const nextCount = Math.max(
135 0,
136 (pendingPickCountByTabRef.current.get(pendingKey) ?? 0) - 1,
137 );
138 if (nextCount === 0) pendingPickCountByTabRef.current.delete(pendingKey);
139 else pendingPickCountByTabRef.current.set(pendingKey, nextCount);
140 // A superseded completion no longer owns the visible selection. Only the
141 // latest failed click may roll back and reconcile with the backend.
142 if (
143 switched ||
144 pickSeqByTabRef.current.get(pendingKey) !== pickSeq ||
145 currentTabKeyRef.current !== pendingKey
146 ) {
147 return;
148 }
149 setModels(previousModels);
150 void loadModelsForTab(tabId);
151 };
152 try {
153 void Promise.resolve(onPick(model.ref)).then(
154 (switched) => settlePick(switched),
155 () => settlePick(false),
156 );
157 } catch (err) {
158 settlePick(false);
159 throw err;
160 }
161 };
162
163 return (
164 <div className="modelsw">
165 <Tooltip label={triggerLabel} fill>
166 <button
167 ref={triggerRef}
168 type="button"
169 className="modelsw__trigger"
170 aria-label={triggerLabel}
171 aria-expanded={open}
172 onClick={() => setOpen((v) => !v)}
173 >
174 <Brain size={14} className="modelsw__kind" />
175 <span className="modelsw__label">{label}</span>
176 <ChevronsUpDown size={11} />
177 </button>
178 </Tooltip>
179 <AnchoredPopover
180 open={open}
181 anchorRef={triggerRef}
182 onClose={() => setOpen(false)}
183 className="modelsw__menu modelsw__menu--portal"
184 style={{ minWidth: Math.max(triggerWidth || 200, 200), maxWidth: "min(90vw, 480px)" }}
185 >
186 <div role="listbox">
187 <div className="modelsw__search" role="presentation">
188 <Search size={13} />
189 <input
190 ref={inputRef}
191 type="text"
192 className="modelsw__search-input"
193 placeholder={t("modelSwitcher.searchPlaceholder")}
194 value={query}
195 onChange={(e) => setQuery(e.target.value)}
196 onKeyDown={(e) => {
197 if (e.key === "Escape") setOpen(false);
198 if (e.key === "Enter" && filtered.length === 1) pick(filtered[0]);
199 }}
200 />
201 </div>
202 {models.length === 0 && <div className="modelsw__empty">{t("status.noModels")}</div>}
203 {models.length > 0 && filtered.length === 0 && query && <div className="modelsw__empty">{t("modelSwitcher.noMatches")}</div>}
204 {groups.map((g) => (
205 <div key={g.provider} role="group" aria-label={g.label} className="modelsw__group">
206 <div className="modelsw__group-label" role="presentation"><Brain size={11} />{g.label}</div>
207 {g.items.map((m) => (
208 <button
209 key={m.ref}
210 type="button"
211 role="option"
212 aria-selected={m.current}
213 className={`modelsw__item ${m.current ? "modelsw__item--current" : ""}`}
214 onClick={() => pick(m)}
215 >
216 <span className="modelsw__copy">
217 <span className="modelsw__model">{m.model}</span>
218 </span>
219 {m.current && <Check size={13} className="modelsw__check" />}
220 </button>
221 ))}
222 </div>
223 ))}
224 </div>
225 </AnchoredPopover>
226 </div>
227 );
228 }
229
230 export function normalizeModelInfo(model: ModelInfo): ModelInfo {
231 return {
232 ...model,
233 provider: String(model.provider ?? ""),
234 model: String(model.model ?? ""),
235 };
236 }
237
238 function providerLabel(provider: string, t: ReturnType<typeof useT>): string {
239 switch (provider) {
240 case "deepseek":
241 case "deepseek-flash":
242 case "deepseek-pro":
243 return t("settings.providerLabel.deepseek");
244 default:
245 return provider;
246 }
247 }
248
248 lines Plain Text