tensorgrid-ui 1.10.0 → 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 +131 -101
- package/lib/http-runtime.js +39 -21
- package/lib/index.js +4 -2
- package/lib/review-settings.js +156 -224
- package/lib/review.js +36 -26
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -180,10 +180,7 @@ 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',
|
|
@@ -193,9 +190,17 @@ window.__ModuleLoader__.load({
|
|
|
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.
|
|
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,10 +256,7 @@ 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': '尚未核实',
|
|
@@ -264,9 +266,17 @@ window.__ModuleLoader__.load({
|
|
|
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,10 +1629,7 @@ 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': 'ещё не проверено',
|
|
@@ -1632,9 +1639,17 @@ window.__ModuleLoader__.load({
|
|
|
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-
|
|
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
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
save({ ...
|
|
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
|
|
2462
|
-
if (
|
|
2463
|
-
|
|
2464
|
-
|
|
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
|
-
|
|
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:
|
|
2524
|
-
const
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
const
|
|
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' + (
|
|
2522
|
+
className: 'dsx-rev__card' + (entry.enabled ? ' dsx-rev__card--on' : ''),
|
|
2533
2523
|
children: [
|
|
2534
|
-
jsxs('
|
|
2524
|
+
jsxs('div', {
|
|
2535
2525
|
className: 'dsx-rev__head',
|
|
2536
2526
|
children: [
|
|
2537
|
-
jsx('input', {
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
2583
|
+
value: entry.effort,
|
|
2574
2584
|
disabled: busy,
|
|
2575
2585
|
placeholder: t('review.effortName'),
|
|
2576
|
-
options: (
|
|
2577
|
-
onPick: (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.
|
|
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
|
})
|
package/lib/http-runtime.js
CHANGED
|
@@ -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,21 +302,17 @@ 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
|
-
|
|
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
|
// Журнал отдаётся вместе с настройками: панель находок и настройки —
|
|
@@ -308,7 +331,7 @@ export async function handleReview(req, res, loadModule, services) {
|
|
|
308
331
|
}
|
|
309
332
|
|
|
310
333
|
if (req.method === 'GET') {
|
|
311
|
-
reply(res, 200, { catalogue,
|
|
334
|
+
reply(res, 200, { catalogue, settings: await settings.readSettings(null), journal: digest })
|
|
312
335
|
return
|
|
313
336
|
}
|
|
314
337
|
|
|
@@ -346,7 +369,6 @@ export async function handleReview(req, res, loadModule, services) {
|
|
|
346
369
|
const after = await log.read()
|
|
347
370
|
reply(res, 200, {
|
|
348
371
|
catalogue,
|
|
349
|
-
modes,
|
|
350
372
|
settings: await settings.readSettings(null),
|
|
351
373
|
journal: { latest: log.latest(after), accuracy: log.accuracy(after), runs: after.runs.length },
|
|
352
374
|
// Отклонённое возражение должно быть ВИДНО: иначе человек считает,
|
|
@@ -356,14 +378,10 @@ export async function handleReview(req, res, loadModule, services) {
|
|
|
356
378
|
return
|
|
357
379
|
}
|
|
358
380
|
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
? settings.applyMode(await settings.readSettings(null), body.mode)
|
|
364
|
-
: body
|
|
365
|
-
|
|
366
|
-
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 })
|
|
367
385
|
} catch (error) {
|
|
368
386
|
reply(res, 500, { problem: String(error?.message ?? error) })
|
|
369
387
|
}
|
package/lib/index.js
CHANGED
|
@@ -224,14 +224,16 @@ export function apply(ctx) {
|
|
|
224
224
|
// подключённых моделей для выбора ревизора. Отдельным — потому что
|
|
225
225
|
// объединение с парой выше связало бы настройки обзора со стойкой
|
|
226
226
|
// входа: нет одной — пропала бы и другая, хотя они независимы.
|
|
227
|
-
const modelServices = { llm: null, generation: null }
|
|
228
|
-
web.inject(['llm'], (bound) => {
|
|
227
|
+
const modelServices = { llm: null, credentials: null, generation: null }
|
|
228
|
+
web.inject(['llm', 'credentials'], (bound) => {
|
|
229
229
|
const generation = {}
|
|
230
230
|
modelServices.llm = bound.llm
|
|
231
|
+
modelServices.credentials = bound.credentials
|
|
231
232
|
modelServices.generation = generation
|
|
232
233
|
bound.effect(() => () => {
|
|
233
234
|
if (modelServices.generation !== generation) return
|
|
234
235
|
modelServices.llm = null
|
|
236
|
+
modelServices.credentials = null
|
|
235
237
|
modelServices.generation = null
|
|
236
238
|
})
|
|
237
239
|
})
|
package/lib/review-settings.js
CHANGED
|
@@ -6,12 +6,21 @@
|
|
|
6
6
|
* значит хранилище должно быть общим для обеих сторон. Память браузера для
|
|
7
7
|
* этого не годится, Host её не видит.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* ФОРМА: СПИСОК, А НЕ ФИКСИРОВАННЫЕ ГНЁЗДА
|
|
10
|
+
*
|
|
11
|
+
* Раньше ревизоры были четырьмя жёсткими гнёздами, а сверху стояли три
|
|
12
|
+
* готовых режима — быстрый, обычный, глубокий. Это оказалось не тем
|
|
13
|
+
* решением: готовый режим отвечает за человека на вопрос, на который он
|
|
14
|
+
* вполне способен ответить сам, а четыре гнезда не дают позвать, скажем,
|
|
15
|
+
* три разные модели одного семейства.
|
|
16
|
+
*
|
|
17
|
+
* Теперь это список произвольной длины. Каждая запись говорит: кем
|
|
18
|
+
* работать (нашим подагентом или чужим продуктом), какой моделью, с каким
|
|
19
|
+
* усилием, участвует ли сейчас.
|
|
11
20
|
*
|
|
12
21
|
* Импортируются только встроенные модули Node.
|
|
13
22
|
*/
|
|
14
|
-
import { KNOWN } from './reviewers.js'
|
|
23
|
+
import { KNOWN, REVIEWERS } from './reviewers.js'
|
|
15
24
|
import { randomBytes } from 'node:crypto'
|
|
16
25
|
import { readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
17
26
|
import { existsSync } from 'node:fs'
|
|
@@ -20,133 +29,160 @@ import { fileURLToPath } from 'node:url'
|
|
|
20
29
|
|
|
21
30
|
const FILE_NAME = 'tensorgrid-review.json'
|
|
22
31
|
|
|
23
|
-
/**
|
|
24
|
-
* Ревизоры берутся из `reviewers.js` — единственного источника.
|
|
25
|
-
* Свой литерал здесь был третьим по счёту и расходился бы молча.
|
|
26
|
-
*/
|
|
32
|
+
/** Ревизоры берутся из `reviewers.js` — единственного источника. */
|
|
27
33
|
export { KNOWN } from './reviewers.js'
|
|
28
|
-
import { REVIEWERS } from './reviewers.js'
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
|
-
*
|
|
36
|
+
* Сколько ревизоров можно завести.
|
|
32
37
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* обзоре. В интерфейсе это было невидимо — список с неизвестным значением
|
|
37
|
-
* показывает пустое поле, неотличимое от «как настроено».
|
|
38
|
+
* Предел есть, потому что каждый — отдельный прогон модели: десяток
|
|
39
|
+
* означает десяток ожиданий и десяток счетов. Число намеренно щедрое:
|
|
40
|
+
* это заслон от случайной сотни, а не указание, сколько «правильно».
|
|
38
41
|
*/
|
|
39
|
-
const
|
|
40
|
-
const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._\-\/]{0,95}$/
|
|
42
|
+
export const MAX_REVIEWERS = 8
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
|
-
*
|
|
45
|
+
* Что допустимо в провайдере, модели и усилии.
|
|
44
46
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
47
|
+
* Проверка стоит ЗДЕСЬ, при сохранении, а не в момент вызова: раньше
|
|
48
|
+
* кривое значение сохранялось молча и отвергалось через двадцать минут,
|
|
49
|
+
* при следующем обзоре, причём в интерфейсе выглядело как «как настроено».
|
|
48
50
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
+
* Строгий вариант — для внешних продуктов: значение уходит в аргументы
|
|
52
|
+
* командной строки, и косая черта там ни в одном имени не нужна.
|
|
53
|
+
* Послабленный — для наших: маршрут называет модели вида `x-ai/grok-4.6`,
|
|
54
|
+
* и общая проверка отвергала бы половину подключённых.
|
|
51
55
|
*/
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
label: 'Быстрый',
|
|
55
|
-
hint: 'Один ревизор, обычное усилие. Минуты. Для рядовой работы.',
|
|
56
|
-
enabled: ['internal'],
|
|
57
|
-
effort: null,
|
|
58
|
-
},
|
|
59
|
-
normal: {
|
|
60
|
-
label: 'Обычный',
|
|
61
|
-
hint: 'Две разные модели. Они находят разное — одна упускает половину.',
|
|
62
|
-
// Два НАШИХ ревизора на разных семействах моделей.
|
|
63
|
-
//
|
|
64
|
-
// Раньше здесь стояли два чужих продукта. Чистый опыт показал, что
|
|
65
|
-
// обвязка почти ничего не решает — совпадение 13 находок из 14, — а
|
|
66
|
-
// разное находят разные модели. Обе подписки работают и у нас, так что
|
|
67
|
-
// тот же результат достаётся без двух лишних установок сотруднику.
|
|
68
|
-
enabled: ['internal', 'internal-alt'],
|
|
69
|
-
effort: null,
|
|
70
|
-
},
|
|
71
|
-
deep: {
|
|
72
|
-
label: 'Глубокий',
|
|
73
|
-
hint: 'Все доступные ревизоры на полном усилии. Долго и дорого — для того, что увидят люди.',
|
|
74
|
-
// «Глубокий» означает ВСЕ, а не перечисленных поимённо: иначе добавление
|
|
75
|
-
// четвёртого ревизора молча оставило бы самый тщательный режим без него.
|
|
76
|
-
// Остальные режимы называют состав явно — в этом и есть их смысл.
|
|
77
|
-
//
|
|
78
|
-
// Сюда попадают и внешние продукты. Если они не установлены, обзор
|
|
79
|
-
// прямо скажет «не найден» — это лучше, чем молча пропустить.
|
|
80
|
-
enabled: [...KNOWN],
|
|
81
|
-
effort: 'high',
|
|
82
|
-
},
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Режим по умолчанию: две модели — проверенная середина. */
|
|
86
|
-
const DEFAULT_MODE = 'normal'
|
|
56
|
+
const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
57
|
+
const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._\-/]{0,95}$/
|
|
87
58
|
|
|
88
59
|
/**
|
|
89
60
|
* Что действует, пока пользователь ничего не выбрал.
|
|
90
61
|
*
|
|
91
|
-
* Два ревизора
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
62
|
+
* Два ревизора на РАЗНЫХ семействах моделей из двух подписок. Два —
|
|
63
|
+
* потому что они находят разное: один упускает половину. Разных семейств —
|
|
64
|
+
* потому что два прогона одной модели дают почти одинаковые находки.
|
|
65
|
+
*
|
|
66
|
+
* Обе модели по подписке: обзор по умолчанию не должен молча тратить
|
|
67
|
+
* деньги поштучно.
|
|
95
68
|
*/
|
|
96
69
|
export function defaults() {
|
|
97
70
|
return {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
71
|
+
reviewers: [
|
|
72
|
+
{
|
|
73
|
+
id: 'r1',
|
|
74
|
+
kind: 'internal',
|
|
75
|
+
provider: REVIEWERS.internal.defaultProvider,
|
|
76
|
+
model: REVIEWERS.internal.defaultModel,
|
|
77
|
+
effort: null,
|
|
78
|
+
enabled: true,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'r2',
|
|
82
|
+
kind: 'internal',
|
|
83
|
+
provider: REVIEWERS['internal-alt'].defaultProvider,
|
|
84
|
+
model: REVIEWERS['internal-alt'].defaultModel,
|
|
85
|
+
effort: null,
|
|
86
|
+
enabled: true,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
113
89
|
}
|
|
114
90
|
}
|
|
115
91
|
|
|
92
|
+
/** Признак записи. Короткий и без общих счётчиков: их сложно держать в файле. */
|
|
93
|
+
function freshId() {
|
|
94
|
+
return `r${randomBytes(4).toString('hex')}`
|
|
95
|
+
}
|
|
96
|
+
|
|
116
97
|
/**
|
|
117
|
-
*
|
|
98
|
+
* Привести прочитанное к ожидаемой форме: файл правит и человек руками.
|
|
118
99
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
100
|
+
* Понимает и ПРЕЖНЮЮ форму — `enabled` + `choices` + `mode`. Без этого
|
|
101
|
+
* переход на список молча обнулил бы настройки у всех, кто уже что-то
|
|
102
|
+
* выбрал: файл бы не разобрался, а пустой файл означает умолчания.
|
|
122
103
|
*/
|
|
123
|
-
export function
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
// `MODES[mode] !== undefined` пропускало унаследованные имена: у обычного
|
|
127
|
-
// объекта есть `constructor`, `toString` и прочие. Строка `"mode":
|
|
128
|
-
// "constructor"` в правленом файле принималась как допустимый режим, а
|
|
129
|
-
// нажатие любой кнопки уходило сюда и роняло маршрут: `spec.enabled` у
|
|
130
|
-
// функции-конструктора не перебирается.
|
|
131
|
-
const spec = Object.hasOwn(MODES, mode) ? MODES[mode] : undefined
|
|
132
|
-
if (spec === undefined) return settings
|
|
104
|
+
export function normalise(raw) {
|
|
105
|
+
if (raw === null || typeof raw !== 'object') return defaults()
|
|
133
106
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
107
|
+
const source = Array.isArray(raw.reviewers) ? raw.reviewers : migrate(raw)
|
|
108
|
+
if (source === null) return defaults()
|
|
109
|
+
|
|
110
|
+
const seen = new Set()
|
|
111
|
+
const reviewers = []
|
|
112
|
+
|
|
113
|
+
for (const entry of source) {
|
|
114
|
+
if (reviewers.length >= MAX_REVIEWERS) break
|
|
115
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
116
|
+
|
|
117
|
+
const kind = KNOWN.includes(entry.kind) ? entry.kind : null
|
|
118
|
+
if (kind === null) continue
|
|
119
|
+
|
|
120
|
+
const allowed = REVIEWERS[kind].kind === 'internal' ? SAFE_ROUTE_VALUE : SAFE_VALUE
|
|
121
|
+
const pick = (value) => {
|
|
122
|
+
if (typeof value !== 'string') return null
|
|
123
|
+
const trimmed = value.trim()
|
|
124
|
+
return trimmed !== '' && allowed.test(trimmed) ? trimmed : null
|
|
141
125
|
}
|
|
126
|
+
|
|
127
|
+
// Признак обязан быть уникальным: по нему интерфейс отличает строки, и
|
|
128
|
+
// совпадение слило бы две записи в одну при первой же правке.
|
|
129
|
+
let id = typeof entry.id === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(entry.id) ? entry.id : freshId()
|
|
130
|
+
while (seen.has(id)) id = freshId()
|
|
131
|
+
seen.add(id)
|
|
132
|
+
|
|
133
|
+
reviewers.push({
|
|
134
|
+
id,
|
|
135
|
+
kind,
|
|
136
|
+
// Провайдер имеет смысл только у наших: внешний продукт ходит своим
|
|
137
|
+
// путём, и наш маршрут ему не указ.
|
|
138
|
+
provider: REVIEWERS[kind].kind === 'internal' ? pick(entry.provider) : null,
|
|
139
|
+
model: pick(entry.model),
|
|
140
|
+
effort: pick(entry.effort),
|
|
141
|
+
enabled: entry.enabled !== false,
|
|
142
|
+
})
|
|
142
143
|
}
|
|
143
|
-
|
|
144
|
+
|
|
145
|
+
// Пустой список допустим: это осознанный выбор «никого не звать», и
|
|
146
|
+
// подменять его умолчаниями значило бы звать тех, кого выключили.
|
|
147
|
+
return { reviewers }
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
151
|
+
* Перевести настройки прежней формы в список.
|
|
152
|
+
*
|
|
153
|
+
* Прежде было: `enabled` — какие гнёзда включены, `choices` — настройка
|
|
154
|
+
* каждого гнезда. Переносим один к одному, сохраняя и выключенные: человек
|
|
155
|
+
* их настраивал, и терять эту работу при обновлении незачем.
|
|
156
|
+
*
|
|
157
|
+
* @returns записи в новой форме либо null, если прежней формы не видно.
|
|
149
158
|
*/
|
|
159
|
+
function migrate(raw) {
|
|
160
|
+
const hasOld = Array.isArray(raw.enabled) || (raw.choices !== null && typeof raw.choices === 'object')
|
|
161
|
+
if (!hasOld) return null
|
|
162
|
+
|
|
163
|
+
const enabled = new Set(Array.isArray(raw.enabled) ? raw.enabled : [])
|
|
164
|
+
const entries = []
|
|
165
|
+
|
|
166
|
+
for (const kind of KNOWN) {
|
|
167
|
+
const choice = raw.choices?.[kind]
|
|
168
|
+
const configured = choice !== undefined && choice !== null && typeof choice === 'object'
|
|
169
|
+
// Гнездо, которое не включали и не настраивали, переносить незачем:
|
|
170
|
+
// иначе список сразу открывается четырьмя пустыми строками.
|
|
171
|
+
if (!enabled.has(kind) && !configured) continue
|
|
172
|
+
entries.push({
|
|
173
|
+
id: freshId(),
|
|
174
|
+
kind,
|
|
175
|
+
provider: configured ? choice.provider : null,
|
|
176
|
+
model: configured ? choice.model : null,
|
|
177
|
+
effort: configured ? choice.effort : null,
|
|
178
|
+
enabled: enabled.has(kind),
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return entries
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Каталог профиля: поднимаемся, пока не найдём отметку об установке. */
|
|
150
186
|
function findProfileRoot(startDir) {
|
|
151
187
|
let dir = startDir
|
|
152
188
|
for (let depth = 0; depth < 10; depth += 1) {
|
|
@@ -161,90 +197,21 @@ function findProfileRoot(startDir) {
|
|
|
161
197
|
/**
|
|
162
198
|
* Где лежит файл настроек.
|
|
163
199
|
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
200
|
+
* Каталог ищется ТОЛЬКО от собственного расположения модуля. Запасной
|
|
201
|
+
* подъём от рабочей папки убран намеренно: подложенная в папку чата отметка
|
|
202
|
+
* об установке увела бы настройки в чужое дерево — в тот самый
|
|
203
|
+
* непроверенный код, который отдан на обзор. Две половины одного продукта
|
|
204
|
+
* не могут искать свой файл по разным правилам.
|
|
167
205
|
*
|
|
168
|
-
* @param cwd -
|
|
206
|
+
* @param cwd - оставлен ради совместимости вызовов; не используется.
|
|
207
|
+
* @param root - явный каталог; только для проверок.
|
|
169
208
|
*/
|
|
170
209
|
export function settingsPath(cwd, root = null) {
|
|
171
210
|
if (root !== null) return join(root, FILE_NAME)
|
|
172
|
-
// Каталог ищется ТОЛЬКО от собственного расположения модуля.
|
|
173
|
-
//
|
|
174
|
-
// Запасной подъём от рабочей папки убран намеренно: журнал рядом прямо
|
|
175
|
-
// объявляет его опасным, и справедливо — подложенная в папку чата отметка
|
|
176
|
-
// об установке увела бы настройки в чужое дерево, то есть в тот самый
|
|
177
|
-
// непроверенный код, который отдан на обзор. Две половины одного продукта
|
|
178
|
-
// не могут искать свой файл по разным правилам.
|
|
179
211
|
const found = findProfileRoot(dirname(fileURLToPath(import.meta.url)))
|
|
180
212
|
return found === null ? null : join(found, FILE_NAME)
|
|
181
213
|
}
|
|
182
214
|
|
|
183
|
-
/** Привести прочитанное к ожидаемой форме: файл правит человек, и правит с ошибками. */
|
|
184
|
-
export function normalise(raw) {
|
|
185
|
-
const base = defaults()
|
|
186
|
-
if (raw === null || typeof raw !== 'object') return base
|
|
187
|
-
|
|
188
|
-
// Повторы отбрасываются: список запускает по процессу на элемент, и сотня
|
|
189
|
-
// одинаковых имён означала бы сотню платных обзоров разом.
|
|
190
|
-
const enabled = Array.isArray(raw.enabled)
|
|
191
|
-
? [...new Set(raw.enabled.filter((kind) => KNOWN.includes(kind)))]
|
|
192
|
-
: base.enabled
|
|
193
|
-
|
|
194
|
-
const choices = {}
|
|
195
|
-
for (const kind of KNOWN) {
|
|
196
|
-
const source = raw.choices !== null && typeof raw.choices === 'object' ? raw.choices[kind] : undefined
|
|
197
|
-
// Недопустимое значение отбрасывается молча и превращается в «как
|
|
198
|
-
// настроено»: сохранить его значило бы выключить обзор до тех пор, пока
|
|
199
|
-
// кто-нибудь не догадается открыть файл.
|
|
200
|
-
//
|
|
201
|
-
// Наш ревизор ходит по маршруту, где имена содержат косую черту
|
|
202
|
-
// (`x-ai/grok-4.6`), внешние — по командной строке, где её быть не
|
|
203
|
-
// должно. Одна общая проверка отвергала бы половину моделей.
|
|
204
|
-
const allowed = kind === 'internal' ? SAFE_ROUTE_VALUE : SAFE_VALUE
|
|
205
|
-
const pick = (value) => {
|
|
206
|
-
if (typeof value !== 'string') return null
|
|
207
|
-
const trimmed = value.trim()
|
|
208
|
-
return trimmed !== '' && allowed.test(trimmed) ? trimmed : null
|
|
209
|
-
}
|
|
210
|
-
choices[kind] = {
|
|
211
|
-
provider: pick(source?.provider),
|
|
212
|
-
model: pick(source?.model),
|
|
213
|
-
effort: pick(source?.effort),
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Режим ВСЕГДА выводится из состава, а не берётся из файла.
|
|
218
|
-
//
|
|
219
|
-
// Прежде сохранённое имя принималось на веру и никогда не сверялось с
|
|
220
|
-
// `enabled`, хотя соседний комментарий обещал ровно эту сверку. После
|
|
221
|
-
// ручной правки состава человек видел подсвеченным «Быстрый — один
|
|
222
|
-
// ревизор, минуты», а запускались трое на полном усилии: счёт и время
|
|
223
|
-
// втрое против показанного. Состав — факт, режим — его имя; имя не может
|
|
224
|
-
// противоречить факту.
|
|
225
|
-
return { mode: matchMode(enabled), enabled, choices }
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Узнать режим по составу ревизоров.
|
|
230
|
-
*
|
|
231
|
-
* Нужно, когда состав правили поштучно: режим в файле мог остаться от
|
|
232
|
-
* прошлого выбора и врал бы в интерфейсе. Не совпал ни с одним — значит
|
|
233
|
-
* набор свой, так и покажем.
|
|
234
|
-
*/
|
|
235
|
-
function matchMode(enabled) {
|
|
236
|
-
const same = (a, b) => a.length === b.length && a.every((item) => b.includes(item))
|
|
237
|
-
for (const [name, spec] of Object.entries(MODES)) {
|
|
238
|
-
if (same(spec.enabled, enabled)) return name
|
|
239
|
-
}
|
|
240
|
-
return null
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Есть ли такой режим. Только собственные ключи — см. `applyMode`. */
|
|
244
|
-
export function isMode(name) {
|
|
245
|
-
return typeof name === 'string' && Object.hasOwn(MODES, name)
|
|
246
|
-
}
|
|
247
|
-
|
|
248
215
|
/**
|
|
249
216
|
* Очередь записи на каждый файл.
|
|
250
217
|
*
|
|
@@ -253,35 +220,26 @@ export function isMode(name) {
|
|
|
253
220
|
* своё: побеждает последний, остальные исчезают бесследно. Проверено —
|
|
254
221
|
* двенадцать одновременных записей давали одну.
|
|
255
222
|
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* момент, когда завершается второй обзор.
|
|
259
|
-
*
|
|
260
|
-
* Очередь внутрипроцессная, и этого достаточно: оба пути проходят через
|
|
261
|
-
* один Host. От двух ОДНОВРЕМЕННО ЗАПУЩЕННЫХ приложений она не спасает —
|
|
262
|
-
* такой случай потребовал бы файловой блокировки, а он у нас невозможен:
|
|
263
|
-
* второй экземпляр не поднимется на занятом порту.
|
|
223
|
+
* Очередь внутрипроцессная, и этого достаточно: оба пути — Host и браузер —
|
|
224
|
+
* проходят через один процесс.
|
|
264
225
|
*/
|
|
265
226
|
const queues = new Map()
|
|
266
227
|
|
|
267
228
|
function serialise(path, work) {
|
|
268
229
|
const previous = queues.get(path) ?? Promise.resolve()
|
|
269
230
|
const next = previous.then(work, work)
|
|
270
|
-
// В очереди держим
|
|
271
|
-
//
|
|
231
|
+
// В очереди держим только факт завершения: иначе одна неудачная запись
|
|
232
|
+
// уронила бы все следующие.
|
|
272
233
|
queues.set(path, next.then(() => undefined, () => undefined))
|
|
273
234
|
return next
|
|
274
235
|
}
|
|
236
|
+
|
|
275
237
|
/**
|
|
276
238
|
* Переименовать с повтором.
|
|
277
239
|
*
|
|
278
240
|
* На Windows одновременное переименование двух временных файлов в одну цель
|
|
279
241
|
* изредка отказывает с EPERM или EBUSY: файл в этот момент держит чужая
|
|
280
|
-
* операция. Это не ошибка вызывающего и не повод терять
|
|
281
|
-
* повторить через несколько миллисекунд.
|
|
282
|
-
*
|
|
283
|
-
* Повторяются только эти два кода. Всё прочее — настоящая ошибка (нет прав
|
|
284
|
-
* на каталог, кончилось место), и глотать её нельзя.
|
|
242
|
+
* операция. Это не ошибка вызывающего и не повод терять запись.
|
|
285
243
|
*/
|
|
286
244
|
async function renameWithRetry(from, to, attempts = 5) {
|
|
287
245
|
for (let attempt = 1; ; attempt += 1) {
|
|
@@ -295,6 +253,7 @@ async function renameWithRetry(from, to, attempts = 5) {
|
|
|
295
253
|
}
|
|
296
254
|
}
|
|
297
255
|
}
|
|
256
|
+
|
|
298
257
|
/** Прочитать настройки. Отсутствие файла — не ошибка, а «ничего не выбрано». */
|
|
299
258
|
export async function readSettings(cwd, root = null) {
|
|
300
259
|
const path = settingsPath(cwd, root)
|
|
@@ -310,10 +269,10 @@ export async function readSettings(cwd, root = null) {
|
|
|
310
269
|
/**
|
|
311
270
|
* Записать настройки. Возвращает то, что действительно сохранено.
|
|
312
271
|
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
272
|
+
* Присланное ЗАМЕЩАЕТ список целиком, и это правильно именно для списка:
|
|
273
|
+
* удаление записи иначе было бы невыразимо — слияние всегда возвращало бы
|
|
274
|
+
* удалённое обратно. Ответственность за целость списка лежит на том, кто
|
|
275
|
+
* его прислал, а интерфейс всегда шлёт его полностью.
|
|
317
276
|
*/
|
|
318
277
|
export async function writeSettings(cwd, value, root = null) {
|
|
319
278
|
return serialise(settingsPath(cwd, root) ?? 'нет-пути', () => writeSettingsNow(cwd, value, root))
|
|
@@ -323,36 +282,9 @@ async function writeSettingsNow(cwd, value, root) {
|
|
|
323
282
|
const path = settingsPath(cwd, root)
|
|
324
283
|
if (path === null) throw new Error('не найден каталог профиля — некуда сохранить настройки обзора')
|
|
325
284
|
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
// Прежде маршрут писал объект целиком. Две открытые вкладки настроек — и
|
|
329
|
-
// та, что открыта дольше, одним нажатием галочки возвращала старые модели
|
|
330
|
-
// и усилия: она шлёт свой устаревший снимок целиком. Любой отправитель,
|
|
331
|
-
// приславший только `enabled`, стирал ВЕСЬ выбор моделей без единого слова.
|
|
332
|
-
//
|
|
333
|
-
// Сливаем поштучно: пришло поле — берём пришедшее, не пришло — оставляем
|
|
334
|
-
// сохранённое.
|
|
335
|
-
const current = await readSettings(cwd, root)
|
|
336
|
-
const merged = {
|
|
337
|
-
enabled: Array.isArray(value?.enabled) ? value.enabled : current.enabled,
|
|
338
|
-
choices: Object.fromEntries(
|
|
339
|
-
KNOWN.map((kind) => {
|
|
340
|
-
const was = current.choices[kind] ?? { provider: null, model: null, effort: null }
|
|
341
|
-
const came = value?.choices?.[kind]
|
|
342
|
-
if (came === undefined || came === null || typeof came !== 'object') return [kind, was]
|
|
343
|
-
return [kind, {
|
|
344
|
-
provider: 'provider' in came ? came.provider : was.provider,
|
|
345
|
-
model: 'model' in came ? came.model : was.model,
|
|
346
|
-
effort: 'effort' in came ? came.effort : was.effort,
|
|
347
|
-
}]
|
|
348
|
-
}),
|
|
349
|
-
),
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const normalised = normalise(merged)
|
|
285
|
+
const normalised = normalise(value)
|
|
353
286
|
// Уникальное имя временного файла: у двух одновременных записей прежнее
|
|
354
|
-
// совпадало, и вторая получала отказ на
|
|
355
|
-
// унесла общий файл.
|
|
287
|
+
// совпадало, и вторая получала отказ на переименовании.
|
|
356
288
|
const temporary = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
|
|
357
289
|
try {
|
|
358
290
|
await writeFile(temporary, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
|
package/lib/review.js
CHANGED
|
@@ -292,17 +292,17 @@ export function parseFindings(text, reviewer) {
|
|
|
292
292
|
* Отказ одного НЕ отменяет обзор: половина ответа полезнее отказа, и
|
|
293
293
|
* пользователь должен видеть, кто именно не ответил.
|
|
294
294
|
*/
|
|
295
|
-
async function askReviewer(ctx, runReviewer, kind, prompt, cwd,
|
|
295
|
+
async function askReviewer(ctx, runReviewer, kind, prompt, cwd, entry, signal) {
|
|
296
296
|
const outcome = await runReviewer(ctx, kind, {
|
|
297
297
|
prompt,
|
|
298
298
|
cwd,
|
|
299
|
-
provider:
|
|
300
|
-
model:
|
|
301
|
-
effort:
|
|
299
|
+
provider: entry?.provider ?? null,
|
|
300
|
+
model: entry?.model ?? null,
|
|
301
|
+
effort: entry?.effort ?? null,
|
|
302
302
|
signal,
|
|
303
303
|
})
|
|
304
304
|
|
|
305
|
-
const used = { model:
|
|
305
|
+
const used = { model: entry?.model ?? null, effort: entry?.effort ?? null }
|
|
306
306
|
|
|
307
307
|
if (outcome.failed !== null) {
|
|
308
308
|
return { provider: kind, failed: outcome.failed, cancelled: outcome.cancelled === true, findings: [], ...used }
|
|
@@ -458,47 +458,57 @@ export async function apply(ctx) {
|
|
|
458
458
|
}
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
-
//
|
|
461
|
+
// Состав целиком из настроек: список, который человек собрал сам.
|
|
462
462
|
const settings = await readSettings(cwd)
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
return {
|
|
463
|
+
let chosen = settings.reviewers.filter((entry) => entry.enabled)
|
|
464
|
+
|
|
465
|
+
// Вызов может сузить состав по виду ревизора — например «только
|
|
466
|
+
// Codex». Это именно СУЖЕНИЕ настроенного, а не свой набор: модель
|
|
467
|
+
// и усилие всё равно берутся из настроек, потому что их выбирал
|
|
468
|
+
// человек, а не тот, кто зовёт инструмент.
|
|
469
|
+
if (Array.isArray(args.reviewers) && args.reviewers.length > 0) {
|
|
470
|
+
const kinds = new Set(args.reviewers.filter((kind) => KNOWN_REVIEWERS.includes(kind)))
|
|
471
|
+
if (kinds.size > 0) chosen = chosen.filter((entry) => kinds.has(entry.kind))
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (chosen.length === 0) {
|
|
475
|
+
const anyConfigured = settings.reviewers.length > 0
|
|
476
|
+
return {
|
|
477
|
+
findings: [],
|
|
478
|
+
reviewers: [],
|
|
479
|
+
problem: anyConfigured
|
|
480
|
+
? 'ни один ревизор не включён — включите нужных в настройках'
|
|
481
|
+
: 'ревизоры не настроены — добавьте хотя бы одного в настройках',
|
|
482
|
+
}
|
|
477
483
|
}
|
|
478
484
|
|
|
479
485
|
const prompt = buildPrompt(args)
|
|
480
486
|
|
|
481
|
-
// Спрашиваем
|
|
482
|
-
//
|
|
487
|
+
// Спрашиваем ВСЕХ разом: они независимы, и последовательный опрос
|
|
488
|
+
// умножил бы ожидание без всякой пользы.
|
|
483
489
|
const answers = await Promise.all(
|
|
484
|
-
|
|
490
|
+
chosen.map((entry) =>
|
|
485
491
|
askReviewer(
|
|
486
492
|
// Наш ревизор заводит подагента, и тому нужен родитель —
|
|
487
493
|
// агент, от имени которого идёт вызов. Внешним он не нужен.
|
|
488
494
|
{ ...ctx, get: (name) => ctx.get(name), reviewParent: exec.agent },
|
|
489
495
|
runReviewer,
|
|
490
|
-
kind,
|
|
496
|
+
entry.kind,
|
|
491
497
|
prompt,
|
|
492
498
|
cwd,
|
|
493
|
-
|
|
499
|
+
entry,
|
|
494
500
|
exec.signal,
|
|
495
501
|
),
|
|
496
502
|
),
|
|
497
503
|
)
|
|
498
504
|
|
|
499
505
|
const findings = ordered(answers.flatMap((entry) => entry.findings))
|
|
500
|
-
const reviewers = answers.map((entry) => ({
|
|
506
|
+
const reviewers = answers.map((entry, index) => ({
|
|
501
507
|
provider: entry.provider,
|
|
508
|
+
// Признак записи сохраняется вместе с итогом: в списке может быть
|
|
509
|
+
// несколько ревизоров одного вида на разных моделях, и без него
|
|
510
|
+
// статистика слила бы их в одну строку.
|
|
511
|
+
slot: chosen[index].id,
|
|
502
512
|
failed: entry.failed,
|
|
503
513
|
cancelled: entry.cancelled === true,
|
|
504
514
|
found: entry.findings.length,
|
package/package.json
CHANGED