@goodandready/dsh-subscriptions 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -35,15 +35,76 @@ window.__ModuleLoader__.load({
35
35
  document.head.appendChild(tag)
36
36
  }
37
37
 
38
- function badgeFor(account) {
39
- if (!account || !account.configured) return 'Not connected'
40
- if (account.validationUrl) return 'Verify account'
41
- if (account.cooldownUntil && account.cooldownUntil > Date.now()) return 'Cooling down'
42
- if (account.usagePercent != null && account.usagePercent >= 100) return 'Usage 100%'
43
- return 'Connected'
38
+ // Строки карточки живут в реестре локалей: так их переводит отдельный
39
+ // пакет, не трогая код этого плагина. Английский — язык по умолчанию,
40
+ // на него же приходится откат, если перевода нет.
41
+ const NS = 'dsh-subscriptions'
42
+ const en = {
43
+ 'notConnected': 'Not connected',
44
+ 'verifyAccount': 'Verify account',
45
+ 'coolingDown': 'Cooling down',
46
+ 'usageFull': 'Usage 100%',
47
+ 'connected': 'Connected',
48
+ 'title': 'Subscriptions',
49
+ 'intro': 'Log in with a consumer subscription. Tokens stay on the host credentials store. The browser never reads them back. After Connect, if the provider lands on localhost or a vendor page, paste the redirected URL or the code here.',
50
+ 'useOrigin': 'Use this Web UI origin as OAuth redirect_uri',
51
+ 'useOriginHint': 'Leave off unless you registered your own OAuth client for this origin. Vendor CLI clients usually require their published redirect URI plus the paste step.',
52
+ 'reconnect': 'Reconnect',
53
+ 'connect': 'Connect',
54
+ 'disconnect': 'Disconnect',
55
+ 'removeSlot': 'Remove slot',
56
+ 'pastePlaceholder': 'Paste redirected URL or code',
57
+ 'submitCode': 'Submit code',
58
+ 'verifyPrefix': 'Google requires one-time account verification. ',
59
+ 'verifyLink': 'Open verification link',
60
+ 'verifySuffix': ' then reconnect.',
61
+ 'addAccount': '+ Add account',
62
+ 'save': 'Save',
63
+ 'saved': 'Saved',
64
+ 'loading': 'Loading\u2026',
65
+ 'accountLabel': 'Account',
66
+ 'plan': 'Plan',
67
+ 'storedAs': 'Stored as',
68
+ }
69
+ const ru = {
70
+ 'notConnected': 'Не подключено',
71
+ 'verifyAccount': 'Требуется проверка',
72
+ 'coolingDown': 'Пауза после лимита',
73
+ 'usageFull': 'Лимит исчерпан',
74
+ 'connected': 'Подключено',
75
+ 'title': 'Подписки',
76
+ 'intro': 'Вход по обычной пользовательской подписке. Токены остаются в хранилище учётных данных харнесса, браузер их обратно не читает. Если после «Подключить» провайдер увёл на localhost или на свою страницу, вставьте сюда адрес перехода или код.',
77
+ 'useOrigin': 'Использовать адрес этого веб-интерфейса как redirect_uri',
78
+ 'useOriginHint': 'Включайте, только если зарегистрировали собственного клиента OAuth на этот адрес. Штатным консольным клиентам провайдеров нужен их опубликованный адрес возврата и вставка кода вручную.',
79
+ 'reconnect': 'Переподключить',
80
+ 'connect': 'Подключить',
81
+ 'disconnect': 'Отключить',
82
+ 'removeSlot': 'Убрать место',
83
+ 'pastePlaceholder': 'Адрес перехода или код',
84
+ 'submitCode': 'Отправить код',
85
+ 'verifyPrefix': 'Google требует однократной проверки аккаунта. ',
86
+ 'verifyLink': 'Открыть страницу проверки',
87
+ 'verifySuffix': ' затем подключитесь заново.',
88
+ 'addAccount': '+ Добавить аккаунт',
89
+ 'save': 'Сохранить',
90
+ 'saved': 'Сохранено',
91
+ 'loading': 'Загрузка…',
92
+ 'accountLabel': 'Аккаунт',
93
+ 'plan': 'Тариф',
94
+ 'storedAs': 'Хранится как',
95
+ }
96
+
97
+ function badgeFor(account, t) {
98
+ if (!account || !account.configured) return t('notConnected')
99
+ if (account.validationUrl) return t('verifyAccount')
100
+ if (account.cooldownUntil && account.cooldownUntil > Date.now()) return t('coolingDown')
101
+ if (account.usagePercent != null && account.usagePercent >= 100) return t('usageFull')
102
+ return t('connected')
44
103
  }
45
104
 
46
- function SubsSection() {
105
+ function SubsSection(props) {
106
+ // Переводчик приходит от слота, потому что в его записи указан locale.
107
+ const t = (props && props.t) || ((key) => key)
47
108
  const [draft, setDraft] = React.useState(null)
48
109
  const [accounts, setAccounts] = React.useState([])
49
110
  const [providers, setProviders] = React.useState([])
@@ -67,7 +128,7 @@ window.__ModuleLoader__.load({
67
128
  return () => { alive = false }
68
129
  }, [])
69
130
 
70
- if (!draft) return React.createElement('div', { className: 'dsub-wrap' }, 'Loading\u2026')
131
+ if (!draft) return React.createElement('div', { className: 'dsub-wrap' }, t('loading'))
71
132
 
72
133
  const slots = Array.isArray(draft.slots) ? draft.slots : []
73
134
  const setSlots = (next) => setDraft((d) => Object.assign({}, d, { slots: next }))
@@ -137,19 +198,19 @@ window.__ModuleLoader__.load({
137
198
 
138
199
  return React.createElement('div', { className: 'dsub-wrap' },
139
200
  React.createElement('div', { className: 'dsub-block' },
140
- React.createElement('div', { className: 'dsub-h' }, 'Subscriptions'),
201
+ React.createElement('div', { className: 'dsub-h' }, t('title')),
141
202
  React.createElement('div', { className: 'dsub-sub' },
142
- 'Log in with a consumer subscription. Tokens stay on the host credentials store. The browser never reads them back. After Connect, if the provider lands on localhost or a vendor page, paste the redirected URL or the code here.'),
203
+ t('intro')),
143
204
  React.createElement('label', { className: 'dsub-row' },
144
205
  React.createElement('input', {
145
206
  type: 'checkbox',
146
207
  checked: !!draft.useWebCallback,
147
208
  onChange: (e) => setDraft((d) => Object.assign({}, d, { useWebCallback: e.target.checked })),
148
209
  }),
149
- React.createElement('span', null, 'Use this Web UI origin as OAuth redirect_uri'),
210
+ React.createElement('span', null, t('useOrigin')),
150
211
  ),
151
212
  React.createElement('div', { className: 'dsub-sub' },
152
- 'Leave off unless you registered your own OAuth client for this origin. Vendor CLI clients usually require their published redirect URI plus the paste step.'),
213
+ t('useOriginHint')),
153
214
  ),
154
215
  names.map((prov) => {
155
216
  const rows = slots
@@ -166,25 +227,25 @@ window.__ModuleLoader__.load({
166
227
  React.createElement('input', {
167
228
  className: 'dsub-grow',
168
229
  value: row.slot.label || '',
169
- placeholder: account.label || ('Account ' + row.slot.index),
230
+ placeholder: account.label || (t('accountLabel') + ' ' + row.slot.index),
170
231
  onChange: (e) => {
171
232
  const next = slots.slice()
172
233
  next[row.i] = Object.assign({}, next[row.i], { label: e.target.value })
173
234
  setSlots(next)
174
235
  },
175
236
  }),
176
- React.createElement('span', { className: 'dsub-badge' + (account.validationUrl ? ' dsub-badge-warn' : (on ? ' dsub-badge-on' : '')) }, badgeFor(account)),
237
+ React.createElement('span', { className: 'dsub-badge' + (account.validationUrl ? ' dsub-badge-warn' : (on ? ' dsub-badge-on' : '')) }, badgeFor(account, t)),
177
238
  React.createElement('button', {
178
239
  type: 'button', className: 'dsub-mini',
179
240
  onClick: () => connect(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
180
- }, on ? 'Reconnect' : 'Connect'),
241
+ }, on ? t('reconnect') : t('connect')),
181
242
  React.createElement('button', {
182
243
  type: 'button', className: 'dsub-mini',
183
244
  disabled: !on,
184
245
  onClick: () => logout(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
185
- }, 'Disconnect'),
246
+ }, t('disconnect')),
186
247
  React.createElement('button', {
187
- type: 'button', className: 'dsub-mini', title: 'Remove slot',
248
+ type: 'button', className: 'dsub-mini', title: t('removeSlot'),
188
249
  onClick: () => {
189
250
  const slot = row.slot
190
251
  Promise.resolve(on ? logout(slot.provider, slot.index) : null)
@@ -197,49 +258,61 @@ window.__ModuleLoader__.load({
197
258
  React.createElement('input', {
198
259
  className: 'dsub-grow',
199
260
  value: paste[key] || '',
200
- placeholder: 'Paste redirected URL or code',
261
+ placeholder: t('pastePlaceholder'),
201
262
  onChange: (e) => setPaste((p) => Object.assign({}, p, { [key]: e.target.value })),
202
263
  }),
203
264
  React.createElement('button', {
204
265
  type: 'button', className: 'dsub-mini',
205
266
  onClick: () => complete(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
206
- }, 'Submit code'),
267
+ }, t('submitCode')),
207
268
  ),
208
269
  account.accountNotice ? React.createElement('div', { className: 'dsub-verify' }, account.accountNotice) : null,
209
270
  account.validationUrl ? React.createElement('div', { className: 'dsub-verify' },
210
- 'Google requires one-time account verification. ',
211
- React.createElement('a', { href: account.validationUrl, target: '_blank', rel: 'noopener noreferrer' }, 'Open verification link'),
212
- ' then reconnect.',
271
+ t('verifyPrefix'),
272
+ React.createElement('a', { href: account.validationUrl, target: '_blank', rel: 'noopener noreferrer' }, t('verifyLink')),
273
+ t('verifySuffix'),
213
274
  ) : null,
214
- account.paidTierName ? React.createElement('span', { className: 'dsub-sub' }, 'Plan: ' + account.paidTierName) : null,
215
- account.ref ? React.createElement('span', { className: 'dsub-sub' }, 'Stored as ' + account.ref) : null,
275
+ account.paidTierName ? React.createElement('span', { className: 'dsub-sub' }, t('plan') + ': ' + account.paidTierName) : null,
276
+ account.ref ? React.createElement('span', { className: 'dsub-sub' }, t('storedAs') + ' ' + account.ref) : null,
216
277
  )
217
278
  }),
218
279
  React.createElement('button', {
219
280
  type: 'button', className: 'dsub-mini',
220
281
  onClick: () => addSlot(prov.id),
221
- }, '+ Add account'),
282
+ }, t('addAccount')),
222
283
  )
223
284
  }),
224
285
  React.createElement('div', { className: 'dsub-row' },
225
286
  React.createElement('button', {
226
287
  type: 'button', className: 'dsub-save',
227
288
  onClick: () => save().catch((e) => setErr(String(e.message || e))),
228
- }, 'Save'),
229
- saved ? React.createElement('span', { className: 'dsub-ok' }, 'Saved') : null,
289
+ }, t('save')),
290
+ saved ? React.createElement('span', { className: 'dsub-ok' }, t('saved')) : null,
230
291
  err ? React.createElement('span', { className: 'dsub-bad' }, err) : null,
231
292
  ),
232
293
  )
233
294
  }
234
295
 
235
296
  function registerSettings(ctx) {
297
+ ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-subscriptions: словари')
298
+ // Подпись раздела рисует боковой список, а не наш компонент: props.t
299
+ // туда не доходит, поэтому берём переводчик, привязанный к namespace.
300
+ const t = ctx.locale.bind(NS)
236
301
  ctx.slots.inject('settings.section', () => ctx.slots.register(
237
- { name: 'settings.section', id: '@goodandready/dsh-subscriptions', order: 28, label: () => 'Subscriptions', inject: () => ({ ctx: ctx }) },
302
+ {
303
+ name: 'settings.section',
304
+ id: '@goodandready/dsh-subscriptions',
305
+ order: 28,
306
+ // locale в записи слота — то, из-за чего компонент получает props.t.
307
+ locale: NS,
308
+ label: () => t('title'),
309
+ inject: () => ({ ctx: ctx }),
310
+ },
238
311
  SubsSection,
239
312
  ))
240
313
  }
241
314
 
242
- exports.inject = ['slots']
315
+ exports.inject = ['slots', 'locale']
243
316
  exports.apply = function apply(ctx) {
244
317
  registerSettings(ctx)
245
318
  }
package/lib/images.js ADDED
@@ -0,0 +1,111 @@
1
+ // Генерация картинок на подписке.
2
+ //
3
+ // Здесь только протокол: куда идти, что послать и как прочитать ответ. Всё
4
+ // остальное — сохранение файла, вложение в разговор, карточка — дело плагина
5
+ // генерации; этот плагин лишь одалживает свой аккаунт.
6
+ //
7
+ // Токен наружу не отдаётся: плагин объявляет службу внутри процесса, а не
8
+ // маршрут в сети. Харнесс на этой машине доступен без пароля, и ручка,
9
+ // раздающая живой токен подписки, была бы дырой пошире тех, что мы закрывали.
10
+
11
+ /** Куда уходит запрос у подписки ChatGPT. */
12
+ export const CODEX_URL = 'https://chatgpt.com/backend-api/codex/images/generations'
13
+ /** Модель, которую отдаёт этот адрес. */
14
+ export const CODEX_MODEL = 'gpt-image-2'
15
+ /** Куда уходит запрос у подписки Grok. */
16
+ export const GROK_URL = 'https://api.x.ai/v1/images/generations'
17
+ /** Модель, которую отдаёт этот адрес. */
18
+ export const GROK_MODEL = 'grok-imagine-image-2.0'
19
+
20
+ /** Размеры, которые понимает ChatGPT. */
21
+ export const SIZES = ['1024x1024', '1024x1536', '1536x1024', 'auto']
22
+
23
+ /** Grok мыслит не размерами, а соотношением сторон. */
24
+ const GROK_ASPECT = {
25
+ '1024x1024': '1:1',
26
+ '1024x1536': '2:3',
27
+ '1536x1024': '3:2',
28
+ auto: 'auto',
29
+ }
30
+
31
+ export function codexBody({ prompt, size, quality }) {
32
+ const text = String(prompt || '').trim()
33
+ if (!text) throw new Error('нужен непустой запрос')
34
+ return {
35
+ prompt: text,
36
+ model: CODEX_MODEL,
37
+ ...(size ? { size } : {}),
38
+ ...(quality ? { quality } : {}),
39
+ }
40
+ }
41
+
42
+ export function grokBody({ prompt, size, quality }) {
43
+ const text = String(prompt || '').trim()
44
+ if (!text) throw new Error('нужен непустой запрос')
45
+ // У Grok качество всего двух ступеней: высокое складывается со средним.
46
+ const level = quality === 'low' ? 'low'
47
+ : (quality === 'medium' || quality === 'high') ? 'medium'
48
+ : undefined
49
+ return {
50
+ prompt: text,
51
+ model: GROK_MODEL,
52
+ response_format: 'b64_json',
53
+ ...(size && GROK_ASPECT[size] ? { aspect_ratio: GROK_ASPECT[size] } : {}),
54
+ ...(level ? { quality: level } : {}),
55
+ }
56
+ }
57
+
58
+ /** Разбор ответа: обе стороны отвечают одинаково. */
59
+ export function parseImages(payload) {
60
+ const body = payload && typeof payload === 'object' ? payload : {}
61
+ const rows = Array.isArray(body.data) ? body.data : []
62
+ const images = []
63
+ for (const row of rows) {
64
+ if (!row || typeof row !== 'object') continue
65
+ if (typeof row.b64_json !== 'string' || !row.b64_json) continue
66
+ images.push({
67
+ b64_json: row.b64_json,
68
+ ...(typeof row.revised_prompt === 'string' && row.revised_prompt
69
+ ? { revisedPrompt: row.revised_prompt }
70
+ : {}),
71
+ })
72
+ }
73
+ if (!images.length) throw new Error('в ответе нет картинок')
74
+ return images
75
+ }
76
+
77
+ /**
78
+ * Один запрос к нужному адресу с заголовками этого провайдера.
79
+ *
80
+ * @param options {{provider, prompt, size, quality, session, fetchImpl, signal}}
81
+ * session — уже освежённый блок токенов: accessToken и, для ChatGPT, accountId.
82
+ */
83
+ export async function generateOnce(options) {
84
+ const { provider, session, fetchImpl, signal } = options
85
+ const isCodex = provider === 'codex'
86
+ const url = isCodex ? CODEX_URL : GROK_URL
87
+ const body = isCodex ? codexBody(options) : grokBody(options)
88
+ const headers = isCodex
89
+ ? {
90
+ authorization: `Bearer ${session.accessToken}`,
91
+ // ChatGPT различает аккаунты отдельным заголовком, без него отвечает отказом.
92
+ 'chatgpt-account-id': session.accountId || '',
93
+ originator: 'codex_cli_rs',
94
+ 'content-type': 'application/json',
95
+ accept: 'application/json',
96
+ }
97
+ : {
98
+ authorization: `Bearer ${session.accessToken}`,
99
+ 'content-type': 'application/json',
100
+ accept: 'application/json',
101
+ }
102
+
103
+ const res = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify(body), signal })
104
+ const payload = await res.json().catch(() => ({}))
105
+ if (!res.ok) {
106
+ const detail = payload && payload.error
107
+ && (payload.error.message || payload.error.code || payload.error)
108
+ throw new Error(`${provider} HTTP ${res.status}${detail ? ': ' + String(detail).slice(0, 200) : ''}`)
109
+ }
110
+ return parseImages(payload)
111
+ }
package/lib/index.js CHANGED
@@ -5,6 +5,8 @@ import { parseCallbackInput, requestOrigin, webCallbackUri } from './oauth.js'
5
5
  import { writeJson, writeHtml, readBody, isTrustedSettingsRequest, queryOf } from './http.js'
6
6
  import { getVendor } from './vendors/index.js'
7
7
  import { SubscriptionAdapter } from './adapter.js'
8
+ import { generateOnce, SIZES as IMAGE_SIZES } from './images.js'
9
+ import { parseBlob } from './blob.js'
8
10
  import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
9
11
  import {
10
12
  inspectGoogleAccount,
@@ -77,6 +79,46 @@ export function apply(ctx, config) {
77
79
 
78
80
  const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
79
81
  const store = createAccountStore({ credentials: ctx.credentials, getConfig: live, fetchImpl: fetch })
82
+
83
+ // Служба генерации картинок на подписке.
84
+ //
85
+ // Наружу отдаётся действие, а не токен: маршрут в сети раздавал бы живой
86
+ // ключ доступа каждому, кто дотянется до харнесса, а служба живёт внутри
87
+ // процесса и видна только другим плагинам. Обновлением токена по-прежнему
88
+ // занимается один хозяин — этот плагин.
89
+ ctx.effect(() => ctx.provide('subscriptionImages', {
90
+ /** Провайдеры, у которых есть вход прямо сейчас. */
91
+ async available() {
92
+ const logged = await store.loggedInProviders()
93
+ return ['codex', 'grok'].filter((name) => logged && logged[name])
94
+ },
95
+ sizes: IMAGE_SIZES,
96
+ /**
97
+ * @param request {{provider, prompt, size, quality, signal}}
98
+ * @returns [{ b64_json, revisedPrompt? }]
99
+ */
100
+ async generate(request) {
101
+ const provider = request && request.provider
102
+ if (provider !== 'codex' && provider !== 'grok') {
103
+ throw new Error(`неизвестный провайдер подписки: ${provider}`)
104
+ }
105
+ const accounts = await store.listAccounts(provider)
106
+ const slot = (accounts || []).find((row) => row && row.ref)
107
+ if (!slot) throw new Error(`нет входа в ${provider}: войдите в разделе «Подписки»`)
108
+ const raw = await store.resolveRaw(slot.ref)
109
+ if (!raw) throw new Error(`нет входа в ${provider}: войдите в разделе «Подписки»`)
110
+ const session = await store.ensureFresh(provider, parseBlob(raw), slot.ref)
111
+ return generateOnce({
112
+ provider,
113
+ prompt: request.prompt,
114
+ size: request.size,
115
+ quality: request.quality,
116
+ session,
117
+ fetchImpl: fetch,
118
+ signal: request.signal,
119
+ })
120
+ },
121
+ }), 'dsh-subscriptions: служба генерации картинок')
80
122
  const pending = new Map()
81
123
  const adapter = new SubscriptionAdapter({
82
124
  listAccounts: (provider) => store.listAccounts(provider),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, and Antigravity subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",