diff --git a/client.js b/client.js index 15ed27e..2d6f29e 100644 --- a/client.js +++ b/client.js @@ -27,26 +27,35 @@ window.__ModuleLoader__.load({ hint: '审查每一步并给出提示或阻断意见。', }; + // Mirrors model-selector-plus (`.msp-*`) — same surface, rows, type scale and tokens — so the + // advisor popup reads as a sibling of the model picker in every theme. const CSS = ` .oadv-root{position:relative;display:flex;align-items:center;min-width:0} -.oadv-trigger{border:0;background:none;color:var(--dsw-alias-label-secondary);height:28px;max-width:200px;padding:0 6px 0 6px;border-radius:var(--dsw-radius-sm);display:flex;align-items:center;gap:5px;font:inherit;font-size:13px;cursor:pointer;min-width:0} +.oadv-trigger{border-radius:var(--dsw-radius-sm);min-width:0;max-width:min(240px,30cqw);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} .oadv-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)} +.oadv-trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))} .oadv-trigger[data-off]{color:var(--dsw-alias-label-caption)} -.oadv-trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline:none} -.oadv-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} -.oadv-effort{color:var(--dsw-alias-label-caption);white-space:nowrap} -.oadv-menu,.oadv-sub{position:fixed;z-index:1100;min-width:240px;max-width:min(380px,100vw - 32px);max-height:min(420px,100vh - 96px);overflow-y:auto;padding:4px;color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-prominent);border-radius:var(--dsw-radius-lg,10px);background:var(--dsw-alias-bg-l1,var(--dsw-alias-surface-l1,#1e1e1e))} -.oadv-sub{min-width:160px} -.oadv-head{padding:6px 8px 4px;font-size:12px;color:var(--dsw-alias-label-tertiary)} -.oadv-group{padding:8px 8px 2px;font-size:11px;letter-spacing:.02em;color:var(--dsw-alias-label-caption);text-transform:uppercase} -.oadv-item{width:100%;display:flex;align-items:center;gap:8px;min-height:32px;padding:0 8px;border:0;background:none;color:inherit;font:inherit;font-size:13px;text-align:left;border-radius:var(--dsw-radius-md);cursor:pointer} -.oadv-item:hover,.oadv-item[data-active]{background:var(--dsw-alias-interactive-bg-hover)} -.oadv-item-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.oadv-item-meta{color:var(--dsw-alias-label-caption);font-size:12px;white-space:nowrap} -.oadv-check{width:14px;flex:none;display:inline-flex;justify-content:center} -.oadv-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l1,rgba(127,127,127,.2))} -.oadv-status{padding:8px;font-size:12px;color:var(--dsw-alias-label-tertiary)} -.oadv-err{padding:6px 8px;font-size:12px;color:var(--dsw-alias-state-danger,#f85149)} +.oadv-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden} +.oadv-effort{color:var(--dsw-alias-label-caption);white-space:nowrap;flex-shrink:1000} +.oadv-chevron{color:var(--dsw-alias-label-caption);flex:none;transition:transform .12s} +.oadv-chevron-open{transform:rotate(180deg)} +.oadv-icon{flex:none;display:inline-block;line-height:1} +.oadv-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} +.oadv-sub{min-width:min(180px,100vw - 32px)} +.oadv-groups{min-height:0;overflow-y:auto} +.oadv-option{box-sizing:border-box;width:100%;color:inherit;text-align:left;cursor:pointer;background:0 0;border:none;outline:none;align-items:center;gap:8px;min-height:34px;padding:5px 7px;display:flex;border-radius:var(--dsw-radius-md);font:inherit} +.oadv-option:hover,.oadv-option:focus-visible,.oadv-option[data-active]{background:var(--dsw-alias-interactive-bg-hover)} +.oadv-option:disabled{color:var(--dsw-alias-label-dimmed);cursor:default} +.oadv-copy{flex-direction:column;flex:1;min-width:0;display:flex} +.oadv-name{color:inherit;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:400;line-height:18px;overflow:hidden} +.oadv-sub-text{color:var(--dsw-alias-label-caption);text-overflow:ellipsis;white-space:nowrap;font-size:11px;line-height:14px;overflow:hidden} +.oadv-meta{color:var(--dsw-alias-label-tertiary);font-size:12px;white-space:nowrap} +.oadv-check{color:var(--dsw-alias-label-primary);flex:0 0 14px;place-items:center;display:grid} +.oadv-check svg{width:14px;height:14px} +.oadv-more{width:12px;height:12px;color:var(--dsw-alias-menu-icon,var(--dsw-alias-label-caption));flex:none} +.oadv-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l1)} +.oadv-status{color:var(--dsw-alias-label-tertiary);padding:8px;font-size:12px;line-height:18px} +.oadv-err{border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);margin-bottom:3px;padding:6px 7px;font-size:11px;line-height:16px} `; let cssDone = false; function installCss() { @@ -63,6 +72,15 @@ window.__ModuleLoader__.load({ const IconChevronDown = Ic('IconChevronDownOutlineRegular'); const IconChevronRight = Ic('IconChevronRightOutlineRegular'); const IconAdvisor = P.IconSparkleRegular || P.IconEyeOutlineRegular || (() => null); + // Shared surfaces from DSH primitives; plain divs if a build lacks them. + const Surface = P.MenuSurface || React.forwardRef((p, ref) => h('div', { ...p, ref })); + const Group = P.MenuGroup || (({ label, children }) => h('div', null, h('div', { className: 'oadv-status' }, label), children)); + /** Brand icon from model-selector-plus when it is mounted; an empty 16px slot otherwise. */ + function Brand({ group, model }) { + const b = window.__dshModelBrands; + if (b) return h(b.BrandIcon, { brand: b.brandOf(group.id, group.name, model.id, model.name) }); + return h('span', { className: 'oadv-icon', style: { width: 16, height: 16 }, 'aria-hidden': true }); + } const fill = (s, v) => String(s).replace(/\{(\w+)\}/g, (_, k) => (v && v[k] !== undefined ? v[k] : '')); const effortName = (e) => (e?.id === 'xhigh' ? 'Extra' : e?.name ?? e?.id); @@ -183,74 +201,81 @@ window.__ModuleLoader__.load({ }; const leaveRow = () => { clearTimeout(hoverTimer.current); }; - const check = (on) => h('span', { className: 'oadv-check' }, on ? h(IconCheck, { size: 14 }) : null); + const check = (on) => h('span', { className: 'oadv-check' }, on ? h(IconCheck) : null); + const hidden = { left: 0, top: 0, visibility: 'hidden' }; + const isSel = (group, model) => enabled && own && st.provider === group.id && st.model === model.id; - const menu = open ? ReactDOM.createPortal(h('div', { + /** One row in the model picker's shape: icon · name (+ caption) · meta · check · chevron. */ + const option = ({ key, checked, icon, name, caption, meta, more, active, ...rest }) => h('button', { + key, type: 'button', role: 'menuitemradio', 'aria-checked': checked, className: 'oadv-option', + 'data-active': active ? '' : undefined, ...rest, + }, + icon ?? null, + h('span', { className: 'oadv-copy' }, + h('span', { className: 'oadv-name' }, name), + caption ? h('span', { className: 'oadv-sub-text' }, caption) : null), + meta ? h('span', { className: 'oadv-meta' }, meta) : null, + check(checked), + more ? h(IconChevronRight, { className: 'oadv-more' }) : null); + + const menu = open ? ReactDOM.createPortal(h(Surface, { ref: menuRef, className: 'oadv-menu', role: 'menu', 'aria-label': t('title'), - style: pos ? { left: pos.left, top: pos.top } : { left: -9999, top: -9999, visibility: 'hidden' }, + style: pos ? { left: pos.left, top: pos.top } : hidden, onMouseLeave: () => { clearTimeout(hoverTimer.current); }, }, - h('div', { className: 'oadv-head' }, t('title'), ' · ', t('hint')), - err ? h('div', { className: 'oadv-err' }, fill(t('error'), { msg: err })) : null, - h('button', { type: 'button', role: 'menuitemradio', 'aria-checked': !enabled, className: 'oadv-item', onMouseEnter: () => setHover(null), onClick: () => choose({ enabled: false }) }, - check(!enabled), h('span', { className: 'oadv-item-name' }, t('offItem'))), - h('button', { type: 'button', role: 'menuitemradio', 'aria-checked': enabled && !own, className: 'oadv-item', onMouseEnter: () => setHover(null), onClick: () => choose({ enabled: true, provider: '', model: '', reasoningEffort: null }) }, - check(enabled && !own), h('span', { className: 'oadv-item-name' }, t('sameItem'))), - h('div', { className: 'oadv-sep' }), - dir?.status === 'loading' && !groups.length ? h('div', { className: 'oadv-status' }, t('loading')) : null, - ...groups.flatMap((group) => [ - h('div', { key: `g:${group.id}`, className: 'oadv-group' }, group.name ?? group.id), - ...group.models.map((model) => { - const key = `${group.id}/${model.id}`; - const selected = enabled && own && st.provider === group.id && st.model === model.id; - const hasEfforts = !!model.reasoning?.efforts?.length; - return h('button', { - key, type: 'button', role: 'menuitemradio', 'aria-checked': selected, 'aria-haspopup': hasEfforts ? 'menu' : undefined, - className: 'oadv-item', 'data-active': hover?.key === key ? '' : undefined, - onMouseEnter: (e) => enterRow(group, model, e.currentTarget), - onMouseLeave: leaveRow, - onFocus: (e) => enterRow(group, model, e.currentTarget), - onKeyDown: (e) => { if (e.key === 'ArrowRight' && hasEfforts) { setHover({ key, el: e.currentTarget, group, model }); setTimeout(() => subRef.current?.querySelector('button')?.focus(), 0); } }, - onClick: () => pickModel(group, model), - disabled: model.available === false, - }, - check(selected), - h('span', { className: 'oadv-item-name' }, model.name ?? model.id), - selected && st.reasoningEffort ? h('span', { className: 'oadv-item-meta' }, effortOf(model, st.reasoningEffort)) : null, - hasEfforts ? h(IconChevronRight, { size: 12 }) : null); - }), - ])), document.body) : null; + err ? h('div', { className: 'oadv-err', role: 'alert' }, fill(t('error'), { msg: err })) : null, + h('div', { className: 'oadv-groups' }, + h(Group, { label: t('title') }, + option({ key: 'off', checked: !enabled, icon: h(IconAdvisor, { size: 16 }), name: t('offItem'), caption: t('hint'), onMouseEnter: () => setHover(null), onClick: () => choose({ enabled: false }) }), + option({ key: 'same', checked: enabled && !own, icon: h('span', { className: 'oadv-icon', style: { width: 16, height: 16 } }), name: t('sameItem'), onMouseEnter: () => setHover(null), onClick: () => choose({ enabled: true, provider: '', model: '', reasoningEffort: null }) })), + dir?.status === 'loading' && !groups.length ? h('div', { className: 'oadv-status', role: 'status' }, t('loading')) : null, + ...groups.map((group) => h(Group, { key: `g:${group.id}`, label: group.id === 'deepseek-account' ? 'DeepSeek' : (group.name ?? group.id) }, + group.models.map((model) => { + const key = `${group.id}/${model.id}`; + const selected = isSel(group, model); + const hasEfforts = !!model.reasoning?.efforts?.length; + return option({ + key, checked: selected, icon: h(Brand, { group, model }), name: model.name ?? model.id, + meta: selected && st.reasoningEffort ? effortOf(model, st.reasoningEffort) : null, + more: hasEfforts, active: hover?.key === key, + 'aria-haspopup': hasEfforts ? 'menu' : undefined, disabled: model.available === false, + onMouseEnter: (e) => enterRow(group, model, e.currentTarget), + onMouseLeave: leaveRow, + onFocus: (e) => enterRow(group, model, e.currentTarget), + onKeyDown: (e) => { if (e.key === 'ArrowRight' && hasEfforts) { setHover({ key, el: e.currentTarget, group, model }); setTimeout(() => subRef.current?.querySelector('button')?.focus(), 0); } }, + onClick: () => pickModel(group, model), + }); + }))))), document.body) : null; - const sub = open && hover ? ReactDOM.createPortal(h('div', { - ref: subRef, className: 'oadv-sub', role: 'menu', 'aria-label': t('effort'), - style: subPos ? { left: subPos.left, top: subPos.top } : { left: -9999, top: -9999, visibility: 'hidden' }, + const sub = open && hover ? ReactDOM.createPortal(h(Surface, { + ref: subRef, className: 'oadv-menu oadv-sub', role: 'menu', 'aria-label': t('effort'), + style: subPos ? { left: subPos.left, top: subPos.top } : hidden, onMouseEnter: () => clearTimeout(hoverTimer.current), onKeyDown: (e) => { if (e.key === 'ArrowLeft') { hover.el?.focus(); setHover(null); } }, }, - h('div', { className: 'oadv-head' }, hover.model.name ?? hover.model.id, ' · ', t('effort')), - ...(hover.model.reasoning.defaultEffort === undefined ? [h('button', { - key: 'def', type: 'button', role: 'menuitemradio', className: 'oadv-item', - 'aria-checked': enabled && own && st.provider === hover.group.id && st.model === hover.model.id && !st.reasoningEffort, - onClick: () => pickModel(hover.group, hover.model, null), - }, check(enabled && own && st.provider === hover.group.id && st.model === hover.model.id && !st.reasoningEffort), h('span', { className: 'oadv-item-name' }, t('providerDefault')))] : []), - ...hover.model.reasoning.efforts.map((e) => { - const on = enabled && own && st.provider === hover.group.id && st.model === hover.model.id - && (st.reasoningEffort ?? hover.model.reasoning.defaultEffort) === e.id; - return h('button', { key: e.id, type: 'button', role: 'menuitemradio', 'aria-checked': on, className: 'oadv-item', onClick: () => pickModel(hover.group, hover.model, e.id) }, - check(on), h('span', { className: 'oadv-item-name' }, effortName(e)), - e.id === hover.model.reasoning.defaultEffort ? h('span', { className: 'oadv-item-meta' }, t('providerDefault')) : null); - })), document.body) : null; + h(Group, { label: `${hover.model.name ?? hover.model.id} · ${t('effort')}` }, + ...(hover.model.reasoning.defaultEffort === undefined ? [option({ + key: 'def', checked: isSel(hover.group, hover.model) && !st.reasoningEffort, name: t('providerDefault'), + onClick: () => pickModel(hover.group, hover.model, null), + })] : []), + ...hover.model.reasoning.efforts.map((e) => option({ + key: e.id, name: effortName(e), + checked: isSel(hover.group, hover.model) && (st.reasoningEffort ?? hover.model.reasoning.defaultEffort) === e.id, + meta: e.id === hover.model.reasoning.defaultEffort ? t('providerDefault') : null, + onClick: () => pickModel(hover.group, hover.model, e.id), + })))), document.body) : null; + const triggerIcon = enabled && own && current ? h(Brand, { group: groups.find((g) => g.id === st.provider), model: current }) : h(IconAdvisor, { size: 16 }); return h('div', { ref: rootRef, className: 'oadv-root' }, h('button', { ref: trigRef, type: 'button', className: 'oadv-trigger', 'data-off': enabled ? undefined : '', 'aria-haspopup': 'menu', 'aria-expanded': open, title: t('title'), onClick: () => setOpen((v) => !v), }, - h(IconAdvisor, { size: 14 }), + triggerIcon, h('span', { className: 'oadv-label' }, label), effortLabel ? h('span', { className: 'oadv-effort' }, effortLabel) : null, - h(IconChevronDown, { size: 12 })), + h(IconChevronDown, { className: open ? 'oadv-chevron oadv-chevron-open' : 'oadv-chevron' })), menu, sub); } diff --git a/package.json b/package.json index 78f1c0c..dc066d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@local/omp-advisor", - "version": "1.1.0", + "version": "1.1.1", "description": "oh-my-pi style advisor for DSH: a second model reviews every agent step and injects notes, concerns or blockers.", "private": true, "type": "module",