tensorgrid-ui 1.4.1 → 1.6.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
@@ -177,6 +177,22 @@ window.__ModuleLoader__.load({
177
177
  'intensity.vivid': 'Vivid',
178
178
  'accent.title': 'Obsidian / Ion accent',
179
179
  'accent.hint': 'Leading tone of the interface and the glow',
180
+ 'review.effortName': 'effort',
181
+ 'review.other': 'another…',
182
+ 'review.modelName': 'model name',
183
+ 'review.custom': 'Your own set of reviewers — no mode matches it.',
184
+ 'review.details': 'Detailed settings',
185
+ 'review.fromList': 'back to the list',
186
+ 'review.ours': 'ours',
187
+ 'review.objectFalse': 'I disagree — this is real',
188
+ 'review.accuracy': 'How often each reviewer turned out to be right',
189
+ 'review.notChecked': 'not verified yet',
190
+ 'review.byAgent': 'checked by the assistant',
191
+ 'review.humanEvidence': 'Marked by the user.',
192
+ 'review.lastReview': 'Last review',
193
+ 'review.objectReal': 'I disagree — this is wrong',
194
+ 'review.of': 'of',
195
+ 'review.byHuman': 'your call',
180
196
  'review.nav': 'Reviewers',
181
197
  'review.title': 'Second opinion',
182
198
  'review.hint': 'Independent reviewers examine finished work and report what is wrong. They run as separate products and do not see this conversation.',
@@ -232,6 +248,22 @@ window.__ModuleLoader__.load({
232
248
  'intensity.vivid': '强烈',
233
249
  'accent.title': 'Obsidian / Ion 主色',
234
250
  'accent.hint': '界面与光晕的主色调',
251
+ 'review.effortName': '强度',
252
+ 'review.other': '其他…',
253
+ 'review.modelName': '模型名称',
254
+ 'review.custom': '自定义审阅者组合——没有匹配的模式。',
255
+ 'review.details': '详细设置',
256
+ 'review.fromList': '返回列表',
257
+ 'review.ours': '我们的',
258
+ 'review.objectFalse': '我不同意——这是真的',
259
+ 'review.accuracy': '各审阅者的正确率',
260
+ 'review.notChecked': '尚未核实',
261
+ 'review.byAgent': '由助手核实',
262
+ 'review.humanEvidence': '由用户标记。',
263
+ 'review.lastReview': '上次审阅',
264
+ 'review.objectReal': '我不同意——这是错的',
265
+ 'review.of': '共',
266
+ 'review.byHuman': '你的判断',
235
267
  'review.nav': '审阅者',
236
268
  'review.title': '第二意见',
237
269
  'review.hint': '独立审阅者检查已完成的工作并报告问题。它们作为独立产品运行,看不到本次对话。',
@@ -1584,6 +1616,22 @@ window.__ModuleLoader__.load({
1584
1616
  'intensity.vivid': 'Ярко',
1585
1617
  'accent.title': 'Акцент Obsidian / Ion',
1586
1618
  'accent.hint': 'Ведущий тон интерфейса и свечения',
1619
+ 'review.effortName': 'усилие',
1620
+ 'review.other': 'другая…',
1621
+ 'review.modelName': 'имя модели',
1622
+ 'review.custom': 'Свой набор ревизоров — ни один режим ему не соответствует.',
1623
+ 'review.details': 'Подробные настройки',
1624
+ 'review.fromList': 'вернуться к списку',
1625
+ 'review.ours': 'наш',
1626
+ 'review.objectFalse': 'Не согласен — это настоящая',
1627
+ 'review.accuracy': 'Как часто каждый ревизор оказывался прав',
1628
+ 'review.notChecked': 'ещё не проверено',
1629
+ 'review.byAgent': 'проверил помощник',
1630
+ 'review.humanEvidence': 'Отмечено пользователем.',
1631
+ 'review.lastReview': 'Последний обзор',
1632
+ 'review.objectReal': 'Не согласен — это ложная',
1633
+ 'review.of': 'из',
1634
+ 'review.byHuman': 'ваше решение',
1587
1635
  'review.nav': 'Ревизоры',
1588
1636
  'review.title': 'Второе мнение',
1589
1637
  'review.hint': 'Независимые ревизоры смотрят готовую работу и сообщают, что в ней не так. Работают как отдельные продукты и нашей переписки не видят.',
@@ -1740,7 +1788,7 @@ window.__ModuleLoader__.load({
1740
1788
  // поэтому строка остаётся согласованной при любой палитре.
1741
1789
  '.dsx-setting{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;}',
1742
1790
  '.dsx-setting__title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;}',
1743
- '.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;}',
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;}',
1744
1792
  '.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;}',
1745
1793
  '.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;}',
1746
1794
  '.dsx-seg:hover{color:var(--dsw-alias-label-primary);}',
@@ -2241,6 +2289,21 @@ window.__ModuleLoader__.load({
2241
2289
  })
2242
2290
  }
2243
2291
 
2292
+ /** Класс важности: имена уровней приходят с Host по-русски. */
2293
+ function severityClass(severity) {
2294
+ if (severity === 'критично') return 'crit'
2295
+ if (severity === 'серьёзно') return 'high'
2296
+ if (severity === 'не указана') return 'unknown'
2297
+ return 'low'
2298
+ }
2299
+
2300
+ /** Знак приговора. Пустой кружок — проверки ещё не было. */
2301
+ function verdictMark(verdict) {
2302
+ if (verdict === 'confirmed') return '✓'
2303
+ if (verdict === 'false') return '✗'
2304
+ if (verdict === 'deferred') return '⋯'
2305
+ return '○'
2306
+ }
2244
2307
  // ── Ревизоры ─────────────────────────────────────────────────────────
2245
2308
  //
2246
2309
  // Раздел настроек, где выбирают, кто смотрит работу вторым мнением,
@@ -2252,9 +2315,86 @@ window.__ModuleLoader__.load({
2252
2315
  // свой Claude Code и Codex, и навязывать поверх нечего.
2253
2316
  const REVIEW_PATH = '/api/tensorgrid.review'
2254
2317
 
2318
+ /**
2319
+ * Выбор значения: известные варианты плюс «своё».
2320
+ *
2321
+ * Список моделей — подсказка, а не ограничение. У наших ревизоров он
2322
+ * приходит живым из маршрута, у внешних продуктов зашит коротким: спросить
2323
+ * у Claude Code или Codex их собственный перечень нечем. Поэтому рядом со
2324
+ * списком всегда есть «другая» — иначе любой новый выпуск модели делал бы
2325
+ * интерфейс бесполезным до следующего обновления пакета.
2326
+ */
2327
+ function ValuePicker({ t, value, options, disabled, onPick, placeholder }) {
2328
+ const known = options.some((option) => option.value === value)
2329
+ const [custom, setCustom] = React.useState(value !== null && !known)
2330
+
2331
+ if (custom) {
2332
+ return jsxs('span', {
2333
+ className: 'dsx-rev__custom',
2334
+ children: [
2335
+ jsx('input', {
2336
+ className: 'dsx-rev__input',
2337
+ type: 'text',
2338
+ value: value === null ? '' : value,
2339
+ placeholder: placeholder,
2340
+ disabled: disabled,
2341
+ onChange: (event) => onPick(event.target.value.trim() === '' ? null : event.target.value.trim()),
2342
+ }),
2343
+ jsx('button', {
2344
+ type: 'button',
2345
+ className: 'dsx-rev__back',
2346
+ disabled: disabled,
2347
+ title: t('review.fromList'),
2348
+ onClick: () => { setCustom(false); onPick(null) },
2349
+ children: '×',
2350
+ }),
2351
+ ],
2352
+ })
2353
+ }
2354
+
2355
+ return jsxs('select', {
2356
+ className: 'dsx-rev__select',
2357
+ value: value === null ? '' : value,
2358
+ disabled: disabled,
2359
+ onChange: (event) => {
2360
+ if (event.target.value === '\u0000custom') { setCustom(true); return }
2361
+ onPick(event.target.value === '' ? null : event.target.value)
2362
+ },
2363
+ children: [
2364
+ jsx('option', { value: '', children: t('review.asConfigured') }, ''),
2365
+ ...options.map((option) => jsx('option', { value: option.value, children: option.label }, option.value)),
2366
+ jsx('option', { value: '\u0000custom', children: t('review.other') }, 'custom'),
2367
+ ],
2368
+ })
2369
+ }
2370
+
2371
+ /** Класс важности: имена уровней приходят с Host по-русски. */
2372
+ function severityClass(severity) {
2373
+ if (severity === 'критично') return 'crit'
2374
+ if (severity === 'серьёзно') return 'high'
2375
+ if (severity === 'не указана') return 'unknown'
2376
+ return 'low'
2377
+ }
2378
+
2379
+ /** Знак приговора. Пустой кружок — проверки ещё не было. */
2380
+ function verdictMark(verdict) {
2381
+ if (verdict === 'confirmed') return '✓'
2382
+ if (verdict === 'false') return '✗'
2383
+ if (verdict === 'deferred') return '⋯'
2384
+ return '○'
2385
+ }
2386
+ // ── Ревизоры ─────────────────────────────────────────────────────────
2387
+ //
2388
+ // Раздел настроек: чем и как проверять работу вторым мнением.
2389
+ //
2390
+ // Сверху режимы — один выбор для тех, кто не хочет вникать. Ниже
2391
+ // поштучно: кто участвует, какая модель, какое усилие. Поштучное НЕ
2392
+ // спрятано за «для опытных»: человек, знающий, какая модель ему нужна,
2393
+ // не должен её выпрашивать.
2255
2394
  function ReviewRow({ t }) {
2256
2395
  const [state, setState] = React.useState(null)
2257
2396
  const [busy, setBusy] = React.useState(false)
2397
+ const [expanded, setExpanded] = React.useState(false)
2258
2398
 
2259
2399
  const translate = React.useRef(t)
2260
2400
  translate.current = t
@@ -2284,12 +2424,9 @@ window.__ModuleLoader__.load({
2284
2424
  setBusy(true)
2285
2425
  try {
2286
2426
  const next = await send(body)
2287
- // Ответ без каталога — это отказ, а не новое состояние.
2288
- //
2289
- // Раньше состояние заменялось целиком, и одно неудачное нажатие
2290
- // галочки убирало все карточки: ни повторить, ни увидеть выбранное
2291
- // было нельзя, помогала только перезагрузка. Ту же ошибку я уже
2292
- // допускал в строке входа и там же чинил.
2427
+ // Ответ без каталога — это отказ, а не новое состояние. Замена
2428
+ // состояния целиком убирала все карточки после одного неудачного
2429
+ // нажатия: ни повторить, ни увидеть выбранное было нельзя.
2293
2430
  setState((previous) => {
2294
2431
  const complete = Array.isArray(next.catalogue) && next.catalogue.length > 0
2295
2432
  if (complete) return next
@@ -2306,11 +2443,12 @@ window.__ModuleLoader__.load({
2306
2443
  React.useEffect(() => { refresh() }, [refresh])
2307
2444
 
2308
2445
  const catalogue = state !== null && Array.isArray(state.catalogue) ? state.catalogue : []
2446
+ const modes = state !== null && Array.isArray(state.modes) ? state.modes : []
2309
2447
  const settings = state !== null && state.settings ? state.settings : null
2448
+ const journal = state !== null && state.journal ? state.journal : null
2310
2449
 
2311
- // Изменение любого поля сразу сохраняется: отдельная кнопка «применить»
2312
- // добавляет состояние «изменено, но не сохранено», в котором легко
2313
- // забыть нажать и потом удивляться.
2450
+ // Изменение сохраняется сразу. Отдельная кнопка «применить» создаёт
2451
+ // состояние «изменено, но не сохранено», в котором легко забыть нажать.
2314
2452
  const save = React.useCallback((next) => { refresh(next) }, [refresh])
2315
2453
 
2316
2454
  const toggle = (kind) => {
@@ -2322,21 +2460,23 @@ window.__ModuleLoader__.load({
2322
2460
 
2323
2461
  const pick = (kind, field, value) => {
2324
2462
  if (settings === null) return
2325
- const choices = { ...settings.choices, [kind]: { ...settings.choices[kind], [field]: value } }
2463
+ const previous = settings.choices[kind] || { provider: null, model: null, effort: null }
2464
+ const choices = { ...settings.choices, [kind]: { ...previous, [field]: value } }
2326
2465
  save({ ...settings, choices })
2327
2466
  }
2328
2467
 
2329
- const selector = (kind, field, options, current) =>
2330
- jsxs('select', {
2331
- className: 'dsx-rev__select',
2332
- value: current === null ? '' : current,
2333
- disabled: busy,
2334
- onChange: (event) => pick(kind, field, event.target.value === '' ? null : event.target.value),
2335
- children: [
2336
- jsx('option', { value: '', children: t('review.asConfigured') }, ''),
2337
- ...options.map((option) => jsx('option', { value: option, children: option }, option)),
2338
- ],
2339
- })
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
+ }
2340
2480
 
2341
2481
  return jsxs('div', {
2342
2482
  className: 'dsx-subs',
@@ -2351,29 +2491,54 @@ window.__ModuleLoader__.load({
2351
2491
  state !== null && state.problem
2352
2492
  ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: state.problem })
2353
2493
  : null,
2354
- jsx('div', {
2494
+
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', {
2355
2522
  className: 'dsx-rev__list',
2356
2523
  children: catalogue.map((entry) => {
2357
2524
  const on = settings !== null && settings.enabled.includes(entry.kind)
2358
2525
  // Запись может отсутствовать, если списки Host и браузера
2359
- // разойдутся. Раньше следующая строка бросала исключение прямо
2360
- // в отрисовке, и падал ВЕСЬ раздел настроек, а не только наша
2361
- // карточка.
2526
+ // разойдутся; раньше обращение к ней роняло весь раздел.
2362
2527
  const stored = settings === null ? null : settings.choices[entry.kind]
2363
- const choice = stored ?? { model: null, effort: null }
2528
+ const choice = stored || { provider: null, model: null, effort: null }
2529
+ const models = Array.isArray(entry.models) ? entry.models : []
2530
+
2364
2531
  return jsxs('div', {
2365
2532
  className: 'dsx-rev__card' + (on ? ' dsx-rev__card--on' : ''),
2366
2533
  children: [
2367
2534
  jsxs('label', {
2368
2535
  className: 'dsx-rev__head',
2369
2536
  children: [
2370
- jsx('input', {
2371
- type: 'checkbox',
2372
- checked: on,
2373
- disabled: busy,
2374
- onChange: () => toggle(entry.kind),
2375
- }),
2537
+ jsx('input', { type: 'checkbox', checked: on, disabled: busy, onChange: () => toggle(entry.kind) }),
2376
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,
2377
2542
  ],
2378
2543
  }),
2379
2544
  jsxs('div', {
@@ -2383,14 +2548,34 @@ window.__ModuleLoader__.load({
2383
2548
  className: 'dsx-rev__field',
2384
2549
  children: [
2385
2550
  jsx('span', { children: t('review.model') }),
2386
- selector(entry.kind, 'model', entry.models, choice.model),
2551
+ jsx(ValuePicker, {
2552
+ t: t,
2553
+ value: choice.model,
2554
+ disabled: busy,
2555
+ placeholder: t('review.modelName'),
2556
+ options: models.map((m) => ({
2557
+ value: m.id,
2558
+ label: m.provider === null ? m.id : m.provider + ' · ' + m.id,
2559
+ })),
2560
+ onPick: (value) => {
2561
+ const found = models.find((m) => m.id === value)
2562
+ pickModel(entry.kind, value === null ? null : (found || { provider: choice.provider, id: value }))
2563
+ },
2564
+ }),
2387
2565
  ],
2388
2566
  }),
2389
2567
  jsxs('label', {
2390
2568
  className: 'dsx-rev__field',
2391
2569
  children: [
2392
2570
  jsx('span', { children: t('review.effort') }),
2393
- selector(entry.kind, 'effort', entry.efforts, choice.effort),
2571
+ jsx(ValuePicker, {
2572
+ t: t,
2573
+ value: choice.effort,
2574
+ disabled: busy,
2575
+ placeholder: t('review.effortName'),
2576
+ options: (entry.efforts || []).map((e) => ({ value: e, label: e })),
2577
+ onPick: (value) => pick(entry.kind, 'effort', value),
2578
+ }),
2394
2579
  ],
2395
2580
  }),
2396
2581
  ],
@@ -2399,6 +2584,102 @@ window.__ModuleLoader__.load({
2399
2584
  }, entry.kind)
2400
2585
  }),
2401
2586
  }),
2587
+
2588
+ // ── Панель находок ──────────────────────────────────────────
2589
+ //
2590
+ // Показывает последний обзор: что нашли ревизоры и что из этого
2591
+ // подтвердилось проверкой.
2592
+ //
2593
+ // Судит НЕ тот, кто смотрит. Приговор выносит агент и обязан
2594
+ // приложить доказательство — что именно он запустил или прочитал.
2595
+ // Просить человека, не разбирающегося в предмете, решить
2596
+ // «настоящая это ошибка или нет» значит просить невозможного и
2597
+ // получить случайные нажатия, которые ещё и будут выглядеть как
2598
+ // данные.
2599
+ //
2600
+ // Возразить можно, но не нужно: возражение человека перекрывает
2601
+ // приговор агента и обратно уже не отменяется.
2602
+ journal === null || journal.latest === null ? null : jsxs('div', {
2603
+ className: 'dsx-rev__panel',
2604
+ children: [
2605
+ jsxs('div', {
2606
+ className: 'dsx-rev__panel-head',
2607
+ children: [
2608
+ jsx('span', { className: 'dsx-subs__title', children: t('review.lastReview') }),
2609
+ jsx('span', { className: 'dsx-rev__count', children: journal.latest.findings.length }),
2610
+ ],
2611
+ }),
2612
+ jsx('div', {
2613
+ className: 'dsx-rev__findings',
2614
+ children: journal.latest.findings.map((finding) => jsxs('div', {
2615
+ className: 'dsx-rev__finding dsx-rev__finding--' + finding.verdict,
2616
+ children: [
2617
+ jsxs('div', {
2618
+ className: 'dsx-rev__finding-head',
2619
+ children: [
2620
+ jsx('span', { className: 'dsx-rev__sev dsx-rev__sev--' + severityClass(finding.severity), children: finding.severity }),
2621
+ jsx('span', { className: 'dsx-rev__finding-title', children: finding.title }),
2622
+ jsx('span', { className: 'dsx-rev__who', children: finding.reviewer }),
2623
+ ],
2624
+ }),
2625
+ finding.where === null ? null : jsx('div', { className: 'dsx-rev__where', children: finding.where }),
2626
+ finding.what === null ? null : jsx('div', { className: 'dsx-rev__what', children: finding.what }),
2627
+ jsxs('div', {
2628
+ className: 'dsx-rev__verdict',
2629
+ children: [
2630
+ jsx('span', {
2631
+ className: 'dsx-rev__verdict-mark',
2632
+ children: verdictMark(finding.verdict),
2633
+ }),
2634
+ jsx('span', {
2635
+ className: 'dsx-rev__verdict-text',
2636
+ children: finding.evidence === ''
2637
+ ? t('review.notChecked')
2638
+ : finding.evidence,
2639
+ }),
2640
+ finding.by === null ? null : jsx('span', {
2641
+ className: 'dsx-rev__by',
2642
+ children: finding.by === 'human' ? t('review.byHuman') : t('review.byAgent'),
2643
+ }),
2644
+ ],
2645
+ }),
2646
+ jsx('div', {
2647
+ className: 'dsx-rev__object',
2648
+ children: jsx('button', {
2649
+ type: 'button',
2650
+ className: 'dsx-rev__object-btn',
2651
+ disabled: busy,
2652
+ onClick: () => save({
2653
+ findingId: finding.id,
2654
+ verdict: finding.verdict === 'confirmed' ? 'false' : 'confirmed',
2655
+ evidence: t('review.humanEvidence'),
2656
+ }),
2657
+ children: finding.verdict === 'confirmed' ? t('review.objectFalse') : t('review.objectReal'),
2658
+ }),
2659
+ }),
2660
+ ],
2661
+ }, finding.id)),
2662
+ }),
2663
+ journal.accuracy.length === 0 ? null : jsxs('div', {
2664
+ className: 'dsx-rev__stats',
2665
+ children: [
2666
+ jsx('div', { className: 'dsx-setting__hint', children: t('review.accuracy') }),
2667
+ ...journal.accuracy.map((stat) => jsxs('div', {
2668
+ className: 'dsx-rev__stat',
2669
+ children: [
2670
+ jsx('span', { className: 'dsx-rev__stat-name', children: stat.reviewer }),
2671
+ jsx('span', {
2672
+ className: 'dsx-rev__stat-rate',
2673
+ // Доля без числа находок ничего не значит: «100%» по
2674
+ // одной проверенной находке выглядит как надёжность.
2675
+ children: Math.round(stat.rate * 100) + '% ' + t('review.of') + ' ' + stat.judged,
2676
+ }),
2677
+ ],
2678
+ }, stat.reviewer)),
2679
+ ],
2680
+ }),
2681
+ ],
2682
+ }),
2402
2683
  settings !== null && settings.enabled.length === 0
2403
2684
  ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: t('review.none') })
2404
2685
  : null,
@@ -215,6 +215,44 @@ export async function handleUpdate(req, res, state, loadModule) {
215
215
  }
216
216
  }
217
217
 
218
+ /**
219
+ * Собрать модели, доступные на маршруте.
220
+ *
221
+ * Это единственный список, который можно узнать ЖИВЫМ: наш ревизор ходит
222
+ * тем же маршрутом, что и основной агент. Отказ любого провайдера не должен
223
+ * ронять весь справочник — один не ответил, остальные покажем.
224
+ *
225
+ * @param llm - служба моделей; может отсутствовать в профиле без неё.
226
+ * @returns пары «провайдер и имя модели», пригодные для показа.
227
+ */
228
+ async function listRouteModels(llm) {
229
+ if (llm === undefined || llm === null || typeof llm.listProviders !== 'function') return []
230
+
231
+ const providers = (() => {
232
+ try {
233
+ return llm.listProviders()
234
+ } catch {
235
+ return []
236
+ }
237
+ })()
238
+
239
+ const collected = []
240
+ for (const info of providers) {
241
+ const name = typeof info === 'string' ? info : info?.name ?? info?.provider
242
+ if (typeof name !== 'string' || name === '') continue
243
+ try {
244
+ const models = await llm.listModels(name)
245
+ for (const model of models ?? []) {
246
+ const id = typeof model === 'string' ? model : model?.id ?? model?.name
247
+ if (typeof id === 'string' && id !== '') collected.push({ provider: name, id })
248
+ }
249
+ } catch {
250
+ // Провайдер без списка или с истёкшим ключом просто не добавит своих.
251
+ }
252
+ }
253
+ return collected
254
+ }
255
+
218
256
  /**
219
257
  * Обслужить `/api/tensorgrid.review` — настройки обзора.
220
258
  *
@@ -225,20 +263,43 @@ export async function handleUpdate(req, res, state, loadModule) {
225
263
  * POST сохраняет выбор. Форма проверяется на Host — файл правит и человек
226
264
  * руками, и надеяться, что дошло только отправленное браузером, нельзя.
227
265
  */
228
- export async function handleReview(req, res, loadModule) {
266
+ export async function handleReview(req, res, loadModule, services) {
229
267
  try {
230
268
  const settings = await loadModule('./review-settings.js')
231
269
  const { REVIEWERS } = await loadModule('./reviewers.js')
232
270
 
271
+ // Модели НАШЕГО ревизора берутся живыми из маршрута, а не из зашитого
272
+ // списка: подключил человек OpenRouter — и в выборе появились сотни
273
+ // моделей, ничего не переустанавливая.
274
+ //
275
+ // Для внешних продуктов так не выйдет, и это не лень: Claude Code и
276
+ // Codex понимают только СВОИ имена моделей, а спросить у них список
277
+ // нечем. Там короткий перечень плюс возможность вписать своё.
278
+ const routeModels = await listRouteModels(services?.llm)
279
+
233
280
  const catalogue = Object.entries(REVIEWERS).map(([kind, spec]) => ({
234
281
  kind,
235
282
  label: spec.label,
236
- models: spec.models,
283
+ internal: spec.kind === 'internal',
284
+ models: spec.kind === 'internal' ? routeModels : spec.models.map((id) => ({ provider: null, id })),
237
285
  efforts: spec.efforts,
238
286
  }))
239
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,
293
+ }))
294
+
295
+ // Журнал отдаётся вместе с настройками: панель находок и настройки —
296
+ // один раздел, и второй запрос за тем же экраном был бы лишней задержкой.
297
+ const log = await loadModule('./review-log.js')
298
+ const journal = await log.read()
299
+ const digest = { latest: log.latest(journal), accuracy: log.accuracy(journal), runs: journal.runs.length }
300
+
240
301
  if (req.method === 'GET') {
241
- reply(res, 200, { catalogue, settings: await settings.readSettings(null) })
302
+ reply(res, 200, { catalogue, modes, settings: await settings.readSettings(null), journal: digest })
242
303
  return
243
304
  }
244
305
 
@@ -263,7 +324,29 @@ export async function handleReview(req, res, loadModule) {
263
324
  return
264
325
  }
265
326
 
266
- reply(res, 200, { catalogue, settings: await settings.writeSettings(null, body) })
327
+ // Возражение человека по находке. Оно перекрывает приговор агента и
328
+ // обратно уже не отменяется: следующий обзор не должен молча стереть
329
+ // ровно то несогласие, ради которого панель и сделана.
330
+ if (typeof body.findingId === 'string' && typeof body.verdict === 'string') {
331
+ await log.recordVerdict(body.findingId, body.verdict, typeof body.evidence === 'string' ? body.evidence : '', 'human')
332
+ const after = await log.read()
333
+ reply(res, 200, {
334
+ catalogue,
335
+ modes,
336
+ settings: await settings.readSettings(null),
337
+ journal: { latest: log.latest(after), accuracy: log.accuracy(after), runs: after.runs.length },
338
+ })
339
+ return
340
+ }
341
+
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 })
267
350
  } catch (error) {
268
351
  reply(res, 500, { problem: String(error?.message ?? error) })
269
352
  }
package/lib/index.js CHANGED
@@ -220,6 +220,22 @@ export function apply(ctx) {
220
220
  })
221
221
  })
222
222
 
223
+ // Служба моделей нужна отдельным рядом: она отдаёт живой список
224
+ // подключённых моделей для выбора ревизора. Отдельным — потому что
225
+ // объединение с парой выше связало бы настройки обзора со стойкой
226
+ // входа: нет одной — пропала бы и другая, хотя они независимы.
227
+ const modelServices = { llm: null, generation: null }
228
+ web.inject(['llm'], (bound) => {
229
+ const generation = {}
230
+ modelServices.llm = bound.llm
231
+ modelServices.generation = generation
232
+ bound.effect(() => () => {
233
+ if (modelServices.generation !== generation) return
234
+ modelServices.llm = null
235
+ modelServices.generation = null
236
+ })
237
+ })
238
+
223
239
  // Манифест собирается в горячем модуле: он ЧИТАЕТ поставочный файл и
224
240
  // дополняет его нашими полями, а значит зависит от чужой формы, которая
225
241
  // может поменяться. Такую логику правильнее держать там, где её можно
@@ -247,7 +263,7 @@ export function apply(ctx) {
247
263
  async handler(req, res) {
248
264
  try {
249
265
  const runtime = await loadModule('./http-runtime.js')
250
- await runtime.handleReview(req, res, loadModule)
266
+ await runtime.handleReview(req, res, loadModule, modelServices)
251
267
  } catch (error) {
252
268
  fail(res, error)
253
269
  }