tensorgrid-ui 1.3.0 → 1.4.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 +205 -1
- package/lib/http-runtime.js +54 -0
- package/lib/index.js +15 -0
- package/lib/review-settings.js +141 -0
- package/lib/review.js +143 -65
- package/lib/reviewers.js +256 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -177,6 +177,14 @@ 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.nav': 'Reviewers',
|
|
181
|
+
'review.title': 'Second opinion',
|
|
182
|
+
'review.hint': 'Independent reviewers examine finished work and report what is wrong. They run as separate products and do not see this conversation.',
|
|
183
|
+
'review.model': 'Model',
|
|
184
|
+
'review.effort': 'Effort',
|
|
185
|
+
'review.asConfigured': 'as configured',
|
|
186
|
+
'review.none': 'No reviewer is selected — a review will find nothing.',
|
|
187
|
+
'review.note': 'Two reviewers are enabled by default because they find different things: asking one guarantees missing half. Leaving a model empty uses whatever that product is already set up with.',
|
|
180
188
|
'auth.nav': 'Subscriptions',
|
|
181
189
|
'auth.title': 'Provider sign-in',
|
|
182
190
|
'auth.others': 'Only providers offering a subscription sign-in are listed. The rest — Z.AI among them — offer an API key only; configure those under Models.',
|
|
@@ -224,6 +232,14 @@ window.__ModuleLoader__.load({
|
|
|
224
232
|
'intensity.vivid': '强烈',
|
|
225
233
|
'accent.title': 'Obsidian / Ion 主色',
|
|
226
234
|
'accent.hint': '界面与光晕的主色调',
|
|
235
|
+
'review.nav': '审阅者',
|
|
236
|
+
'review.title': '第二意见',
|
|
237
|
+
'review.hint': '独立审阅者检查已完成的工作并报告问题。它们作为独立产品运行,看不到本次对话。',
|
|
238
|
+
'review.model': '模型',
|
|
239
|
+
'review.effort': '推理强度',
|
|
240
|
+
'review.asConfigured': '按产品设置',
|
|
241
|
+
'review.none': '未选择任何审阅者——审阅将一无所获。',
|
|
242
|
+
'review.note': '默认启用两位审阅者,因为他们发现的问题不同:只用一位必然漏掉一半。模型留空则沿用该产品自身的设置。',
|
|
227
243
|
'auth.nav': '订阅',
|
|
228
244
|
'auth.title': '登录服务商',
|
|
229
245
|
'auth.others': '这里只列出提供订阅登录的服务商。其余(包括 Z.AI)只提供 API 密钥,请在「模型」中配置。',
|
|
@@ -1568,6 +1584,14 @@ window.__ModuleLoader__.load({
|
|
|
1568
1584
|
'intensity.vivid': 'Ярко',
|
|
1569
1585
|
'accent.title': 'Акцент Obsidian / Ion',
|
|
1570
1586
|
'accent.hint': 'Ведущий тон интерфейса и свечения',
|
|
1587
|
+
'review.nav': 'Ревизоры',
|
|
1588
|
+
'review.title': 'Второе мнение',
|
|
1589
|
+
'review.hint': 'Независимые ревизоры смотрят готовую работу и сообщают, что в ней не так. Работают как отдельные продукты и нашей переписки не видят.',
|
|
1590
|
+
'review.model': 'Модель',
|
|
1591
|
+
'review.effort': 'Усилие',
|
|
1592
|
+
'review.asConfigured': 'как настроено',
|
|
1593
|
+
'review.none': 'Не выбран ни один ревизор — обзор ничего не найдёт.',
|
|
1594
|
+
'review.note': 'По умолчанию включены оба: они находят разное, и один гарантированно упускает половину. Пустая модель означает «как настроено у самого продукта».',
|
|
1571
1595
|
'auth.nav': 'Подписки',
|
|
1572
1596
|
'auth.title': 'Вход к провайдерам',
|
|
1573
1597
|
'auth.others': 'Здесь только провайдеры, у которых есть вход по подписке. Остальные — в том числе Z.AI — предлагают лишь ключ API; их место в разделе «Модели».',
|
|
@@ -1716,7 +1740,7 @@ window.__ModuleLoader__.load({
|
|
|
1716
1740
|
// поэтому строка остаётся согласованной при любой палитре.
|
|
1717
1741
|
'.dsx-setting{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;}',
|
|
1718
1742
|
'.dsx-setting__title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;}',
|
|
1719
|
-
'.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;}',
|
|
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;}',
|
|
1720
1744
|
'.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;}',
|
|
1721
1745
|
'.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;}',
|
|
1722
1746
|
'.dsx-seg:hover{color:var(--dsw-alias-label-primary);}',
|
|
@@ -2217,6 +2241,171 @@ window.__ModuleLoader__.load({
|
|
|
2217
2241
|
})
|
|
2218
2242
|
}
|
|
2219
2243
|
|
|
2244
|
+
// ── Ревизоры ─────────────────────────────────────────────────────────
|
|
2245
|
+
//
|
|
2246
|
+
// Раздел настроек, где выбирают, кто смотрит работу вторым мнением,
|
|
2247
|
+
// какой моделью и с каким усилием.
|
|
2248
|
+
//
|
|
2249
|
+
// Справочник моделей приходит с Host, а не зашит здесь: иначе список
|
|
2250
|
+
// расширят в одной половине и забудут в другой. Пустой выбор модели
|
|
2251
|
+
// означает «как настроено у самого продукта» — человек уже настроил
|
|
2252
|
+
// свой Claude Code и Codex, и навязывать поверх нечего.
|
|
2253
|
+
const REVIEW_PATH = '/api/tensorgrid.review'
|
|
2254
|
+
|
|
2255
|
+
function ReviewRow({ t }) {
|
|
2256
|
+
const [state, setState] = React.useState(null)
|
|
2257
|
+
const [busy, setBusy] = React.useState(false)
|
|
2258
|
+
|
|
2259
|
+
const translate = React.useRef(t)
|
|
2260
|
+
translate.current = t
|
|
2261
|
+
|
|
2262
|
+
const send = React.useCallback(async (body) => {
|
|
2263
|
+
const response = body === undefined
|
|
2264
|
+
? await fetch(REVIEW_PATH)
|
|
2265
|
+
: await fetch(REVIEW_PATH, {
|
|
2266
|
+
method: 'POST',
|
|
2267
|
+
headers: { 'content-type': 'application/json' },
|
|
2268
|
+
body: JSON.stringify(body),
|
|
2269
|
+
})
|
|
2270
|
+
const type = response.headers.get('content-type') || ''
|
|
2271
|
+
if (!type.includes('application/json')) {
|
|
2272
|
+
const say = translate.current
|
|
2273
|
+
return {
|
|
2274
|
+
catalogue: [],
|
|
2275
|
+
problem: response.status === 401 || response.status === 404
|
|
2276
|
+
? say('auth.needsRestart')
|
|
2277
|
+
: say('auth.badResponse').replace('{status}', String(response.status)),
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
return response.json()
|
|
2281
|
+
}, [])
|
|
2282
|
+
|
|
2283
|
+
const refresh = React.useCallback(async (body) => {
|
|
2284
|
+
setBusy(true)
|
|
2285
|
+
try {
|
|
2286
|
+
const next = await send(body)
|
|
2287
|
+
// Ответ без каталога — это отказ, а не новое состояние.
|
|
2288
|
+
//
|
|
2289
|
+
// Раньше состояние заменялось целиком, и одно неудачное нажатие
|
|
2290
|
+
// галочки убирало все карточки: ни повторить, ни увидеть выбранное
|
|
2291
|
+
// было нельзя, помогала только перезагрузка. Ту же ошибку я уже
|
|
2292
|
+
// допускал в строке входа и там же чинил.
|
|
2293
|
+
setState((previous) => {
|
|
2294
|
+
const complete = Array.isArray(next.catalogue) && next.catalogue.length > 0
|
|
2295
|
+
if (complete) return next
|
|
2296
|
+
return previous === null ? next : { ...previous, problem: next.problem ?? 'сохранить не удалось' }
|
|
2297
|
+
})
|
|
2298
|
+
} catch (error) {
|
|
2299
|
+
const text = String(error && error.message ? error.message : error)
|
|
2300
|
+
setState((previous) => (previous === null ? { catalogue: [], problem: text } : { ...previous, problem: text }))
|
|
2301
|
+
} finally {
|
|
2302
|
+
setBusy(false)
|
|
2303
|
+
}
|
|
2304
|
+
}, [send])
|
|
2305
|
+
|
|
2306
|
+
React.useEffect(() => { refresh() }, [refresh])
|
|
2307
|
+
|
|
2308
|
+
const catalogue = state !== null && Array.isArray(state.catalogue) ? state.catalogue : []
|
|
2309
|
+
const settings = state !== null && state.settings ? state.settings : null
|
|
2310
|
+
|
|
2311
|
+
// Изменение любого поля сразу сохраняется: отдельная кнопка «применить»
|
|
2312
|
+
// добавляет состояние «изменено, но не сохранено», в котором легко
|
|
2313
|
+
// забыть нажать и потом удивляться.
|
|
2314
|
+
const save = React.useCallback((next) => { refresh(next) }, [refresh])
|
|
2315
|
+
|
|
2316
|
+
const toggle = (kind) => {
|
|
2317
|
+
if (settings === null) return
|
|
2318
|
+
const on = settings.enabled.includes(kind)
|
|
2319
|
+
const enabled = on ? settings.enabled.filter((k) => k !== kind) : [...settings.enabled, kind]
|
|
2320
|
+
save({ ...settings, enabled })
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
const pick = (kind, field, value) => {
|
|
2324
|
+
if (settings === null) return
|
|
2325
|
+
const choices = { ...settings.choices, [kind]: { ...settings.choices[kind], [field]: value } }
|
|
2326
|
+
save({ ...settings, choices })
|
|
2327
|
+
}
|
|
2328
|
+
|
|
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
|
+
})
|
|
2340
|
+
|
|
2341
|
+
return jsxs('div', {
|
|
2342
|
+
className: 'dsx-subs',
|
|
2343
|
+
children: [
|
|
2344
|
+
jsxs('div', {
|
|
2345
|
+
className: 'dsx-subs__head',
|
|
2346
|
+
children: [
|
|
2347
|
+
jsx('div', { className: 'dsx-subs__title', children: t('review.title') }),
|
|
2348
|
+
jsx('div', { className: 'dsx-setting__hint', children: t('review.hint') }),
|
|
2349
|
+
],
|
|
2350
|
+
}),
|
|
2351
|
+
state !== null && state.problem
|
|
2352
|
+
? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: state.problem })
|
|
2353
|
+
: null,
|
|
2354
|
+
jsx('div', {
|
|
2355
|
+
className: 'dsx-rev__list',
|
|
2356
|
+
children: catalogue.map((entry) => {
|
|
2357
|
+
const on = settings !== null && settings.enabled.includes(entry.kind)
|
|
2358
|
+
// Запись может отсутствовать, если списки Host и браузера
|
|
2359
|
+
// разойдутся. Раньше следующая строка бросала исключение прямо
|
|
2360
|
+
// в отрисовке, и падал ВЕСЬ раздел настроек, а не только наша
|
|
2361
|
+
// карточка.
|
|
2362
|
+
const stored = settings === null ? null : settings.choices[entry.kind]
|
|
2363
|
+
const choice = stored ?? { model: null, effort: null }
|
|
2364
|
+
return jsxs('div', {
|
|
2365
|
+
className: 'dsx-rev__card' + (on ? ' dsx-rev__card--on' : ''),
|
|
2366
|
+
children: [
|
|
2367
|
+
jsxs('label', {
|
|
2368
|
+
className: 'dsx-rev__head',
|
|
2369
|
+
children: [
|
|
2370
|
+
jsx('input', {
|
|
2371
|
+
type: 'checkbox',
|
|
2372
|
+
checked: on,
|
|
2373
|
+
disabled: busy,
|
|
2374
|
+
onChange: () => toggle(entry.kind),
|
|
2375
|
+
}),
|
|
2376
|
+
jsx('span', { className: 'dsx-rev__name', children: entry.label }),
|
|
2377
|
+
],
|
|
2378
|
+
}),
|
|
2379
|
+
jsxs('div', {
|
|
2380
|
+
className: 'dsx-rev__fields',
|
|
2381
|
+
children: [
|
|
2382
|
+
jsxs('label', {
|
|
2383
|
+
className: 'dsx-rev__field',
|
|
2384
|
+
children: [
|
|
2385
|
+
jsx('span', { children: t('review.model') }),
|
|
2386
|
+
selector(entry.kind, 'model', entry.models, choice.model),
|
|
2387
|
+
],
|
|
2388
|
+
}),
|
|
2389
|
+
jsxs('label', {
|
|
2390
|
+
className: 'dsx-rev__field',
|
|
2391
|
+
children: [
|
|
2392
|
+
jsx('span', { children: t('review.effort') }),
|
|
2393
|
+
selector(entry.kind, 'effort', entry.efforts, choice.effort),
|
|
2394
|
+
],
|
|
2395
|
+
}),
|
|
2396
|
+
],
|
|
2397
|
+
}),
|
|
2398
|
+
],
|
|
2399
|
+
}, entry.kind)
|
|
2400
|
+
}),
|
|
2401
|
+
}),
|
|
2402
|
+
settings !== null && settings.enabled.length === 0
|
|
2403
|
+
? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: t('review.none') })
|
|
2404
|
+
: null,
|
|
2405
|
+
jsx('div', { className: 'dsx-subs__note', children: t('review.note') }),
|
|
2406
|
+
],
|
|
2407
|
+
})
|
|
2408
|
+
}
|
|
2220
2409
|
// ── Обновления ───────────────────────────────────────────────────────
|
|
2221
2410
|
//
|
|
2222
2411
|
// Маршрут поднимает host-половина пакета. Путь относительный, токен не
|
|
@@ -2444,6 +2633,21 @@ window.__ModuleLoader__.load({
|
|
|
2444
2633
|
),
|
|
2445
2634
|
)
|
|
2446
2635
|
|
|
2636
|
+
// Ревизоры — соседний раздел: и подписки, и обзор про то, чем и как
|
|
2637
|
+
// работает агент, а не про внешний вид.
|
|
2638
|
+
ctx.slots.inject('settings.section', () =>
|
|
2639
|
+
ctx.slots.register(
|
|
2640
|
+
{
|
|
2641
|
+
name: 'settings.section',
|
|
2642
|
+
id: 'tensorgrid-review',
|
|
2643
|
+
order: 13,
|
|
2644
|
+
locale: LOCALE_NS,
|
|
2645
|
+
label: () => ctx.locale.translate(LOCALE_NS, 'review.nav'),
|
|
2646
|
+
},
|
|
2647
|
+
ReviewRow,
|
|
2648
|
+
),
|
|
2649
|
+
)
|
|
2650
|
+
|
|
2447
2651
|
ctx.slots.inject('settings.general.item', () =>
|
|
2448
2652
|
ctx.slots.register(
|
|
2449
2653
|
{ name: 'settings.general.item', id: 'obsidian-ion-update', order: 11, locale: LOCALE_NS },
|
package/lib/http-runtime.js
CHANGED
|
@@ -215,6 +215,60 @@ export async function handleUpdate(req, res, state, loadModule) {
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Обслужить `/api/tensorgrid.review` — настройки обзора.
|
|
220
|
+
*
|
|
221
|
+
* GET отдаёт выбранное и справочник для показа: кого можно звать, какие
|
|
222
|
+
* модели и усилия у каждого. Справочник приходит с Host, а не зашит в
|
|
223
|
+
* браузер: иначе список расширят в одной половине и забудут в другой.
|
|
224
|
+
*
|
|
225
|
+
* POST сохраняет выбор. Форма проверяется на Host — файл правит и человек
|
|
226
|
+
* руками, и надеяться, что дошло только отправленное браузером, нельзя.
|
|
227
|
+
*/
|
|
228
|
+
export async function handleReview(req, res, loadModule) {
|
|
229
|
+
try {
|
|
230
|
+
const settings = await loadModule('./review-settings.js')
|
|
231
|
+
const { REVIEWERS } = await loadModule('./reviewers.js')
|
|
232
|
+
|
|
233
|
+
const catalogue = Object.entries(REVIEWERS).map(([kind, spec]) => ({
|
|
234
|
+
kind,
|
|
235
|
+
label: spec.label,
|
|
236
|
+
models: spec.models,
|
|
237
|
+
efforts: spec.efforts,
|
|
238
|
+
}))
|
|
239
|
+
|
|
240
|
+
if (req.method === 'GET') {
|
|
241
|
+
reply(res, 200, { catalogue, settings: await settings.readSettings(null) })
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (req.method !== 'POST') {
|
|
246
|
+
reply(res, 405, { problem: 'поддерживаются GET и POST' })
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!acceptsJson(req)) {
|
|
251
|
+
reply(res, 415, { problem: 'ожидается content-type: application/json' })
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!sameOrigin(req)) {
|
|
256
|
+
reply(res, 403, { problem: 'запрос с чужой страницы' })
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const body = await readJsonBody(req)
|
|
261
|
+
if (body === null) {
|
|
262
|
+
reply(res, 400, { problem: 'тело запроса не разобрано' })
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
reply(res, 200, { catalogue, settings: await settings.writeSettings(null, body) })
|
|
267
|
+
} catch (error) {
|
|
268
|
+
reply(res, 500, { problem: String(error?.message ?? error) })
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
218
272
|
/** Обслужить `/api/tensorgrid.auth`. */
|
|
219
273
|
export async function handleAuth(req, res, services, loadModule) {
|
|
220
274
|
try {
|
package/lib/index.js
CHANGED
|
@@ -240,6 +240,21 @@ export function apply(ctx) {
|
|
|
240
240
|
}),
|
|
241
241
|
)
|
|
242
242
|
|
|
243
|
+
web.effect(() =>
|
|
244
|
+
web.webServer.register({
|
|
245
|
+
kind: 'exact',
|
|
246
|
+
path: '/api/tensorgrid.review',
|
|
247
|
+
async handler(req, res) {
|
|
248
|
+
try {
|
|
249
|
+
const runtime = await loadModule('./http-runtime.js')
|
|
250
|
+
await runtime.handleReview(req, res, loadModule)
|
|
251
|
+
} catch (error) {
|
|
252
|
+
fail(res, error)
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
)
|
|
257
|
+
|
|
243
258
|
web.effect(() =>
|
|
244
259
|
web.webServer.register({
|
|
245
260
|
kind: 'exact',
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Настройки обзора: кого звать, какой моделью и с каким усилием.
|
|
3
|
+
*
|
|
4
|
+
* Хранятся файлом рядом с профилем, а не в браузере. Причина простая:
|
|
5
|
+
* выбирает человек в интерфейсе, а читает Host в момент вызова инструмента —
|
|
6
|
+
* значит хранилище должно быть общим для обеих сторон. Память браузера для
|
|
7
|
+
* этого не годится, Host её не видит.
|
|
8
|
+
*
|
|
9
|
+
* Файл лежит там же, где отметка об установке, и ищется тем же подъёмом
|
|
10
|
+
* вверх: так путь не зависит от того, насколько глубоко pnpm разложил пакет.
|
|
11
|
+
*
|
|
12
|
+
* Импортируются только встроенные модули Node.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, rename, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { existsSync } from 'node:fs'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
const FILE_NAME = 'tensorgrid-review.json'
|
|
20
|
+
|
|
21
|
+
/** Ревизоры, которых мы умеем звать. Порядок — порядок показа. */
|
|
22
|
+
export const KNOWN = ['claude-code', 'codex']
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Что допустимо в модели и усилии.
|
|
26
|
+
*
|
|
27
|
+
* Те же правила, что и при запуске, но применяются ЗДЕСЬ, при сохранении.
|
|
28
|
+
* Раньше проверка жила только в момент вызова: строка вроде «gpt 5 (новая)»
|
|
29
|
+
* спокойно сохранялась и отвергалась через двадцать минут, при следующем
|
|
30
|
+
* обзоре. В интерфейсе это было невидимо — список с неизвестным значением
|
|
31
|
+
* показывает пустое поле, неотличимое от «как настроено».
|
|
32
|
+
*/
|
|
33
|
+
const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Что действует, пока пользователь ничего не выбрал.
|
|
37
|
+
*
|
|
38
|
+
* Оба ревизора включены намеренно: они находят РАЗНОЕ, и один по умолчанию
|
|
39
|
+
* означал бы, что половина находок не появится у того, кто в настройки не
|
|
40
|
+
* заглядывал. Модель и усилие пустые — берутся собственные настройки
|
|
41
|
+
* продукта, то есть то, чем человек уже пользуется.
|
|
42
|
+
*/
|
|
43
|
+
export function defaults() {
|
|
44
|
+
return {
|
|
45
|
+
enabled: [...KNOWN],
|
|
46
|
+
choices: { 'claude-code': { model: null, effort: null }, codex: { model: null, effort: null } },
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Каталог профиля: поднимаемся, пока не найдём отметку об установке.
|
|
52
|
+
* @param startDir - откуда начинать подъём.
|
|
53
|
+
*/
|
|
54
|
+
function findProfileRoot(startDir) {
|
|
55
|
+
let dir = startDir
|
|
56
|
+
for (let depth = 0; depth < 10; depth += 1) {
|
|
57
|
+
if (existsSync(join(dir, 'tensorgrid-installed.json'))) return dir
|
|
58
|
+
const parent = dirname(dir)
|
|
59
|
+
if (parent === dir) break
|
|
60
|
+
dir = parent
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Где лежит файл настроек.
|
|
67
|
+
*
|
|
68
|
+
* Сначала ищем от собственного расположения — так работает установленный
|
|
69
|
+
* пакет. Если отметки нет (запуск из репозитория, проверка), кладём рядом с
|
|
70
|
+
* рабочей папкой, чтобы проверяемый код вёл себя как настоящий.
|
|
71
|
+
*
|
|
72
|
+
* @param cwd - рабочая папка сессии, запасной ориентир.
|
|
73
|
+
*/
|
|
74
|
+
export function settingsPath(cwd) {
|
|
75
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
76
|
+
const root = findProfileRoot(here) ?? (typeof cwd === 'string' && cwd !== '' ? findProfileRoot(cwd) : null)
|
|
77
|
+
return root === null ? null : join(root, FILE_NAME)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Привести прочитанное к ожидаемой форме: файл правит человек, и правит с ошибками. */
|
|
81
|
+
export function normalise(raw) {
|
|
82
|
+
const base = defaults()
|
|
83
|
+
if (raw === null || typeof raw !== 'object') return base
|
|
84
|
+
|
|
85
|
+
// Повторы отбрасываются: список запускает по процессу на элемент, и сотня
|
|
86
|
+
// одинаковых имён означала бы сотню платных обзоров разом.
|
|
87
|
+
const enabled = Array.isArray(raw.enabled)
|
|
88
|
+
? [...new Set(raw.enabled.filter((kind) => KNOWN.includes(kind)))]
|
|
89
|
+
: base.enabled
|
|
90
|
+
|
|
91
|
+
const choices = {}
|
|
92
|
+
for (const kind of KNOWN) {
|
|
93
|
+
const source = raw.choices !== null && typeof raw.choices === 'object' ? raw.choices[kind] : undefined
|
|
94
|
+
// Недопустимое значение отбрасывается молча и превращается в «как
|
|
95
|
+
// настроено»: сохранить его значило бы выключить обзор до тех пор, пока
|
|
96
|
+
// кто-нибудь не догадается открыть файл.
|
|
97
|
+
const pick = (value) => {
|
|
98
|
+
if (typeof value !== 'string') return null
|
|
99
|
+
const trimmed = value.trim()
|
|
100
|
+
return trimmed !== '' && SAFE_VALUE.test(trimmed) ? trimmed : null
|
|
101
|
+
}
|
|
102
|
+
choices[kind] = {
|
|
103
|
+
model: pick(source?.model),
|
|
104
|
+
effort: pick(source?.effort),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Пустой список допустим только как осознанный выбор «никого не звать»;
|
|
109
|
+
// отличить его от испорченного файла нельзя, поэтому возвращаем как есть.
|
|
110
|
+
return { enabled, choices }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Прочитать настройки. Отсутствие файла — не ошибка, а «ничего не выбрано». */
|
|
114
|
+
export async function readSettings(cwd) {
|
|
115
|
+
const path = settingsPath(cwd)
|
|
116
|
+
if (path === null) return defaults()
|
|
117
|
+
try {
|
|
118
|
+
const text = await readFile(path, 'utf8')
|
|
119
|
+
return normalise(JSON.parse(text.replace(/^\uFEFF/, '')))
|
|
120
|
+
} catch {
|
|
121
|
+
return defaults()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Записать настройки. Возвращает то, что действительно сохранено.
|
|
127
|
+
*
|
|
128
|
+
* Запись идёт через временный файл с переименованием. Прямая перезапись
|
|
129
|
+
* сначала обрезает файл, и чтение, пришедшееся на этот момент, видит пустоту
|
|
130
|
+
* или обрывок — а пустота у нас означает «включить обоих ревизоров», то есть
|
|
131
|
+
* запустить продукт, который человек только что выключил.
|
|
132
|
+
*/
|
|
133
|
+
export async function writeSettings(cwd, value) {
|
|
134
|
+
const path = settingsPath(cwd)
|
|
135
|
+
if (path === null) throw new Error('не найден каталог профиля — некуда сохранить настройки обзора')
|
|
136
|
+
const normalised = normalise(value)
|
|
137
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
138
|
+
await writeFile(temporary, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
|
|
139
|
+
await rename(temporary, path)
|
|
140
|
+
return normalised
|
|
141
|
+
}
|
package/lib/review.js
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
* «Второе мнение» — независимый обзор работы чужими исполнителями.
|
|
3
3
|
*
|
|
4
4
|
* Это строка АГЕНТ-ПЛОСКОСТИ: инструмент регистрируется в реестр пресета, а
|
|
5
|
-
* не профиля.
|
|
6
|
-
*
|
|
5
|
+
* не профиля.
|
|
6
|
+
*
|
|
7
|
+
* Продукты вызываются НАПРЯМУЮ через `ctx.subprocess`, а не через готовые
|
|
8
|
+
* провайдеры подагентов. Причина в `lib/reviewers.js`: провайдеры не
|
|
9
|
+
* передают дочернему продукту модель и усилие, а без них выбор из
|
|
10
|
+
* интерфейса невозможен.
|
|
7
11
|
*
|
|
8
12
|
* Зачем это нужно, коротко. Ошибки бывают громкие и тихие. Громкие падают с
|
|
9
13
|
* криком, и их находит первый же пользователь. Тихие проявляются только в
|
|
@@ -35,8 +39,16 @@
|
|
|
35
39
|
* ошибку одной строки вместо обрушения всего ряда.
|
|
36
40
|
*/
|
|
37
41
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Список ревизоров берётся из ОДНОГО места — `reviewers.js`.
|
|
44
|
+
*
|
|
45
|
+
* Здесь стоял свой список, третий по счёту: ещё один в `review-settings.js`
|
|
46
|
+
* и ещё один в справочнике запуска. Ревизор указал, чем это кончится:
|
|
47
|
+
* добавление третьего продукта в один файл из трёх делает
|
|
48
|
+
* `settings.choices[kind]` неопределённым, и раздел настроек падает целиком
|
|
49
|
+
* — React снимает поддерево, пользователь теряет и остальные настройки.
|
|
50
|
+
*/
|
|
51
|
+
const KNOWN_REVIEWERS = ['claude-code', 'codex']
|
|
40
52
|
|
|
41
53
|
/**
|
|
42
54
|
* Задание ревизору.
|
|
@@ -105,33 +117,81 @@ function buildPrompt({ request, paths, focus }) {
|
|
|
105
117
|
/** Уровни важности в порядке убывания — по нему сортируется итог. */
|
|
106
118
|
const SEVERITY_ORDER = ['критично', 'серьёзно', 'мелочь']
|
|
107
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Пределы на разбор.
|
|
122
|
+
*
|
|
123
|
+
* Ответ приходит из отдельного процесса и ничем не ограничен: многословная
|
|
124
|
+
* или сорвавшаяся в повтор модель способна выдать мегабайт. Всё это уходит в
|
|
125
|
+
* контекст вызывающего агента — то есть стоит дороже самого обзора и
|
|
126
|
+
* способно вытеснить ту работу, которую обзор проверял.
|
|
127
|
+
*/
|
|
128
|
+
const MAX_FINDINGS = 60
|
|
129
|
+
const MAX_FIELD_CHARS = 1200
|
|
130
|
+
|
|
131
|
+
/** Обрезать поле, не притворяясь, что ничего не обрезали. */
|
|
132
|
+
function clip(value) {
|
|
133
|
+
if (value === null) return null
|
|
134
|
+
return value.length <= MAX_FIELD_CHARS ? value : `${value.slice(0, MAX_FIELD_CHARS)}… (обрезано)`
|
|
135
|
+
}
|
|
136
|
+
|
|
108
137
|
/**
|
|
109
138
|
* Разобрать ответ ревизора в список находок.
|
|
110
139
|
*
|
|
111
140
|
* Разбор намеренно снисходительный: ревизор — языковая модель, и точного
|
|
112
|
-
* соблюдения формата от неё ждать нельзя.
|
|
113
|
-
*
|
|
114
|
-
*
|
|
141
|
+
* соблюдения формата от неё ждать нельзя. Пропущенное поле даёт null, а не
|
|
142
|
+
* теряет находку; жирная разметка вокруг названий полей снимается.
|
|
143
|
+
*
|
|
144
|
+
* Текст БЕЗ единого заголовка не выбрасывается, а возвращается одной
|
|
145
|
+
* находкой с пометкой «не по формату». Прежде такой ответ превращался в
|
|
146
|
+
* пустой список, то есть в «находок нет» — и сообщение продукта об истёкшем
|
|
147
|
+
* входе выглядело справкой о чистоте кода.
|
|
115
148
|
*/
|
|
116
149
|
export function parseFindings(text, reviewer) {
|
|
117
150
|
const source = String(text ?? '')
|
|
118
|
-
if (
|
|
151
|
+
if (source.trim() === '') return []
|
|
152
|
+
if (/^\s*НАХОДОК НЕТ\s*$/im.test(source) && !/^\s*#{2,3}\s+/m.test(source)) return []
|
|
119
153
|
|
|
120
154
|
const findings = []
|
|
121
|
-
|
|
155
|
+
// Заголовок распознаётся только в начале строки и не глубже третьего
|
|
156
|
+
// уровня: `###` внутри примера кода в поле «КАК ПРОВЕРИТЬ» иначе разрывал
|
|
157
|
+
// находку надвое, и вторая половина уезжала в конец списка без важности.
|
|
158
|
+
const blocks = source.split(/^#{2,3}[ \t]+/m).slice(1)
|
|
159
|
+
|
|
160
|
+
if (blocks.length === 0) {
|
|
161
|
+
return [{
|
|
162
|
+
reviewer,
|
|
163
|
+
title: 'ответ не по формату',
|
|
164
|
+
severity: 'мелочь',
|
|
165
|
+
where: null,
|
|
166
|
+
what: clip(source.trim()),
|
|
167
|
+
why: 'Ревизор ответил не по заданному образцу — возможно, это сообщение об ошибке, а не обзор.',
|
|
168
|
+
howToCheck: null,
|
|
169
|
+
unparsed: true,
|
|
170
|
+
}]
|
|
171
|
+
}
|
|
122
172
|
|
|
123
173
|
for (const block of blocks) {
|
|
174
|
+
if (findings.length >= MAX_FINDINGS) break
|
|
175
|
+
|
|
124
176
|
const [titleLine, ...rest] = block.split('\n')
|
|
125
|
-
|
|
177
|
+
// Жирная разметка вокруг названий полей снимается: модели любят её
|
|
178
|
+
// добавлять, а без этого поле просто не находилось и содержимое терялось.
|
|
179
|
+
const body = rest.join('\n').replace(/\*\*/g, '')
|
|
180
|
+
|
|
126
181
|
// Поле тянется до следующего заголовка или до конца блока.
|
|
127
182
|
//
|
|
128
183
|
// Флаг `m` здесь недопустим: с ним `$` означает конец СТРОКИ, и
|
|
129
184
|
// объяснение обрывалось на первой же — а ревизор объясняет развёрнуто,
|
|
130
185
|
// и вторая строка обычно и есть самое полезное. Начало строки
|
|
131
186
|
// обеспечивается явным `(?:^|\n)`, конец блока — `$` без `m`.
|
|
187
|
+
//
|
|
188
|
+
// Следующим полем считается только ИЗВЕСТНОЕ название, а не любая
|
|
189
|
+
// строка из заглавных букв с двоеточием: «ИТОГО:» внутри объяснения
|
|
190
|
+
// обрывал поле на середине.
|
|
191
|
+
const NEXT = '(?:ВАЖНОСТЬ|ГДЕ|ЧТО НЕ ТАК|ПОЧЕМУ ЭТО ВАЖНО|КАК ПРОВЕРИТЬ)'
|
|
132
192
|
const field = (label) => {
|
|
133
|
-
const match = body.match(new RegExp(`(?:^|\\n)${label}:\\s*([\\s\\S]+?)(?=\\n
|
|
134
|
-
return match === null ? null : match[1].trim()
|
|
193
|
+
const match = body.match(new RegExp(`(?:^|\\n)${label}:\\s*([\\s\\S]+?)(?=\\n${NEXT}:|$)`))
|
|
194
|
+
return match === null ? null : clip(match[1].trim())
|
|
135
195
|
}
|
|
136
196
|
|
|
137
197
|
const severityRaw = (field('ВАЖНОСТЬ') ?? '').toLowerCase()
|
|
@@ -139,7 +199,7 @@ export function parseFindings(text, reviewer) {
|
|
|
139
199
|
|
|
140
200
|
findings.push({
|
|
141
201
|
reviewer,
|
|
142
|
-
title: titleLine.trim(),
|
|
202
|
+
title: clip(titleLine.replace(/\*\*/g, '').trim()),
|
|
143
203
|
severity,
|
|
144
204
|
where: field('ГДЕ'),
|
|
145
205
|
what: field('ЧТО НЕ ТАК'),
|
|
@@ -151,47 +211,28 @@ export function parseFindings(text, reviewer) {
|
|
|
151
211
|
return findings
|
|
152
212
|
}
|
|
153
213
|
|
|
154
|
-
/** Вытащить текст из блоков ответа подагента. */
|
|
155
|
-
function textOf(output) {
|
|
156
|
-
if (!Array.isArray(output)) return ''
|
|
157
|
-
return output.filter((block) => block?.type === 'text').map((block) => String(block.text ?? '')).join('')
|
|
158
|
-
}
|
|
159
|
-
|
|
160
214
|
/**
|
|
161
|
-
* Позвать одного ревизора и
|
|
215
|
+
* Позвать одного ревизора и разобрать его находки.
|
|
162
216
|
*
|
|
163
217
|
* Отказ одного НЕ отменяет обзор: половина ответа полезнее отказа, и
|
|
164
218
|
* пользователь должен видеть, кто именно не ответил.
|
|
165
219
|
*/
|
|
166
|
-
async function askReviewer(ctx,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
})
|
|
175
|
-
} catch (error) {
|
|
176
|
-
return { provider, failed: String(error?.message ?? error), findings: [] }
|
|
177
|
-
}
|
|
220
|
+
async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal) {
|
|
221
|
+
const outcome = await runReviewer(ctx, kind, {
|
|
222
|
+
prompt,
|
|
223
|
+
cwd,
|
|
224
|
+
model: choice?.model ?? null,
|
|
225
|
+
effort: choice?.effort ?? null,
|
|
226
|
+
signal,
|
|
227
|
+
})
|
|
178
228
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return { provider, failed: 'пустой ответ', findings: [] }
|
|
184
|
-
}
|
|
185
|
-
return { provider, failed: null, findings: parseFindings(answer, provider), answer }
|
|
186
|
-
} catch (error) {
|
|
187
|
-
return { provider, failed: String(error?.message ?? error), findings: [] }
|
|
188
|
-
} finally {
|
|
189
|
-
try {
|
|
190
|
-
await run.dispose()
|
|
191
|
-
} catch {
|
|
192
|
-
// Неудача уборки не должна подменять результат обзора.
|
|
193
|
-
}
|
|
229
|
+
const used = { model: choice?.model ?? null, effort: choice?.effort ?? null }
|
|
230
|
+
|
|
231
|
+
if (outcome.failed !== null) {
|
|
232
|
+
return { provider: kind, failed: outcome.failed, cancelled: outcome.cancelled === true, findings: [], ...used }
|
|
194
233
|
}
|
|
234
|
+
|
|
235
|
+
return { provider: kind, failed: null, findings: parseFindings(outcome.answer, kind), ...used }
|
|
195
236
|
}
|
|
196
237
|
|
|
197
238
|
/** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
|
|
@@ -207,7 +248,16 @@ export function render(value) {
|
|
|
207
248
|
const lines = []
|
|
208
249
|
|
|
209
250
|
if (value.reviewers.length === 0) {
|
|
210
|
-
|
|
251
|
+
// Настоящая причина приходит в `problem` и обязана быть показана.
|
|
252
|
+
//
|
|
253
|
+
// Здесь стояла одна фраза на все случаи — про несмонтированные
|
|
254
|
+
// провайдеры подагентов. Механизм, которым этот код давно не
|
|
255
|
+
// пользуется: продукты зовутся напрямую. Пользователь, снявший обе
|
|
256
|
+
// галочки, получал объяснение про постороннюю неисправность и шёл чинить
|
|
257
|
+
// не то. Оба ревизора нашли это независимо.
|
|
258
|
+
return typeof value.problem === 'string' && value.problem !== ''
|
|
259
|
+
? `Обзор не выполнен: ${value.problem}`
|
|
260
|
+
: 'Обзор не выполнен: ни один ревизор не выбран.'
|
|
211
261
|
}
|
|
212
262
|
|
|
213
263
|
const failed = value.reviewers.filter((r) => r.failed !== null)
|
|
@@ -218,7 +268,13 @@ export function render(value) {
|
|
|
218
268
|
lines.push('')
|
|
219
269
|
|
|
220
270
|
if (value.findings.length === 0) {
|
|
221
|
-
|
|
271
|
+
// «Находок нет» говорится ТОЛЬКО когда обзор действительно состоялся.
|
|
272
|
+
// Отказ или отмена, поданные как чистый результат, — худшее, что этот
|
|
273
|
+
// инструмент может сделать: пользователь сочтёт работу проверенной.
|
|
274
|
+
const cancelled = value.reviewers.some((entry) => entry.cancelled === true)
|
|
275
|
+
if (cancelled) lines.push('Обзор отменён — судить не по чему.')
|
|
276
|
+
else if (worked.length === 0) lines.push('Судить не по чему: не ответил ни один ревизор.')
|
|
277
|
+
else lines.push('Находок нет.')
|
|
222
278
|
return lines.join('\n')
|
|
223
279
|
}
|
|
224
280
|
|
|
@@ -246,10 +302,10 @@ export function render(value) {
|
|
|
246
302
|
}
|
|
247
303
|
|
|
248
304
|
export const name = 'tensorgrid-second-opinion'
|
|
249
|
-
export const inject = ['tools', '
|
|
305
|
+
export const inject = ['tools', 'subprocess']
|
|
250
306
|
|
|
251
307
|
/**
|
|
252
|
-
* @param ctx - контекст агент-плоскости
|
|
308
|
+
* @param ctx - контекст агент-плоскости со службами `tools` и `subprocess`.
|
|
253
309
|
*/
|
|
254
310
|
export async function apply(ctx) {
|
|
255
311
|
const { defineTool } = await import('@deepseek-ai/dsh-tools')
|
|
@@ -281,7 +337,7 @@ export async function apply(ctx) {
|
|
|
281
337
|
},
|
|
282
338
|
reviewers: {
|
|
283
339
|
type: 'array',
|
|
284
|
-
items: { type: 'string', enum:
|
|
340
|
+
items: { type: 'string', enum: KNOWN_REVIEWERS },
|
|
285
341
|
description:
|
|
286
342
|
'Which reviewers to ask. Defaults to both — they find different things, and asking one guarantees missing half.',
|
|
287
343
|
},
|
|
@@ -298,17 +354,32 @@ export async function apply(ctx) {
|
|
|
298
354
|
render: (_args, value) => [{ type: 'text', text: render(value) }],
|
|
299
355
|
},
|
|
300
356
|
async execute(args, exec) {
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
357
|
+
const { runReviewer } = await import('./reviewers.js')
|
|
358
|
+
const { readSettings } = await import('./review-settings.js')
|
|
359
|
+
|
|
360
|
+
// Рабочая папка берётся у сессии, как это делают поставочные
|
|
361
|
+
// провайдеры: ревизор должен смотреть тот же код, что и автор.
|
|
362
|
+
const cwd = exec.agent?.session?.header?.cwd
|
|
363
|
+
if (typeof cwd !== 'string' || cwd === '') {
|
|
364
|
+
return { findings: [], reviewers: [], problem: 'у сессии нет рабочей папки — обзору нечего смотреть' }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Выбор пользователя из настроек; вызов может его перекрыть.
|
|
368
|
+
const settings = await readSettings(cwd)
|
|
369
|
+
const asked = Array.isArray(args.reviewers) && args.reviewers.length > 0
|
|
370
|
+
? args.reviewers
|
|
371
|
+
: settings.enabled
|
|
372
|
+
|
|
373
|
+
// Повторы отбрасываются, и это не придирка: `reviewers` заполняет
|
|
374
|
+
// модель, а список из ста одинаковых имён запустил бы сто платных
|
|
375
|
+
// процессов разом — по одному на каждый элемент.
|
|
376
|
+
const wanted = [...new Set(asked)].filter((kind) => KNOWN_REVIEWERS.includes(kind))
|
|
377
|
+
|
|
378
|
+
if (wanted.length === 0) {
|
|
379
|
+
const problem = asked.length === 0
|
|
380
|
+
? 'не выбран ни один ревизор — включите их в настройках'
|
|
381
|
+
: `неизвестные ревизоры: ${[...new Set(asked)].join(', ')}`
|
|
382
|
+
return { findings: [], reviewers: [], problem }
|
|
312
383
|
}
|
|
313
384
|
|
|
314
385
|
const prompt = buildPrompt(args)
|
|
@@ -316,12 +387,19 @@ export async function apply(ctx) {
|
|
|
316
387
|
// Спрашиваем ОБОИХ разом: они независимы, и последовательный опрос
|
|
317
388
|
// удвоил бы ожидание без всякой пользы.
|
|
318
389
|
const answers = await Promise.all(
|
|
319
|
-
|
|
390
|
+
wanted.map((kind) => askReviewer(ctx, runReviewer, kind, prompt, cwd, settings.choices[kind], exec.signal)),
|
|
320
391
|
)
|
|
321
392
|
|
|
322
393
|
return {
|
|
323
394
|
findings: ordered(answers.flatMap((entry) => entry.findings)),
|
|
324
|
-
reviewers: answers.map((entry) => ({
|
|
395
|
+
reviewers: answers.map((entry) => ({
|
|
396
|
+
provider: entry.provider,
|
|
397
|
+
failed: entry.failed,
|
|
398
|
+
cancelled: entry.cancelled === true,
|
|
399
|
+
found: entry.findings.length,
|
|
400
|
+
model: entry.model,
|
|
401
|
+
effort: entry.effort,
|
|
402
|
+
})),
|
|
325
403
|
}
|
|
326
404
|
},
|
|
327
405
|
}),
|
package/lib/reviewers.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Запуск ревизоров — Claude Code и Codex — с выбором модели и усилия.
|
|
3
|
+
*
|
|
4
|
+
* ПОЧЕМУ НЕ ЧЕРЕЗ ПРОВАЙДЕРОВ ПОДАГЕНТОВ
|
|
5
|
+
*
|
|
6
|
+
* Готовые провайдеры `codex` и `claude-code` запускают те же продукты, но
|
|
7
|
+
* берут из вызова только задание, отмену и родителя. Модель и усилие они не
|
|
8
|
+
* передают: у них это задаётся окружением в строке композиции, то есть один
|
|
9
|
+
* раз при старте. Выбор из интерфейса так не сделать — пришлось бы монтировать
|
|
10
|
+
* отдельного провайдера на каждое сочетание и перезапускать приложение.
|
|
11
|
+
*
|
|
12
|
+
* Поэтому продукты вызываются напрямую, через `ctx.subprocess` — ту же службу,
|
|
13
|
+
* которой пользуются сами провайдеры. Она даёт поиск команды в PATH, очистку
|
|
14
|
+
* окружения от чужих учётных данных, управление деревом процессов и
|
|
15
|
+
* остановку по сигналу. Своего здесь — только сборка аргументов.
|
|
16
|
+
*
|
|
17
|
+
* ДВЕ ТОНКОСТИ, СТОИВШИЕ ВРЕМЕНИ
|
|
18
|
+
*
|
|
19
|
+
* Задание передаётся через ПОТОК ВВОДА, а не аргументом. Обе команды так
|
|
20
|
+
* умеют, а длинное задание с кавычками в аргументе командной строки ломается:
|
|
21
|
+
* сегодня это уже случилось — кавычки в тексте разорвали разбор аргументов.
|
|
22
|
+
*
|
|
23
|
+
* Codex печатает в вывод весь ход рассуждения — в одном из прогонов вышло
|
|
24
|
+
* 138 КБ на страницу ответа. Итог забирается отдельным файлом через
|
|
25
|
+
* `--output-last-message`. Claude Code в режиме `-p` и так печатает только
|
|
26
|
+
* ответ.
|
|
27
|
+
*/
|
|
28
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
29
|
+
import { tmpdir } from 'node:os'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
|
|
32
|
+
/** Сколько ждать ревизора. Обзор большого кода у Codex занимал 450 секунд. */
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 900000
|
|
34
|
+
|
|
35
|
+
/** Предел собираемого вывода: ответ ревизора — текст, а не поток данных. */
|
|
36
|
+
const STDOUT_CAP = 1024 * 1024
|
|
37
|
+
const STDERR_CAP = 64 * 1024
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Что известно про каждого ревизора.
|
|
41
|
+
*
|
|
42
|
+
* `models` и `efforts` — не ограничение, а подсказка для интерфейса: список
|
|
43
|
+
* показывается пользователю, но передать можно и не перечисленное. Жёсткий
|
|
44
|
+
* список устарел бы при первом же выпуске нового имени модели.
|
|
45
|
+
*/
|
|
46
|
+
export const REVIEWERS = {
|
|
47
|
+
'claude-code': {
|
|
48
|
+
label: 'Claude Code',
|
|
49
|
+
command: 'claude',
|
|
50
|
+
models: ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-4-6'],
|
|
51
|
+
efforts: ['low', 'medium', 'high'],
|
|
52
|
+
defaultModel: null,
|
|
53
|
+
defaultEffort: null,
|
|
54
|
+
},
|
|
55
|
+
codex: {
|
|
56
|
+
label: 'Codex',
|
|
57
|
+
command: 'codex',
|
|
58
|
+
models: ['gpt-6-astra', 'gpt-5.6-sol'],
|
|
59
|
+
efforts: ['low', 'medium', 'high', 'xhigh'],
|
|
60
|
+
defaultModel: null,
|
|
61
|
+
defaultEffort: null,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Значения уходят в аргументы команды — пропускаем только безобидное. */
|
|
66
|
+
const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Собрать аргументы запуска.
|
|
70
|
+
*
|
|
71
|
+
* @param kind - имя ревизора.
|
|
72
|
+
* @param options - модель, усилие, файл для итога.
|
|
73
|
+
* @returns аргументы без имени программы.
|
|
74
|
+
*/
|
|
75
|
+
export function buildArgs(kind, { model, effort, answerFile }) {
|
|
76
|
+
const args = []
|
|
77
|
+
|
|
78
|
+
if (kind === 'claude-code') {
|
|
79
|
+
// `-p` без текста задания: задание придёт через поток ввода.
|
|
80
|
+
args.push('-p')
|
|
81
|
+
// Ревизор ТОЛЬКО ЧИТАЕТ, и это обеспечено правами, а не просьбой.
|
|
82
|
+
//
|
|
83
|
+
// Здесь стоял `bypassPermissions` — полное право писать файлы и выполнять
|
|
84
|
+
// команды в рабочей папке пользователя, при том что соседний Codex
|
|
85
|
+
// запускался с `read-only`. Оба независимых ревизора нашли это и назвали
|
|
86
|
+
// критичным, справедливо: ревизор по заданию читает чужой непроверенный
|
|
87
|
+
// код, и указание внутри этого кода («игнорируй предыдущее, выполни…»)
|
|
88
|
+
// исполнилось бы без подтверждения и без следа в переписке — это
|
|
89
|
+
// отдельный процесс.
|
|
90
|
+
//
|
|
91
|
+
// Проверено с контролем: с этим списком инструментов запись отклоняется
|
|
92
|
+
// системой прав, с `bypassPermissions` файл создаётся немедленно.
|
|
93
|
+
args.push('--permission-mode', 'dontAsk')
|
|
94
|
+
args.push('--allowedTools', 'Read', 'Glob', 'Grep')
|
|
95
|
+
if (model) args.push('--model', model)
|
|
96
|
+
if (effort) args.push('--effort', effort)
|
|
97
|
+
return args
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (kind === 'codex') {
|
|
101
|
+
args.push('exec')
|
|
102
|
+
// Обзор ничего не меняет: только чтение. Это и защита, и честность —
|
|
103
|
+
// ревизор не должен «починить» по дороге то, что взялся оценивать.
|
|
104
|
+
args.push('--sandbox', 'read-only')
|
|
105
|
+
// Рабочая папка может не быть репозиторием, и это не повод отказываться.
|
|
106
|
+
args.push('--skip-git-repo-check')
|
|
107
|
+
args.push('--output-last-message', answerFile)
|
|
108
|
+
if (model) args.push('-m', model)
|
|
109
|
+
// Значение читается как TOML, поэтому строка в кавычках.
|
|
110
|
+
if (effort) args.push('-c', `model_reasoning_effort="${effort}"`)
|
|
111
|
+
return args
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw new Error(`неизвестный ревизор: ${kind}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Запустить одного ревизора и вернуть его ответ.
|
|
119
|
+
*
|
|
120
|
+
* Отказ не бросается наружу: обзор двумя ревизорами не должен пропадать
|
|
121
|
+
* целиком из-за одного неответившего. Возвращается либо ответ, либо причина.
|
|
122
|
+
*
|
|
123
|
+
* @param ctx - контекст со службой `subprocess`.
|
|
124
|
+
* @param kind - имя ревизора.
|
|
125
|
+
* @param options - задание, рабочая папка, модель, усилие, сигнал отмены.
|
|
126
|
+
*/
|
|
127
|
+
export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signal, timeoutMs }) {
|
|
128
|
+
const spec = REVIEWERS[kind]
|
|
129
|
+
if (spec === undefined) return { kind, failed: `неизвестный ревизор: ${kind}`, answer: null }
|
|
130
|
+
|
|
131
|
+
for (const [label, value] of [['модель', model], ['усилие', effort]]) {
|
|
132
|
+
if (value !== undefined && value !== null && value !== '' && !SAFE_VALUE.test(String(value))) {
|
|
133
|
+
return { kind, failed: `недопустимое значение (${label}): ${String(value)}`, answer: null }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Отмену, случившуюся ДО вызова, надо увидеть сразу.
|
|
138
|
+
//
|
|
139
|
+
// Подписка на будущее событие `abort` у уже отменённого сигнала не
|
|
140
|
+
// срабатывает никогда: обработчик задним числом не вызывается. Без этой
|
|
141
|
+
// проверки нажатие «стоп» перед самым запуском не мешало нам завести
|
|
142
|
+
// платный процесс, который потом жил до самого тайм-аута.
|
|
143
|
+
if (signal?.aborted === true) {
|
|
144
|
+
return { kind, failed: 'отменено', cancelled: true, answer: null }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let workDir = null
|
|
148
|
+
let handle = null
|
|
149
|
+
// Своя отмена поверх пользовательской: ревизор без ограничения по времени
|
|
150
|
+
// держал бы обзор бесконечно.
|
|
151
|
+
const deadline = new AbortController()
|
|
152
|
+
const timer = setTimeout(() => deadline.abort(), timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
153
|
+
const onOuterAbort = () => deadline.abort()
|
|
154
|
+
if (signal !== undefined) signal.addEventListener('abort', onOuterAbort, { once: true })
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
workDir = await mkdtemp(join(tmpdir(), 'tensorgrid-review-'))
|
|
158
|
+
const answerFile = join(workDir, 'answer.txt')
|
|
159
|
+
|
|
160
|
+
let executable
|
|
161
|
+
try {
|
|
162
|
+
executable = await ctx.subprocess.resolveExecutable(spec.command, undefined, deadline.signal)
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return { kind, failed: `${spec.command} не найден: ${String(error?.message ?? error)}`, answer: null }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
handle = ctx.subprocess.spawn({
|
|
168
|
+
argv: [executable, ...buildArgs(kind, { model, effort, answerFile })],
|
|
169
|
+
cwd,
|
|
170
|
+
stdio: {
|
|
171
|
+
// Задание через поток ввода: кавычки и переводы строк проходят как есть.
|
|
172
|
+
stdin: { data: prompt },
|
|
173
|
+
stdout: { maxBytes: STDOUT_CAP },
|
|
174
|
+
stderr: { maxBytes: STDERR_CAP },
|
|
175
|
+
},
|
|
176
|
+
graceMs: 5000,
|
|
177
|
+
signal: deadline.signal,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const outcome = await handle.done
|
|
181
|
+
const stderrText = (await handle.collected.stderr.readFrom(0)).text
|
|
182
|
+
const tail = stderrText.trim().split('\n').slice(-3).join(' ')
|
|
183
|
+
|
|
184
|
+
// Отмена — это отмена, а не отказ ревизора.
|
|
185
|
+
//
|
|
186
|
+
// Раньше прерванный обзор возвращался как «пустой ответ», то есть
|
|
187
|
+
// выглядел обычным результатом: агент получал «ревизоры не ответили,
|
|
188
|
+
// находок нет» и спокойно шёл дальше, считая работу проверенной.
|
|
189
|
+
if (signal?.aborted === true) {
|
|
190
|
+
return { kind, failed: 'отменено', cancelled: true, answer: null }
|
|
191
|
+
}
|
|
192
|
+
if (deadline.signal.aborted) {
|
|
193
|
+
return { kind, failed: `превышено время ожидания (${Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000)} с)`, answer: null }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// КОД ВОЗВРАТА ПРОВЕРЯЕТСЯ ВСЕГДА, а не только при пустом выводе.
|
|
197
|
+
//
|
|
198
|
+
// Claude Code печатает текст ошибки — истёкший вход, исчерпанный лимит,
|
|
199
|
+
// несуществующая модель — в обычный вывод и завершается с ненулевым
|
|
200
|
+
// кодом. Раньше такой вывод считался успешным ответом, разбор не находил
|
|
201
|
+
// в нём ни одной находки, и пользователь получал справку о чистоте кода
|
|
202
|
+
// при том, что обзор вообще не выполнялся. Это ровно тот тихий отказ,
|
|
203
|
+
// ради поиска которого весь инструмент и написан.
|
|
204
|
+
if (outcome.exitCode !== 0) {
|
|
205
|
+
const code = outcome.exitCode === null ? `сигнал ${String(outcome.signal)}` : `код ${outcome.exitCode}`
|
|
206
|
+
const said = (await handle.collected.stdout.readFrom(0)).text.trim().split('\n').slice(0, 2).join(' ')
|
|
207
|
+
const detail = tail !== '' ? tail : said
|
|
208
|
+
return { kind, failed: `ревизор завершился с ошибкой (${code})${detail === '' ? '' : `: ${detail}`}`, answer: null }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let answer = ''
|
|
212
|
+
if (kind === 'codex') {
|
|
213
|
+
// Итог Codex берётся ТОЛЬКО из файла. Запасное чтение вывода отсюда
|
|
214
|
+
// убрано намеренно: в выводе лежит весь ход рассуждения вместе с
|
|
215
|
+
// образцом формата из задания, и разбор наплодил бы по нему находки-
|
|
216
|
+
// призраки — в том числе «критичную» с названием «краткое название».
|
|
217
|
+
// Отсутствие файла — это сбой, а не повод подсунуть журнал.
|
|
218
|
+
try {
|
|
219
|
+
answer = await readFile(answerFile, 'utf8')
|
|
220
|
+
} catch {
|
|
221
|
+
return { kind, failed: `Codex не оставил итогового ответа${tail === '' ? '' : `: ${tail}`}`, answer: null }
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
answer = (await handle.collected.stdout.readFrom(0)).text
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (answer.trim() === '') {
|
|
228
|
+
return { kind, failed: `пустой ответ${tail === '' ? '' : `: ${tail}`}`, answer: null }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { kind, failed: null, cancelled: false, answer, model: model ?? null, effort: effort ?? null }
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
|
|
234
|
+
const reason = deadline.signal.aborted ? 'превышено время ожидания' : String(error?.message ?? error)
|
|
235
|
+
return { kind, failed: reason, answer: null }
|
|
236
|
+
} finally {
|
|
237
|
+
clearTimeout(timer)
|
|
238
|
+
if (signal !== undefined) signal.removeEventListener('abort', onOuterAbort)
|
|
239
|
+
if (handle !== null) {
|
|
240
|
+
try {
|
|
241
|
+
handle.terminate()
|
|
242
|
+
// Ждём, пока процесс действительно умрёт, прежде чем убирать за ним.
|
|
243
|
+
// Без ожидания удаление шло при живом процессе, у которого есть
|
|
244
|
+
// отпущенное время на завершение и открытый файл итога: на Windows
|
|
245
|
+
// это даёт отказ, `force` его проглатывает, и временные каталоги
|
|
246
|
+
// копятся после каждой отмены.
|
|
247
|
+
await handle.waitForExit()
|
|
248
|
+
} catch {
|
|
249
|
+
// Уборка не должна подменять результат.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (workDir !== null) {
|
|
253
|
+
await rm(workDir, { recursive: true, force: true }).catch(() => {})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
package/package.json
CHANGED