Newer
Older
AI_Lab_SCRUME_Co / desktop-plugins / ailab-limit / plugin.js
/**
 * 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: data.alias || 'ключ'
          })
        ]
      }),
      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, {})
    })
  }
}