# dsh-balance-indicator [English](README.md) · **Русский** Плагин для DeepSeek Harness: показывает **баланс аккаунта** и **изменение баланса за текущий сеанс** прямо в доке композера — рядом со штатными «пилюлями» токенов и кэш-хитов. Строка выглядит так: ``` 💰 Topped-up: $8.505 -¥0.189 Session: ¥0.000 Granted: $15.310 ¥47.285 [Refresh] [Пополнить] ``` - **Сетка, а не поток.** Строка — это CSS-сетка из четырёх содержательных колонок плюс две служебные: монета, подпись, по колонке на каждую валюту, подпись `Session` и её значение. Поэтому доллар стоит строго под долларом, а юань под юанем, и подписи обеих строк начинаются на одной вертикали. Разделителей ` + ` и ` · ` нет — выравнивание само является разделителем. - **Порядок валют** берётся из строки `Topped-up` (по убыванию), затем добавляются валюты, которые есть только в подарочном балансе. Порядок общий для обеих строк — иначе колонки не совпали бы. - **`Topped-up:`** — пополненный баланс (`normal_wallets`). Если все валюты округляются до `0.000`, показывается крупнейшая: нулевой баланс — это информация, а пустая строка нет. - **`Granted:`** — подарочный баланс (`bonus_wallets`). Строка появляется **только если он непустой**: нет подарочных средств (или они округляются до `0.000`) — нет ни строки, ни подписи, а блок становится однострочным, и кнопки переезжают в первую строку. - **Символ перед числом** — `¥1.910`, `$8.990`. Символы берутся из небольшой карты (`CNY → ¥`, `USD → $`), а не из `Intl`: в английской локали `Intl` печатает `CN¥` и `US$`, а нужен компактный знак. Валюта, которой нет в карте, показывается своим кодом — читается всё равно осмысленно. - **Знаков после запятой — три.** Они задаются одной константой `FRACTION_DIGITS`, а порог «это ноль» выводится из неё (`ZERO_EPSILON = 0.5 · 10⁻³`), так что число знаков и порог не разъедутся. - **Отрицательный остаток** (перерасход) выводится со знаком минус **перед символом**: `-$0.010`. Сумма ниже порога печатается как `0.000` без знака: Platform хранит много знаков (`0.1068931130…`), иначе `-0.0004` давало бы `-0.000`. Точное значение при этом не теряется — оно остаётся во всплывающей подсказке строкой `Exact value`. - **За каким кошельком следит `Session`** — за тем, который реально тратится. Baseline ведётся **для каждого кошелька отдельно** (ключ `<группа>:<валюта>`), потому что Platform может списывать сессию как с пополненного баланса, так и с подарочного. Фигура берётся у того кошелька, чьё изменение по модулю больше; если ещё ничего не двигалось — у самого крупного, чтобы строка не оставалась пустой. В подсказке указано, откуда именно: `Session: ¥1.910 — Granted CNY`. - **`reset` рядом со значением `Session`** — сбрасывает расход за сессию в ноль. Технически это не «обнуление счётчика», а **перестановка якоря**: baseline каждого кошелька перезаписывается текущим балансом — и в `sessionStorage`, и в снапшоте, — поэтому следующее чтение не возвращает старую разницу. Это **слово `reset` на маленькой заливке** (10px, высота плашки 11px — на волос выше текста), без рамки и без подчёркивания, вплотную к цифре (зазор 6px), с подсказкой `Reset the session spend`; в zh — `清零`. Вариант выбран из десяти отрисованных вариантов выделения: заливка даёт «кнопочность», не рисуя контур, а нейтральный серый с alpha 0.20 читается и в светлой, и в тёмной теме. - **Валюты с нулевым показанным остатком** (после округления `0.000`, включая пылевые бонусы) в строку не попадают вовсе — соответствующая ячейка остаётся пустой, чтобы колонки не сдвинулись. - **Подложка** — строка оформлена блоком с **прямыми углами** (`border-radius: 0`): так попросили явно, и это намеренно отличает блок балансов от скруглённых пилюль дока. Типографика и цвет взяты из тех же токенов, что использует штатная пилюля токенов/кэша (`--dsw-alias-label-tertiary`, `tabular-nums`), а цифры — из `--dsw-alias-label-primary`, поэтому при смене темы всё перекрашивается вместе с интерфейсом. - **`[Refresh]`** — принудительное обновление. Кнопка прямоугольная, как и рамка блока. - **`[Пополнить]`** — кнопка со ссылкой на страницу пополнения Platform (`links.topUpUrl` из `remote.account.getState()`, запасной вариант — `usageUrl`), обычный якорь `` с прямыми углами. Сама строка ссылкой **не** является: так клик по кнопкам и переход на сайт не конкурируют за одну цель. **Тонкость, на которой легко ошибиться:** монета растянута на обе строки через `grid-row: 1 / -1`, а `-1` отсчитывается только от **явной** сетки. Без `grid-template-rows` монета заняла бы одну ячейку, и подпись `Granted` уехала бы под неё, сломав выравнивание. Поэтому число строк задаётся явно — `auto auto` при наличии подарочного баланса и `auto` без него. Автообновление — раз в 15 секунд. ## Проверки ``` node test/smoke.mjs # 18 проверок браузерной половины (vm, без DOM) ``` ## Статус: работает Проверено в живом GUI (DSH 0.2.0-rc.2): строка отображается в доке композера и показывает баланс — на момент той проверки `💰 $0.76 — $0.00 session` (аккаунт, валюта USD; разделитель и раскладка с тех пор изменились, см. выше). Главная ловушка, из-за которой строка сначала висела на `…`: в клиентском плагине **обязательно объявлять namespaced-сервис**, а не только корневой. Работает: ```js const inject = ['slots', 'remote', 'remote.account', 'locale'] ``` С одним `'remote'` вызов `ctx.remote.account.getBalance(...)` уходит и не получает ответа — бесконечно «loading». Штатный `@deepseek-ai/dsh-client-ui-chat` делает так же для `remote.session`. Вторая правка, которая вступит в силу после перезапуска DSH (ESM-модуль кэшируется): серверная половина объявляет `export const inject = ['deepseekAccount']` — без этого обращение к сервису в Cordis бросает исключение, и диагностический маячок обрывается после первой строки. ## Когда обновляется баланс | Повод | Условие | |---|---| | Активация плагина | Однократно при загрузке страницы | | Один ранний повтор через 2 с | Если **первое** чтение не удалось (ждём namespace аккаунта) | | **Каждые 15 секунд** | Постоянный интервал, пока вкладка открыта | | Клик по `↻` | Вручную, в любой момент | | `F5` | Заново при монтировании | Обновление **намеренно не привязано к ходам агента**: ход может длиться минутами, и чтение по его завершению оставляло бы цифру устаревшей ровно на это время. Цикл идёт строго по таймеру. Наложение запросов исключено: пока предыдущее чтение не завершилось, следующий тик пропускается. Это важно, потому что интервал (15 с) короче таймаута одного запроса (20 с) — без защиты запросы копились бы при каждом подвисании. Частота: один запрос в 15 секунд, то есть **4 в минуту** и ~5760 в сутки на открытую вкладку. DSH не кэширует баланс: каждое чтение — реальный `GET https://platform.deepseek.com/api/v0/users/get_user_summary` с токеном аккаунта. При ошибке строка показывает `💰 !` с текстом причины, а следующий тик пробует снова. ## Что важно понимать про «потрачено за сессию» В DSH **нет денежного учёта расхода**: есть только баланс аккаунта (с валютами) и расход в токенах (`tokenUsage`: uncached input / output / cache read / cache write). Поэтому вторая часть строки — это **разница баланса**, зафиксированная от первого успешного чтения баланса на этой странице до текущего момента. Baseline берётся **в момент первого чтения**, а не первого рендера: док композера может смонтироваться позже загрузки страницы, и если якорить на рендер, расход, накопленный до открытия чата, терялся бы. Хранится в `sessionStorage` — **ключ на каждый кошелёк** (`topped:CNY`, `granted:USD`, …), поэтому перезагрузка страницы его не сбрасывает. Baseline нужен каждому кошельку потому, что Platform списывает сессию не обязательно с пополненного баланса: у аккаунта с подарочными средствами расход может идти из них, и тогда пополненный баланс стоит на месте. Раньше фигура сессии была привязана к «самой крупной валюте» — и если крупнее оказывался подарочный кошелёк, у которого baseline не было, `Session` замирала на `0.000` на всю сессию. Теперь фигура идёт за тем кошельком, чьё изменение больше по модулю, а подсказка называет его (`Session: ¥1.910 — Granted CNY`). Следствие: если параллельно тратит другой сеанс или другое окно, его расход тоже попадёт в эту разницу. Подпись в подсказке это отражает. ## Откуда данные - Клиентская половина: RPC `ctx.remote.account.getBalance({ version, locale, timezoneOffsetSeconds })` → `{ ok: true, value: { status, value: [{ currency, balance }], bonusWallets: [...] } }`, `null` при отсутствии входа. - Хост-половина: `ctx.deepseekAccount` → `GET https://platform.deepseek.com/api/v0/users/get_user_summary` с заголовком `x-dsh-auth-token` (токен остаётся на хосте и в браузер не попадает). - Валюты: `CNY`, `USD`. Подарочные кошельки (`bonus_wallets`) идут в строку группой `Granted:`, а в подсказке — с кодами валют рядом с суммами. ## Место в интерфейсе Слот `conversation.composer.dock` (list, session-scope, «Ambient entries below the composer card»). Штатная запись — `StatsPills` из `@deepseek-ai/dsh-client-ui-chat` с `id: 'stats'`, `order: 0`. Этот плагин использует `id: 'balance-indicator'`, `order: 1000`: списки сортируются по `priority`, затем по `order` **по возрастанию**, поэтому блок встаёт последним и ничего не подменяет. **Граница возможного.** Правее дока в той же строке стоит кольцо заполнения контекста (`ContextMeter`, те самые «43%»), и оно рисуется композером **после** слота, а не внутри него: ```js children: [ renderSlot('conversation.composer.dock', {}), activity ? null : ContextMeter({ ... }), ] ``` Поэтому быть правее кольца нельзя никаким `order` — можно быть только последней записью дока. Когда идёт ход, кольцо скрывается (`activity ? null : …`), и тогда блок оказывается самым правым элементом строки. ### Грабли, найденные в живом доке 1. **Док — центрированная flex-строка** (`.dock{display:flex; justify-content:center; gap:12px}`), а блок — её flex-элемент. По умолчанию `flex-shrink: 1`, поэтому когда док заполняется (например, добавился ещё один плагин со своей записью) блок сжимается, `auto`-колонки сетки схлопываются ниже своего содержимого и подписи налезают на цифры. Лечится `flexShrink: 0` в стиле блока — блок сохраняет свою ширину, а переносит ответственность за нехватку места на соседей. 2. **Вторая строка нужна всегда.** Кнопки `Refresh`/`Top up` живут во второй строке. Если при пустом подарочном балансе делать сетку однострочной (как было), кнопки попадают в те же ячейки, что подпись и значение `Session`, и рисуются прямо поверх текста. Поэтому `gridTemplateRows` всегда `auto auto`, а `gridRow` кнопок всегда `2` — даже когда слева во второй строке пусто. Обе регрессии закрыты проверками в `test/smoke.mjs`. ## Состав | Файл | Роль | |---|---| | `package.json` | объявляет `dsh.bundle.patch` и `dsh.client` (`platform: web`, `immediately`) | | `cordis.patch.yml` | вставляет строку плагина в композицию профиля | | `index.js` | серверная половина: пустая, нужна только для наличия loader-строки | | `client.js` | браузерная половина: CJS-фабрика в `window.__ModuleLoader__`, React из seed-таблицы шелла | ## Установка (уже выполнена) ```powershell # 1. пакет лежит вне профиля, ссылка — через file: # C:\Users\rrrED\.dsh\profiles\desktop\package.json # "dsh-balance-indicator": "file:../../local-plugins/dsh-balance-indicator" # и имя добавлено в "dsh.profile.bundles" # 2. связывание cd C:\Users\rrrED\.dsh\profiles\desktop node "G:\Soft\DSH\resources\runtime\pnpm\bin\pnpm.cjs" install --prefer-offline ``` ## Активация Композиция профиля читается хостом при старте, поэтому после ручной правки `package.json` нужно одно из: 1. **Без перезапуска:** сайдбар → **Plugins** → выключить и включить `dsh-balance-indicator` (менеджер плагинов перекомпоновывает дерево через HMR на лету). 2. Либо перезапустить DSH. После этого правки `client.js` подхватываются на лету: HMR-поллинг проверяет файл раз в 500 мс. Правка `index.js` уже установленного пакета требует перезапуска. ## Тесты ```powershell node test/smoke.mjs ``` Смоук-тест грузит `client.js` в `node:vm` с поддельным `window.__ModuleLoader__`, вызывает фабрику и `apply` с фейковым клиентским контекстом и проверяет: маппинг кошельков, формат денег, появление выпадающего списка только при нескольких валютах, дельту относительно baseline, ветки «нет входа» / «ошибка remote» / «исключение» / «нет namespace» и наличие cleanup у каждого эффекта. React подменён заглушкой, поэтому сам рендер React не проверяется — это проверка логики плагина, а не вёрстки. ## Проверка композиции (без запуска GUI) Штатный профиль `desktop` CLI не отдаёт (`managed exclusively by the Electron application`), поэтому проверка делается на одноразовой копии профиля: ```powershell $prof = 'C:\Users\rrrED\.dsh\profiles\desktop' $dst = 'C:\Users\rrrED\.dsh\profiles\plugin-check' New-Item -ItemType Directory -Path $dst -Force | Out-Null foreach ($f in @('package.json','cordis.yml','cordis.patch.yml','pnpm-lock.yaml','compatibility.json','pnpm-workspace.yaml')) { Copy-Item "$prof\$f" $dst -Force -ErrorAction SilentlyContinue } cmd /c "mklink /J `"$dst\node_modules`" `"$prof\node_modules`"" & "G:\Soft\DSH\resources\runtime\cli\bin\dsh.cmd" --profile plugin-check --dump-config | Select-String -Pattern 'balance-indicator' -Context 2,1 # уборка: rmdir без /S снимает только junction, содержимое target не трогает cmd /c "rmdir `"$dst\node_modules`"" Remove-Item $dst -Recurse -Force ``` В дереве должно появиться: ``` # == dsh-balance-indicator - id: balance-indicator name: dsh-balance-indicator ``` ## Диагностика - Строка не появилась → проверьте в **Plugins**, что бандл включён, и что в нём нет статуса ошибки. - `💰 !` с подсказкой об ошибке → хост не смог ответить (нет входа, нет сети, ошибка Platform). - `💰 —` → аккаунт не подключён (нет гранта в `.credentials.yaml`). - Ничего не показывает, но ошибок нет → вкладка открыта вне сеанса чата: слот живёт только внутри композера.