// Model selector plus: pinned + drag-reorderable models and brand icons. // client.js is generated by build.mjs (this file + ./icons/*.svg). Edit this file, then run `node build.mjs`. window.__ModuleLoader__.load({ id: '@local/model-selector-plus', factory(require) { const React = require('react'); const ReactDOM = require('react-dom'); const P = require('@deepseek-ai/dsh-client-ui-primitives'); const h = React.createElement; const NS = 'modelPlus'; /* ------------------------------------------------------------------ icons */ const ICONS = {"ai21":"","anthropic":"","aws":"","azure":"","baichuan":"","baidu":"","bedrock":"","bytedance":"","cerebras":"","cohere":"","deepseek":"","doubao":"","fireworks":"","gemini":"","gemma":"","githubcopilot":"","google":"","grok":"","groq":"","huggingface":"","hunyuan":"","internlm":"","liquid":"","lmstudio":"","longcat":"","meta":"","microsoft":"","minimax":"","mistral":"","moonshot":"","nousresearch":"","nova":"","nvidia":"","ollama":"","openai":"","openrouter":"","perplexity":"","qwen":"","stepfun":"","together":"","xiaomimimo":"","yi":"","zai":"","zhipu":""}; // [icon, regexp]. Tested against the model id/name first, then the provider id/name. const BRAND_RULES = [ ['anthropic', /claude|anthropic|(^|[^a-z])(sonnet|opus|haiku)([^a-z]|$)/], ['openai', /chatgpt|(^|[^a-z0-9])gpt|openai|(^|[\/\-_: ])o[1-9](-|$|\s)|davinci|codex|dall-?e|whisper-1|text-embedding-3/], ['gemma', /gemma/], ['gemini', /gemini/], ['google', /google|palm|bard|(^|[^a-z])imagen|(^|[^a-z])veo[-\d]/], ['deepseek', /deepseek/], ['moonshot', /moonshot|kimi/], ['xiaomimimo', /xiaomi|(^|[^a-z])mimo/], ['qwen', /qwen|qwq|tongyi|alibaba|dashscope/], ['zai', /(^|[^a-z])glm|zhipu|chatglm|z-ai|(^|[^a-z])zai([^a-z]|$)|z\.ai/], ['meta', /llama|(^|[^a-z])meta([^a-z]|$)/], ['mistral', /mistral|mixtral|codestral|ministral|devstral|magistral|pixtral/], ['grok', /grok|(^|[^a-z])x-?ai([^a-z]|$)/], ['minimax', /minimax|(^|[^a-z])abab/], ['cohere', /cohere|command-?(r|a)([^a-z]|$)/], ['perplexity', /perplexity|(^|[^a-z])sonar|pplx/], ['nvidia', /nvidia|nemotron/], ['microsoft', /microsoft|(^|[^a-z])phi-?\d|(^|[^a-z])mai-/], ['nova', /(^|[^a-z])amazon|(^|[^a-z])nova-|(^|[^a-z])titan-/], ['doubao', /doubao|(^|[^a-z])seed-|volcengine|volcano/], ['bytedance', /bytedance|(^|[^a-z])ui-tars/], ['hunyuan', /hunyuan|tencent/], ['baidu', /baidu|(^|[^a-z])ernie/], ['yi', /(^|[^a-z])yi-|01-ai|01\.ai|lingyiwanwu/], ['stepfun', /stepfun|(^|[^a-z])step-\d|(^|[^a-z])step\d/], ['longcat', /longcat|meituan/], ['internlm', /internlm|(^|[^a-z])intern-/], ['baichuan', /baichuan/], ['ai21', /ai21|jamba/], ['nousresearch', /nousresearch|(^|[^a-z])nous([^a-z]|$)|(^|[^a-z])hermes/], ['liquid', /(^|[^a-z])lfm|liquid/], ['cerebras', /cerebras/], ]; // Serving platforms: only meaningful when the model name itself says nothing. const PROVIDER_RULES = [ ['openrouter', /openrouter/], ['ollama', /ollama/], ['lmstudio', /lm-?studio/], ['groq', /(^|[^a-z])groq/], ['together', /together/], ['fireworks', /fireworks/], ['huggingface', /hugging-?face|(^|[^a-z])hf([^a-z]|$)/], ['githubcopilot', /copilot/], ['azure', /azure/], ['bedrock', /bedrock/], ['aws', /(^|[^a-z])aws([^a-z]|$)/], ]; const brandCache = new Map(); function brandOf(providerId, providerName, modelId, modelName) { const ck = providerId + '\u0000' + modelId; if (brandCache.has(ck)) return brandCache.get(ck); const modelText = ((modelId || '') + ' ' + (modelName || '')).toLowerCase(); const providerText = ((providerId || '') + ' ' + (providerName || '')).toLowerCase(); let found; for (const [icon, re] of BRAND_RULES) if (re.test(modelText)) { found = icon; break; } if (!found) for (const [icon, re] of BRAND_RULES) if (re.test(providerText)) { found = icon; break; } if (!found) for (const [icon, re] of PROVIDER_RULES) if (re.test(providerText)) { found = icon; break; } if (found && !ICONS[found]) found = undefined; brandCache.set(ck, found); return found; } function BrandIcon({ brand, size = 16 }) { if (!brand) return h('span', { className: 'msp-icon msp-icon-empty', style: { width: size, height: size }, 'aria-hidden': true }); return h('svg', { className: 'msp-icon', 'aria-hidden': true, focusable: false, width: size, height: size, viewBox: '0 0 24 24', fill: 'currentColor', fillRule: 'evenodd', 'data-brand': brand, dangerouslySetInnerHTML: { __html: ICONS[brand] }, }); } const Grip = () => h('svg', { width: 10, height: 14, viewBox: '0 0 10 14', fill: 'currentColor', 'aria-hidden': true }, [1.5, 7].flatMap((x) => [2, 7, 12].map((y) => h('circle', { key: x + ':' + y, cx: x + 0.5, cy: y, r: 1.1 })))); const Ic = (name) => P[name] || (() => null); const IconCheck = Ic('IconCheckOutlineRegular'); const IconChevronDown = Ic('IconChevronDownOutlineRegular'); const IconChevronRight = Ic('IconChevronRightOutlineRegular'); const IconClose = Ic('IconCloseFillRegular'); const IconPinFill = Ic('IconPinFillRegular'); const IconPinOutline = Ic('IconPinOutlineRegular'); /* ------------------------------------------------------------------ pins */ const PIN_KEY = 'dsh.modelSelectorPlus.pins.v1'; const pinKey = (provider, model) => provider + '\u0000' + model; function readPins() { try { const raw = JSON.parse(localStorage.getItem(PIN_KEY) || '[]'); return Array.isArray(raw) ? raw.filter((x) => typeof x === 'string') : []; } catch { return []; } } const pinStore = { list: readPins(), listeners: new Set(), getSnapshot() { return pinStore.list; }, subscribe(fn) { pinStore.listeners.add(fn); return () => pinStore.listeners.delete(fn); }, set(next) { pinStore.list = next; try { localStorage.setItem(PIN_KEY, JSON.stringify(next)); } catch { /* storage unavailable: pins stay in memory */ } pinStore.listeners.forEach((fn) => fn()); }, }; const onStorage = (e) => { if (e.key !== PIN_KEY) return; pinStore.list = readPins(); pinStore.listeners.forEach((fn) => fn()); }; // DSH derives effort names from the level key ("xhigh" -> "Xhigh"); show friendlier ones. const EFFORT_NAMES = { xhigh: 'Extra' }; const effortName = (e) => (e === undefined ? undefined : EFFORT_NAMES[e.id] ?? e.name); function moveKey(order, from, to, pos) { if (from === to) return order; const without = order.filter((k) => k !== from); const at = without.indexOf(to); if (at < 0) return order; without.splice(pos === 'after' ? at + 1 : at, 0, from); return without; } /* ------------------------------------------------------------------ copy */ const en = { 'provider.account': 'DeepSeek Account', 'trigger.fallback': 'Select model', 'trigger.loading': 'Loading models…', 'trigger.aria': 'Select model, current {model}', 'trigger.effortAria': 'Reasoning effort, current {effort}', 'menu.aria': 'Model and reasoning effort', 'menu.model': 'Model', 'menu.effort': 'Effort', 'effort.providerDefault': 'Default', 'status.loading': 'Refreshing model list…', 'error.action': 'Model operation failed: {message}', 'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance. Quit other running DSH instances and try again.', 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', 'search.placeholder': 'Search models…', 'search.clear': 'Clear search', 'search.empty': 'No matching models.', 'empty.models': 'No models available.', 'empty.efforts': 'This model provides no reasoning effort levels.', pinned: 'Pinned', pin: 'Pin to top', unpin: 'Unpin', reorder: 'Drag to reorder (or Alt+↑/↓)', addModels: 'Add models', addModelsHint: 'Open model settings', }; const zh = { 'provider.account': 'DeepSeek 账号', 'trigger.fallback': '请选择模型', 'trigger.loading': '正在加载模型…', 'trigger.aria': '选择模型,当前 {model}', 'trigger.effortAria': '推理等级,当前 {effort}', 'menu.aria': '模型与推理等级', 'menu.model': '模型', 'menu.effort': '推理等级', 'effort.providerDefault': 'Default', 'status.loading': '正在刷新模型列表…', 'error.action': '模型操作失败:{message}', 'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的,请退出其他 DSH 后重试。', 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', 'search.placeholder': '搜索模型…', 'search.clear': '清除搜索', 'search.empty': '没有匹配的模型。', 'empty.models': '没有可用的模型。', 'empty.efforts': '当前模型未提供推理等级。', pinned: '已固定', pin: '固定到顶部', unpin: '取消固定', reorder: '拖动排序(或 Alt+↑/↓)', addModels: '添加模型', addModelsHint: '打开模型设置', }; /* ------------------------------------------------------------------ css */ const CSS = ` .msp-root{min-width:0;position:relative;display:flex;align-items:center;gap:2px} .msp-trigger{border-radius:var(--dsw-radius-sm);min-width:0;max-width:min(360px,45cqw);height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;outline:none;align-items:center;gap:6px;padding:0 4px 0 8px;font-size:13px;font-weight:400;line-height:20px;display:flex} .msp-trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)} .msp-trigger:focus-visible:not([data-selection-focus]){box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))} .msp-trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default} .msp-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden} .msp-effort{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden} .msp-trigger-effort{color:var(--dsw-alias-label-caption);flex-shrink:1000;padding-left:6px} .msp-trigger-effort:disabled{opacity:.6} .msp-chevron{color:var(--dsw-alias-label-caption);flex:none;transition:transform .12s} .msp-chevron-open{transform:rotate(180deg)} .msp-icon{flex:none;display:inline-block;line-height:1} .msp-menu{z-index:1100;--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);width:max-content;min-width:min(260px,100vw - 32px);max-width:min(420px,100vw - 32px);max-height:min(420px,100vh - 96px);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border:0;flex-direction:column;padding:4px;display:flex;position:fixed;overflow:hidden} .msp-status,.msp-empty{color:var(--dsw-alias-label-tertiary);padding:8px;font-size:12px;line-height:18px} .msp-error,.msp-warning{border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);justify-content:space-between;align-items:flex-start;gap:6px;margin-bottom:3px;padding:6px 7px;font-size:11px;line-height:16px;display:flex} .msp-warning{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-state-warn-label)} .msp-retry{color:inherit;cursor:pointer;font:inherit;text-decoration:underline;background:0 0;border:0;padding:0;flex:none} .msp-search-row{flex-shrink:0;margin:2px 0 3px;position:relative} .msp-search-row .msp-search{border-radius:var(--dsw-radius-md);background:0 0;border:0 solid #0000;height:auto;padding:5px 7px;display:flex} .msp-search-row .msp-search-q{padding-right:34px} .msp-search-row .msp-search:focus-within{border-color:#0000} .msp-search-row .msp-search input{padding:0;font-size:12px;line-height:normal} .msp-search-row .msp-search input::placeholder{color:var(--dsw-alias-label-caption)} .msp-search-clear{width:24px;height:24px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;justify-content:center;align-items:center;padding:0;display:inline-flex;position:absolute;top:50%;right:4px;transform:translateY(-50%)} .msp-search-clear:hover,.msp-search-clear:focus-visible{background:var(--dsw-alias-interactive-bg-hover);outline:none} .msp-groups{min-height:0;overflow-y:auto} .msp-row{box-sizing:border-box;border-radius:var(--dsw-radius-md);min-width:100%;min-height:34px;align-items:center;gap:2px;padding:0 2px 0 0;display:flex;position:relative} .msp-row:hover,.msp-row-active{background:var(--dsw-alias-interactive-bg-hover)} .msp-row-selected{background:0 0} .msp-row-selected:hover,.msp-row-selected.msp-row-active{background:var(--dsw-alias-interactive-bg-hover)} .msp-row[draggable=true]{cursor:grab} .msp-row-dragging{opacity:.4} .msp-row-drop-before::before,.msp-row-drop-after::after{content:"";background:var(--dsw-alias-state-business-primary);pointer-events:none;border-radius:1px;height:2px;position:absolute;left:6px;right:6px;z-index:2} .msp-row-drop-before::before{top:-1px} .msp-row-drop-after::after{bottom:-1px} .msp-grip{width:12px;color:var(--dsw-alias-label-caption);flex:none;justify-content:center;align-items:center;margin-left:2px;display:flex;opacity:.55} .msp-row:hover .msp-grip{opacity:1} .msp-option{box-sizing:border-box;color:inherit;text-align:left;cursor:pointer;background:0 0;border:none;outline:none;flex:1;align-items:center;gap:8px;min-width:0;min-height:34px;padding:5px 5px 5px 7px;display:flex;border-radius:var(--dsw-radius-md)} .msp-option[aria-disabled=true]{color:var(--dsw-alias-label-dimmed);cursor:default} .msp-option:focus-visible{background:var(--dsw-alias-interactive-bg-hover)} .msp-copy{flex-direction:column;flex:1;min-width:0;display:flex} .msp-name{color:inherit;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:400;line-height:18px;overflow:hidden} .msp-provider{color:var(--dsw-alias-label-caption);text-overflow:ellipsis;white-space:nowrap;font-size:11px;line-height:14px;overflow:hidden} .msp-check{color:var(--dsw-alias-label-primary);flex:0 0 14px;place-items:center;display:grid} .msp-check svg{width:14px;height:14px} .msp-pin{width:26px;height:26px;color:var(--dsw-alias-label-caption);cursor:pointer;background:0 0;border:none;border-radius:var(--dsw-radius-sm);justify-content:center;align-items:center;padding:0;display:inline-flex;flex:none;opacity:0;outline:none} .msp-row:hover .msp-pin,.msp-row-active .msp-pin,.msp-pin:focus-visible,.msp-pin-on{opacity:1} .msp-pin:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)} .msp-pin-on{color:var(--dsw-alias-label-secondary)} .msp-pin:disabled{cursor:default;opacity:0} .msp-pin svg{width:14px;height:14px} .msp-title{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:18px;margin:-2px 0} .msp-title-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .msp-title-row{display:flex;justify-content:flex-end;padding:2px 3px 0} .msp-add,.msp-title .msp-add{box-sizing:border-box;width:20px;min-width:20px;max-width:20px;height:20px;min-height:20px;margin:0;color:var(--dsw-alias-label-caption);cursor:pointer;background:0 0;border:none;border-radius:var(--dsw-radius-sm);justify-content:center;align-items:center;padding:0;display:inline-flex;flex:none;outline:none} .msp-add:hover,.msp-add:focus-visible{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)} .msp-cell{box-sizing:border-box;border-radius:var(--dsw-radius-md);width:auto;min-width:100%;height:34px;color:var(--dsw-alias-label-primary);cursor:pointer;text-align:left;background:0 0;border:none;outline:none;align-items:center;gap:6px;padding:0 8px;font-size:13px;line-height:20px;display:flex} .msp-cell:hover,.msp-cell:focus-visible{background:var(--dsw-alias-interactive-bg-hover)} .msp-cell-label{white-space:nowrap;flex:none} .msp-cell-value{text-overflow:ellipsis;white-space:nowrap;text-align:right;min-width:0;color:var(--dsw-alias-label-tertiary);flex:auto;align-items:center;justify-content:flex-end;gap:6px;display:flex;overflow:hidden} .msp-cell-chevron{width:12px;height:12px;color:var(--dsw-alias-menu-icon);flex:none} .msp-effort-option{box-sizing:border-box;border-radius:var(--dsw-radius-md);width:100%;min-height:34px;color:inherit;font-size:13px;text-align:left;cursor:pointer;background:0 0;border:none;outline:none;align-items:center;gap:6px;padding:5px 7px;display:flex} .msp-effort-option:hover:not(:disabled),.msp-effort-option:focus-visible{background:var(--dsw-alias-interactive-bg-hover)} .msp-effort-option:disabled{color:var(--dsw-alias-label-dimmed);cursor:default} `; function installCss() { if (typeof document === 'undefined') return; // Always refresh: after a live plugin reload an older tag may linger with outdated rules. let tag = document.querySelector('style[data-model-plus-css]'); if (!tag) { tag = document.createElement('style'); tag.dataset.modelPlusCss = ''; document.head.appendChild(tag); } if (tag.textContent !== CSS) tag.textContent = CSS; } const cx = (...a) => a.filter(Boolean).join(' '); const MEASURE_STYLE = { visibility: 'hidden', left: 0, top: 0 }; /* ------------------------------------------------------------------ component */ function orderProviders(groups) { const rank = (g) => (g.id === 'deepseek-account' ? 0 : g.id === 'deepseek-official' ? 1 : 2); return groups.toSorted((a, b) => rank(a) - rank(b)); } function rankByName(items, q) { if (P.rankByName) return P.rankByName(items, q); const lq = q.toLowerCase(); return lq === '' ? items : items.filter((i) => i.name.toLowerCase().includes(lq)); } function ModelSelectPlus({ locked, available, directory, load, select, openSettings, t }) { const state = React.useSyncExternalStore((fn) => directory.subscribe(fn), () => directory.getSnapshot()); const pins = React.useSyncExternalStore(pinStore.subscribe, pinStore.getSnapshot); // pane: null (closed) | 'model' | 'effort' — each has its own trigger button. const [pane, setPane] = React.useState(null); const open = pane !== null; const [query, setQuery] = React.useState(''); const [highlightedIndex, setHighlightedIndex] = React.useState(null); const [selectionFocus, setSelectionFocus] = React.useState(false); const [actionError, setActionError] = React.useState(null); const [drag, setDrag] = React.useState(null); // { key, overKey, pos } const [menuPos, setMenuPos] = React.useState(null); const lastActionRef = React.useRef('load'); const rootRef = React.useRef(null); const modelTriggerRef = React.useRef(null); const effortTriggerRef = React.useRef(null); const searchRef = React.useRef(null); const menuRef = React.useRef(null); const itemRefs = React.useRef([]); const focusKeyRef = React.useRef(null); const id = React.useId(); React.useEffect(() => { installCss(); window.addEventListener('storage', onStorage); return () => window.removeEventListener('storage', onStorage); }, []); const providerLabel = (g) => (g.id === 'deepseek-account' ? t('provider.account') : g.name); const groups = React.useMemo(() => orderProviders(state.groups), [state.groups]); const choices = React.useMemo(() => groups.flatMap((group) => group.models.map((model) => ({ key: pinKey(group.id, model.id), group, model, name: model.name, brand: brandOf(group.id, group.name, model.id, model.name), selection: { provider: group.id, model: model.id, ...(model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: model.reasoning.defaultEffort }), }, }))), [groups]); const choiceByKey = React.useMemo(() => new Map(choices.map((c) => [c.key, c])), [choices]); const showSearch = choices.length > 4; const q = showSearch ? query.trim() : ''; const pinnedAll = React.useMemo(() => pins.map((k) => choiceByKey.get(k)).filter(Boolean), [pins, choiceByKey]); const pinSet = React.useMemo(() => new Set(pins), [pins]); const pinnedShown = React.useMemo(() => rankByName(pinnedAll, q), [pinnedAll, q]); const filteredGroups = React.useMemo(() => groups.map((group) => ({ ...group, models: rankByName( group.models.filter((m) => !pinSet.has(pinKey(group.id, m.id))).map((m) => ({ name: m.name, model: m })), q, ).map((x) => x.model), })).filter((g) => g.models.length > 0), [groups, pinSet, q]); const visible = React.useMemo(() => [ ...pinnedShown.map((c) => ({ key: c.key, provider: c.group.id, model: c.model.id })), ...filteredGroups.flatMap((g) => g.models.map((m) => ({ key: pinKey(g.id, m.id), provider: g.id, model: m.id }))), ], [pinnedShown, filteredGroups]); const currentVisibleIndex = visible.findIndex((v) => v.provider === state.current?.provider && v.model === state.current.model); const activeModelIndex = Math.min(highlightedIndex ?? Math.max(0, currentVisibleIndex), visible.length - 1); const currentChoice = state.current === null ? undefined : choices.find((c) => c.selection.provider === state.current.provider && c.selection.model === state.current.model); const reasoning = currentChoice?.model.reasoning; const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort; const effortLabel = reasoning === undefined ? state.retainedEffort : effectiveEffort === undefined ? t('effort.providerDefault') : effortName(reasoning.efforts.find((l) => l.id === effectiveEffort)) ?? effectiveEffort; const effortChoices = React.useMemo(() => (reasoning === undefined ? [] : [ ...(reasoning.defaultEffort === undefined ? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }] : []), ...reasoning.efforts.map((e) => ({ key: 'effort:' + e.id, effort: e.id, label: effortName(e) })), ]), [reasoning, t]); const { pending } = state; const busy = pending !== null; const reload = () => { lastActionRef.current = 'load'; load(); }; /* ---- effects */ React.useEffect(() => { if (!open) return undefined; const closeOutside = (e) => { if (rootRef.current?.contains(e.target) === true) return; if (menuRef.current?.contains(e.target) === true) return; setPane(null); }; document.addEventListener('mousedown', closeOutside); return () => document.removeEventListener('mousedown', closeOutside); }, [open]); React.useLayoutEffect(() => { if (!showSearch) { setQuery(''); setHighlightedIndex(null); } }, [showSearch]); // Move keyboard focus into the menu when a pane opens (search box, else the checked row). React.useEffect(() => { if (!open) return; const menu = menuRef.current; if (pane === 'model' && showSearch) { searchRef.current?.focus(); return; } (menu?.querySelector('[aria-checked="true"]:not([aria-disabled="true"]):not([disabled])') ?? itemRefs.current.find((i) => i))?.focus(); }, [open, pane, showSearch]); // Keep keyboard focus on a pinned row after Alt+Arrow reorder. React.useLayoutEffect(() => { const key = focusKeyRef.current; if (key === null) return; focusKeyRef.current = null; const rows = menuRef.current?.querySelectorAll('[data-pinned-row] [data-row]') ?? []; for (const r of rows) if (r.dataset.key === key) { r.focus(); break; } }, [pins]); React.useLayoutEffect(() => { if (!open) { setMenuPos(null); return undefined; } const place = () => { const rect = (pane === 'effort' ? effortTriggerRef : modelTriggerRef).current?.getBoundingClientRect(); if (rect === undefined) return; const MARGIN = 12; const lw = menuRef.current?.offsetWidth ?? 0; const lh = menuRef.current?.offsetHeight ?? 0; let x = rect.left; let y = rect.top - 8 - lh; if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN); if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN); setMenuPos({ left: x, top: y }); }; place(); window.addEventListener('scroll', place, true); window.addEventListener('resize', place); return () => { window.removeEventListener('scroll', place, true); window.removeEventListener('resize', place); }; }, [open, pane, state, query, pins]); if (!available) return null; /* ---- verbs */ const triggerOf = (which) => (which === 'effort' ? effortTriggerRef : modelTriggerRef); const show = (which) => { setSelectionFocus(false); setQuery(''); setHighlightedIndex(null); setActionError(null); setPane(which); reload(); }; const toggle = (which) => { if (pane === which) close(true); else show(which); }; const changeQuery = (next) => { setQuery(next); setHighlightedIndex(0); }; const close = (restoreFocus = false) => { const ref = triggerOf(pane); setPane(null); setDrag(null); if (restoreFocus) queueMicrotask(() => ref.current?.focus()); }; const closeAfterSelection = () => { setSelectionFocus(true); close(true); }; const rowEls = () => itemRefs.current.filter((i) => i); const moveFocus = (offset) => { const items = rowEls(); if (items.length === 0) return; const active = items.findIndex((i) => i === document.activeElement); items[active === -1 ? (offset > 0 ? 0 : items.length - 1) : (active + offset + items.length) % items.length]?.focus(); }; const settleSelection = (result) => { if (result === undefined) return; if (result.ok) { setActionError(null); if (rootRef.current !== null) closeAfterSelection(); return; } const { error } = result; setActionError(error.code === 'session/writer-held' ? t('error.sessionInUse') : t('error.action', { message: `${error.code}: ${error.message}` })); }; const submit = (selection) => { lastActionRef.current = 'select'; setActionError(null); setSelectionFocus(true); triggerOf(pane).current?.focus(); select(selection).then(settleSelection); }; const choose = (selection) => { if (state.current?.provider === selection.provider && state.current.model === selection.model) { closeAfterSelection(); return; } const c = choiceByKey.get(pinKey(selection.provider, selection.model)); // Keep the effort the model already defaults to, like the stock selector. submit(c ? c.selection : selection); }; const chooseEffort = (effort) => { if (state.current === null) return; if (effectiveEffort === effort) { closeAfterSelection(); return; } submit({ provider: state.current.provider, model: state.current.model, ...(effort === undefined ? {} : { reasoningEffort: effort }) }); }; const togglePin = (key) => { pinStore.set(pinSet.has(key) ? pins.filter((k) => k !== key) : [...pins, key]); }; const reorderBy = (key, delta) => { const at = pins.indexOf(key); const to = at + delta; if (at < 0 || to < 0 || to >= pins.length) return; const next = pins.slice(); [next[at], next[to]] = [next[to], next[at]]; focusKeyRef.current = key; pinStore.set(next); }; const onRootKeyDown = (event) => { if (event.nativeEvent.isComposing) return; const target = event.target; const rowEl = target instanceof Element ? target.closest('[data-row]') : null; if (event.key === 'Escape' && open) { event.preventDefault(); close(true); return; } if (!open) return; if (pane === 'model' && showSearch && (event.key === 'ArrowDown' || event.key === 'ArrowUp') && !event.altKey) { event.preventDefault(); if (!busy && visible.length > 0) { setHighlightedIndex((activeModelIndex + (event.key === 'ArrowDown' ? 1 : -1) + visible.length) % visible.length); searchRef.current?.focus(); } return; } if (pane === 'model' && showSearch && target instanceof HTMLInputElement && (event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey))) { if (event.key === 'Tab' && visible.length === 0) return; event.preventDefault(); const hl = visible[activeModelIndex]; if (!busy && hl !== undefined) choose(hl); return; } if (rowEl && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); rowEl.click(); return; } if (rowEl && !event.altKey && !event.ctrlKey && !event.metaKey && (event.key === 'p' || event.key === 'P') && rowEl.dataset.key) { event.preventDefault(); togglePin(rowEl.dataset.key); return; } if (rowEl && event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown') && rowEl.closest('[data-pinned-row]') && q === '') { event.preventDefault(); reorderBy(rowEl.dataset.key, event.key === 'ArrowDown' ? 1 : -1); return; } if (event.key === 'Tab') { if (event.shiftKey) { event.preventDefault(); close(true); return; } const focused = document.activeElement; const rows = rowEls(); if (focused instanceof HTMLElement && rows.includes(focused)) { event.preventDefault(); focused.click(); return; } if (focused !== modelTriggerRef.current && focused !== effortTriggerRef.current) return; event.preventDefault(); if (pane === 'model' && showSearch) { setHighlightedIndex(null); searchRef.current?.focus(); return; } (menuRef.current?.querySelector('[aria-checked="true"]:not([aria-disabled="true"]):not([disabled])') ?? rows[0])?.focus(); return; } if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); moveFocus(event.key === 'ArrowDown' ? 1 : -1); } }; const onBlur = (event) => { if (event.relatedTarget instanceof Node && (rootRef.current?.contains(event.relatedTarget) === true || menuRef.current?.contains(event.relatedTarget) === true)) return; close(); }; /* ---- drag & drop (pinned rows only) */ const dragEnabled = q === '' && !busy; const dragHandlers = (key) => ({ draggable: dragEnabled, onDragStart: (e) => { if (!dragEnabled) { e.preventDefault(); return; } e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', key); } catch { /* some engines refuse */ } setDrag({ key, overKey: null, pos: 'before' }); }, onDragOver: (e) => { if (!drag || drag.key === key) { if (drag && drag.overKey !== null) setDrag({ ...drag, overKey: null }); return; } e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = e.currentTarget.getBoundingClientRect(); const pos = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; if (drag.overKey !== key || drag.pos !== pos) setDrag({ ...drag, overKey: key, pos }); }, onDrop: (e) => { if (!drag) return; e.preventDefault(); if (drag.overKey !== null) { const next = moveKey(pins, drag.key, drag.overKey, drag.pos); if (next !== pins) pinStore.set(next); } setDrag(null); }, onDragEnd: () => setDrag(null), }); /* ---- render */ const waiting = state.current === null && state.status === 'loading'; const modelLabel = waiting ? t('trigger.loading') : currentChoice?.model.name ?? (state.current === null ? t('trigger.fallback') : `${state.current.provider}/${state.current.model}`); const triggerBrand = currentChoice?.brand ?? (state.current === null ? undefined : brandOf(state.current.provider, state.current.provider, state.current.model, state.current.model)); const modelAria = state.current === null ? t('trigger.fallback') : t('trigger.aria', { model: modelLabel }); const effortAria = t('trigger.effortAria', { effort: effortLabel ?? '' }); let modelIndex = 0; const renderRow = (choice, pinnedRow) => { const index = modelIndex++; const { key, group, model } = choice; const selected = state.current?.provider === group.id && state.current.model === model.id; const isPending = pending?.provider === group.id && pending.model === model.id; const dropping = drag && drag.overKey === key; const pinned = pinSet.has(key); return h('div', { key: (pinnedRow ? 'p:' : 'g:') + key, className: cx('msp-row', selected && 'msp-row-selected', index === activeModelIndex && 'msp-row-active', drag && drag.key === key && pinnedRow && 'msp-row-dragging', dropping && pinnedRow && (drag.pos === 'before' ? 'msp-row-drop-before' : 'msp-row-drop-after')), onMouseMove: busy || index === activeModelIndex || !showSearch ? undefined : () => setHighlightedIndex(index), ...(pinnedRow ? dragHandlers(key) : {}), }, pinnedRow && h('span', { className: 'msp-grip', title: t('reorder'), 'aria-hidden': true }, h(Grip)), h('div', { ref: (el) => { itemRefs.current[index] = el; }, role: 'menuitemradio', 'aria-checked': selected, 'aria-disabled': busy ? 'true' : undefined, tabIndex: showSearch ? -1 : 0, id: `${id}-model-${index}`, 'data-row': '', 'data-key': key, className: 'msp-option', title: pinnedRow ? `${model.name} — ${providerLabel(group)}` : model.name, onFocus: () => setHighlightedIndex(index), onClick: () => { if (!busy) choose({ provider: group.id, model: model.id }); }, }, h(BrandIcon, { brand: choice.brand }), h('span', { className: 'msp-copy' }, h('span', { className: 'msp-name' }, model.name), pinnedRow && h('span', { className: 'msp-provider' }, providerLabel(group))), h('span', { className: 'msp-check' }, isPending ? h(P.StateDot, { state: 'ongoing' }) : selected ? h(IconCheck) : null)), h('button', { type: 'button', tabIndex: -1, disabled: busy, className: cx('msp-pin', pinned && 'msp-pin-on'), 'aria-pressed': pinned, 'aria-label': pinned ? t('unpin') : t('pin'), title: pinned ? t('unpin') : t('pin'), onMouseDown: (e) => e.preventDefault(), onClick: (e) => { e.stopPropagation(); togglePin(key); }, }, pinned ? h(IconPinFill) : h(IconPinOutline))); }; const loadError = state.error !== null && lastActionRef.current === 'load' ? h('div', { className: 'msp-error' }, h('span', null, t('error.action', { message: state.error })), h('button', { type: 'button', className: 'msp-retry', onClick: reload }, t('action.reload'))) : null; const addButton = h('button', { type: 'button', className: 'msp-add', title: t('addModelsHint'), 'aria-label': t('addModelsHint'), tabIndex: -1, onMouseDown: (e) => e.preventDefault(), onClick: (e) => { e.stopPropagation(); setPane(null); setDrag(null); openSettings(); }, }, h('svg', { width: 12, height: 12, viewBox: '0 0 14 14', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', 'aria-hidden': true }, h('path', { d: 'M7 2v10M2 7h10' }))); // A group heading; the first one in the list also carries the "add models" button at its right end. const titled = (text, first) => (first ? h('span', { className: 'msp-title' }, h('span', { className: 'msp-title-text' }, text), addButton) : text); const menu = open && ReactDOM.createPortal(h(P.MenuSurface, { ref: menuRef, id: `${id}-menu`, className: 'msp-menu', style: menuPos ?? MEASURE_STYLE, role: pane === 'model' ? 'group' : 'menu', 'aria-label': pane === 'effort' ? t('menu.effort') : t('menu.model'), 'aria-busy': state.status === 'loading' || busy, }, pane === 'model' && h(React.Fragment, null, showSearch && h('div', { className: 'msp-search-row' }, h(P.Input, { ref: searchRef, className: cx('msp-search', query !== '' && 'msp-search-q'), type: 'text', role: 'searchbox', 'aria-label': t('search.placeholder'), 'aria-controls': `${id}-models`, 'aria-activedescendant': activeModelIndex < 0 ? undefined : `${id}-model-${activeModelIndex}`, placeholder: t('search.placeholder'), value: query, readOnly: busy, onChange: (e) => changeQuery(e.target.value), }), query !== '' && h('button', { type: 'button', className: 'msp-search-clear', 'aria-label': t('search.clear'), disabled: busy, onClick: () => { changeQuery(''); searchRef.current?.focus(); }, }, h(IconClose))), state.status === 'loading' && h('div', { className: 'msp-status' }, t('status.loading')), loadError, actionError !== null && h('div', { className: 'msp-error', role: 'alert' }, h('span', null, actionError)), state.failures.map((f) => h('div', { key: f.id, className: 'msp-warning' }, h('span', null, t('warning.groupLoad', { name: f.id === 'deepseek-account' ? t('provider.account') : f.name, message: f.message })), h('button', { type: 'button', className: 'msp-retry', onClick: reload }, t('action.reload')))), h('div', { id: `${id}-models`, className: 'msp-groups scrollable', role: 'menu', 'aria-label': t('menu.model'), hidden: pinnedShown.length === 0 && filteredGroups.length === 0, }, pinnedShown.length > 0 && h('div', { key: 'pinned', 'data-pinned-row': '' }, h(P.MenuGroup, { label: titled(t('pinned'), true) }, pinnedShown.map((c) => renderRow(c, true)))), filteredGroups.map((g, gi) => h(P.MenuGroup, { key: g.id, label: titled(providerLabel(g), pinnedShown.length === 0 && gi === 0) }, g.models.map((m) => renderRow(choiceByKey.get(pinKey(g.id, m.id)), false))))), state.status === 'ready' && pinnedShown.length === 0 && filteredGroups.length === 0 && h('div', { className: 'msp-empty', role: 'status' }, t(choices.length === 0 ? 'empty.models' : 'search.empty')), pinnedShown.length === 0 && filteredGroups.length === 0 && h('div', { className: 'msp-title-row' }, addButton)), pane === 'effort' && h(React.Fragment, null, loadError, actionError !== null && h('div', { className: 'msp-error', role: 'alert' }, h('span', null, actionError)), effortChoices.length === 0 ? h('div', { className: 'msp-empty', role: 'status' }, t('empty.efforts')) : h('div', { role: 'menu', 'aria-label': t('menu.effort') }, effortChoices.map((c, i) => h('button', { key: c.key, ref: (el) => { itemRefs.current[i] = el; }, type: 'button', role: 'menuitemradio', 'aria-checked': c.effort === effectiveEffort, className: 'msp-effort-option', disabled: busy, onClick: () => chooseEffort(c.effort), }, h('span', { style: { flex: 1, minWidth: 0 } }, c.label), h('span', { className: 'msp-check' }, pending !== null && pending.reasoningEffort === c.effort ? h(P.StateDot, { state: 'ongoing' }) : c.effort === effectiveEffort ? h(IconCheck) : null)))))), document.body); const busyDot = h(P.StateDot, { state: 'ongoing' }); const chev = (which) => h(IconChevronDown, { className: cx('msp-chevron', pane === which && 'msp-chevron-open') }); return h('div', { ref: rootRef, className: 'msp-root', onKeyDown: onRootKeyDown, onBlur }, h('button', { ref: modelTriggerRef, type: 'button', className: 'msp-trigger', 'aria-label': modelAria, 'aria-haspopup': 'menu', 'aria-expanded': pane === 'model', 'aria-controls': pane === 'model' ? `${id}-menu` : undefined, title: modelLabel, 'aria-busy': busy, 'data-selection-focus': selectionFocus ? '' : undefined, onBlur: () => setSelectionFocus(false), disabled: locked, onMouseDown: (e) => e.preventDefault(), onClick: () => toggle('model'), }, triggerBrand ? h(BrandIcon, { brand: triggerBrand }) : null, h('span', { className: 'msp-label' }, modelLabel), busy && !reasoning ? busyDot : chev('model')), effortLabel !== undefined && h('button', { ref: effortTriggerRef, type: 'button', className: 'msp-trigger msp-trigger-effort', 'aria-label': effortAria, 'aria-haspopup': 'menu', 'aria-expanded': pane === 'effort', 'aria-controls': pane === 'effort' ? `${id}-menu` : undefined, title: effortAria, 'aria-busy': busy, 'data-selection-focus': selectionFocus ? '' : undefined, onBlur: () => setSelectionFocus(false), disabled: locked || reasoning === undefined, onMouseDown: (e) => e.preventDefault(), onClick: () => toggle('effort'), }, h('span', { className: 'msp-effort' }, effortLabel), busy ? busyDot : chev('effort')), menu); } /* ------------------------------------------------------------------ plugin */ // Open Settings on its Models section. Primary: the settings shell's own store (reached through the // `sidebar.settings` slot entry); fallback: click the settings trigger and then the Models nav cell. function openModelSettings(ctx) { try { const entry = ctx.slots.entries('sidebar.settings')[0]; const instance = entry?.options?.store?.create?.(); if (instance?.actions?.openSection) { instance.actions.openSection('models'); return; } } catch { /* fall through to the DOM path */ } const trigger = document.querySelector('button[aria-haspopup="dialog"][aria-label]'); if (!trigger) return; trigger.click(); let tries = 0; const tick = () => { const dlg = document.querySelector('[role="dialog"][data-shortcut-modal="settings"]'); const cell = dlg && [...dlg.querySelectorAll('nav button')].find((b) => /^(models|模型)$/i.test(b.textContent.trim())); if (cell) cell.click(); else if (++tries < 20) setTimeout(tick, 50); }; tick(); } return { inject: ['slots', 'locale', 'sessions', 'remote', 'remote.session', 'modelDirectories'], apply(ctx) { ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'model-selector-plus: dictionaries'); // Share brand icons with sibling plugins (e.g. omp-advisor's picker) without duplicating SVGs. ctx.effect(() => { window.__dshModelBrands = { brandOf, BrandIcon }; return () => { if (window.__dshModelBrands?.brandOf === brandOf) delete window.__dshModelBrands; }; }, 'model-selector-plus: brand icons'); // Priority -1 shadows the stock occupant (lowest priority renders). ctx.slots.inject('conversation.input.model', () => ctx.slots.register({ name: 'conversation.input.model', locale: NS, priority: -1, inject: (sessionId) => { const directory = ctx.modelDirectories.directoryFor(sessionId); const available = ctx.sessions.subagentAddress(sessionId) === undefined; return { available, directory: directory.store, load: () => { if (available) directory.load().catch(() => {}); }, select: (selection) => (available ? directory.select(selection) : Promise.resolve(undefined)), openSettings: () => openModelSettings(ctx), }; }, }, ModelSelectPlus)); }, }; }, });