diff --git a/desktop-plugins/polza-limit/plugin.js b/desktop-plugins/polza-limit/plugin.js new file mode 100644 index 0000000..6cbece1 --- /dev/null +++ b/desktop-plugins/polza-limit/plugin.js @@ -0,0 +1,421 @@ +/** + * 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, {}) + }) + } +} diff --git a/distribution.yaml b/distribution.yaml index 0aba84b..cd685b3 100644 --- a/distribution.yaml +++ b/distribution.yaml @@ -1,5 +1,5 @@ name: ailab_scrume_agent -version: 1.3.0 +version: 1.3.1 description: "Личный помощник: документы, письма, договоры, расчёты, поиск, задачи." hermes_requires: ">=0.21.6" author: "AI laboratory SCRUM company" diff --git a/scripts/polza-limits/polza-limits.cmd b/scripts/polza-limits/polza-limits.cmd new file mode 100644 index 0000000..57345fc --- /dev/null +++ b/scripts/polza-limits/polza-limits.cmd @@ -0,0 +1,9 @@ +@echo off +rem Суточный лимит Polza.ai по ключу владельца — компактный JSON в stdout. +rem Вызывается плагином polza-limit через gateway RPC shell.exec. +rem Путь %LOCALAPPDATA% раскрывается cmd.exe: у каждого пользователя свой. +setlocal +set "PY=%LOCALAPPDATA%\hermes\tools\python-3.14.7+202****0901-win32-x64\python.exe" +if not exist "%PY%" for /d %%D in ("%LOCALAPPDATA%\hermes\tools\python-*") do set "PY=%%D\python.exe" +"%PY%" "%LOCALAPPDATA%\hermes\.polza-limits\polza_limits.py" %* +endlocal diff --git a/scripts/polza-limits/polza_limits.py b/scripts/polza-limits/polza_limits.py new file mode 100644 index 0000000..092c61f --- /dev/null +++ b/scripts/polza-limits/polza_limits.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Суточные лимиты Polza.ai по ДВУМ ключам профилей — компактный JSON для чипа. + +Ключи (значения в вывод не попадают): + + * default + local → CUSTOM_PROVIDER_POLZA_AI_KEY из %LOCALAPPDATA%\\hermes\\.env + * ai-si → HERMES_CUSTOM_POLZA_AI_API_KEY из %LOCALAPPDATA%\\hermes\\profiles\\ai-si\\.env + +Порядок работы: сначала /key и /balance на каждый ключ (полосы готовы даже если +история не досчитается), затем генерации за местные сутки с жёстким дедлайном — +плагин получает от shell.exec только 30 с и хвост stdout в 4000 символов. + +Формат: одна строка JSON + {"ok":true,"day":"2026-10-14","k":[ + {"p":"default + local","limit":500.0,"used":174.63,"left":325.37,"reset":"daily", + "amount":4829.57,"avail":325.37, + "m":["DeepSeek: DeepSeek V4.1 Flash\\x1f169.80\\x1f518", ...]}, + {"p":"ai-si","limit":1500.0,...,"m":[...]}]} + +Модели — плоские строки через \\x1f (имя, рубли, число запросов): объекты с +длинными ключами не влезают в бюджет вывода. +""" +import argparse +import collections +import datetime +import json +import os +import re +import sys +import time +import urllib.parse +import urllib.request + +API = "https://polza.ai/api/v1" +HERE = os.path.dirname(os.path.abspath(__file__)) + +# Метка, .env, имя переменной. Путь к корневому .env агента: у получателя +# установка кладёт персону, конфиг и ключ именно туда. +TARGETS = [ + ("мой ключ", + os.path.expandvars(r"%LOCALAPPDATA%\hermes\.env"), + "HERMES_CUSTOM_POLZA_AI_API_KEY"), +] + +GROUP_SHARE = 0.001 # порог «мелочи»: доля от суточного расхода +GROUP_RUB = 0.05 # порог «мелочи»: абсолютная сумма +MAX_MODELS = 6 # на ключ; плюс «Другое» +OUT_BUDGET = 3400 # оставляем запас до 4000 символов shell.exec +GEN_DEADLINE_S = 18.0 # генерации не должны съесть 30-секундный потолок + + +def read_key(path: str, name: str) -> str | None: + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + text = fh.read() + except OSError: + return None + m = re.search(rf"^{re.escape(name)}=(.+)$", text, re.M) + return m.group(1).strip().strip('"').strip("'") if m else None + + +def api_get(path: str, key: str, timeout: float = 8.0) -> dict: + req = urllib.request.Request(API + path, headers={"Authorization": f"Bearer {key}"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def fetch_generations(key: str, start: datetime.datetime, end: datetime.datetime) -> list[dict]: + """Все генерации за сутки, но не дольше GEN_DEADLINE_S суммарно.""" + items: list[dict] = [] + page = 1 + deadline = time.monotonic() + GEN_DEADLINE_S + while page <= 20: + if time.monotonic() > deadline: + break + query = urllib.parse.urlencode({ + "limit": 100, + "page": page, + "dateFrom": start.isoformat(), + "dateTo": end.isoformat(), + "sortBy": "createdAt", + "sortOrder": "desc", + }) + try: + data = api_get("/history/generations?" + query, key) + except Exception: + break + batch = data.get("items") or [] + items.extend(batch) + meta = data.get("meta") or {} + if page >= int(meta.get("totalPages") or 1) or not batch: + break + page += 1 + return items + + +def compact_models(rows: list[tuple[str, float, int]], total: float, max_models: int) -> list[str]: + head, dropped = [], [] + for name, cost, count in rows: + if (cost >= GROUP_RUB and (total <= 0 or cost / total >= GROUP_SHARE) + and len(head) < max_models): + head.append((name, cost, count)) + else: + dropped.append((cost, count)) + out = [f"{n.replace(chr(31), ' ')}\x1f{c:.2f}\x1f{k}" for n, c, k in head] + tail_cost = sum(c for c, _ in dropped) + tail_count = sum(k for _, k in dropped) + if tail_cost > 0.0 or tail_count: + out.append(f"Другое\x1f{tail_cost:.2f}\x1f{tail_count}") + return out + + +def collect(label: str, env_path: str, env_name: str, + start: datetime.datetime, end: datetime.datetime) -> dict: + row: dict = {"p": label} + key = read_key(env_path, env_name) + if not key: + row["err"] = f"ключ {env_name} не найден в .env" + return row + + try: + info = api_get("/key", key) + except Exception as exc: + row["err"] = f"{type(exc).__name__}: {exc}" + return row + + row.update({ + "limit": float(info.get("limit") or 0), + "used": float(info.get("usage_daily") or 0), + "left": float(info.get("limit_remaining") or 0), + "reset": info.get("limit_reset") or "", + "week": float(info.get("usage_weekly") or 0), + }) + + try: + bal = api_get("/balance", key) + row["amount"] = float(bal.get("amount") or 0) + row["avail"] = float(bal.get("available") or 0) + except Exception: + pass + + items = fetch_generations(key, start, end) + per: dict[str, list] = collections.defaultdict(lambda: [0.0, 0]) + for it in items: + cost = float(it.get("cost") or 0) + name = it.get("modelDisplayName") or it.get("model") or "—" + per[name][0] += cost + per[name][1] += 1 + rows = sorted(((n, v[0], v[1]) for n, v in per.items()), key=lambda r: -r[1]) + row["gen"] = sum(r[2] for r in rows) + row["_rows"] = rows + return row + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--pretty", action="store_true", help="человекочитаемый вывод") + args = ap.parse_args() + + now = datetime.datetime.now().astimezone() + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + rows = [collect(label, path, name, start, now) for label, path, name in TARGETS] + + # Модели укладываем в бюджет: пробуем 6 → 4 → 2 → 0 на ключ. + keys_models = [(r, r.pop("_rows", [])) for r in rows] + payload: dict = {} + for cap in (MAX_MODELS, 4, 2, 0): + for r, src in keys_models: + total = sum(c for _, c, _ in src) + r["m"] = compact_models(src, total, cap) if cap else [] + payload = {"ok": True, "day": start.strftime("%Y-%m-%d"), "k": rows} + flat = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + if len(flat) <= OUT_BUDGET: + break + + if args.pretty: + for r in rows: + if r.get("err"): + print(f"{r['p']}: ОШИБКА {r['err']}") + continue + pct = (r["used"] / r["limit"] * 100) if r["limit"] else 0.0 + print(f"{r['p']}: {r['used']:.2f} из {r['limit']:.2f} ₽ ({pct:.1f} %), " + f"остаток {r['left']:.2f} ₽, генераций {r.get('gen', 0)}, сброс {r['reset']}") + for m in r["m"]: + n, c, k = m.split("\x1f") + print(f" {float(c):>8.2f} ₽ {k:>5} {n}") + return 0 + + json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":")) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())