tensorgrid-ui 1.6.1 → 2.0.0

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
@@ -180,22 +180,27 @@ window.__ModuleLoader__.load({
180
180
  'review.effortName': 'effort',
181
181
  'review.other': 'another…',
182
182
  'review.modelName': 'model name',
183
- 'review.custom': 'Your own set of reviewers — no mode matches it.',
184
- 'review.details': 'Detailed settings',
185
183
  'review.fromList': 'back to the list',
186
- 'review.ours': 'ours',
187
184
  'review.objectFalse': 'I disagree — this is real',
188
185
  'review.accuracy': 'How often each reviewer turned out to be right',
189
186
  'review.notChecked': 'not verified yet',
190
187
  'review.byAgent': 'checked by the assistant',
191
- 'review.humanEvidence': 'Marked by the user.',
188
+ 'review.humanEvidence': 'The user disagreed with the assistant\'s verdict, having looked at the finding.',
192
189
  'review.lastReview': 'Last review',
193
190
  'review.objectReal': 'I disagree — this is wrong',
194
191
  'review.of': 'of',
195
192
  'review.byHuman': 'your call',
193
+ 'review.needsProduct': 'Works only if that product is installed separately',
194
+ 'review.add': 'Add a reviewer:',
195
+ 'review.participates': 'Take part in reviews',
196
+ 'review.noneActive': 'Every reviewer is switched off — a review will find nothing.',
197
+ 'review.remove': 'Remove this reviewer',
198
+ 'review.bySubscription': 'subscription',
199
+ 'review.limit': 'That is as many reviewers as one review can hold.',
200
+ 'review.byApiKey': 'paid per token',
196
201
  'review.nav': 'Reviewers',
197
202
  'review.title': 'Second opinion',
198
- 'review.hint': 'Independent reviewers examine finished work and report what is wrong. They run as separate products and do not see this conversation.',
203
+ 'review.hint': 'Independent reviewers examine finished work and report what is wrong. Choose how many and which ones: different models find different things.',
199
204
  'review.model': 'Model',
200
205
  'review.effort': 'Effort',
201
206
  'review.asConfigured': 'as configured',
@@ -251,22 +256,27 @@ window.__ModuleLoader__.load({
251
256
  'review.effortName': '强度',
252
257
  'review.other': '其他…',
253
258
  'review.modelName': '模型名称',
254
- 'review.custom': '自定义审阅者组合——没有匹配的模式。',
255
- 'review.details': '详细设置',
256
259
  'review.fromList': '返回列表',
257
- 'review.ours': '我们的',
258
260
  'review.objectFalse': '我不同意——这是真的',
259
261
  'review.accuracy': '各审阅者的正确率',
260
262
  'review.notChecked': '尚未核实',
261
263
  'review.byAgent': '由助手核实',
262
- 'review.humanEvidence': '由用户标记。',
264
+ 'review.humanEvidence': '用户查看该发现后,不同意助手的判定。',
263
265
  'review.lastReview': '上次审阅',
264
266
  'review.objectReal': '我不同意——这是错的',
265
267
  'review.of': '共',
266
268
  'review.byHuman': '你的判断',
269
+ 'review.needsProduct': '仅在单独安装该产品后可用',
270
+ 'review.add': '添加审阅者:',
271
+ 'review.participates': '参与审阅',
272
+ 'review.noneActive': '所有审阅者都已关闭——审阅将一无所获。',
273
+ 'review.remove': '移除该审阅者',
274
+ 'review.bySubscription': '订阅',
275
+ 'review.limit': '一次审阅最多只能有这么多审阅者。',
276
+ 'review.byApiKey': '按量计费',
267
277
  'review.nav': '审阅者',
268
278
  'review.title': '第二意见',
269
- 'review.hint': '独立审阅者检查已完成的工作并报告问题。它们作为独立产品运行,看不到本次对话。',
279
+ 'review.hint': '独立审阅者检查已完成的工作并报告问题。自行决定数量与人选:不同模型发现的问题不同。',
270
280
  'review.model': '模型',
271
281
  'review.effort': '推理强度',
272
282
  'review.asConfigured': '按产品设置',
@@ -1619,22 +1629,27 @@ window.__ModuleLoader__.load({
1619
1629
  'review.effortName': 'усилие',
1620
1630
  'review.other': 'другая…',
1621
1631
  'review.modelName': 'имя модели',
1622
- 'review.custom': 'Свой набор ревизоров — ни один режим ему не соответствует.',
1623
- 'review.details': 'Подробные настройки',
1624
1632
  'review.fromList': 'вернуться к списку',
1625
- 'review.ours': 'наш',
1626
1633
  'review.objectFalse': 'Не согласен — это настоящая',
1627
1634
  'review.accuracy': 'Как часто каждый ревизор оказывался прав',
1628
1635
  'review.notChecked': 'ещё не проверено',
1629
1636
  'review.byAgent': 'проверил помощник',
1630
- 'review.humanEvidence': 'Отмечено пользователем.',
1637
+ 'review.humanEvidence': 'Пользователь не согласился с приговором помощника, посмотрев находку.',
1631
1638
  'review.lastReview': 'Последний обзор',
1632
1639
  'review.objectReal': 'Не согласен — это ложная',
1633
1640
  'review.of': 'из',
1634
1641
  'review.byHuman': 'ваше решение',
1642
+ 'review.needsProduct': 'Работает, только если этот продукт установлен отдельно',
1643
+ 'review.add': 'Добавить ревизора:',
1644
+ 'review.participates': 'Участвует в обзорах',
1645
+ 'review.noneActive': 'Все ревизоры выключены — обзор ничего не найдёт.',
1646
+ 'review.remove': 'Убрать этого ревизора',
1647
+ 'review.bySubscription': 'по подписке',
1648
+ 'review.limit': 'Больше ревизоров в один обзор не поместится.',
1649
+ 'review.byApiKey': 'по токенам',
1635
1650
  'review.nav': 'Ревизоры',
1636
1651
  'review.title': 'Второе мнение',
1637
- 'review.hint': 'Независимые ревизоры смотрят готовую работу и сообщают, что в ней не так. Работают как отдельные продукты и нашей переписки не видят.',
1652
+ 'review.hint': 'Независимые ревизоры смотрят готовую работу и сообщают, что в ней не так. Сколько их и какие выбираете вы: разные модели находят разное.',
1638
1653
  'review.model': 'Модель',
1639
1654
  'review.effort': 'Усилие',
1640
1655
  'review.asConfigured': 'как настроено',
@@ -1788,7 +1803,7 @@ window.__ModuleLoader__.load({
1788
1803
  // поэтому строка остаётся согласованной при любой палитре.
1789
1804
  '.dsx-setting{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;}',
1790
1805
  '.dsx-setting__title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;}',
1791
- '.dsx-rev__panel{margin-top:14px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1)}.dsx-rev__panel-head{display:flex;align-items:center;gap:8px;margin-bottom:8px}.dsx-rev__count{padding:1px 7px;border-radius:99px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary);font-size:11px}.dsx-rev__findings{display:flex;flex-direction:column;gap:8px}.dsx-rev__finding{padding:9px 11px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2)}.dsx-rev__finding--false{opacity:.55}.dsx-rev__finding-head{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap}.dsx-rev__finding-title{font-weight:600;color:var(--dsw-alias-label-primary);font-size:13px;flex:1}.dsx-rev__sev{padding:1px 6px;border-radius:4px;font-size:10px;text-transform:uppercase;letter-spacing:.03em}.dsx-rev__sev--crit{background:#5c1f22;color:#ffb4b4}.dsx-rev__sev--high{background:#5a3f16;color:#f0cd8a}.dsx-rev__sev--unknown{background:#3a3a44;color:#c9c9d4}.dsx-rev__sev--low{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary)}.dsx-rev__who{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dsx-rev__where{margin-top:4px;font-family:ui-monospace,monospace;font-size:11px;color:var(--dsw-alias-label-tertiary)}.dsx-rev__what{margin-top:4px;font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.45}.dsx-rev__verdict{display:flex;align-items:flex-start;gap:6px;margin-top:7px;padding-top:7px;border-top:1px dashed var(--dsw-alias-border-l1);font-size:11px;color:var(--dsw-alias-label-secondary)}.dsx-rev__verdict-mark{font-weight:700}.dsx-rev__verdict-text{flex:1;line-height:1.4}.dsx-rev__by{color:var(--dsw-alias-label-tertiary);white-space:nowrap}.dsx-rev__object{margin-top:5px}.dsx-rev__object-btn{border:0;background:none;padding:0;color:var(--dsw-alias-label-tertiary);font-size:11px;cursor:pointer;text-decoration:underline dotted}.dsx-rev__object-btn:hover{color:var(--dsw-alias-label-primary)}.dsx-rev__stats{margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}.dsx-rev__stat{display:flex;justify-content:space-between;gap:10px;margin-top:4px;font-size:12px}.dsx-rev__stat-name{color:var(--dsw-alias-label-secondary)}.dsx-rev__stat-rate{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}.dsx-rev__modes{display:flex;gap:8px;margin:10px 0 4px}.dsx-rev__mode{flex:1;display:flex;flex-direction:column;gap:3px;padding:9px 11px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);cursor:pointer;text-align:left;transition:border-color .15s ease,background .15s ease}.dsx-rev__mode:hover{border-color:var(--dsw-alias-border-l2)}.dsx-rev__mode--on{border-color:var(--dsw-alias-brand-default);background:var(--dsw-alias-bg-layer-1)}.dsx-rev__mode-name{font-weight:600;color:var(--dsw-alias-label-primary);font-size:13px}.dsx-rev__mode-hint{font-size:11px;line-height:1.35}.dsx-rev__toggle{align-self:flex-start;margin-top:6px;padding:2px 0;border:0;background:none;color:var(--dsw-alias-label-secondary);font-size:12px;cursor:pointer}.dsx-rev__toggle:hover{color:var(--dsw-alias-label-primary)}.dsx-rev__tag{margin-left:6px;padding:1px 6px;border-radius:99px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary);font-size:10px}.dsx-rev__custom{display:inline-flex;align-items:center;gap:4px}.dsx-rev__input{padding:3px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px;width:170px}.dsx-rev__back{border:0;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;line-height:1;padding:0 2px}.dsx-rev__list{display:flex;flex-direction:column;gap:8px}.dsx-rev__card{padding:10px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);opacity:.6;transition:opacity .15s ease,border-color .15s ease}.dsx-rev__card--on{opacity:1;border-color:var(--dsw-alias-border-l2)}.dsx-rev__head{display:flex;align-items:center;gap:8px;cursor:pointer}.dsx-rev__name{font-weight:600;color:var(--dsw-alias-label-primary)}.dsx-rev__fields{display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-left:22px}.dsx-rev__field{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px}.dsx-rev__select{padding:3px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px}.dsx-subs{display:flex;flex-direction:column;gap:14px;padding:4px 0}.dsx-subs__head{display:flex;flex-direction:column;gap:4px}.dsx-subs__title{font-weight:600;font-size:15px;color:var(--dsw-alias-label-primary)}.dsx-subs__note{padding-top:6px;border-top:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}.dsx-setting--stack{flex-direction:column;align-items:stretch;gap:10px}.dsx-auth__list{display:flex;flex-direction:column;gap:6px}.dsx-auth__row{display:flex;align-items:center;justify-content:space-between;gap:12px}.dsx-auth__name{color:var(--dsw-alias-label-primary)}.dsx-auth__badge{margin-left:8px;padding:1px 6px;border-radius:999px;font-size:11px;color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-border-l2)}.dsx-auth__panel{display:flex;flex-direction:column;gap:8px;padding:10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1)}.dsx-auth__notice,.dsx-auth__prompt{display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-label-secondary)}.dsx-auth__link{color:var(--dsw-alias-brand-primary);word-break:break-all}.dsx-auth__code{font-family:ui-monospace,monospace;letter-spacing:.08em;color:var(--dsw-alias-label-primary)}.dsx-auth__input{flex:1;min-width:0;padding:4px 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dsx-setting__hint--strong{color:var(--dsw-alias-label-primary)}.dsx-setting__hint--warn{color:var(--dsw-alias-state-warn-primary)}.dsx-setting__hint{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:2px;}',
1806
+ '.dsx-rev__panel{margin-top:14px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1)}.dsx-rev__panel-head{display:flex;align-items:center;gap:8px;margin-bottom:8px}.dsx-rev__count{padding:1px 7px;border-radius:99px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary);font-size:11px}.dsx-rev__findings{display:flex;flex-direction:column;gap:8px}.dsx-rev__finding{padding:9px 11px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2)}.dsx-rev__finding--false{opacity:.55}.dsx-rev__finding-head{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap}.dsx-rev__finding-title{font-weight:600;color:var(--dsw-alias-label-primary);font-size:13px;flex:1}.dsx-rev__sev{padding:1px 6px;border-radius:4px;font-size:10px;text-transform:uppercase;letter-spacing:.03em}.dsx-rev__sev--crit{background:#5c1f22;color:#ffb4b4}.dsx-rev__sev--high{background:#5a3f16;color:#f0cd8a}.dsx-rev__sev--unknown{background:#3a3a44;color:#c9c9d4}.dsx-rev__sev--low{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary)}.dsx-rev__who{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dsx-rev__where{margin-top:4px;font-family:ui-monospace,monospace;font-size:11px;color:var(--dsw-alias-label-tertiary)}.dsx-rev__what{margin-top:4px;font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.45}.dsx-rev__verdict{display:flex;align-items:flex-start;gap:6px;margin-top:7px;padding-top:7px;border-top:1px dashed var(--dsw-alias-border-l1);font-size:11px;color:var(--dsw-alias-label-secondary)}.dsx-rev__verdict-mark{font-weight:700}.dsx-rev__verdict-text{flex:1;line-height:1.4}.dsx-rev__by{color:var(--dsw-alias-label-tertiary);white-space:nowrap}.dsx-rev__object{margin-top:5px}.dsx-rev__object-btn{border:0;background:none;padding:0;color:var(--dsw-alias-label-tertiary);font-size:11px;cursor:pointer;text-decoration:underline dotted}.dsx-rev__object-btn:hover{color:var(--dsw-alias-label-primary)}.dsx-rev__stats{margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}.dsx-rev__stat{display:flex;justify-content:space-between;gap:10px;margin-top:4px;font-size:12px}.dsx-rev__stat-name{color:var(--dsw-alias-label-secondary)}.dsx-rev__stat-rate{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}.dsx-rev__tag{margin-left:6px;padding:1px 6px;border-radius:99px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary);font-size:10px}.dsx-rev__custom{display:inline-flex;align-items:center;gap:4px}.dsx-rev__input{padding:3px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px;width:170px}.dsx-rev__back{border:0;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;line-height:1;padding:0 2px}.dsx-rev__remove{margin-left:auto;border:0;background:none;color:var(--dsw-alias-label-tertiary);font-size:16px;line-height:1;padding:0 4px;cursor:pointer}.dsx-rev__remove:hover{color:#ffb4b4}.dsx-rev__add{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:12px}.dsx-rev__add-label{color:var(--dsw-alias-label-secondary);font-size:12px;margin-right:2px}.dsx-rev__add-btn{padding:4px 11px;border:1px dashed var(--dsw-alias-border-l2);border-radius:99px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;cursor:pointer;font-family:inherit}.dsx-rev__add-btn:hover:not(:disabled){border-style:solid;color:var(--dsw-alias-label-primary)}.dsx-rev__add-btn:disabled{opacity:.4;cursor:default}.dsx-rev__list{display:flex;flex-direction:column;gap:8px}.dsx-rev__card{padding:10px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);opacity:.6;transition:opacity .15s ease,border-color .15s ease}.dsx-rev__card--on{opacity:1;border-color:var(--dsw-alias-border-l2)}.dsx-rev__head{display:flex;align-items:center;gap:8px;cursor:pointer}.dsx-rev__name{font-weight:600;color:var(--dsw-alias-label-primary)}.dsx-rev__fields{display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-left:22px}.dsx-rev__field{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px}.dsx-rev__select{padding:3px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px}.dsx-subs{display:flex;flex-direction:column;gap:14px;padding:4px 0}.dsx-subs__head{display:flex;flex-direction:column;gap:4px}.dsx-subs__title{font-weight:600;font-size:15px;color:var(--dsw-alias-label-primary)}.dsx-subs__note{padding-top:6px;border-top:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}.dsx-setting--stack{flex-direction:column;align-items:stretch;gap:10px}.dsx-auth__list{display:flex;flex-direction:column;gap:6px}.dsx-auth__row{display:flex;align-items:center;justify-content:space-between;gap:12px}.dsx-auth__name{color:var(--dsw-alias-label-primary)}.dsx-auth__badge{margin-left:8px;padding:1px 6px;border-radius:999px;font-size:11px;color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-border-l2)}.dsx-auth__panel{display:flex;flex-direction:column;gap:8px;padding:10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1)}.dsx-auth__notice,.dsx-auth__prompt{display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-label-secondary)}.dsx-auth__link{color:var(--dsw-alias-brand-primary);word-break:break-all}.dsx-auth__code{font-family:ui-monospace,monospace;letter-spacing:.08em;color:var(--dsw-alias-label-primary)}.dsx-auth__input{flex:1;min-width:0;padding:4px 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dsx-setting__hint--strong{color:var(--dsw-alias-label-primary)}.dsx-setting__hint--warn{color:var(--dsw-alias-state-warn-primary)}.dsx-setting__hint{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:2px;}',
1792
1807
  '.dsx-setting__control{display:inline-flex;padding:2px;gap:2px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);flex:none;}',
1793
1808
  '.dsx-seg{appearance:none;border:0;cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;line-height:18px;background:transparent;color:var(--dsw-alias-label-secondary);transition:background .16s ease,color .16s ease;}',
1794
1809
  '.dsx-seg:hover{color:var(--dsw-alias-label-primary);}',
@@ -2346,7 +2361,7 @@ window.__ModuleLoader__.load({
2346
2361
  disabled: disabled,
2347
2362
  title: t('review.fromList'),
2348
2363
  onClick: () => { setCustom(false); onPick(null) },
2349
- children: '×',
2364
+ children: '\u00d7',
2350
2365
  }),
2351
2366
  ],
2352
2367
  })
@@ -2367,7 +2382,6 @@ window.__ModuleLoader__.load({
2367
2382
  ],
2368
2383
  })
2369
2384
  }
2370
-
2371
2385
  /** Класс важности: имена уровней приходят с Host по-русски. */
2372
2386
  function severityClass(severity) {
2373
2387
  if (severity === 'критично') return 'crit'
@@ -2385,16 +2399,19 @@ window.__ModuleLoader__.load({
2385
2399
  }
2386
2400
  // ── Ревизоры ─────────────────────────────────────────────────────────
2387
2401
  //
2388
- // Раздел настроек: чем и как проверять работу вторым мнением.
2402
+ // Свободный список, а не готовые режимы.
2403
+ //
2404
+ // Режимы «быстрый, обычный, глубокий» отвечали за человека на вопрос, на
2405
+ // который он вполне способен ответить сам, и при этом не давали позвать,
2406
+ // скажем, три разные модели одного семейства. Теперь: сколько угодно
2407
+ // записей, у каждой свой вид, модель и усилие.
2389
2408
  //
2390
- // Сверху режимы один выбор для тех, кто не хочет вникать. Ниже
2391
- // поштучно: кто участвует, какая модель, какое усилие. Поштучное НЕ
2392
- // спрятано за «для опытных»: человек, знающий, какая модель ему нужна,
2393
- // не должен её выпрашивать.
2409
+ // Рядом с каждой моделью показано, чем она оплачена. Это не украшение:
2410
+ // подписка безлимитна, а ключ API считается по токенам, и человек,
2411
+ // собирающий состав, должен видеть разницу ДО того, как получит счёт.
2394
2412
  function ReviewRow({ t }) {
2395
2413
  const [state, setState] = React.useState(null)
2396
2414
  const [busy, setBusy] = React.useState(false)
2397
- const [expanded, setExpanded] = React.useState(false)
2398
2415
 
2399
2416
  const translate = React.useRef(t)
2400
2417
  translate.current = t
@@ -2443,40 +2460,41 @@ window.__ModuleLoader__.load({
2443
2460
  React.useEffect(() => { refresh() }, [refresh])
2444
2461
 
2445
2462
  const catalogue = state !== null && Array.isArray(state.catalogue) ? state.catalogue : []
2446
- const modes = state !== null && Array.isArray(state.modes) ? state.modes : []
2447
- const settings = state !== null && state.settings ? state.settings : null
2448
2463
  const journal = state !== null && state.journal ? state.journal : null
2449
-
2450
- // Изменение сохраняется сразу. Отдельная кнопка «применить» создаёт
2451
- // состояние «изменено, но не сохранено», в котором легко забыть нажать.
2452
- const save = React.useCallback((next) => { refresh(next) }, [refresh])
2453
-
2454
- const toggle = (kind) => {
2455
- if (settings === null) return
2456
- const on = settings.enabled.includes(kind)
2457
- const enabled = on ? settings.enabled.filter((k) => k !== kind) : [...settings.enabled, kind]
2458
- save({ ...settings, enabled })
2464
+ const reviewers = state !== null && state.settings && Array.isArray(state.settings.reviewers)
2465
+ ? state.settings.reviewers
2466
+ : []
2467
+
2468
+ // Список уходит ЦЕЛИКОМ: удаление записи иначе было бы невыразимо —
2469
+ // слияние на стороне Host всегда возвращало бы удалённое обратно.
2470
+ const save = React.useCallback((next) => { refresh({ reviewers: next }) }, [refresh])
2471
+
2472
+ const patch = (id, changes) =>
2473
+ save(reviewers.map((entry) => (entry.id === id ? { ...entry, ...changes } : entry)))
2474
+
2475
+ const remove = (id) => save(reviewers.filter((entry) => entry.id !== id))
2476
+
2477
+ const add = (kind) => {
2478
+ const spec = catalogue.find((entry) => entry.kind === kind)
2479
+ save([...reviewers, {
2480
+ kind,
2481
+ // Умолчание берётся из справочника: пустая модель означала бы
2482
+ // умолчание маршрута, то есть копию уже имеющегося ревизора.
2483
+ provider: spec && spec.internal ? spec.defaultProvider : null,
2484
+ model: spec ? spec.defaultModel : null,
2485
+ effort: null,
2486
+ enabled: true,
2487
+ }])
2459
2488
  }
2460
2489
 
2461
- const pick = (kind, field, value) => {
2462
- if (settings === null) return
2463
- const previous = settings.choices[kind] || { provider: null, model: null, effort: null }
2464
- const choices = { ...settings.choices, [kind]: { ...previous, [field]: value } }
2465
- save({ ...settings, choices })
2490
+ const billingLabel = (kind) => {
2491
+ if (kind === 'subscription') return t('review.bySubscription')
2492
+ if (kind === 'api-key') return t('review.byApiKey')
2493
+ return null
2466
2494
  }
2467
2495
 
2468
- // Модель нашего ревизора приходит парой «провайдер и имя»: у OpenRouter
2469
- // имя само содержит косую черту, и делить по ней нельзя — провайдер там
2470
- // называется иначе, чем первая часть имени.
2471
- const pickModel = (kind, entry) => {
2472
- if (settings === null) return
2473
- const previous = settings.choices[kind] || { provider: null, model: null, effort: null }
2474
- const choices = {
2475
- ...settings.choices,
2476
- [kind]: { ...previous, provider: entry === null ? null : entry.provider, model: entry === null ? null : entry.id },
2477
- }
2478
- save({ ...settings, choices })
2479
- }
2496
+ const atLimit = reviewers.length >= 8
2497
+ const activeCount = reviewers.filter((entry) => entry.enabled).length
2480
2498
 
2481
2499
  return jsxs('div', {
2482
2500
  className: 'dsx-subs',
@@ -2492,53 +2510,37 @@ window.__ModuleLoader__.load({
2492
2510
  ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: state.problem })
2493
2511
  : null,
2494
2512
 
2495
- // Режимы
2496
- modes.length === 0 ? null : jsx('div', {
2497
- className: 'dsx-rev__modes',
2498
- children: modes.map((mode) => jsxs('button', {
2499
- type: 'button',
2500
- className: 'dsx-rev__mode' + (settings !== null && settings.mode === mode.id ? ' dsx-rev__mode--on' : ''),
2501
- disabled: busy,
2502
- onClick: () => save({ mode: mode.id, applyMode: true }),
2503
- children: [
2504
- jsx('span', { className: 'dsx-rev__mode-name', children: mode.label }),
2505
- jsx('span', { className: 'dsx-rev__mode-hint', children: mode.hint }),
2506
- ],
2507
- }, mode.id)),
2508
- }),
2509
- settings !== null && settings.mode === null
2510
- ? jsx('div', { className: 'dsx-setting__hint', children: t('review.custom') })
2511
- : null,
2512
-
2513
- // Подробные настройки
2514
- jsx('button', {
2515
- type: 'button',
2516
- className: 'dsx-rev__toggle',
2517
- onClick: () => setExpanded(!expanded),
2518
- children: (expanded ? '▾ ' : '▸ ') + t('review.details'),
2519
- }),
2520
-
2521
- !expanded ? null : jsx('div', {
2513
+ jsx('div', {
2522
2514
  className: 'dsx-rev__list',
2523
- children: catalogue.map((entry) => {
2524
- const on = settings !== null && settings.enabled.includes(entry.kind)
2525
- // Запись может отсутствовать, если списки Host и браузера
2526
- // разойдутся; раньше обращение к ней роняло весь раздел.
2527
- const stored = settings === null ? null : settings.choices[entry.kind]
2528
- const choice = stored || { provider: null, model: null, effort: null }
2529
- const models = Array.isArray(entry.models) ? entry.models : []
2515
+ children: reviewers.map((entry) => {
2516
+ const spec = catalogue.find((item) => item.kind === entry.kind)
2517
+ const models = spec && Array.isArray(spec.models) ? spec.models : []
2518
+ const chosen = models.find((m) => m.id === entry.model)
2519
+ const billing = billingLabel(chosen ? chosen.billing : null)
2530
2520
 
2531
2521
  return jsxs('div', {
2532
- className: 'dsx-rev__card' + (on ? ' dsx-rev__card--on' : ''),
2522
+ className: 'dsx-rev__card' + (entry.enabled ? ' dsx-rev__card--on' : ''),
2533
2523
  children: [
2534
- jsxs('label', {
2524
+ jsxs('div', {
2535
2525
  className: 'dsx-rev__head',
2536
2526
  children: [
2537
- jsx('input', { type: 'checkbox', checked: on, disabled: busy, onChange: () => toggle(entry.kind) }),
2538
- jsx('span', { className: 'dsx-rev__name', children: entry.label }),
2539
- entry.internal
2540
- ? jsx('span', { className: 'dsx-rev__tag', children: t('review.ours') })
2541
- : null,
2527
+ jsx('input', {
2528
+ type: 'checkbox',
2529
+ checked: entry.enabled,
2530
+ disabled: busy,
2531
+ title: t('review.participates'),
2532
+ onChange: () => patch(entry.id, { enabled: !entry.enabled }),
2533
+ }),
2534
+ jsx('span', { className: 'dsx-rev__name', children: spec ? spec.label : entry.kind }),
2535
+ billing === null ? null : jsx('span', { className: 'dsx-rev__tag', children: billing }),
2536
+ jsx('button', {
2537
+ type: 'button',
2538
+ className: 'dsx-rev__remove',
2539
+ disabled: busy,
2540
+ title: t('review.remove'),
2541
+ onClick: () => remove(entry.id),
2542
+ children: '\u00d7',
2543
+ }),
2542
2544
  ],
2543
2545
  }),
2544
2546
  jsxs('div', {
@@ -2550,16 +2552,24 @@ window.__ModuleLoader__.load({
2550
2552
  jsx('span', { children: t('review.model') }),
2551
2553
  jsx(ValuePicker, {
2552
2554
  t: t,
2553
- value: choice.model,
2555
+ value: entry.model,
2554
2556
  disabled: busy,
2555
2557
  placeholder: t('review.modelName'),
2556
2558
  options: models.map((m) => ({
2557
2559
  value: m.id,
2558
- label: m.provider === null ? m.id : m.provider + ' · ' + 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') : ''),
2559
2566
  })),
2560
2567
  onPick: (value) => {
2561
2568
  const found = models.find((m) => m.id === value)
2562
- pickModel(entry.kind, value === null ? null : (found || { provider: choice.provider, id: value }))
2569
+ patch(entry.id, {
2570
+ provider: value === null ? null : (found ? found.provider : entry.provider),
2571
+ model: value,
2572
+ })
2563
2573
  },
2564
2574
  }),
2565
2575
  ],
@@ -2570,21 +2580,44 @@ window.__ModuleLoader__.load({
2570
2580
  jsx('span', { children: t('review.effort') }),
2571
2581
  jsx(ValuePicker, {
2572
2582
  t: t,
2573
- value: choice.effort,
2583
+ value: entry.effort,
2574
2584
  disabled: busy,
2575
2585
  placeholder: t('review.effortName'),
2576
- options: (entry.efforts || []).map((e) => ({ value: e, label: e })),
2577
- onPick: (value) => pick(entry.kind, 'effort', value),
2586
+ options: (spec && spec.efforts ? spec.efforts : []).map((e) => ({ value: e, label: e })),
2587
+ onPick: (value) => patch(entry.id, { effort: value }),
2578
2588
  }),
2579
2589
  ],
2580
2590
  }),
2581
2591
  ],
2582
2592
  }),
2583
2593
  ],
2584
- }, entry.kind)
2594
+ }, entry.id)
2585
2595
  }),
2586
2596
  }),
2587
2597
 
2598
+ // Добавление: по кнопке на каждый вид ревизора. Внешние продукты
2599
+ // помечены — они работают, только если установлены отдельно.
2600
+ jsxs('div', {
2601
+ className: 'dsx-rev__add',
2602
+ children: [
2603
+ jsx('span', { className: 'dsx-rev__add-label', children: t('review.add') }),
2604
+ ...catalogue.map((spec) => jsx('button', {
2605
+ type: 'button',
2606
+ className: 'dsx-rev__add-btn',
2607
+ disabled: busy || atLimit,
2608
+ title: spec.optional ? t('review.needsProduct') : '',
2609
+ onClick: () => add(spec.kind),
2610
+ children: spec.label + (spec.optional ? ' \u2217' : ''),
2611
+ }, spec.kind)),
2612
+ ],
2613
+ }),
2614
+ atLimit ? jsx('div', { className: 'dsx-setting__hint', children: t('review.limit') }) : null,
2615
+
2616
+ reviewers.length === 0
2617
+ ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: t('review.none') })
2618
+ : activeCount === 0
2619
+ ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: t('review.noneActive') })
2620
+ : null,
2588
2621
  // ── Панель находок ──────────────────────────────────────────
2589
2622
  //
2590
2623
  // Показывает последний обзор: что нашли ревизоры и что из этого
@@ -2680,9 +2713,6 @@ window.__ModuleLoader__.load({
2680
2713
  }),
2681
2714
  ],
2682
2715
  }),
2683
- settings !== null && settings.enabled.length === 0
2684
- ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: t('review.none') })
2685
- : null,
2686
2716
  jsx('div', { className: 'dsx-subs__note', children: t('review.note') }),
2687
2717
  ],
2688
2718
  })
@@ -225,7 +225,7 @@ export async function handleUpdate(req, res, state, loadModule) {
225
225
  * @param llm - служба моделей; может отсутствовать в профиле без неё.
226
226
  * @returns пары «провайдер и имя модели», пригодные для показа.
227
227
  */
228
- async function listRouteModels(llm) {
228
+ async function listRouteModels(llm, credentials) {
229
229
  if (llm === undefined || llm === null || typeof llm.listProviders !== 'function') return []
230
230
 
231
231
  const providers = (() => {
@@ -236,15 +236,42 @@ async function listRouteModels(llm) {
236
236
  }
237
237
  })()
238
238
 
239
+ // ── ЧЕМ ОПЛАЧЕН КАЖДЫЙ ПРОВАЙДЕР ────────────────────────────────────
240
+ //
241
+ // Это не украшение: подписка безлимитна, а ключ API считается по токенам.
242
+ // Человек, собирающий состав ревизоров, должен видеть разницу ДО того,
243
+ // как получит счёт, — особенно когда ревизоров несколько и каждый читает
244
+ // весь код.
245
+ //
246
+ // Признак берётся из хранилища учётных данных: запись вида `grant` — это
247
+ // вход по подписке, `api-key` — сохранённый ключ. Провайдер, который
248
+ // работает, но записи не имеет, настроен извне: у OpenRouter ключ лежит
249
+ // переменной окружения, и по записям он выглядел бы ненастроенным, хотя
250
+ // отвечает. Проверено на живых данных.
251
+ const billing = new Map()
252
+ if (credentials !== undefined && credentials !== null) {
253
+ try {
254
+ for (const record of await credentials.listRecords()) {
255
+ const key = String(record.key ?? '')
256
+ const slash = key.lastIndexOf('/')
257
+ const name = slash >= 0 ? key.slice(slash + 1) : key
258
+ billing.set(name, record.kind === 'grant' ? 'subscription' : 'api-key')
259
+ }
260
+ } catch {
261
+ // Хранилище недоступно — покажем модели без отметки, это лучше отказа.
262
+ }
263
+ }
264
+
239
265
  const collected = []
240
266
  for (const info of providers) {
241
267
  const name = typeof info === 'string' ? info : info?.name ?? info?.provider
242
268
  if (typeof name !== 'string' || name === '') continue
269
+ const kind = billing.get(name) ?? 'external'
243
270
  try {
244
271
  const models = await llm.listModels(name)
245
272
  for (const model of models ?? []) {
246
273
  const id = typeof model === 'string' ? model : model?.id ?? model?.name
247
- if (typeof id === 'string' && id !== '') collected.push({ provider: name, id })
274
+ if (typeof id === 'string' && id !== '') collected.push({ provider: name, id, billing: kind })
248
275
  }
249
276
  } catch {
250
277
  // Провайдер без списка или с истёкшим ключом просто не добавит своих.
@@ -275,31 +302,36 @@ export async function handleReview(req, res, loadModule, services) {
275
302
  // Для внешних продуктов так не выйдет, и это не лень: Claude Code и
276
303
  // Codex понимают только СВОИ имена моделей, а спросить у них список
277
304
  // нечем. Там короткий перечень плюс возможность вписать своё.
278
- const routeModels = await listRouteModels(services?.llm)
305
+ const routeModels = await listRouteModels(services?.llm, services?.credentials)
279
306
 
280
307
  const catalogue = Object.entries(REVIEWERS).map(([kind, spec]) => ({
281
308
  kind,
282
309
  label: spec.label,
283
310
  internal: spec.kind === 'internal',
284
- models: spec.kind === 'internal' ? routeModels : spec.models.map((id) => ({ provider: null, id })),
311
+ optional: spec.optional === true,
312
+ models: spec.kind === 'internal' ? routeModels : spec.models.map((id) => ({ provider: null, id, billing: 'external' })),
285
313
  efforts: spec.efforts,
286
- }))
287
-
288
- const modes = Object.entries(settings.MODES).map(([id, spec]) => ({
289
- id,
290
- label: spec.label,
291
- hint: spec.hint,
292
- enabled: spec.enabled,
314
+ defaultProvider: spec.defaultProvider ?? null,
315
+ defaultModel: spec.defaultModel ?? null,
293
316
  }))
294
317
 
295
318
  // Журнал отдаётся вместе с настройками: панель находок и настройки —
296
319
  // один раздел, и второй запрос за тем же экраном был бы лишней задержкой.
297
320
  const log = await loadModule('./review-log.js')
298
321
  const journal = await log.read()
299
- const digest = { latest: log.latest(journal), accuracy: log.accuracy(journal), runs: journal.runs.length }
322
+ // Повреждённый журнал не должен уносить с собой весь раздел: состав
323
+ // ревизоров, режимы и выбор моделей к нему не относятся и обязаны
324
+ // остаться доступными. Пользователю показывается причина, а не ошибка
325
+ // инструмента вместо настроек.
326
+ const digest = {
327
+ latest: log.latest(journal),
328
+ accuracy: log.accuracy(journal),
329
+ runs: journal.runs.length,
330
+ ...(journal.intact === false ? { problem: journal.problem ?? 'журнал обзоров повреждён' } : {}),
331
+ }
300
332
 
301
333
  if (req.method === 'GET') {
302
- reply(res, 200, { catalogue, modes, settings: await settings.readSettings(null), journal: digest })
334
+ reply(res, 200, { catalogue, settings: await settings.readSettings(null), journal: digest })
303
335
  return
304
336
  }
305
337
 
@@ -328,25 +360,28 @@ export async function handleReview(req, res, loadModule, services) {
328
360
  // обратно уже не отменяется: следующий обзор не должен молча стереть
329
361
  // ровно то несогласие, ради которого панель и сделана.
330
362
  if (typeof body.findingId === 'string' && typeof body.verdict === 'string') {
331
- await log.recordVerdict(body.findingId, body.verdict, typeof body.evidence === 'string' ? body.evidence : '', 'human')
363
+ const outcome = await log.recordVerdict(
364
+ body.findingId,
365
+ body.verdict,
366
+ typeof body.evidence === 'string' ? body.evidence : '',
367
+ 'human',
368
+ )
332
369
  const after = await log.read()
333
370
  reply(res, 200, {
334
371
  catalogue,
335
- modes,
336
372
  settings: await settings.readSettings(null),
337
373
  journal: { latest: log.latest(after), accuracy: log.accuracy(after), runs: after.runs.length },
374
+ // Отклонённое возражение должно быть ВИДНО: иначе человек считает,
375
+ // что высказался, а в панели ничего не изменилось.
376
+ ...(outcome.applied === false ? { problem: outcome.problem ?? 'возражение не принято' } : {}),
338
377
  })
339
378
  return
340
379
  }
341
380
 
342
- // Переключение режима и поштучная правка приходят одним путём: если в
343
- // теле есть `mode`, состав считает Host по режиму, а не браузер. Так
344
- // определение режима живёт в одном месте, а не в двух.
345
- const next = typeof body.mode === 'string' && body.applyMode === true
346
- ? settings.applyMode(await settings.readSettings(null), body.mode)
347
- : body
348
-
349
- reply(res, 200, { catalogue, modes, settings: await settings.writeSettings(null, next), journal: digest })
381
+ // Список приходит целиком и целиком же замещает сохранённый. Для списка
382
+ // это единственно верно: слияние возвращало бы удалённую запись обратно,
383
+ // то есть удалить ревизора стало бы нельзя.
384
+ reply(res, 200, { catalogue, settings: await settings.writeSettings(null, body), journal: digest })
350
385
  } catch (error) {
351
386
  reply(res, 500, { problem: String(error?.message ?? error) })
352
387
  }