tensorgrid-ui 2.0.0 → 2.1.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
@@ -198,6 +198,8 @@ window.__ModuleLoader__.load({
198
198
  'review.bySubscription': 'subscription',
199
199
  'review.limit': 'That is as many reviewers as one review can hold.',
200
200
  'review.byApiKey': 'paid per token',
201
+ 'review.groupSubscription': 'Included in a subscription',
202
+ 'review.groupPaid': 'Paid separately \u2014 per token',
201
203
  'review.nav': 'Reviewers',
202
204
  'review.title': 'Second opinion',
203
205
  'review.hint': 'Independent reviewers examine finished work and report what is wrong. Choose how many and which ones: different models find different things.',
@@ -274,6 +276,8 @@ window.__ModuleLoader__.load({
274
276
  'review.bySubscription': '订阅',
275
277
  'review.limit': '一次审阅最多只能有这么多审阅者。',
276
278
  'review.byApiKey': '按量计费',
279
+ 'review.groupSubscription': '包含在订阅中',
280
+ 'review.groupPaid': '单独计费 \u2014 按量',
277
281
  'review.nav': '审阅者',
278
282
  'review.title': '第二意见',
279
283
  'review.hint': '独立审阅者检查已完成的工作并报告问题。自行决定数量与人选:不同模型发现的问题不同。',
@@ -1647,6 +1651,8 @@ window.__ModuleLoader__.load({
1647
1651
  'review.bySubscription': 'по подписке',
1648
1652
  'review.limit': 'Больше ревизоров в один обзор не поместится.',
1649
1653
  'review.byApiKey': 'по токенам',
1654
+ 'review.groupSubscription': 'Входят в подписку',
1655
+ 'review.groupPaid': 'Оплата отдельно \u2014 по токенам',
1650
1656
  'review.nav': 'Ревизоры',
1651
1657
  'review.title': 'Второе мнение',
1652
1658
  'review.hint': 'Независимые ревизоры смотрят готовую работу и сообщают, что в ней не так. Сколько их и какие — выбираете вы: разные модели находят разное.',
@@ -2333,13 +2339,12 @@ window.__ModuleLoader__.load({
2333
2339
  /**
2334
2340
  * Выбор значения: известные варианты плюс «своё».
2335
2341
  *
2336
- * Список моделей подсказка, а не ограничение. У наших ревизоров он
2337
- * приходит живым из маршрута, у внешних продуктов зашит коротким: спросить
2338
- * у Claude Code или Codex их собственный перечень нечем. Поэтому рядом со
2339
- * списком всегда есть «другая» — иначе любой новый выпуск модели делал бы
2340
- * интерфейс бесполезным до следующего обновления пакета.
2342
+ * `groups` разносит варианты по подписям. Для моделей это принципиально:
2343
+ * один и тот же `claude-opus-5` доступен и по подписке, и через
2344
+ * OpenRouter по токенам, и вперемешку они неразличимы а разница в том,
2345
+ * придёт ли за них счёт.
2341
2346
  */
2342
- function ValuePicker({ t, value, options, disabled, onPick, placeholder }) {
2347
+ function ValuePicker({ t, value, options, disabled, onPick, placeholder, groups }) {
2343
2348
  const known = options.some((option) => option.value === value)
2344
2349
  const [custom, setCustom] = React.useState(value !== null && !known)
2345
2350
 
@@ -2367,6 +2372,17 @@ window.__ModuleLoader__.load({
2367
2372
  })
2368
2373
  }
2369
2374
 
2375
+ // Группы — это `optgroup`, а не подписи-разделители внутри списка:
2376
+ // браузер сам не даст выбрать заголовок и показывает его иначе.
2377
+ const body = groups === undefined
2378
+ ? options.map((option) => jsx('option', { value: option.value, children: option.label }, option.value))
2379
+ : groups
2380
+ .filter((group) => group.options.length > 0)
2381
+ .map((group) => jsx('optgroup', {
2382
+ label: group.label,
2383
+ children: group.options.map((option) => jsx('option', { value: option.value, children: option.label }, option.value)),
2384
+ }, group.label))
2385
+
2370
2386
  return jsxs('select', {
2371
2387
  className: 'dsx-rev__select',
2372
2388
  value: value === null ? '' : value,
@@ -2377,7 +2393,7 @@ window.__ModuleLoader__.load({
2377
2393
  },
2378
2394
  children: [
2379
2395
  jsx('option', { value: '', children: t('review.asConfigured') }, ''),
2380
- ...options.map((option) => jsx('option', { value: option.value, children: option.label }, option.value)),
2396
+ ...body,
2381
2397
  jsx('option', { value: '\u0000custom', children: t('review.other') }, 'custom'),
2382
2398
  ],
2383
2399
  })
@@ -2461,6 +2477,21 @@ window.__ModuleLoader__.load({
2461
2477
 
2462
2478
  const catalogue = state !== null && Array.isArray(state.catalogue) ? state.catalogue : []
2463
2479
  const journal = state !== null && state.journal ? state.journal : null
2480
+ const effortMap = state !== null && state.efforts ? state.efforts : {}
2481
+
2482
+ /**
2483
+ * Уровни усилия для записи.
2484
+ *
2485
+ * У наших ревизоров приходят с Host по выбранной модели; у внешних
2486
+ * продуктов остаются зашитыми — спросить у них нечем.
2487
+ */
2488
+ const effortsFor = (entry) => {
2489
+ const spec = catalogue.find((item) => item.kind === entry.kind)
2490
+ if (spec && !spec.internal) return spec.efforts || []
2491
+ if (entry.provider === null || entry.model === null) return []
2492
+ const info = effortMap[entry.provider + '/' + entry.model]
2493
+ return info && Array.isArray(info.efforts) ? info.efforts : []
2494
+ }
2464
2495
  const reviewers = state !== null && state.settings && Array.isArray(state.settings.reviewers)
2465
2496
  ? state.settings.reviewers
2466
2497
  : []
@@ -2487,6 +2518,18 @@ window.__ModuleLoader__.load({
2487
2518
  }])
2488
2519
  }
2489
2520
 
2521
+ /**
2522
+ * Отметка на карточке: по подписке, за отдельные деньги или молчание.
2523
+ *
2524
+ * ТРИ состояния, и третье обязательно. Прежде их было два, и всё, что
2525
+ * не подписка, объявлялось платным — включая случай «сказать нечего».
2526
+ * Получалось, что человек видел «по токенам» на модели, которая ему
2527
+ * давно оплачена, а внешний продукт с собственной подпиской получал
2528
+ * ту же метку.
2529
+ *
2530
+ * Молчание честнее догадки: отсутствие отметки означает «не знаю», и
2531
+ * это ровно то, что есть на самом деле.
2532
+ */
2490
2533
  const billingLabel = (kind) => {
2491
2534
  if (kind === 'subscription') return t('review.bySubscription')
2492
2535
  if (kind === 'api-key') return t('review.byApiKey')
@@ -2555,15 +2598,40 @@ window.__ModuleLoader__.load({
2555
2598
  value: entry.model,
2556
2599
  disabled: busy,
2557
2600
  placeholder: t('review.modelName'),
2558
- options: models.map((m) => ({
2559
- value: m.id,
2560
- // Провайдер и способ оплаты стоят в самом
2561
- // названии: у OpenRouter сотни моделей, и без
2562
- // этого выбрать осмысленно нельзя.
2563
- label: (m.provider === null ? '' : m.provider + ' \u00b7 ') + m.id
2564
- + (m.billing === 'subscription' ? ' \u2014 ' + t('review.bySubscription') : '')
2565
- + (m.billing === 'api-key' ? ' \u2014 ' + t('review.byApiKey') : ''),
2566
- })),
2601
+ options: models.map((m) => ({ value: m.id, label: m.id })),
2602
+ // РАЗДЕЛЕНИЕ ПО ОПЛАТЕ.
2603
+ //
2604
+ // Один и тот же claude-opus-5 доступен и по
2605
+ // подписке, и через OpenRouter по токенам. В общем
2606
+ // списке они неразличимы, а разница в том, придёт
2607
+ // ли за них счёт. Провайдер стоит в названии: у
2608
+ // OpenRouter сотни моделей, и без него не понять,
2609
+ // чья она.
2610
+ // Групп ДВЕ, а не три. Различие, которое важно
2611
+ // человеку, одно: придёт ли за эту модель
2612
+ // отдельный счёт. Ключ, сохранённый в dsh, и
2613
+ // ключ из окружения — с этой стороны одно и то
2614
+ // же, а третья группа оказывалась почти пустой и
2615
+ // только мешала выбирать.
2616
+ // Группы появляются, ТОЛЬКО когда признак оплаты
2617
+ // действительно известен. Иначе — простой список:
2618
+ // деление на «подписка» и «по токенам», в котором
2619
+ // первая корзина пуста, а вторая содержит всё,
2620
+ // сообщает неправду, а не отсутствие сведений.
2621
+ groups: models.some((m) => m.billing !== null && m.billing !== undefined)
2622
+ ? [
2623
+ {
2624
+ label: t('review.groupSubscription'),
2625
+ options: models.filter((m) => m.billing === 'subscription')
2626
+ .map((m) => ({ value: m.id, label: m.provider + ' \u00b7 ' + m.id })),
2627
+ },
2628
+ {
2629
+ label: t('review.groupPaid'),
2630
+ options: models.filter((m) => m.billing !== 'subscription')
2631
+ .map((m) => ({ value: m.id, label: (m.provider === null ? '' : m.provider + ' \u00b7 ') + m.id })),
2632
+ },
2633
+ ]
2634
+ : undefined,
2567
2635
  onPick: (value) => {
2568
2636
  const found = models.find((m) => m.id === value)
2569
2637
  patch(entry.id, {
@@ -2583,7 +2651,15 @@ window.__ModuleLoader__.load({
2583
2651
  value: entry.effort,
2584
2652
  disabled: busy,
2585
2653
  placeholder: t('review.effortName'),
2586
- options: (spec && spec.efforts ? spec.efforts : []).map((e) => ({ value: e, label: e })),
2654
+ // Уровни у КАЖДОЙ МОДЕЛИ свои, и зашитый список
2655
+ // врал почти везде. Проверено: у claude-opus-5 их
2656
+ // шесть, у deepseek-v4-pro — три, а одна и та же
2657
+ // модель через подписку и через OpenRouter даёт
2658
+ // разные наборы.
2659
+ //
2660
+ // Пока модель не выбрана, спрашивать нечего:
2661
+ // остаётся ввод своего значения.
2662
+ options: effortsFor(entry).map((e) => ({ value: e, label: e })),
2587
2663
  onPick: (value) => patch(entry.id, { effort: value }),
2588
2664
  }),
2589
2665
  ],
@@ -215,6 +215,43 @@ export async function handleUpdate(req, res, state, loadModule) {
215
215
  }
216
216
  }
217
217
 
218
+ /**
219
+ * Уровни усилия ДЛЯ КОНКРЕТНОЙ МОДЕЛИ.
220
+ *
221
+ * Зашитый список был неверен почти для всех. Проверено на живых данных:
222
+ *
223
+ * anthropic/claude-opus-5 minimal, low, medium, high, xhigh, max
224
+ * anthropic/claude-sonnet-4-5 off, minimal, low, medium, high
225
+ * openrouter/x-ai/grok-4.6 low, medium, high, xhigh
226
+ * openrouter/deepseek-v4-pro off, high, xhigh
227
+ *
228
+ * Мало того, ОДНА И ТА ЖЕ модель через подписку и через OpenRouter даёт
229
+ * разные наборы: у `claude-opus-5` в подписке есть `minimal`, а через
230
+ * OpenRouter его нет. Это разные маршруты, и общего списка тут быть не может.
231
+ *
232
+ * Спрашивается только для ВЫБРАННЫХ моделей — их единицы. Обойти так все
233
+ * 366 моделей OpenRouter значило бы держать открытие настроек минутами.
234
+ */
235
+ async function modelEfforts(llm, provider, model) {
236
+ if (llm === null || llm === undefined || typeof llm.resolveModelInfo !== 'function') return null
237
+ if (typeof provider !== 'string' || provider === '' || typeof model !== 'string' || model === '') return null
238
+ try {
239
+ const info = await llm.resolveModelInfo(provider, model)
240
+ const efforts = info?.reasoning?.efforts
241
+ if (!Array.isArray(efforts) || efforts.length === 0) return null
242
+ return {
243
+ efforts: efforts
244
+ .map((entry) => (typeof entry === 'string' ? entry : String(entry?.id ?? entry?.name ?? '')))
245
+ .filter((id) => id !== ''),
246
+ defaultEffort: info.reasoning.defaultEffort === undefined ? null : String(info.reasoning.defaultEffort),
247
+ }
248
+ } catch {
249
+ // Модель могла исчезнуть или провайдер не ответить — покажем без списка,
250
+ // и человек впишет своё значение вручную.
251
+ return null
252
+ }
253
+ }
254
+
218
255
  /**
219
256
  * Собрать модели, доступные на маршруте.
220
257
  *
@@ -248,6 +285,22 @@ async function listRouteModels(llm, credentials) {
248
285
  // работает, но записи не имеет, настроен извне: у OpenRouter ключ лежит
249
286
  // переменной окружения, и по записям он выглядел бы ненастроенным, хотя
250
287
  // отвечает. Проверено на живых данных.
288
+ //
289
+ // «НЕ ЗНАЮ» — ОТДЕЛЬНЫЙ ОТВЕТ, А НЕ «ПО ТОКЕНАМ».
290
+ //
291
+ // Здесь стояла дыра, которая врала в самую дорогую сторону. Когда служба
292
+ // хранилища недоступна, прежний код помечал ВСЕ модели как платные —
293
+ // включая подписочные. Человек видел «по токенам» на claude-opus-5,
294
+ // который ему давно оплачен, и мог отказаться от ревизора из-за счёта,
295
+ // которого не будет.
296
+ //
297
+ // Случай не выдуманный: он воспроизвёлся сразу. Обработчики
298
+ // перечитываются на лету, а оболочка — только при старте, и до
299
+ // перезапуска новый код спрашивал хранилище у старой оболочки, которая
300
+ // его не передавала. Все 388 моделей стали «по токенам».
301
+ //
302
+ // Теперь неизвестность возвращается как `null`, и показ обязан молчать.
303
+ let known = false
251
304
  const billing = new Map()
252
305
  if (credentials !== undefined && credentials !== null) {
253
306
  try {
@@ -257,8 +310,9 @@ async function listRouteModels(llm, credentials) {
257
310
  const name = slash >= 0 ? key.slice(slash + 1) : key
258
311
  billing.set(name, record.kind === 'grant' ? 'subscription' : 'api-key')
259
312
  }
313
+ known = true
260
314
  } catch {
261
- // Хранилище недоступно — покажем модели без отметки, это лучше отказа.
315
+ // Хранилище не ответило — покажем модели без отметки, это лучше лжи.
262
316
  }
263
317
  }
264
318
 
@@ -266,7 +320,10 @@ async function listRouteModels(llm, credentials) {
266
320
  for (const info of providers) {
267
321
  const name = typeof info === 'string' ? info : info?.name ?? info?.provider
268
322
  if (typeof name !== 'string' || name === '') continue
269
- const kind = billing.get(name) ?? 'external'
323
+ // Провайдер без записи, но отвечающий, настроен вне dsh — ключом из
324
+ // окружения. Для человека это значит то же, что сохранённый ключ: счёт
325
+ // придёт. А вот когда хранилище вовсе не спросили, сказать нечего.
326
+ const kind = known ? billing.get(name) ?? 'api-key' : null
270
327
  try {
271
328
  const models = await llm.listModels(name)
272
329
  for (const model of models ?? []) {
@@ -309,12 +366,40 @@ export async function handleReview(req, res, loadModule, services) {
309
366
  label: spec.label,
310
367
  internal: spec.kind === 'internal',
311
368
  optional: spec.optional === true,
312
- models: spec.kind === 'internal' ? routeModels : spec.models.map((id) => ({ provider: null, id, billing: 'external' })),
313
- efforts: spec.efforts,
369
+ // У ВНЕШНИХ ПРОДУКТОВ ОТМЕТКИ ОПЛАТЫ НЕТ, и это не упущение.
370
+ //
371
+ // Claude Code и Codex работают на СВОИХ подписках и нашего маршрута не
372
+ // касаются — мы не знаем и не можем знать, во что обойдётся их запуск.
373
+ // Прежде им доставалась метка «по токенам» просто потому, что она была
374
+ // всем, кроме подписки. Это было неверно.
375
+ models: spec.kind === 'internal' ? routeModels : spec.models.map((id) => ({ provider: null, id, billing: null })),
376
+ // Для ВНЕШНИХ продуктов список остаётся зашитым: спросить у Claude Code
377
+ // или Codex их уровни нечем, они не наш маршрут.
378
+ efforts: spec.kind === 'internal' ? [] : spec.efforts,
314
379
  defaultProvider: spec.defaultProvider ?? null,
315
380
  defaultModel: spec.defaultModel ?? null,
316
381
  }))
317
382
 
383
+ /**
384
+ * Уровни усилия для тех моделей, что УЖЕ выбраны.
385
+ *
386
+ * Их единицы, и спросить их дёшево. Обходить так все 366 моделей
387
+ * OpenRouter значило бы держать открытие настроек минутами.
388
+ */
389
+ const effortsFor = async (chosen) => {
390
+ const map = {}
391
+ const seen = new Set()
392
+ for (const entry of chosen.reviewers ?? []) {
393
+ if (entry.provider === null || entry.model === null) continue
394
+ const key = `${entry.provider}/${entry.model}`
395
+ if (seen.has(key)) continue
396
+ seen.add(key)
397
+ const info = await modelEfforts(services?.llm, entry.provider, entry.model)
398
+ if (info !== null) map[key] = info
399
+ }
400
+ return map
401
+ }
402
+
318
403
  // Журнал отдаётся вместе с настройками: панель находок и настройки —
319
404
  // один раздел, и второй запрос за тем же экраном был бы лишней задержкой.
320
405
  const log = await loadModule('./review-log.js')
@@ -331,7 +416,8 @@ export async function handleReview(req, res, loadModule, services) {
331
416
  }
332
417
 
333
418
  if (req.method === 'GET') {
334
- reply(res, 200, { catalogue, settings: await settings.readSettings(null), journal: digest })
419
+ const current = await settings.readSettings(null)
420
+ reply(res, 200, { catalogue, settings: current, efforts: await effortsFor(current), journal: digest })
335
421
  return
336
422
  }
337
423
 
@@ -367,9 +453,11 @@ export async function handleReview(req, res, loadModule, services) {
367
453
  'human',
368
454
  )
369
455
  const after = await log.read()
456
+ const current = await settings.readSettings(null)
370
457
  reply(res, 200, {
371
458
  catalogue,
372
- settings: await settings.readSettings(null),
459
+ settings: current,
460
+ efforts: await effortsFor(current),
373
461
  journal: { latest: log.latest(after), accuracy: log.accuracy(after), runs: after.runs.length },
374
462
  // Отклонённое возражение должно быть ВИДНО: иначе человек считает,
375
463
  // что высказался, а в панели ничего не изменилось.
@@ -381,7 +469,8 @@ export async function handleReview(req, res, loadModule, services) {
381
469
  // Список приходит целиком и целиком же замещает сохранённый. Для списка
382
470
  // это единственно верно: слияние возвращало бы удалённую запись обратно,
383
471
  // то есть удалить ревизора стало бы нельзя.
384
- reply(res, 200, { catalogue, settings: await settings.writeSettings(null, body), journal: digest })
472
+ const saved = await settings.writeSettings(null, body)
473
+ reply(res, 200, { catalogue, settings: saved, efforts: await effortsFor(saved), journal: digest })
385
474
  } catch (error) {
386
475
  reply(res, 500, { problem: String(error?.message ?? error) })
387
476
  }
package/lib/reviewers.js CHANGED
@@ -63,7 +63,12 @@ export const REVIEWERS = {
63
63
  label: 'Наш ревизор',
64
64
  kind: 'internal',
65
65
  models: [],
66
- efforts: ['low', 'medium', 'high', 'max'],
66
+ // Уровни усилия НЕ перечисляются: у каждой модели они свои, и зашитый
67
+ // список врал почти везде. Проверено на живых данных: у claude-opus-5
68
+ // их шесть, у deepseek-v4-pro — три, а одна и та же модель через
69
+ // подписку и через OpenRouter даёт РАЗНЫЕ наборы. Их спрашивают у
70
+ // маршрута по выбранной модели.
71
+ efforts: [],
67
72
  defaultProvider: 'anthropic',
68
73
  defaultModel: 'claude-opus-5',
69
74
  },
@@ -71,7 +76,7 @@ export const REVIEWERS = {
71
76
  label: 'Наш ревизор, вторая модель',
72
77
  kind: 'internal',
73
78
  models: [],
74
- efforts: ['low', 'medium', 'high', 'max'],
79
+ efforts: [],
75
80
  // Намеренно ДРУГОЕ семейство: два прогона одной модели дадут почти
76
81
  // одинаковые находки, и второй ревизор не окупит своего времени.
77
82
  defaultProvider: 'openai-codex',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tensorgrid-ui",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",