Initial commit: model-selector-plus DSH plugin with docs
This commit is contained in:
commit
22db8c4596
52 files changed
+1692
No files matched your search
@@ -0,0 +1,769 @@
|
||||
// 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":"<path d=\"M6.47 17l-.367-1.189H2.718L2.35 17H0l3.398-9.789h2.026L8.864 17H6.47zm-2.052-6.993l-1.17 4.028H5.56l-1.142-4.028zm4.707-2.796h2.23V17h-2.23V7.211zM11.955 15c.1-.483.277-.946.524-1.37.214-.359.482-.68.795-.951.32-.273.658-.52 1.013-.741.28-.168.54-.33.781-.483.222-.14.433-.296.632-.468.172-.148.317-.325.428-.525.107-.199.16-.423.157-.65 0-.392-.104-.674-.313-.846a1.176 1.176 0 00-.775-.259 1.207 1.207 0 00-.863.329c-.231.219-.347.585-.347 1.098H11.8a3.387 3.387 0 01.224-1.245c.146-.377.371-.716.66-.993.306-.29.667-.514 1.06-.657A4.04 4.04 0 0115.183 7c.42-.002.84.057 1.244.175.376.107.73.287 1.04.531.305.246.55.562.714.923.185.419.275.875.265 1.335.005.39-.084.774-.259 1.12-.167.328-.38.63-.632.894-.246.259-.517.49-.808.693-.29.2-.554.37-.789.51-.326.224-.596.417-.809.58a3.872 3.872 0 00-.51.455 1.229 1.229 0 00-.265.434 1.633 1.633 0 00-.074.517h4.078V17h-6.606a9.24 9.24 0 01.183-2zM18.8 8.93a5.05 5.05 0 001.135-.105c.25-.049.484-.156.686-.314.163-.139.28-.324.34-.532.068-.25.1-.51.095-.77H23V17h-2.243v-6.475H18.8V8.93z\"></path>","anthropic":"<path d=\"M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z\"></path>","aws":"<path d=\"M6.763 11.212c0 .296.032.535.088.71.064.176.144.368.256.576.04.063.056.127.056.183 0 .08-.048.16-.152.24l-.503.335a.383.383 0 01-.208.072c-.08 0-.16-.04-.239-.112a2.47 2.47 0 01-.287-.375 6.18 6.18 0 01-.248-.471c-.622.734-1.405 1.101-2.347 1.101-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583c0-.607-.127-1.03-.375-1.277-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103-.295.072-.583.16-.862.272-.09.04-.184.075-.28.104a.488.488 0 01-.127.023c-.112 0-.168-.08-.168-.247v-.391c0-.128.016-.224.056-.28a.597.597 0 01.224-.167 4.577 4.577 0 011.005-.36 4.84 4.84 0 011.246-.151c.95 0 1.644.216 2.091.647.44.43.662 1.085.662 1.963v2.586h.016zm-3.24 1.214c.263 0 .534-.048.822-.144a1.78 1.78 0 00.758-.51 1.27 1.27 0 00.272-.512c.047-.191.08-.423.08-.694v-.335a6.66 6.66 0 00-.735-.136 6.02 6.02 0 00-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296zm6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.398 1.398 0 01-.072-.32c0-.128.064-.2.191-.2h.783c.151 0 .255.025.31.08.065.048.113.16.16.312l1.342 5.284 1.245-5.284c.04-.16.088-.264.151-.312a.549.549 0 01.32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348c.048-.16.104-.264.16-.312a.52.52 0 01.311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1.137 1.137 0 01-.056.2l-1.923 6.17c-.048.16-.104.263-.168.311a.51.51 0 01-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08l-.686.001zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.563.563 0 01-.048-.224v-.407c0-.167.064-.247.183-.247.048 0 .096.008.144.024.048.016.12.048.2.08.271.12.566.215.878.279.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 00.415-.758.777.777 0 00-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.902 1.902 0 01-.4-1.158c0-.335.073-.63.216-.886.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088.16.04.312.08.455.127.144.048.256.096.336.144a.69.69 0 01.24.2.43.43 0 01.071.263v.375c0 .168-.064.256-.184.256a.83.83 0 01-.303-.096 3.652 3.652 0 00-1.532-.311c-.455 0-.815.071-1.062.223-.248.152-.375.383-.375.71 0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767.247.327.367.702.367 1.117 0 .343-.072.655-.207.926a2.157 2.157 0 01-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167z\"></path><path d=\"M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351zm23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399z\"></path>","azure":"<path d=\"M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z\" fill-opacity=\".75\"></path><path d=\"M8.295.857c-.477 0-.9.304-1.053.756L.495 21.605a1.11 1.11 0 001.052 1.466h5.43c.477 0 .9-.304 1.053-.755l1.341-3.975-2.318-2.163a.51.51 0 01.347-.882h3L15.271.857H8.295z\" fill-opacity=\".5\"></path><path d=\"M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z\"></path>","baichuan":"<path d=\"M7.333 2h-3.2l-2 4.333V17.8L0 22h5.2l2.028-4.2L7.333 2zm7.334 0h-5.2v20h5.2V2zM16.8 7.73Line truncated
|
||||
|
||||
// [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());
|
||||
};
|
||||
|
||||
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')
|
||||
: reasoning.efforts.find((l) => l.id === effectiveEffort)?.name ?? 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: e.name })),
|
||||
]), [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');
|
||||
// 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));
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user