544 lines
34 KiB
JavaScript
544 lines
34 KiB
JavaScript
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.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.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 });
|
||
} 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('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));
|
||
},
|
||
};
|
||
},
|
||
});
|