window.__ModuleLoader__.load({ id: "dsh-session-cost", factory: (require) => { var module = { exports: {} }; var exports = module.exports; // dsh-session-cost — client half (static bundle). // // One pill in the composer dock, right after the harness's own stats, showing // what the current session has really cost. The number comes from the host // half, which reads it out of the provider response; nothing is computed here. // // Layout contract: // - the dock is a LIST slot, so registering under our own id adds a neighbour // instead of taking the native `stats` seat the way dsh-bottom-info-bar does; // - host data arrives over POST /_dsh/dsh-session-cost/ as JSON. 'use strict'; const React = require('react'); /** Route prefix the host half registers. */ const RPC_BASE = '/_dsh/dsh-session-cost'; /** * The plugin context, captured in `apply`. * * Slot components receive only framework props, so the context is held here — * the same shape the reference bottom-info-bar plugin uses. */ let runtimeCtx = null; /** Poll gap while a session is active, and once idle. */ const POLL_ACTIVE_MS = 1000; const POLL_IDLE_MS = 5000; /** Give up on a request rather than leaving the pill loading forever. */ const RPC_TIMEOUT_MS = 15000; /** Fall back to the browser language when the harness serves no locale. */ function detectLanguage() { try { const tag = typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'en'; return String(tag).toLowerCase().startsWith('ru') ? 'ru' : 'en'; } catch (err) { return 'en'; } } const LOCALES = { ru: { 'pill.aria': 'Стоимость текущей сессии', 'pill.title': 'Стоимость сессии', 'pill.none': 'Пока ничего не потрачено', 'status.title': 'Стоимость сессии', 'status.description': 'Показывает реально списанную стоимость текущей сессии в нижнем баре.', 'status.session': 'Текущая сессия', 'status.total': 'Итого', 'status.calls': 'Оплаченных вызовов', 'status.byPurpose': 'По назначению', 'status.purpose.agent': 'Ответы агента', 'status.purpose.compaction': 'Компактификация', 'status.purpose.session-title': 'Заголовки сессий', 'status.health': 'Состояние учёта', 'status.observed': 'Прочитано ответов с ценой', 'status.attributed': 'Отнесено к сессиям', 'status.cancelled': 'Прервано (не тарифицируется)', 'status.failed': 'Завершилось ошибкой', 'status.unpriced': 'Без цены', 'status.unattributed': 'Не удалось отнести к сессии', 'status.waiting': 'Ожидают цену', 'status.parked': 'Цены без вызова', 'status.sessions': 'Сессий в реестре', 'status.readonly': 'Настроек у плагина нет: цена приходит от провайдера.', 'status.unavailable': 'Данные недоступны: хостовая половина не ответила.', }, en: { 'pill.aria': 'Cost of the current session', 'pill.title': 'Session cost', 'pill.none': 'Nothing spent yet', 'status.title': 'Session cost', 'status.description': 'Shows what the current session has really cost, in the bottom bar.', 'status.session': 'Current session', 'status.total': 'Total', 'status.calls': 'Paid calls', 'status.byPurpose': 'By purpose', 'status.purpose.agent': 'Agent replies', 'status.purpose.compaction': 'Compaction', 'status.purpose.session-title': 'Session titles', 'status.health': 'Accounting health', 'status.observed': 'Responses read with a price', 'status.attributed': 'Attributed to sessions', 'status.cancelled': 'Cancelled (not billed)', 'status.failed': 'Finished with an error', 'status.unpriced': 'Without a price', 'status.unattributed': 'Could not place in a session', 'status.waiting': 'Waiting for a price', 'status.parked': 'Prices without a call', 'status.sessions': 'Sessions in the ledger', 'status.readonly': 'This plugin has no settings: the price comes from the provider.', 'status.unavailable': 'No data: the host half did not answer.', }, }; /** Dictionary namespace owned by this plugin. */ const LOCALE_NAMESPACE = 'sessionCost'; /** Build the reader for one language, falling back to English. */ function makeTranslator() { let language = detectLanguage(); const listeners = new Set(); const read = (key, fallback) => { const table = LOCALES[language] ?? LOCALES.en; return table[key] ?? LOCALES.en[key] ?? fallback ?? key; }; return { t: read, get language() { return language; }, setLanguage(next) { if (next !== 'ru' && next !== 'en') return; if (next === language) return; language = next; listeners.forEach((fn) => fn()); }, subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }, }; } const translator = makeTranslator(); const t = (key, fallback) => translator.t(key, fallback); /** Milliseconds, and never longer than the timeout. */ function scheduleTimeout(fn, ms) { try { return setTimeout(fn, ms); } catch (err) { return null; } } function cancelTimeout(handle) { if (handle === null || handle === undefined) return; try { clearTimeout(handle); } catch (err) { /* nothing to clear */ } } /** One JSON request to the host half. */ function rpc(method, args) { let timedOut = false; const controller = new AbortController(); const timer = scheduleTimeout(() => { timedOut = true; controller.abort(); }, RPC_TIMEOUT_MS); const done = () => cancelTimeout(timer); return fetch(RPC_BASE + '/' + method, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(args ?? {}), signal: controller.signal, }) .then((res) => { if (!res.ok) throw new Error('HTTP ' + res.status); return res.json(); }) .catch((err) => { if (timedOut) throw new Error('timeout'); throw err; }) .finally(done); } /** Two decimals would show 0,00 almost always; the owner chose four. */ function formatAmount(value) { if (typeof value !== 'number' || !Number.isFinite(value)) return null; try { return new Intl.NumberFormat(translator.language === 'ru' ? 'ru-RU' : 'en-US', { minimumFractionDigits: 4, maximumFractionDigits: 4, }).format(value); } catch (err) { return value.toFixed(4).replace('.', translator.language === 'ru' ? ',' : '.'); } } /** Insert this plugin's stylesheet once. */ function installStyles() { const tagId = 'dsh-session-cost/styles'; if (typeof document === 'undefined') return () => {}; if (document.querySelector('style[data-plugin-css="' + tagId + '"]') !== null) return () => {}; const style = document.createElement('style'); style.dataset.plugin = 'dsh-session-cost'; style.dataset.pluginCss = tagId; style.textContent = [ '.dsh-session-cost{display:inline-flex;align-items:center;gap:6px;padding:4px 11px;', 'border-radius:999px;background:var(--dsw-alias-bg-l2, #1b2b21);', 'border:1px solid var(--dsw-alias-border-l1, #2c4a37);', 'color:var(--dsw-alias-label-secondary, #8fd6a8);font-size:12px;line-height:1.4;', 'font-variant-numeric:tabular-nums;white-space:nowrap;}', '.dsh-session-cost__amount{font-weight:600;}', '.dsh-sc-status{display:flex;flex-direction:column;gap:10px;padding:4px 0;font-size:13px;line-height:1.6;}', '.dsh-sc-status h4{margin:6px 0 0;font-size:13px;font-weight:600;}', '.dsh-sc-status table{border-collapse:collapse;width:100%;max-width:520px;}', '.dsh-sc-status th{text-align:left;font-weight:400;color:var(--dsw-alias-label-tertiary, #8b929e);padding:2px 12px 2px 0;}', '.dsh-sc-status td{text-align:right;font-variant-numeric:tabular-nums;padding:2px 0;}', '.dsh-sc-status__note{color:var(--dsw-alias-label-tertiary, #8b929e);}', '.dsh-sc-status__error{color:var(--dsw-alias-label-error, #e06c75);}', ].join(''); document.head.appendChild(style); return () => { if (style.parentNode) style.parentNode.removeChild(style); }; } /** * Ask the host who this session is. * * The standard slot kit answers first; the session snapshot and the runtime * sessions service are fallbacks, because host versions differ. */ function useSessionId(props) { return React.useMemo(() => { try { if (typeof props.sessionId === 'string' && props.sessionId.length > 0) return props.sessionId; if (props.session && typeof props.session.sessionId === 'string') return props.session.sessionId; const sessions = runtimeCtx && runtimeCtx.get ? runtimeCtx.get('sessions') : null; const current = sessions && sessions.list ? sessions.list.getSnapshot().current : null; if (typeof current === 'string' && current.length > 0) return current; } catch (err) { /* fall through to the empty id, which the host answers with null */ } return ''; }, [props.sessionId, props.session]); } /** Poll the host for one session's running total. */ function useSessionCost(sessionId) { const [state, setState] = React.useState({ total: null, loading: true, error: null }); React.useEffect(() => { let cancelled = false; let timer = null; const load = () => { rpc('session', { sessionId }) .then((data) => { if (cancelled) return; setState({ total: data && typeof data.total === 'number' ? data.total : null, loading: false, error: null }); }) .catch(() => { if (cancelled) return; setState((prev) => ({ total: prev.total, loading: false, error: 'unavailable' })); }) .finally(() => { if (cancelled) return; const gap = typeof document !== 'undefined' && document.visibilityState === 'visible' ? POLL_ACTIVE_MS : POLL_IDLE_MS; timer = scheduleTimeout(load, gap); }); }; load(); return () => { cancelled = true; cancelTimeout(timer); }; }, [sessionId]); return state; } /** The pill itself: one figure, right after the harness's own stats. */ function SessionCostPill(props) { const sessionId = useSessionId(props); const { total } = useSessionCost(sessionId); const [, bump] = React.useState(0); React.useEffect(() => translator.subscribe(() => bump((v) => v + 1)), []); if (total === null) return null; // no paid call yet: say nothing const amount = formatAmount(total); if (amount === null) return null; return React.createElement( 'span', { className: 'dsh-session-cost', title: t('pill.title'), 'aria-label': t('pill.aria') }, React.createElement('span', { className: 'dsh-session-cost__amount' }, '\u20BD ' + amount), ); } /** One label/value row in the status page. */ function Row(props) { return React.createElement( 'tr', null, React.createElement('th', null, props.label), React.createElement('td', null, props.value), ); } /** The plugin page: read-only diagnostics, because there is nothing to set. */ function SessionCostStatus(props) { const [data, setData] = React.useState(null); const [failed, setFailed] = React.useState(false); const [, bump] = React.useState(0); React.useEffect(() => translator.subscribe(() => bump((v) => v + 1)), []); React.useEffect(() => { let cancelled = false; let timer = null; const load = () => { rpc('status', {}) .then((payload) => { if (cancelled) return; setData(payload); setFailed(false); }) .catch(() => { if (cancelled) return; setFailed(true); }) .finally(() => { if (!cancelled) timer = scheduleTimeout(load, 3000); }); }; load(); return () => { cancelled = true; cancelTimeout(timer); }; }, []); if (props.view === 'summary') return t('status.description'); const stats = data && data.stats ? data.stats : null; const purposes = data && data.byPurpose ? Object.entries(data.byPurpose) : []; return React.createElement( 'div', { className: 'dsh-sc-status' }, React.createElement('div', { className: 'dsh-sc-status__note' }, t('status.readonly')), failed ? React.createElement('div', { className: 'dsh-sc-status__error' }, t('status.unavailable')) : null, React.createElement('h4', null, t('status.session')), React.createElement( 'table', null, React.createElement( 'tbody', null, React.createElement(Row, { label: t('status.session'), value: data?.sessionId || '\u2014' }), React.createElement(Row, { label: t('status.total'), value: '\u20BD ' + (formatAmount(data?.total ?? 0) ?? '0,0000') }), React.createElement(Row, { label: t('status.calls'), value: String(data?.calls ?? 0) }), ), ), purposes.length > 0 ? React.createElement( React.Fragment, null, React.createElement('h4', null, t('status.byPurpose')), React.createElement( 'table', null, React.createElement( 'tbody', null, purposes.map(([name, amount]) => React.createElement(Row, { key: name, label: t('status.purpose.' + name, name), value: '\u20BD ' + (formatAmount(amount) ?? '0,0000'), }), ), ), ), ) : null, stats !== null ? React.createElement( React.Fragment, null, React.createElement('h4', null, t('status.health')), React.createElement( 'table', null, React.createElement( 'tbody', null, React.createElement(Row, { label: t('status.observed'), value: String(stats.observed) }), React.createElement(Row, { label: t('status.attributed'), value: String(stats.attributed) }), React.createElement(Row, { label: t('status.cancelled'), value: String(stats.cancelled) }), React.createElement(Row, { label: t('status.failed'), value: String(stats.failed) }), React.createElement(Row, { label: t('status.unpriced'), value: String(stats.unpriced) }), React.createElement(Row, { label: t('status.unattributed'), value: String(stats.unattributed) }), React.createElement(Row, { label: t('status.waiting'), value: String(stats.waiting) }), React.createElement(Row, { label: t('status.parked'), value: String(stats.parked) }), React.createElement(Row, { label: t('status.sessions'), value: String(stats.sessions) }), ), ), ) : null, ); } exports.inject = ['slots', 'locale']; exports.apply = async function apply(ctx) { // The locale service may be absent or late; either way the plugin keeps working. let locale = null; try { locale = ctx.locale; } catch (err) { locale = null; } if (!locale && typeof ctx.get === 'function') { try { locale = ctx.get('locale'); } catch (err) { locale = null; } } if (locale && typeof locale.register === 'function') { try { const dispose = locale.register(LOCALE_NAMESPACE, LOCALES); ctx.effect(() => dispose, 'dsh-session-cost: dictionaries'); } catch (err) { /* fall back to the browser language */ } } ctx.effect(() => installStyles(), 'dsh-session-cost: styles'); runtimeCtx = ctx; ctx.effect(() => () => { runtimeCtx = null; }, 'dsh-session-cost: context'); let slots = ctx.slots || (ctx.get ? ctx.get('slots') : undefined); for (let i = 0; slots === undefined && i < 60; i++) { await new Promise((resolve) => { if (scheduleTimeout(resolve, Math.min(300 + i * 20, 1000)) === null) resolve(); }); slots = ctx.slots || (ctx.get ? ctx.get('slots') : undefined); } if (slots === undefined) { console.warn('[dsh-session-cost] the slots service never became available'); return; } // The dock is a list slot: our own id adds a neighbour and leaves the // harness's `stats` pill exactly where it is. ctx.effect( () => slots.inject('conversation.composer.dock', () => slots.register( { name: 'conversation.composer.dock', id: 'session-cost', order: 10, locale: LOCALE_NAMESPACE }, SessionCostPill, ), ), 'dsh-session-cost: pill', ); // The plugin's own page on the Plugins screen. ctx.effect( () => slots.inject('plugins.bundle.config', () => slots.register( { name: 'plugins.bundle.config', key: 'dsh-session-cost', locale: LOCALE_NAMESPACE, label: () => t('status.title'), }, SessionCostStatus, ), ), 'dsh-session-cost: page', ); }; return module.exports; }, });