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