diff --git a/README.md b/README.md index 5e995bf..6035f6e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AILab SCRUME Agent -**Текущая версия: 1.7.1.** +**Текущая версия: 1.7.2.** Дистрибутив Hermes Agent: персона личного помощника, базовый конфиг, 58 скиллов. Автор: AI laboratory SCRUM company. diff --git a/desktop-plugins/ailab-limit/gw-usage.cmd b/desktop-plugins/ailab-limit/gw-usage.cmd new file mode 100644 index 0000000..ad63c1c --- /dev/null +++ b/desktop-plugins/ailab-limit/gw-usage.cmd @@ -0,0 +1,12 @@ +@echo off +rem Расход ключа шлюза AILab SCRUME — компактный JSON в stdout. +rem Вызывается плагином ailab-limit через gateway RPC shell.exec. +setlocal +set "PY=" +for /d %%D in ("%LOCALAPPDATA%\hermes\tools\python-*") do if exist "%%D\python.exe" set "PY=%%D\python.exe" +if not defined PY ( + echo {"ok":false,"err":"python не найден в tools"} + exit /b 0 +) +"%PY%" "%~dp0gw_usage.py" %* +endlocal diff --git a/desktop-plugins/ailab-limit/gw_usage.py b/desktop-plugins/ailab-limit/gw_usage.py new file mode 100644 index 0000000..50cc22f --- /dev/null +++ b/desktop-plugins/ailab-limit/gw_usage.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Расход ключа шлюза AILab SCRUME (gw.ailabscrume.ru) — компактный JSON в stdout. + +Вызывается плагином ailab-limit через shell.exec. Ключ берётся из .env профилей +и в рендерер не попадает. Один запрос: GET /key/info с пользовательским ключом +(master не нужен) — шлюз отдаёт spend, max_budget и budget_duration в рублях. + +Формат ответа: + {"ok":true,"spend":5.21,"budget":100.0,"duration":"1d","pct":5.2,"alias":"testoff"} + {"ok":false,"err":"..."} +""" + +import json +import os +import sys +import urllib.error +import urllib.request + +BASE = "https://gw.ailabscrume.ru" +KEY_NAMES = ("HERMES_CUSTOM_AILAB_API_KEY", "CUSTOM_PROVIDER_AILAB_KEY", "AILAB_API_KEY") + + +def candidates(): + """Пути к .env: корневой и все профили, активный — первым.""" + root = os.path.expandvars(r"%LOCALAPPDATA%\hermes") + out = [] + + active = os.path.expandvars(r"%APPDATA%\hermes\active-profile.json") + if os.path.exists(active): + try: + with open(active, encoding="utf-8") as f: + name = json.load(f).get("profile") or json.load(f).get("active") + if name: + out.append(os.path.join(root, "profiles", name, ".env")) + except Exception: + pass + + out.append(os.path.join(root, ".env")) + prof_dir = os.path.join(root, "profiles") + if os.path.isdir(prof_dir): + for name in sorted(os.listdir(prof_dir)): + out.append(os.path.join(prof_dir, name, ".env")) + + seen, uniq = set(), [] + for p in out: + if p not in seen: + seen.add(p) + uniq.append(p) + return uniq + + +def read_key(path): + """Первая непустая переменная из KEY_NAMES в файле .env.""" + try: + with open(path, encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + except OSError: + return None, None + + found = {} + for line in lines: + s = line.strip() + if not s or s.startswith("#") or "=" not in s: + continue + name, _, value = s.partition("=") + name = name.strip() + if name in KEY_NAMES: + value = value.strip().strip('"').strip("'") + if value.startswith("sk-"): + found[name] = value + + for name in KEY_NAMES: + if name in found: + return name, found[name] + return None, None + + +def fetch(key, timeout=10.0): + req = urllib.request.Request(BASE + "/key/info", headers={"Authorization": "Bearer " + key}) + with urllib.request.urlopen(req, timeout=timeout) as r: + data = json.loads(r.read()) + return data.get("info", data) + + +def main(): + for path in candidates(): + name, key = read_key(path) + if not key: + continue + try: + info = fetch(key) + except urllib.error.HTTPError as e: + print(json.dumps({"ok": False, "err": "шлюз вернул %s" % e.code}, ensure_ascii=False)) + return + except Exception as e: + print(json.dumps({"ok": False, "err": str(e)[:120]}, ensure_ascii=False)) + return + + spend = float(info.get("spend") or 0) + budget = info.get("max_budget") + budget = float(budget) if budget is not None else None + pct = round(spend / budget * 100, 1) if budget else None + + print(json.dumps({ + "ok": True, + "spend": round(spend, 4), + "budget": round(budget, 2) if budget else None, + "duration": info.get("budget_duration") or "", + "pct": pct, + "alias": info.get("key_alias") or "", + }, ensure_ascii=False)) + return + + print(json.dumps({"ok": False, "err": "ключ шлюза не найден в .env"}, ensure_ascii=False)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/desktop-plugins/ailab-limit/plugin.js b/desktop-plugins/ailab-limit/plugin.js new file mode 100644 index 0000000..3781e66 --- /dev/null +++ b/desktop-plugins/ailab-limit/plugin.js @@ -0,0 +1,243 @@ +/** + * 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, {}) + }) + } +} diff --git a/distribution.yaml b/distribution.yaml index 6d94ece..d543fa6 100644 --- a/distribution.yaml +++ b/distribution.yaml @@ -1,5 +1,5 @@ name: ailab_scrume_agent -version: 1.7.1 +version: 1.7.2 description: "Личный помощник: документы, письма, договоры, расчёты, поиск, задачи." hermes_requires: ">=0.21.6" author: "AI laboratory SCRUM company"