Newer
Older
AI_Lab_SCRUME_Co / desktop-plugins / polza-models / plugin.js
/**
 * polza-models — ранжирование моделей Polza.ai по цене в строке управления
 * композера (composer.actions), сразу за чипом расхода (order 15 против 10).
 *
 * Зачем: разброс цен в каталоге — три порядка. В одном и том же каталоге
 * 1,63 ₽ и 17 713 ₽ за миллион токенов входа. При таком разбросе выбирать
 * модель по названию нельзя: для черновой работы и для аудита нужны разные,
 * и «дороже» не значит «лучше для этой задачи».
 *
 * Цены — рублёвые и уже посчитанные: каталог отдаёт их в top_provider.pricing
 * с полем currency = RUB, пересчитывать нечего.
 *
 * Данные достаёт локальный скрипт (scripts/polza-models/polza-models.cmd →
 * polza_models.py): читает /v1/models, фильтрует чат-модели с ценой и печатает
 * их от дешёвых к дорогим. Ключ остаётся в .env, в рендерер не попадает:
 * плагин вызывает готовый .cmd через gateway RPC shell.exec.
 *
 * ОГРАНИЧЕНИЯ shell.exec: только ХВОСТ stdout (4000 символов) и 30 с. Скрипт
 * печатает от самого дешёвого и сам ужимает список под бюджет — иначе обрезка
 * съела бы именно дешёвые модели, самое ценное в этом списке. Опрос — раз в
 * 30 минут: каталог меняется редко, а запрос тянет 600 КБ.
 */

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

const ID = 'polza-models'

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

const QUERY_KEY = [ID, 'catalog']
const REFRESH_MS = 1_800_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) || 'пустой вывод скрипта')
  }

  let data

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

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

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

  return data
}

async function fetchCatalog() {
  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)
}

/** «id \x1f имя \x1f вход \x1f выход \x1f кэш» → объект.
 *
 *  Хвост добавлен позже, чем первые поля: при ручной подмене одного файла из
 *  двух развёртывание может разойтись, поэтому отсутствующее обязано давать
 *  нули, а не NaN.
 */
function parseRows(rows) {
  return (rows || []).map(row => {
    const [id, name, pin, pout, pcache] = String(row).split('\u001f')

    return {
      id: id || '?',
      name: name || id || '?',
      pin: Number(pin) || 0,
      pout: Number(pout) || 0,
      pcache: Number(pcache) || 0
    }
  })
}

/** «1.63» → «1,63»: каталог отдаёт точку, читается это как опечатка. */
function rub(v, digits = 2) {
  return (Number(v) || 0).toFixed(digits).replace('.', ',')
}

/** Три порядка разброса: 17713.2 → «17,7 тыс.», 1771.32 → «1 771». */
function rubShort(v) {
  const n = Number(v) || 0

  if (n >= 10_000) {
    return `${(n / 1000).toFixed(1).replace('.', ',')} тыс.`
  }

  if (n >= 1_000) {
    return `${Math.round(n).toLocaleString('ru-RU')}`
  }

  return rub(n)
}

function LegendHead() {
  return jsxs('div', {
    style: {
      display: 'flex',
      alignItems: 'center',
      gap: '6px',
      color: 'var(--ui-text-quaternary)'
    },
    children: [
      jsx('span', { style: { flex: '1 1 auto' }, children: 'модель' }),
      jsx('span', { style: { minWidth: '52px', textAlign: 'right' }, children: 'вход' }),
      jsx('span', { style: { minWidth: '52px', textAlign: 'right' }, children: 'выход' }),
      jsx('span', { style: { minWidth: '44px', textAlign: 'right' }, children: 'кэш' })
    ]
  })
}

function ModelRow({ m, index }) {
  // Зебра вместо цвета модели: список читается сверху вниз, и полоски
  // помогают не потерять строку. Палитра тут была бы шумом.
  const bg = index % 2 ? 'transparent' : 'var(--ui-bg-secondary, transparent)'

  return jsxs('div', {
    style: {
      display: 'flex',
      alignItems: 'center',
      gap: '6px',
      background: bg,
      borderRadius: '3px',
      padding: '1px 2px'
    },
    children: [
      jsxs('span', {
        style: {
          flex: '1 1 auto',
          overflow: 'hidden',
          textOverflow: 'ellipsis',
          whiteSpace: 'nowrap'
        },
        children: [
          jsx('span', { children: m.name }),
          jsx('span', {
            style: { color: 'var(--ui-text-quaternary)', marginLeft: '6px', fontSize: '10px' },
            children: m.id
          })
        ]
      }),
      jsx('span', {
        style: { fontVariantNumeric: 'tabular-nums', minWidth: '52px', textAlign: 'right' },
        children: rub(m.pin)
      }),
      jsx('span', {
        style: {
          color: 'var(--ui-text-tertiary)',
          fontVariantNumeric: 'tabular-nums',
          minWidth: '52px',
          textAlign: 'right'
        },
        children: rub(m.pout)
      }),
      jsx('span', {
        style: {
          color: 'var(--ui-text-quaternary)',
          fontVariantNumeric: 'tabular-nums',
          minWidth: '44px',
          textAlign: 'right'
        },
        children: m.pcache > 0 ? rub(m.pcache) : '—'
      })
    ]
  }, `row-${index}-${m.id}`)
}

function CatalogCard({ data }) {
  const models = parseRows(data.m)

  return jsxs('div', {
    style: { width: '380px', display: 'flex', flexDirection: 'column', gap: '8px' },
    children: [
      jsxs('div', {
        style: { display: 'flex', justifyContent: 'space-between', fontSize: '11px' },
        children: [
          jsx('span', { style: { color: 'var(--ui-text-tertiary)' }, children: 'Polza.ai · модели по цене' }),
          jsx('span', { style: { color: 'var(--ui-text-quaternary)' }, children: data.cur || 'RUB' })
        ]
      }),
      jsx('div', {
        style: { color: 'var(--ui-text-quaternary)', fontSize: '11px' },
        children: `${data.n} чат-моделей с ценой · ₽ за 1 млн токенов · от ${rub(data.min)} до ${rubShort(data.max)}`
      }),
      jsxs('div', {
        style: { display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '11px' },
        children: [jsx(LegendHead, {}), ...models.map((m, i) => jsx(ModelRow, { m, index: i }, `m-${i}-${m.id}`))]
      }),
      jsx('div', {
        style: { color: 'var(--ui-text-quaternary)', fontSize: '10px' },
        children: `показаны ${models.length} самых дешёвых из ${data.n} · сортировка по входу · клик — обновить`
      })
    ]
  })
}

function ModelsChip() {
  const gateway = useValue(host.state.gateway)
  const connected = Boolean(gateway)

  const { data, error, isError } = useQuery({
    queryKey: [...QUERY_KEY, connected],
    enabled: connected,
    queryFn: fetchCatalog,
    refetchInterval: REFRESH_MS,
    staleTime: 300_000,
    retry: false
  })

  const tip = data
    ? jsx(CatalogCard, { 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: 'Модели'
        }),
        data
          ? jsx('span', {
              style: { fontVariantNumeric: 'tabular-nums' },
              children: `от ${rub(data.min)}`
            })
          : jsx('span', {
              style: { color: 'var(--ui-text-quaternary)', fontSize: '10px' },
              children: isError ? '!' : '…'
            })
      ]
    })
  })
}

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