Files
dsh-cost-plugin/lib/client.js
T
antonov 6edd2e3d52 Fix the session id never reaching the host, and the split lost on restart
Two real defects, both found by testing rather than reading:

1. The client sent sessionId in the POST body while the route read it from
   the query string, so the host always saw an empty id and answered
   total:null -- the pill was correct to stay silent. The client now sends
   the id in both places and the route reads both.
2. The ledger stored only a session total, so a restart kept the sum but
   dropped the per-purpose split and the call count (9.99 total against
   6.24 in parts, seen on the live route). Sessions are now stored as
   objects; a bare number from an older ledger is still accepted.

Also made the client report why it cannot read a total: swallowing the
error made 'nothing spent yet' indistinguishable from 'request failed'.
2026-10-15 15:40:08 +03:00

516 lines
19 KiB
JavaScript

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/<method> 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, query) {
let timedOut = false;
const controller = new AbortController();
const timer = scheduleTimeout(() => {
timedOut = true;
controller.abort();
}, RPC_TIMEOUT_MS);
const done = () => cancelTimeout(timer);
const suffix = query && Object.keys(query).length > 0
? '?' + new URLSearchParams(query).toString()
: '';
return fetch(RPC_BASE + '/' + method + suffix, {
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(() => {
// Why the fallback chain exists: the host route answers `total: null` for an
// id it does not know, and the pill then stays silent -- so a wrong id here
// looks exactly like "nothing spent yet" unless it is reported.
const seen = [];
const record = (source, value) => seen.push(source + '=' + JSON.stringify(value ?? null));
let resolved = '';
try {
record('props.sessionId', props.sessionId);
record('props.session.sessionId', props.session && props.session.sessionId);
record('props keys', Object.keys(props).slice(0, 12));
if (typeof props.sessionId === 'string' && props.sessionId.length > 0) resolved = props.sessionId;
else if (props.session && typeof props.session.sessionId === 'string') resolved = props.session.sessionId;
else {
const sessions = runtimeCtx && runtimeCtx.get ? runtimeCtx.get('sessions') : null;
const current = sessions && sessions.list ? sessions.list.getSnapshot().current : null;
record('sessions.list.current', current);
if (typeof current === 'string' && current.length > 0) resolved = current;
}
} catch (err) {
record('error', String((err && err.message) || err));
}
seen.push('resolved=' + JSON.stringify(resolved));
// Report once per distinct outcome so the console stays readable.
const summary = seen.join(' | ');
if (globalThis.__dshSessionCostId !== summary) {
globalThis.__dshSessionCostId = summary;
console.info('[dsh-session-cost] session id: ' + summary);
}
return resolved;
}, [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 = () => {
// The id goes in the query string as well as the body: the route reads
// either, so this keeps working against a host that predates the fix.
rpc('session', { sessionId }, { sessionId })
.then((data) => {
if (cancelled) return;
setState({ total: data && typeof data.total === 'number' ? data.total : null, loading: false, error: null });
})
.catch((err) => {
if (cancelled) return;
// Say why, once per distinct failure, instead of silently keeping the
// pill empty: "no spend yet" and "cannot reach the host" are
// indistinguishable otherwise, and only one of them is normal.
const why = String((err && err.message) || err);
if (globalThis.__dshSessionCostError !== why) {
globalThis.__dshSessionCostError = why;
console.warn('[dsh-session-cost] cannot read ' + RPC_BASE + '/session: ' + why);
}
setState((prev) => ({ total: prev.total, loading: false, error: why }));
})
.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;
},
});