Implement the session cost plugin
Host half wraps globalThis.fetch to read usage.cost out of provider responses (tee, never clone) and listens on the llm/stream seam for the session; the two halves join on the generation id. Client half adds one pill to the composer dock under its own id, so the harness's native stats stay where they are. Verified against the live API: three calls in one session (agent reply, compaction, session title) attributed with per-purpose totals, an unknown session answered with null, the ledger written and reloaded across instances, and the pill rendering only when a cost exists.
This commit is contained in:
+482
@@ -0,0 +1,482 @@
|
||||
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) {
|
||||
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;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user