Newer
Older
AI_Lab_SCRUME_Co / desktop-plugins / polza-limit / plugin.js
/**
 * polza-limit — прогресс-бар суточного лимита Polza.ai в строке управления
 * композера, слева от выбора модели (composer.actions рендерится ровно перед
 * пилюлей модели). Один чип = ОДИН ключ владельца: тот, что вписан в корневой
 * .env агента.
 *
 * Цифры достаются локальным скриптом (scripts/polza-limits.cmd →
 * polza_limits.py): он опрашивает /v1/key, /v1/balance и /v1/history/generations
 * по этому ключу, складывает стоимость генераций за местные сутки и группирует
 * их по моделям. Ключ остаётся в .env, в рендерер не попадает: плагин вызывает
 * готовый .cmd через gateway RPC shell.exec (путь проверен модулем approval —
 * без -c/-e, не dangerous).
 *
 * Наведение открывает подсказку с карточкой: полоса лимита,
 * разбитая по моделям, легенда с суммами и долями, цифры по лимиту и кошельку.
 * Клик — обновление.
 *
 * ОГРАНИЧЕНИЯ shell.exec: только хвост stdout (4000 символов) и 30 с. Скрипт
 * печатает компактный JSON (~800 символов, ~3 с), опрос идёт раз в 5 минут.
 */

import { COMPOSER_AREAS, cn, haptic, host, queryClient, Tip, useQuery, useValue } from '@hermes/plugin-sdk'
import { jsx, jsxs } from 'react/jsx-runtime'

const ID = 'polza-limit'

// Путь через %LOCALAPPDATA%: shell.exec выполняет команду через cmd.exe, а он
// раскрывает переменную у каждого пользователя. Хардкод C:\\Users\\Admin на чужой
// машине не нашёлся бы. Пробелов в пути нет — кавычки не нужны.
const CMD =
  '%LOCALAPPDATA%\\hermes\\.polza-limits\\polza-limits.cmd'

const QUERY_KEY = [ID, 'usage']
const REFRESH_MS = 300_000

/**
 * Цвета моделей: тема даёт всего несколько акцентов, а моделей в сутках 5-8,
 * поэтому палитра задана явно. «Другое» всегда серое.
 */
const PALETTE = ['#8B7CF6', '#4EA8DE', '#3FB950', '#D29922', '#F85149', '#DB61A2', '#39C5CF']

function colorFor(index, name) {
  return name === 'Другое' ? 'var(--ui-text-quaternary)' : PALETTE[index % PALETTE.length]
}

/** Разбор 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) || 'пустой вывод скрипта')
  }

  let data

  try {
    data = JSON.parse(line.slice(cut))
  } catch {
    throw new Error('ответ обрезан (лимит shell.exec — 4000 символов)')
  }

  if (data.ok === false) {
    throw new Error(data.error || 'скрипт вернул ошибку')
  }

  if (!Array.isArray(data.k) || data.k.length === 0) {
    throw new Error('в ответе нет массива ключей k')
  }

  return data
}

async function fetchUsage() {
  const res = await host.request('shell.exec', { command: CMD }, 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, 160) || `скрипт вернул код ${res ? res.code : '?'}`)
  }

  return parsePayload(res.stdout)
}

function money(v) {
  return Number(v).toFixed(2)
}

/** Цвет всей полосы по заполнению: <30% зелёный, 30–75% акцент, ≥75% красный. */
function toneFor(pct) {
  if (pct === null) {
    return 'var(--ui-text-quaternary)'
  }

  if (pct >= 75) {
    return 'var(--ui-red)'
  }

  if (pct >= 30) {
    return 'var(--ui-accent)'
  }

  return 'var(--ui-green)'
}

/** «Модель \x1f рубли \x1f запросы» → объект с цветом. */
function parseModels(rows) {
  return (rows || []).map((row, i) => {
    const [name, cost, count] = String(row).split('\u001f')

    return {
      name,
      cost: Number(cost) || 0,
      count: Number(count) || 0,
      color: colorFor(i, name)
    }
  })
}

/** Процент суточного лимита ключа; null — лимит не задан (или нет данных). */
function limitPct(k) {
  const lim = Number(k.limit) || 0

  if (!lim) {
    return null
  }

  return Math.max(0, Math.min(100, (Number(k.used) / lim) * 100))
}

/** Короткая подпись ключа в чипе: «default + local» → «default». */
function shortKey(label) {
  return String(label || '').split(/[\s+]+/)[0]
}

/** Карточка ОДНОГО ключа: полоса лимита по моделям, легенда, цифры. */
function KeyCard({ k }) {
  const models = parseModels(k.m)
  const totalLimit = Number(k.limit) || 0
  const used = Number(k.used) || 0
  const pct = totalLimit > 0 ? Math.max(0, Math.min(100, (used / totalLimit) * 100)) : 0
  const modelsSum = models.reduce((acc, m) => acc + m.cost, 0) || 1

  return jsxs('div', {
    style: { width: '300px', display: 'flex', flexDirection: 'column', gap: '8px' },
    children: [
      // 1. Полоса лимита, разбитая по моделям: доля каждой — от ЛИМИТА,
      // поэтому незакрашенный хвост и есть свободный лимит.
      jsxs('div', {
        style: { display: 'flex', flexDirection: 'column', gap: '4px' },
        children: [
          jsxs('div', {
            style: { display: 'flex', justifyContent: 'space-between', fontSize: '11px' },
            children: [
              jsx('span', { children: k.p || 'ключ' }),
              jsx('span', {
                style: { color: toneFor(pct) },
                children: `${money(used)} из ${money(totalLimit)} ₽ · ${pct.toFixed(1)}%`
              })
            ]
          }),
          jsx('span', {
            style: {
              display: 'flex',
              width: '100%',
              height: '10px',
              borderRadius: '3px',
              background: 'var(--ui-stroke-secondary)',
              overflow: 'hidden'
            },
            children: models.map((m, i) =>
              jsx(
                'span',
                {
                  title: `${m.name}: ${money(m.cost)} ₽`,
                  style: {
                    display: 'block',
                    width: `${totalLimit > 0 ? (m.cost / totalLimit) * 100 : 0}%`,
                    height: '100%',
                    background: m.color
                  }
                },
                `bar-${i}-${m.name}`
              )
            )
          })
        ]
      }),
      // 2. Легенда: цвет, модель, доля от расхода, сумма, число генераций.
      jsxs('div', {
        style: { display: 'flex', flexDirection: 'column', gap: '3px', fontSize: '11px' },
        children: models.map((m, i) =>
          jsxs(
            'div',
            {
              style: { display: 'flex', alignItems: 'center', gap: '6px' },
              children: [
                jsx('span', {
                  style: {
                    width: '8px',
                    height: '8px',
                    borderRadius: '2px',
                    background: m.color,
                    flex: '0 0 auto'
                  }
                }),
                jsx('span', {
                  style: {
                    flex: '1 1 auto',
                    overflow: 'hidden',
                    textOverflow: 'ellipsis',
                    whiteSpace: 'nowrap'
                  },
                  children: m.name
                }),
                jsx('span', {
                  style: { color: 'var(--ui-text-tertiary)', fontVariantNumeric: 'tabular-nums' },
                  children: `${((m.cost / modelsSum) * 100).toFixed(1)}%`
                }),
                jsx('span', {
                  style: { fontVariantNumeric: 'tabular-nums', minWidth: '58px', textAlign: 'right' },
                  children: `${money(m.cost)} ₽`
                }),
                jsx('span', {
                  style: {
                    color: 'var(--ui-text-quaternary)',
                    fontVariantNumeric: 'tabular-nums',
                    minWidth: '32px',
                    textAlign: 'right'
                  },
                  children: `${m.count}`
                })
              ]
            },
            `row-${i}-${m.name}`
          )
        )
      }),
      // 3. Прежние цифры по ключу.
      jsxs('div', {
        style: {
          borderTop: '1px solid var(--ui-stroke-secondary)',
          paddingTop: '6px',
          display: 'flex',
          flexDirection: 'column',
          gap: '1px',
          fontSize: '11px',
          color: 'var(--ui-text-tertiary)'
        },
        children: [
          jsx('div', {
            children: `${k.gen || 0} генераций за сутки · сброс лимита: ${k.reset || 'daily'}`
          }),
          jsx('div', { children: `остаток лимита ${money(k.left)} ₽` }),
          jsx('div', {
            children: `кошелёк ${money(k.amount)} ₽ · доступно ${money(k.avail)} ₽`
          }),
          jsx('div', { children: `неделя ${money(k.week)} ₽` })
        ]
      })
    ]
  })
}

/** Подсказка: карточка на каждый ключ + шапка и строка подсказки. */
function UsageCard({ data }) {
  const keys = Array.isArray(data.k) ? data.k : []

  return jsxs('div', {
    style: { width: '300px', display: 'flex', flexDirection: 'column', gap: '12px' },
    children: [
      jsxs('div', {
        style: { display: 'flex', justifyContent: 'space-between', fontSize: '11px' },
        children: [
          jsx('span', { children: 'Polza.ai · суточный лимит' }),
          jsx('span', { style: { color: 'var(--ui-text-quaternary)' }, children: data.day || '' })
        ]
      }),
      keys.map((k, i) =>
        jsx(
          'div',
          {
            style: {
              display: 'flex',
              flexDirection: 'column',
              gap: '8px',
              borderTop: i === 0 ? 'none' : '1px solid var(--ui-stroke-secondary)',
              paddingTop: i === 0 ? '0' : '10px'
            },
            children: k.err
              ? jsx('div', { style: { fontSize: '11px', color: 'var(--ui-red)' }, children: `${k.p}: ${k.err}` })
              : jsx(KeyCard, { k })
          },
          `key-${i}-${k.p}`
        )
      ),
      jsx('div', {
        style: { fontSize: '11px', color: 'var(--ui-text-quaternary)', borderTop: '1px solid var(--ui-stroke-secondary)', paddingTop: '6px' },
        children: 'клик — обновить'
      })
    ]
  })
}

/** Один ключ в чипе: микрополоса + «имя процент». */
function KeyMini({ k }) {
  const pct = limitPct(k)
  const tone = toneFor(pct)
  // Ключ один: в чипе достаточно процента; без лимита — прочерк, а не второе «Polza».
  const label = Number(k.limit) ? `${Math.round(pct)}%` : '—'

  return jsx('span', {
    title: Number(k.limit)
      ? `Polza.ai: ${money(k.used)} из ${money(k.limit)} ₽ за сутки`
      : 'Polza.ai: суточный лимит не задан на ключе',
    style: { display: 'inline-flex', alignItems: 'center', gap: '4px' },
    children: [
      jsx('span', {
        style: {
          display: 'inline-block',
          width: '22px',
          height: '4px',
          borderRadius: '2px',
          background: 'var(--ui-stroke-secondary)',
          overflow: 'hidden'
        },
        children: jsx('span', {
          style: {
            display: 'block',
            width: `${pct === null ? 100 : pct}%`,
            height: '100%',
            borderRadius: '2px',
            background: tone
          }
        })
      }),
      jsx('span', {
        style: { color: tone, fontVariantNumeric: 'tabular-nums' },
        children: label
      })
    ]
  }, `keymini-${k.p}`)
}

function PolzaChip() {
  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: 60_000,
    retry: false
  })

  const keys = data && Array.isArray(data.k) ? data.k : []

  const tip = data
    ? jsx(UsageCard, { data })
    : isError
      ? `Polza.ai: нет данных — ${String(error && error.message ? error.message : error).slice(0, 240)}`
      : connected
        ? 'Polza.ai: загрузка…'
        : 'Polza.ai: шлюз не подключён'

  return jsx(Tip, {
    label: tip,
    placement: 'control',
    // Тема приложения вместо инверсного фона по умолчанию: карточке нужен
    // обычный surface, иначе палитра моделей читается на вывернутом цвете.
    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: [
        jsx('span', {
          style: { color: 'var(--ui-text-quaternary)', fontSize: '10px', lineHeight: '1' },
          children: 'Polza'
        }),
        ...keys.map((k, i) => jsx(KeyMini, { k }, `mini-${i}-${k.p}`)),
        keys.length
          ? null
          : jsx('span', {
              style: { color: 'var(--ui-text-quaternary)', fontSize: '10px' },
              children: isError ? '!' : '…'
            })
      ]
    })
  })
}

export default {
  id: ID,
  name: 'Polza limit',
  defaultEnabled: true,
  register(ctx) {
    // composer.actions рендерится внутри строки контролов, до ModelPill —
    // то есть визуально левее выбора модели. В статус-баре чипа больше нет.
    ctx.register({
      id: 'chip',
      area: COMPOSER_AREAS.actions,
      order: 10,
      render: () => jsx(PolzaChip, {})
    })
  }
}