/**
* ailab-limit — суточный расход ключа шлюза AILab SCRUME в строке композера.
*
* Один чип = один пользовательский ключ `sk-…` из .env активного профиля.
* Цифры берёт локальный скрипт (gw-usage.cmd → gw_usage.py): GET /key/info
* на gw.ailabscrume.ru отдаёт spend и max_budget в рублях — ровно то, что
* списал polza, пересчёт по курсу не нужен. Ключ в рендерер не попадает:
* плагин вызывает готовый .cmd через gateway RPC shell.exec.
*
* Клик — обновить, наведение — карточка с расходом, лимитом и периодом.
*/
import { COMPOSER_AREAS, cn, haptic, host, queryClient, Tip, useQuery, useValue } from '@hermes/plugin-sdk'
import { jsx, jsxs } from 'react/jsx-runtime'
const ID = 'ailab-limit'
// %LOCALAPPDATA% раскрывается cmd-оболочкой shell.exec — путь одинаков у всех
// пользователей, профиль может быть любым (скрипт сам находит .env).
const CMD = '%LOCALAPPDATA%\\hermes\\desktop-plugins\\ailab-limit\\gw-usage.cmd'
const QUERY_KEY = [ID, 'usage']
const REFRESH_MS = 60_000
/** Разбор stdout скрипта: JSON от первой «{» в последней непустой строке. */
function parsePayload(raw) {
const text = String(raw || '').trim()
const line = text.split('\n').map((s) => s.trim()).filter(Boolean).pop() || ''
const cut = line.indexOf('{')
if (cut < 0) {
throw new Error(line.slice(0, 120) || 'пустой вывод скрипта')
}
try {
return JSON.parse(line.slice(cut))
} catch {
throw new Error('ответ обрезан или повреждён')
}
}
async function fetchUsage() {
// Canonical shell.exec требует session_id (Hermes 0.22.5+); legacy-форма
// принимает только command. Передаём оба.
const sessionId = host.state.focusedSessionId.get() || undefined
const res = await host.request('shell.exec', { command: CMD, session_id: sessionId }, 45_000)
if (res && res.blocked) {
throw new Error(res.hint || 'shell.exec заблокирован')
}
if (!res || res.code !== 0) {
const err = String((res && res.stderr) || '').trim()
throw new Error(err.slice(0, 200) || 'скрипт завершился с ошибкой')
}
const data = parsePayload(res.stdout)
if (!data.ok) {
throw new Error(String(data.err || 'нет данных'))
}
return data
}
function money(v) {
const n = Number(v)
if (!Number.isFinite(n)) return '—'
return n.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function pctOf(d) {
if (!d || !Number(d.budget)) return null
return Math.max(0, Math.min(100, (Number(d.spend) / Number(d.budget)) * 100))
}
function toneFor(pct) {
if (pct === null) return 'var(--ui-text-quaternary)'
if (pct >= 90) return '#F85149'
if (pct >= 70) return '#D29922'
return '#3FB950'
}
/** Мини-полоса и сумма в чипе. */
function ChipBody({ data }) {
const pct = pctOf(data)
return jsxs('span', {
style: { display: 'inline-flex', alignItems: 'center', gap: '5px' },
children: [
jsx('span', {
style: { color: 'var(--ui-text-quaternary)', fontSize: '10px', lineHeight: '1' },
children: 'AI'
}),
jsx('span', {
style: {
display: 'inline-block',
width: '26px',
height: '4px',
borderRadius: '2px',
background: 'var(--ui-stroke-secondary)',
overflow: 'hidden'
},
children: jsx('span', {
style: {
display: 'block',
width: `${pct === null ? 0 : pct}%`,
height: '100%',
borderRadius: '2px',
background: toneFor(pct)
}
})
}),
jsx('span', {
style: { fontSize: '11px', fontVariantNumeric: 'tabular-nums' },
children: data.budget ? `${money(data.spend)}/${money(data.budget)}` : `${money(data.spend)} ₽`
})
]
})
}
/** Карточка при наведении. */
function UsageCard({ data }) {
const pct = pctOf(data)
const rows = [
['Израсходовано', `${money(data.spend)} ₽`],
['Суточный лимит', data.budget ? `${money(data.budget)} ₽` : 'не задан'],
['Осталось', data.budget ? `${money(Math.max(0, data.budget - data.spend))} ₽` : '—'],
['Период сброса', data.duration === '1d' ? 'каждые 24 часа' : data.duration || '—']
]
return jsxs('div', {
style: { display: 'flex', flexDirection: 'column', gap: '6px', minWidth: '210px' },
children: [
jsxs('div', {
style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px' },
children: [
jsx('span', { style: { fontSize: '12px', fontWeight: 600 }, children: 'AILab SCRUME' }),
jsx('span', {
style: { fontSize: '11px', color: 'var(--ui-text-tertiary)' },
children: 'за сутки'
})
]
}),
jsx('div', {
style: {
height: '5px',
borderRadius: '3px',
background: 'var(--ui-stroke-secondary)',
overflow: 'hidden'
},
children: jsx('span', {
style: {
display: 'block',
width: `${pct === null ? 0 : pct}%`,
height: '100%',
background: toneFor(pct)
}
})
}),
jsx('div', {
style: { display: 'flex', flexDirection: 'column', gap: '3px' },
children: rows.map(([k, v]) =>
jsxs(
'div',
{
style: { display: 'flex', justifyContent: 'space-between', gap: '16px', fontSize: '11px' },
children: [
jsx('span', { style: { color: 'var(--ui-text-tertiary)' }, children: k }),
jsx('span', { style: { fontVariantNumeric: 'tabular-nums' }, children: v })
]
},
k
)
)
})
]
})
}
function AilabChip() {
const gateway = useValue(host.state.gateway)
const connected = Boolean(gateway)
const { data, error, isError } = useQuery({
queryKey: [...QUERY_KEY, connected],
enabled: connected,
queryFn: fetchUsage,
refetchInterval: REFRESH_MS,
staleTime: 30_000,
retry: false
})
if (isError) {
console.error('[ailab-limit] fetch failed:', String((error && error.message) || error))
}
const tip = data
? jsx(UsageCard, { data })
: isError
? `AILab: нет данных — ${String(error && error.message ? error.message : error).slice(0, 200)}`
: connected
? 'AILab: загрузка…'
: 'AILab: шлюз не подключён'
return jsx(Tip, {
label: tip,
placement: 'control',
className:
'border border-(--ui-stroke-secondary) bg-(--ui-bg-elevated) text-foreground backdrop-blur-md',
children: jsxs('button', {
type: 'button',
className: cn(
'inline-flex h-(--composer-control-size) shrink-0 items-center gap-1.5 rounded-md px-1.5 text-xs transition-colors',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
),
onClick: () => {
haptic('tap')
queryClient.invalidateQueries({ queryKey: QUERY_KEY })
},
children: data
? jsx(ChipBody, { data })
: jsx('span', {
style: { color: 'var(--ui-text-quaternary)', fontSize: '10px' },
children: isError ? 'AI !' : 'AI …'
})
})
})
}
export default {
id: ID,
name: 'AILab limit',
defaultEnabled: true,
register(ctx) {
ctx.register({
id: 'chip',
area: COMPOSER_AREAS.actions,
order: 5,
render: () => jsx(AilabChip, {})
})
}
}