| 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 |