Files

562 lines
36 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.__ModuleLoader__.load({
id: '@local/omniroute',
factory(require) {
const React = require('react');
const h = React.createElement;
const NS = 'omniroute';
const PKG = '@local/omniroute';
const en = {
'settings.title': 'OmniRoute gateway',
'settings.url': 'Gateway URL',
'settings.url.hint': 'Base URL of your OmniRoute instance, for example https://omniroute.example.com',
'settings.key': 'API key',
'settings.key.set': 'A key is stored ({source}). Leave blank to keep it.',
'settings.key.unset': 'No key stored yet.',
'settings.key.placeholder': 'Paste a new key to replace it',
'settings.key.clear': 'Remove stored key',
'settings.provider': 'Provider id for imported models',
'settings.provider.hint': 'The llm-pi-ai provider that receives the models. It is created if missing.',
'settings.discovery': 'Model discovery',
'settings.discovery.hint': 'Import models and combos with context window, output limit, modalities and thinking levels.',
'settings.syncMin': 'Re-import every (minutes, 0 = manual only)',
'settings.updateOnly': 'Update existing entries only',
'settings.updateOnly.hint': 'Refresh context window, output limit, modalities and thinking levels of models you already have. Never adds or removes entries.',
'settings.thinking': 'Auto-discover thinking levels',
'settings.thinking.hint': 'Offer every thinking level OmniRoute reports for each model. When off, existing levels are kept as they are.',
'settings.usage': 'Usage tracking',
'settings.usage.hint': 'Show provider quotas (5-hour, weekly, monthly, …) under the composer.',
'settings.webSearch': 'Web search tools',
'settings.webSearch.hint': 'Give the agent omniroute_web_search, omniroute_web_fetch and omniroute_search_providers, backed by the gateway\'s search providers.',
'settings.searchProvider': 'Search provider (blank = first configured)',
'settings.fetchProvider': 'Fetch provider (blank = first configured)',
'settings.searchMaxResults': 'Default results per search',
'settings.pollSec': 'Refresh quotas every (seconds)',
'settings.test': 'Test connection',
'settings.save': 'Save',
'settings.saved': 'Saved.',
'settings.import': 'Re-discover models',
'settings.filter.title': 'Import filter',
'settings.filter.hint': 'Comma-separated patterns with * and ?, matched against model ids (for example claude*, gemini-*). Include limits what Re-discover may add; exclude always wins. Empty include means everything.',
'settings.include': 'Include',
'settings.exclude': 'Exclude',
'manage.title': 'Manage models',
'manage.load': 'Show models',
'manage.search': 'Search models',
'manage.count': '{shown} shown of {total} ({selected} selected)',
'manage.imported': 'imported',
'manage.mine': 'yours',
'manage.selectShown': 'Select shown',
'manage.clear': 'Clear selection',
'manage.removeSel': 'Remove selected',
'manage.keepSel': 'Keep selected, remove other imported',
'manage.confirm': 'Remove {n} models from this provider? Models used as the default or in the subagent list are skipped.',
'manage.done': 'Removed {removed}; {remaining} left.{protected}',
'manage.protected': ' Skipped (in use): {ids}.',
'settings.update': 'Update existing models',
'settings.restart': 'Restart DSH to enable this button: the running server is an older version and would add models instead of only updating.',
'settings.update.hint': 'Update refreshes only the models you already have. Re-discover also adds new ones and drops ones OmniRoute no longer lists.',
'settings.importing': 'Importing…',
'settings.testing': 'Testing…',
'settings.test.ok': 'Connected in {ms} ms. {models} chat models available.',
'settings.test.mgmt': ' Management access OK ({connections} provider connections).',
'settings.test.nomgmt': ' This key has no management access, so quotas will not be available.',
'settings.import.ok': '{count} models: {added} added, {updated} updated, {removed} removed; {thinking} with thinking levels.',
'settings.lastImport': 'Last import: {count} models ({added} added, {updated} updated) at {at}',
'settings.lastImport.error': 'Last import failed: {error}',
'quota.resets': 'resets {when}',
'quota.unlimited': 'unlimited',
'quota.left': '{pct}% left',
'quota.average': 'Average of {n} accounts',
'quota.dialog.accounts': 'Accounts',
'quota.dialog.left': 'left',
'quota.error.mgmt': 'Quotas need a management-scoped API key',
};
const zh = {
'settings.title': 'OmniRoute 网关',
'settings.url': '网关地址',
'settings.url.hint': 'OmniRoute 实例的基础地址,例如 https://omniroute.example.com',
'settings.key': 'API 密钥',
'settings.key.set': '已保存密钥({source})。留空则保持不变。',
'settings.key.unset': '尚未保存密钥。',
'settings.key.placeholder': '粘贴新密钥以替换',
'settings.key.clear': '删除已保存的密钥',
'settings.provider': '导入模型使用的提供商 ID',
'settings.provider.hint': '接收模型的 llm-pi-ai 提供商;不存在时自动创建。',
'settings.discovery': '模型发现',
'settings.discovery.hint': '导入模型和组合,自动填充上下文窗口、输出上限、模态和思考强度。',
'settings.syncMin': '重新导入间隔(分钟,0 为仅手动)',
'settings.updateOnly': '仅更新已有条目',
'settings.updateOnly.hint': '只刷新已有模型的上下文窗口、输出上限、模态和思考强度,不会新增或删除条目。',
'settings.thinking': '自动发现思考强度',
'settings.thinking.hint': '为每个模型提供 OmniRoute 报告的全部思考强度。关闭时保持现有强度不变。',
'settings.usage': '用量跟踪',
'settings.usage.hint': '在输入框下方显示提供商配额(5 小时、每周、每月等)。',
'settings.webSearch': '网页搜索工具',
'settings.webSearch.hint': '为智能体提供 omniroute_web_search、omniroute_web_fetch 和 omniroute_search_providers 工具,使用网关的搜索提供商。',
'settings.searchProvider': '搜索提供商(留空 = 第一个已配置的)',
'settings.fetchProvider': '抓取提供商(留空 = 第一个已配置的)',
'settings.searchMaxResults': '每次搜索默认结果数',
'settings.pollSec': '配额刷新间隔(秒)',
'settings.test': '测试连接',
'settings.save': '保存',
'settings.saved': '已保存。',
'settings.import': '重新发现模型',
'settings.filter.title': '导入过滤',
'settings.filter.hint': '以逗号分隔的模式,支持 * 和 ?,匹配模型 ID(例如 claude*, gemini-*)。“包含”限制重新发现可添加的模型,“排除”优先生效。包含为空表示全部。',
'settings.include': '包含',
'settings.exclude': '排除',
'manage.title': '管理模型',
'manage.load': '显示模型',
'manage.search': '搜索模型',
'manage.count': '显示 {shown} / 共 {total}(已选 {selected})',
'manage.imported': '已导入',
'manage.mine': '自有',
'manage.selectShown': '选择当前显示',
'manage.clear': '清除选择',
'manage.removeSel': '移除所选',
'manage.keepSel': '保留所选,移除其他已导入',
'manage.confirm': '从此提供商移除 {n} 个模型?默认模型和子代理列表中使用的模型会被跳过。',
'manage.done': '已移除 {removed} 个;剩余 {remaining} 个。{protected}',
'manage.protected': ' 已跳过(使用中):{ids}。',
'settings.update': '更新现有模型',
'settings.restart': '请重启 DSH 以启用此按钮:当前运行的服务端是旧版本,会新增模型而不是只更新。',
'settings.update.hint': '“更新”只刷新已有模型;“重新发现”还会添加新模型,并移除 OmniRoute 不再提供的模型。',
'settings.importing': '导入中…',
'settings.testing': '测试中…',
'settings.test.ok': '已连接,用时 {ms} 毫秒,共 {models} 个对话模型。',
'settings.test.mgmt': ' 管理权限正常({connections} 个提供商连接)。',
'settings.test.nomgmt': ' 此密钥没有管理权限,无法读取配额。',
'settings.import.ok': '共 {count} 个模型:新增 {added},更新 {updated},移除 {removed};{thinking} 个带思考强度。',
'settings.lastImport': '上次导入:{count} 个模型(新增 {added},更新 {updated}),{at}',
'settings.lastImport.error': '上次导入失败:{error}',
'quota.resets': '{when}后重置',
'quota.unlimited': '无限制',
'quota.left': '剩余 {pct}%',
'quota.average': '{n} 个账号的平均值',
'quota.dialog.accounts': '账号',
'quota.dialog.left': '剩余',
'quota.error.mgmt': '读取配额需要具有管理权限的 API 密钥',
};
const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (_, k) => (vars && vars[k] !== undefined ? vars[k] : ''));
async function call(path, init) {
const res = await fetch('omniroute/' + path, {
...init,
headers: { accept: 'application/json', ...(init && init.body ? { 'content-type': 'application/json' } : {}) },
});
let json;
try { json = await res.json(); } catch { json = {}; }
if (!res.ok) throw new Error(json.message || res.statusText || String(res.status));
return json;
}
const post = (path, body) => call(path, { method: 'POST', body: JSON.stringify(body || {}) });
const field = {
display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 14,
};
const labelStyle = { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary, inherit)' };
const hintStyle = { fontSize: 12, color: 'var(--dsw-alias-label-secondary, inherit)', opacity: 0.85 };
const inputStyle = {
padding: '6px 10px', borderRadius: 6, font: 'inherit', fontSize: 13,
border: '1px solid var(--dsw-alias-border-l3, rgba(128,128,128,.4))',
background: 'var(--dsw-specific-input-major, transparent)',
color: 'var(--dsw-alias-label-primary, inherit)', width: '100%', boxSizing: 'border-box',
};
const btnStyle = (primary, disabled) => ({
padding: '6px 14px', borderRadius: 6, font: 'inherit', fontSize: 13, cursor: disabled ? 'default' : 'pointer',
border: primary ? 0 : '1px solid var(--dsw-alias-border-l3, rgba(128,128,128,.4))',
background: primary ? 'var(--dsw-alias-state-business-primary, #247bbf)' : 'transparent',
color: primary ? '#fff' : 'var(--dsw-alias-label-primary, inherit)',
opacity: disabled ? 0.55 : 1,
});
function Toggle({ checked, onChange, label, hint }) {
return h('label', { style: { ...field, flexDirection: 'row', alignItems: 'flex-start', gap: 10, cursor: 'pointer' } },
h('input', { type: 'checkbox', checked, onChange: (e) => onChange(e.target.checked), style: { marginTop: 3 } }),
h('span', { style: { display: 'flex', flexDirection: 'column', gap: 2 } },
h('span', { style: labelStyle }, label),
hint ? h('span', { style: hintStyle }, hint) : null));
}
function ModelManager({ t, onChanged }) {
const [data, setData] = React.useState(null);
const [q, setQ] = React.useState('');
const [sel, setSel] = React.useState(() => new Set());
const [busy, setBusy] = React.useState(false);
const [msg, setMsg] = React.useState(null);
const load = async () => {
setBusy(true); setMsg(null);
try { setData(await call('models')); } catch (e) { setMsg({ bad: true, text: String(e.message) }); } finally { setBusy(false); }
};
const shown = data ? data.models.filter((m) => !q.trim() || (m.id + ' ' + m.name).toLowerCase().includes(q.trim().toLowerCase())) : [];
const visible = shown.slice(0, 200);
const toggle = (id) => setSel((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
const prune = async (body, count) => {
if (!window.confirm(fill(t('manage.confirm'), { n: count }))) return;
setBusy(true); setMsg(null);
try {
const r = await post('prune', body);
setMsg({ text: fill(t('manage.done'), { removed: r.removed, remaining: r.remaining, protected: r.protected && r.protected.length ? fill(t('manage.protected'), { ids: r.protected.join(', ') }) : '' }) });
setSel(new Set());
setData(await call('models'));
if (onChanged) onChanged();
} catch (e) { setMsg({ bad: true, text: String(e.message) }); } finally { setBusy(false); }
};
const importedCount = data ? data.models.filter((m) => m.imported && !sel.has(m.id)).length : 0;
return h('div', { style: { marginTop: 18, paddingTop: 14, borderTop: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.3))' } },
h('div', { style: { ...labelStyle, marginBottom: 8 } }, t('manage.title')),
!data ? h('button', { type: 'button', onClick: load, disabled: busy, style: btnStyle(false, busy) }, t('manage.load')) : h('div', null,
h('input', { value: q, placeholder: t('manage.search'), onChange: (e) => setQ(e.target.value), style: { ...inputStyle, marginBottom: 6 } }),
h('div', { style: { ...hintStyle, marginBottom: 6 } }, fill(t('manage.count'), { shown: visible.length, total: data.total, selected: sel.size })),
h('div', { style: { maxHeight: 240, overflowY: 'auto', border: '1px solid var(--dsw-alias-border-l3, rgba(128,128,128,.4))', borderRadius: 6, padding: 4 } },
visible.map((m) => h('label', { key: m.id, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '2px 4px', fontSize: 13, cursor: 'pointer' } },
h('input', { type: 'checkbox', checked: sel.has(m.id), onChange: () => toggle(m.id) }),
h('span', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: m.id }, m.name === m.id ? m.id : m.name + ' · ' + m.id),
h('span', { style: { ...hintStyle, fontSize: 11 } }, m.imported ? t('manage.imported') : t('manage.mine'))))),
h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 } },
h('button', { type: 'button', onClick: () => setSel((prev) => { const n = new Set(prev); visible.forEach((m) => n.add(m.id)); return n; }), disabled: busy, style: btnStyle(false, busy) }, t('manage.selectShown')),
h('button', { type: 'button', onClick: () => setSel(new Set()), disabled: busy || sel.size === 0, style: btnStyle(false, busy || sel.size === 0) }, t('manage.clear')),
h('button', { type: 'button', onClick: () => prune({ ids: [...sel] }, sel.size), disabled: busy || sel.size === 0, style: btnStyle(true, busy || sel.size === 0) }, t('manage.removeSel')),
h('button', { type: 'button', onClick: () => prune({ keep: [...sel] }, importedCount), disabled: busy || importedCount === 0, style: btnStyle(false, busy || importedCount === 0) }, t('manage.keepSel')))),
msg ? h('div', { role: 'status', style: { fontSize: 13, marginTop: 8, color: msg.bad ? 'var(--dsw-alias-state-danger, #d33)' : 'var(--dsw-alias-label-primary, inherit)' } }, msg.text) : null);
}
function Settings({ view, t }) {
const [status, setStatus] = React.useState(null);
const [form, setForm] = React.useState(null);
const [apiKey, setApiKey] = React.useState('');
const [busy, setBusy] = React.useState('');
const [msg, setMsg] = React.useState(null);
const refresh = React.useCallback(async () => {
try {
const s = await call('status');
setStatus(s);
setForm((f) => f || { url: s.url || s.effectiveUrl || '', provider: s.provider, discovery: s.discovery, updateOnly: !!s.updateOnly, include: s.include || '', exclude: s.exclude || '', discoverThinking: s.discoverThinking !== false, usage: s.usage, pollSec: s.pollSec, syncMin: s.syncMin, webSearch: s.webSearch !== false, searchProvider: s.searchProvider || '', fetchProvider: s.fetchProvider || '', searchMaxResults: s.searchMaxResults || 8 });
} catch (e) { setMsg({ bad: true, text: String(e.message) }); }
}, []);
React.useEffect(() => { refresh(); }, [refresh]);
if (view === 'summary') return null;
if (!form || !status) return h('div', { style: hintStyle }, '…');
const set = (k) => (v) => setForm((f) => ({ ...f, [k]: v }));
const run = (name, fn) => async () => {
setBusy(name); setMsg(null);
try { await fn(); } catch (e) { setMsg({ bad: true, text: String(e.message) }); } finally { setBusy(''); }
};
const save = run('save', async () => {
await post('config', { ...form, ...(apiKey ? { apiKey } : {}) });
setApiKey('');
await refresh();
setMsg({ text: t('settings.saved') });
});
const test = run('test', async () => {
const r = await post('test', { url: form.url, ...(apiKey ? { apiKey } : {}) });
if (!r.ok) { setMsg({ bad: true, text: r.message }); return; }
setMsg({ text: fill(t('settings.test.ok'), r) + (r.management ? fill(t('settings.test.mgmt'), r) : t('settings.test.nomgmt')) });
});
const importWith = (name, mode) => run(name, async () => {
await post('config', { ...form, ...(apiKey ? { apiKey } : {}) });
setApiKey('');
const r = await post('import', { mode });
await refresh();
setMsg({ text: fill(t('settings.import.ok'), r) });
});
const doImport = importWith('import', 'full');
const doUpdate = importWith('update', 'update');
const canUpdate = status.api >= 2; // an older Host ignores the mode and would add models
const clearKey = run('clear', async () => { await post('config', { clearKey: true }); await refresh(); });
const li = status.lastImport;
return h('div', { style: { maxWidth: 560, padding: '4px 0 12px' } },
h('div', { style: field },
h('label', { style: labelStyle }, t('settings.url')),
h('input', { type: 'url', value: form.url, placeholder: 'https://', onChange: (e) => set('url')(e.target.value), style: inputStyle }),
h('span', { style: hintStyle }, t('settings.url.hint'))),
h('div', { style: field },
h('label', { style: labelStyle }, t('settings.key')),
h('input', { type: 'password', autoComplete: 'new-password', value: apiKey, placeholder: t('settings.key.placeholder'), onChange: (e) => setApiKey(e.target.value), style: inputStyle }),
h('span', { style: hintStyle },
status.key.configured ? fill(t('settings.key.set'), { source: status.key.source || status.key.ref }) : t('settings.key.unset'),
status.key.configured ? h('button', { type: 'button', onClick: clearKey, disabled: !!busy, style: { ...btnStyle(false, !!busy), marginLeft: 8, padding: '1px 8px', fontSize: 12 } }, t('settings.key.clear')) : null)),
h('div', { style: field },
h('label', { style: labelStyle }, t('settings.provider')),
h('input', { value: form.provider, onChange: (e) => set('provider')(e.target.value), style: { ...inputStyle, maxWidth: 220 } }),
h('span', { style: hintStyle }, t('settings.provider.hint'))),
h(Toggle, { checked: form.discovery, onChange: set('discovery'), label: t('settings.discovery'), hint: t('settings.discovery.hint') }),
form.discovery ? h('div', { style: { marginLeft: 26 } },
h(Toggle, { checked: form.updateOnly, onChange: set('updateOnly'), label: t('settings.updateOnly'), hint: t('settings.updateOnly.hint') }),
h(Toggle, { checked: form.discoverThinking, onChange: set('discoverThinking'), label: t('settings.thinking'), hint: t('settings.thinking.hint') })) : null,
form.discovery ? h('div', { style: { ...field, marginLeft: 26 } },
h('label', { style: labelStyle }, t('settings.filter.title')),
h('div', { style: { display: 'flex', gap: 8 } },
h('input', { value: form.include, placeholder: t('settings.include') + ': claude*, gemini-*', onChange: (e) => set('include')(e.target.value), style: inputStyle }),
h('input', { value: form.exclude, placeholder: t('settings.exclude') + ': *mini, *preview', onChange: (e) => set('exclude')(e.target.value), style: inputStyle })),
h('span', { style: hintStyle }, t('settings.filter.hint'))) : null,
form.discovery ? h('div', { style: { ...field, marginLeft: 26 } },
h('label', { style: hintStyle }, t('settings.syncMin')),
h('input', { type: 'number', min: 0, max: 1440, value: form.syncMin, onChange: (e) => set('syncMin')(Number(e.target.value)), style: { ...inputStyle, maxWidth: 120 } })) : null,
h(Toggle, { checked: form.usage, onChange: set('usage'), label: t('settings.usage'), hint: t('settings.usage.hint') }),
form.usage ? h('div', { style: { ...field, marginLeft: 26 } },
h('label', { style: hintStyle }, t('settings.pollSec')),
h('input', { type: 'number', min: 30, max: 3600, value: form.pollSec, onChange: (e) => set('pollSec')(Number(e.target.value)), style: { ...inputStyle, maxWidth: 120 } })) : null,
h(Toggle, { checked: form.webSearch, onChange: set('webSearch'), label: t('settings.webSearch'), hint: t('settings.webSearch.hint') }),
form.webSearch ? h('div', { style: { ...field, marginLeft: 26 } },
h('label', { style: hintStyle }, t('settings.searchProvider')),
h('input', { value: form.searchProvider, placeholder: 'anysearch-search', onChange: (e) => set('searchProvider')(e.target.value), style: { ...inputStyle, maxWidth: 260 } }),
h('label', { style: hintStyle }, t('settings.fetchProvider')),
h('input', { value: form.fetchProvider, placeholder: 'anysearch-search', onChange: (e) => set('fetchProvider')(e.target.value), style: { ...inputStyle, maxWidth: 260 } }),
h('label', { style: hintStyle }, t('settings.searchMaxResults')),
h('input', { type: 'number', min: 1, max: 20, value: form.searchMaxResults, onChange: (e) => set('searchMaxResults')(Number(e.target.value)), style: { ...inputStyle, maxWidth: 120 } })) : null,
h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 } },
h('button', { type: 'button', onClick: test, disabled: !!busy, style: btnStyle(false, !!busy) }, busy === 'test' ? t('settings.testing') : t('settings.test')),
h('button', { type: 'button', onClick: save, disabled: !!busy, style: btnStyle(true, !!busy) }, t('settings.save')),
h('button', { type: 'button', onClick: doUpdate, disabled: !!busy || !canUpdate, title: canUpdate ? undefined : t('settings.restart'), style: btnStyle(false, !!busy || !canUpdate) }, busy === 'update' ? t('settings.importing') : t('settings.update')),
h('button', { type: 'button', onClick: doImport, disabled: !!busy, style: btnStyle(false, !!busy) }, busy === 'import' ? t('settings.importing') : t('settings.import'))),
h('div', { style: { ...hintStyle, marginBottom: 8 } }, canUpdate ? t('settings.update.hint') : t('settings.restart')),
msg ? h('div', { role: 'status', style: { fontSize: 13, color: msg.bad ? 'var(--dsw-alias-state-danger, #d33)' : 'var(--dsw-alias-label-primary, inherit)' } }, msg.text) : null,
h(ModelManager, { t, onChanged: refresh }),
li ? h('div', { style: { ...hintStyle, marginTop: 8 } },
li.error ? fill(t('settings.lastImport.error'), { error: li.error }) : fill(t('settings.lastImport'), { count: li.count, added: li.added ?? 0, updated: li.updated ?? 0, at: new Date(li.at).toLocaleString() })) : null);
}
// ---------------------------------------------------------------- quota strip
function relTime(iso) {
const ms = Date.parse(iso) - Date.now();
if (!Number.isFinite(ms)) return '';
if (ms <= 0) return '0m';
const m = Math.round(ms / 60000);
if (m < 60) return m + 'm';
const hrs = Math.floor(m / 60);
if (hrs < 48) return hrs + 'h' + (m % 60 ? (m % 60) + 'm' : '');
return Math.round(hrs / 24) + 'd';
}
// Same geometry and tokens as the host's context meter ring.
const RADIUS = 5.5;
const CIRC = 2 * Math.PI * RADIUS;
const LOW = 'var(--dsw-alias-state-danger, #e5484d)';
// Icons sit a little low next to the text in the stats bar. Lift them 1px: the bar's own pills,
// the context meter ring, and ours. Only the icon moves; nothing else in the host is restyled.
const ICON_LIFT = '-1px';
const CENTER_CSS =
'[data-omniroute-quotas] > * { align-items: center; justify-content: center; text-align: center; }' +
'[data-composer-stats] svg, [data-omniroute-quotas] svg, .omr-title-label svg,' +
'button[class*="_trigger"][aria-haspopup="dialog"] > svg[viewBox="0 0 14 14"],' +
'[role="dialog"] [class*="_titleLabel"] svg' +
' { flex: none; transform: translateY(' + ICON_LIFT + '); }';
function Gauge({ pct, unlimited }) {
const shown = unlimited || pct === null ? 100 : Math.max(0, Math.min(100, pct));
return h('svg', { viewBox: '0 0 14 14', width: 14, height: 14, 'aria-hidden': true, style: { flex: 'none' } },
h('circle', { cx: 7, cy: 7, r: RADIUS, fill: 'none', stroke: 'var(--dsw-alias-border-l3)', strokeWidth: 2 }),
shown > 0 ? h('circle', {
cx: 7, cy: 7, r: RADIUS, fill: 'none', strokeWidth: 2, strokeLinecap: 'round',
stroke: !unlimited && pct !== null && pct <= 10 ? LOW : 'currentColor',
strokeDasharray: (CIRC * shown / 100) + ' ' + CIRC, transform: 'rotate(-90 7 7)',
}) : null);
}
const rootStyle = {
boxSizing: 'border-box', minWidth: 0, maxWidth: '100%', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 12,
fontSize: 'calc(var(--dsh-content-font-size-secondary, 13px) - 1px)',
lineHeight: 'calc(20px + var(--dsh-content-font-delta-secondary, 0px))',
};
const pillStyle = (hover) => ({
boxSizing: 'border-box', maxWidth: '100%', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 6, padding: '1px 8px',
border: 'none', borderRadius: 999, font: 'inherit', lineHeight: 'inherit', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap',
background: hover ? 'var(--dsw-alias-interactive-bg-hover)' : 'transparent',
color: hover ? 'var(--dsw-alias-label-secondary)' : 'var(--dsw-alias-label-tertiary)',
});
// Panel styled with the host's own tokens, mirroring its token-usage dialog.
const PANEL_CSS =
'.omr-panel{z-index:1100;box-sizing:border-box;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);' +
'width:max-content;min-width:min(300px,100vw - 24px);max-width:min(440px,100vw - 24px);backdrop-filter:var(--dsw-menu-backdrop-filter);' +
'--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-secondary);' +
'cursor:default;border:0;padding:16px;font-size:12px;line-height:18px;position:fixed}' +
'.omr-title{color:var(--dsw-alias-label-primary);justify-content:space-between;gap:16px;margin-bottom:8px;font-weight:500;display:flex}' +
'.omr-title-label{align-items:center;gap:6px;min-width:0;display:inline-flex}' +
'.omr-title-value{font-variant-numeric:tabular-nums}' +
'.omr-rule{border-top:.5px solid var(--dsw-alias-border-l2);margin-bottom:10px}' +
'.omr-details{color:var(--dsw-alias-label-tertiary);grid-template-columns:minmax(76px,auto) minmax(0,1fr);gap:6px 16px;margin:0;display:grid}' +
'.omr-details dt,.omr-details dd{min-width:0;margin:0;overflow-wrap:anywhere}' +
'.omr-details dd{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;text-align:right}';
const MEASURE = { visibility: 'hidden', left: 0, top: 0 };
let primitives;
try { primitives = require('@deepseek-ai/dsh-client-ui-primitives'); } catch { primitives = undefined; }
const portal = (() => { try { return require('react-dom').createPortal; } catch { return undefined; } })();
/** The host's anchoring and outside-click hooks when reachable, otherwise equivalent local ones. */
function useAnchored(open, anchorRef, panelRef) {
const [pos, setPos] = React.useState(null);
React.useLayoutEffect(() => {
if (!open) { setPos(null); return undefined; }
const place = () => {
const a = anchorRef.current, p = panelRef.current;
if (!a || !p) return;
const r = a.getBoundingClientRect(), w = p.offsetWidth, hgt = p.offsetHeight, m = 12, gap = 8;
const left = Math.max(m, Math.min(window.innerWidth - w - m, r.left + r.width / 2 - w / 2));
const top = Math.max(m, r.top - hgt - gap);
setPos({ left, top });
};
place();
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
return () => { window.removeEventListener('resize', place); window.removeEventListener('scroll', place, true); };
}, [open]);
return pos;
}
function useDismiss(rootRef, open, setOpen, panelRef) {
React.useEffect(() => {
if (!open) return undefined;
const down = (e) => {
const el = e.target;
if (rootRef.current && rootRef.current.contains(el)) return;
if (panelRef.current && panelRef.current.contains(el)) return;
setOpen(false);
};
const key = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('pointerdown', down, true);
document.addEventListener('keydown', key);
return () => { document.removeEventListener('pointerdown', down, true); document.removeEventListener('keydown', key); };
}, [open]);
}
// Chosen once at load, so every render calls the same hook in the same order.
const useAnchorHook = primitives && primitives.useAnchoredPosition
? (open, anchorRef, panelRef) => primitives.useAnchoredPosition({ open, anchorRef, panelRef, side: 'top', gap: 8, margin: 12 })
: useAnchored;
const useDismissHook = primitives && primitives.useDismissOnOutsidePointer
? (rootRef, open, setOpen, panelRef) => primitives.useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef)
: useDismiss;
function Pill({ g, multi, t }) {
const [open, setOpen] = React.useState(false);
const [hover, setHover] = React.useState(false);
const rootRef = React.useRef(null);
const panelRef = React.useRef(null);
const pos = useAnchorHook(open, rootRef, panelRef);
useDismissHook(rootRef, open, setOpen, panelRef);
// The host's dismiss hook only handles outside clicks; Escape is the dialog's own job (as in the host's stat dialogs).
React.useEffect(() => {
if (!open) return undefined;
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
const pct = g.unlimited ? null : g.remainingPct;
const reading = g.unlimited ? '∞' : pct === null ? '—' : Math.round(pct) + '%';
const label = (multi ? g.provider + ' ' : '') + g.label;
const panel = open ? h('div', {
ref: panelRef, className: 'omr-panel', role: 'dialog', 'aria-label': g.provider + ' ' + g.label, style: pos || MEASURE,
},
h('div', { className: 'omr-title' },
h('span', { className: 'omr-title-label' }, h(Gauge, { pct, unlimited: g.unlimited }), g.provider + ' · ' + g.label),
h('span', { className: 'omr-title-value' }, reading)),
h('div', { className: 'omr-rule' }),
h('dl', { className: 'omr-details' },
g.accounts > 1 && !g.unlimited ? [h('dt', { key: 'avg-k' }, t('quota.average').replace('{n}', g.accounts)), h('dd', { key: 'avg-v' }, reading)] : null,
g.resetAt ? [h('dt', { key: 'rs-k' }, t('quota.resets').replace('{when}', '').trim() || 'resets'), h('dd', { key: 'rs-v' }, relTime(g.resetAt))] : null,
g.items.map((i, n) => [
h('dt', { key: 'a' + n }, g.accounts > 1 ? i.account : t('quota.dialog.accounts')),
h('dd', { key: 'v' + n }, (i.unlimited ? t('quota.unlimited') : i.remainingPct === null ? '—' : Math.round(i.remainingPct) + '%')
+ (i.resetAt ? ' · ' + relTime(i.resetAt) : '')),
]))) : null;
return h('span', { ref: rootRef, style: { minWidth: 0, display: 'inline-flex' } },
h('button', {
type: 'button', 'aria-haspopup': 'dialog', 'aria-expanded': open, 'aria-label': label + ' ' + reading,
onClick: () => setOpen(!open),
onMouseEnter: () => setHover(true), onMouseLeave: () => setHover(false),
style: { ...pillStyle(hover || open), cursor: 'pointer' },
},
h(Gauge, { pct, unlimited: g.unlimited }),
h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, textAlign: 'center' } },
label + ' ' + reading + (g.resetAt ? ' · ' + relTime(g.resetAt) : ''))),
panel && portal ? portal(panel, document.body) : panel);
}
function QuotaStrip({ directory, t }) {
const state = React.useSyncExternalStore((fn) => directory.subscribe(fn), () => directory.getSnapshot());
const providerId = state.current ? state.current.provider : undefined;
const modelId = state.current ? state.current.model : undefined;
const [data, setData] = React.useState(null);
const [pollSec, setPollSec] = React.useState(120);
const [enabled, setEnabled] = React.useState(true);
React.useEffect(() => {
let dead = false;
let timer;
setData(null); // never show the previous model's quotas while the new one loads
const tick = async () => {
try {
const s = await call('status');
if (dead) return;
setEnabled(!!s.usage);
setPollSec(s.pollSec || 120);
if (!s.usage) { setData(null); return; }
if (!providerId || !modelId || providerId !== s.provider) { setData(null); return; }
const q = await call('quotas?provider=' + encodeURIComponent(providerId) + '&model=' + encodeURIComponent(modelId));
if (!dead) setData(q);
} catch { if (!dead) setData(null); }
if (!dead) timer = setTimeout(tick, Math.max(30, pollSec) * 1000);
};
tick();
return () => { dead = true; clearTimeout(timer); };
}, [providerId, modelId, pollSec]);
if (!enabled || !data || !providerId) return null;
if (data.error === 'management-auth') {
return h('div', { style: { ...rootStyle, color: 'var(--dsw-alias-label-tertiary)' } }, t('quota.error.mgmt'));
}
// An older Host sends per-account rows only; show nothing rather than three duplicate windows.
const groups = Array.isArray(data.merged) ? data.merged : [];
if (groups.length === 0) {
if (!data.message || data.scope === 'foreign-model') return null;
return h('div', { 'data-omniroute-quotas': true, title: data.message, style: { ...rootStyle, color: 'var(--dsw-alias-label-tertiary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, 'OmniRoute quotas: ' + data.message);
}
const multi = new Set(groups.map((g) => g.provider)).size > 1;
return h('div', { 'data-omniroute-quotas': true, style: rootStyle },
groups.map((g) => h(Pill, { key: g.provider + ':' + g.key, g, multi, t })));
}
return {
inject: ['slots', 'locale', 'modelDirectories'],
apply(ctx) {
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'omniroute: dictionaries');
ctx.effect(() => {
const el = document.createElement('style');
el.setAttribute('data-omniroute-style', '');
el.textContent = CENTER_CSS + PANEL_CSS;
document.head.appendChild(el);
return () => el.remove();
}, 'omniroute: centring style');
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
name: 'plugins.bundle.config',
key: PKG,
locale: NS,
}, Settings));
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
name: 'conversation.composer.dock',
id: 'omniroute-quotas',
order: 5,
locale: NS,
inject: (sessionId) => ({ directory: ctx.modelDirectories.directoryFor(sessionId).store }),
}, QuotaStrip));
},
};
},
});