Files
omp-advisor/client.js
T

315 lines
18 KiB
JavaScript

// omp-advisor Web client: composer control to pick the advisor model (or turn it off).
//
// Lives in `conversation.input.right` (a list slot) next to the model seat. The menu lists the
// same provider-grouped models as DSH's model picker (the session's model directory), plus
// "Off" and "Same as agent". Hovering a model that has reasoning efforts opens a side submenu to
// pick its effort; clicking the model row picks it with its default effort. The choice is saved
// on the host (POST omp-advisor/settings) and applies from the advisor's next review.
window.__ModuleLoader__.load({
id: '@local/omp-advisor',
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 = 'ompAdvisor';
const en = {
label: 'Advisor', off: 'Advisor off', same: 'Same as agent', offItem: 'Off',
sameItem: 'Same model as the agent', title: 'Second-model advisor', effort: 'Effort',
providerDefault: 'Default', loading: 'Loading models…', error: 'Could not save: {msg}',
hint: 'Reviews each step and adds notes or blockers.',
};
const zh = {
label: '顾问', off: '顾问关闭', same: '同智能体', offItem: '关闭',
sameItem: '与智能体相同的模型', title: '第二模型顾问', effort: '推理强度',
providerDefault: '默认', loading: '正在加载模型…', error: '保存失败:{msg}',
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-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-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() {
if (cssDone || typeof document === 'undefined') return;
const s = document.createElement('style');
s.dataset.ompAdvisor = '';
s.textContent = CSS;
document.head.appendChild(s);
cssDone = true;
}
const Ic = (name) => P[name] || (() => null);
const IconCheck = Ic('IconCheckOutlineRegular');
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);
// ---- host settings, one store per session (the advisor is chosen per session) ----
function makeStore(sessionId) {
let value = null;
let inflight = null;
const subs = new Set();
const emit = () => subs.forEach((f) => f());
const url = `omp-advisor/settings?session=${encodeURIComponent(sessionId)}`;
async function call(init) {
const res = await fetch(url, { ...init, headers: { accept: 'application/json', ...(init?.body ? { 'content-type': 'application/json' } : {}) } });
let json = {};
try { json = await res.json(); } catch { /* empty */ }
if (!res.ok) throw new Error(json.message || res.statusText || String(res.status));
return json;
}
return {
subscribe(f) { subs.add(f); return () => subs.delete(f); },
get() { return value; },
load() {
inflight ??= call().then((v) => { value = v; emit(); }).catch(() => {}).finally(() => { inflight = null; });
return inflight;
},
async save(patch) {
const prev = value;
value = { ...(value ?? {}), ...patch };
emit();
try { value = await call({ method: 'POST', body: JSON.stringify(patch) }); emit(); } catch (e) { value = prev; emit(); throw e; }
},
};
}
const stores = new Map();
const storeFor = (sessionId) => {
let s = stores.get(sessionId);
if (!s) { s = makeStore(sessionId); stores.set(sessionId, s); }
return s;
};
function useSettings(store) {
const v = React.useSyncExternalStore(store.subscribe, store.get);
React.useEffect(() => { if (v === null) store.load(); }, [store, v]);
return v;
}
function place(anchor, menu, { side = 'above', gap = 6 } = {}) {
const a = anchor.getBoundingClientRect();
const m = menu.getBoundingClientRect();
const vw = window.innerWidth; const vh = window.innerHeight;
if (side === 'right') {
let left = a.right + 2;
if (left + m.width > vw - 8) left = a.left - m.width - 2;
let top = Math.min(a.top - 4, vh - m.height - 8);
return { left: Math.max(8, left), top: Math.max(8, top) };
}
let left = Math.min(a.left, vw - m.width - 8);
let top = a.top - m.height - gap;
if (top < 8) top = a.bottom + gap;
return { left: Math.max(8, left), top: Math.max(8, Math.min(top, vh - m.height - 8)) };
}
function AdvisorPicker(props) {
// Session-scoped slot; without an id (draft composer) there is nothing to configure yet.
if (typeof props.sessionId !== 'string' || !props.sessionId) return null;
return h(AdvisorPickerFor, props);
}
function AdvisorPickerFor({ sessionId, directory, load, t }) {
const store = storeFor(sessionId);
const st = useSettings(store);
const dir = React.useSyncExternalStore((f) => directory.subscribe(f), () => directory.getSnapshot());
const [open, setOpen] = React.useState(false);
const [hover, setHover] = React.useState(null); // { key, el, model, group }
const [err, setErr] = React.useState(null);
const [pos, setPos] = React.useState(null);
const [subPos, setSubPos] = React.useState(null);
const rootRef = React.useRef(null);
const trigRef = React.useRef(null);
const menuRef = React.useRef(null);
const subRef = React.useRef(null);
const hoverTimer = React.useRef(null);
const enabled = st?.enabled === true; // off until this session turns it on (also while loading)
const own = !!(st?.provider && st?.model);
const groups = dir?.groups ?? [];
const current = own ? groups.find((g) => g.id === st.provider)?.models.find((m) => m.id === st.model) : null;
const effortOf = (model, id) => effortName(model?.reasoning?.efforts?.find((e) => e.id === id)) ?? id;
const label = !st ? t('label') : !enabled ? t('off') : own ? (current?.name ?? st.model) : t('same');
const effortLabel = enabled && own && current?.reasoning
? (st.reasoningEffort ? effortOf(current, st.reasoningEffort) : t('providerDefault'))
: null;
// open / close behaviour
React.useEffect(() => {
if (!open) return undefined;
if (dir?.status !== 'ready') load();
const onDown = (e) => {
for (const r of [rootRef, menuRef, subRef]) if (r.current?.contains(e.target)) return;
setOpen(false);
};
const onKey = (e) => { if (e.key === 'Escape') { setOpen(false); trigRef.current?.focus(); } };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
}, [open]);
React.useLayoutEffect(() => {
if (!open) { setPos(null); setHover(null); return; }
if (menuRef.current && trigRef.current) setPos(place(trigRef.current, menuRef.current));
}, [open, groups.length, dir?.status]);
React.useLayoutEffect(() => {
if (!hover) { setSubPos(null); return; }
if (subRef.current && hover.el) setSubPos(place(hover.el, subRef.current, { side: 'right' }));
}, [hover]);
const choose = async (patch) => {
setErr(null);
setOpen(false);
try { await store.save(patch); } catch (e) { setErr(String(e.message || e)); setOpen(true); }
};
const pickModel = (group, model, effort) => choose({
enabled: true, provider: group.id, model: model.id,
reasoningEffort: effort === undefined ? (model.reasoning?.defaultEffort ?? null) : effort,
});
const enterRow = (group, model, el) => {
clearTimeout(hoverTimer.current);
if (!model.reasoning?.efforts?.length) { setHover(null); return; }
hoverTimer.current = setTimeout(() => setHover({ key: `${group.id}/${model.id}`, el, group, model }), 120);
};
const leaveRow = () => { clearTimeout(hoverTimer.current); };
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;
/** 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 } : hidden,
onMouseLeave: () => { clearTimeout(hoverTimer.current); },
},
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(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(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),
},
triggerIcon,
h('span', { className: 'oadv-label' }, label),
effortLabel ? h('span', { className: 'oadv-effort' }, effortLabel) : null,
h(IconChevronDown, { className: open ? 'oadv-chevron oadv-chevron-open' : 'oadv-chevron' })),
menu, sub);
}
return {
inject: ['slots', 'locale', 'sessions', 'modelDirectories'],
apply(ctx) {
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'omp-advisor: dictionaries');
ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
name: 'conversation.input.right',
id: 'omp-advisor',
order: 50,
locale: NS,
inject: (sessionId) => {
const directory = ctx.modelDirectories.directoryFor(sessionId);
// Read-only use of the directory: the advisor never calls directory.select (that would
// switch the agent's own model).
return { sessionId, directory: directory.store, load: () => { directory.load().catch(() => {}); } };
},
}, AdvisorPicker));
},
};
},
});