tensorgrid-ui 1.2.2 → 1.4.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 +188 -1
- package/lib/http-runtime.js +54 -0
- package/lib/index.js +15 -0
- package/lib/review-settings.js +110 -0
- package/lib/review.js +327 -0
- package/lib/reviewers.js +199 -0
- package/package.json +12 -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,154 @@ 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
|
+
setState(await send(body))
|
|
2287
|
+
} catch (error) {
|
|
2288
|
+
setState({ catalogue: [], problem: String(error && error.message ? error.message : error) })
|
|
2289
|
+
} finally {
|
|
2290
|
+
setBusy(false)
|
|
2291
|
+
}
|
|
2292
|
+
}, [send])
|
|
2293
|
+
|
|
2294
|
+
React.useEffect(() => { refresh() }, [refresh])
|
|
2295
|
+
|
|
2296
|
+
const catalogue = state !== null && Array.isArray(state.catalogue) ? state.catalogue : []
|
|
2297
|
+
const settings = state !== null && state.settings ? state.settings : null
|
|
2298
|
+
|
|
2299
|
+
// Изменение любого поля сразу сохраняется: отдельная кнопка «применить»
|
|
2300
|
+
// добавляет состояние «изменено, но не сохранено», в котором легко
|
|
2301
|
+
// забыть нажать и потом удивляться.
|
|
2302
|
+
const save = React.useCallback((next) => { refresh(next) }, [refresh])
|
|
2303
|
+
|
|
2304
|
+
const toggle = (kind) => {
|
|
2305
|
+
if (settings === null) return
|
|
2306
|
+
const on = settings.enabled.includes(kind)
|
|
2307
|
+
const enabled = on ? settings.enabled.filter((k) => k !== kind) : [...settings.enabled, kind]
|
|
2308
|
+
save({ ...settings, enabled })
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
const pick = (kind, field, value) => {
|
|
2312
|
+
if (settings === null) return
|
|
2313
|
+
const choices = { ...settings.choices, [kind]: { ...settings.choices[kind], [field]: value } }
|
|
2314
|
+
save({ ...settings, choices })
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
const selector = (kind, field, options, current) =>
|
|
2318
|
+
jsxs('select', {
|
|
2319
|
+
className: 'dsx-rev__select',
|
|
2320
|
+
value: current === null ? '' : current,
|
|
2321
|
+
disabled: busy,
|
|
2322
|
+
onChange: (event) => pick(kind, field, event.target.value === '' ? null : event.target.value),
|
|
2323
|
+
children: [
|
|
2324
|
+
jsx('option', { value: '', children: t('review.asConfigured') }, ''),
|
|
2325
|
+
...options.map((option) => jsx('option', { value: option, children: option }, option)),
|
|
2326
|
+
],
|
|
2327
|
+
})
|
|
2328
|
+
|
|
2329
|
+
return jsxs('div', {
|
|
2330
|
+
className: 'dsx-subs',
|
|
2331
|
+
children: [
|
|
2332
|
+
jsxs('div', {
|
|
2333
|
+
className: 'dsx-subs__head',
|
|
2334
|
+
children: [
|
|
2335
|
+
jsx('div', { className: 'dsx-subs__title', children: t('review.title') }),
|
|
2336
|
+
jsx('div', { className: 'dsx-setting__hint', children: t('review.hint') }),
|
|
2337
|
+
],
|
|
2338
|
+
}),
|
|
2339
|
+
state !== null && state.problem
|
|
2340
|
+
? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: state.problem })
|
|
2341
|
+
: null,
|
|
2342
|
+
jsx('div', {
|
|
2343
|
+
className: 'dsx-rev__list',
|
|
2344
|
+
children: catalogue.map((entry) => {
|
|
2345
|
+
const on = settings !== null && settings.enabled.includes(entry.kind)
|
|
2346
|
+
const choice = settings === null ? { model: null, effort: null } : settings.choices[entry.kind]
|
|
2347
|
+
return jsxs('div', {
|
|
2348
|
+
className: 'dsx-rev__card' + (on ? ' dsx-rev__card--on' : ''),
|
|
2349
|
+
children: [
|
|
2350
|
+
jsxs('label', {
|
|
2351
|
+
className: 'dsx-rev__head',
|
|
2352
|
+
children: [
|
|
2353
|
+
jsx('input', {
|
|
2354
|
+
type: 'checkbox',
|
|
2355
|
+
checked: on,
|
|
2356
|
+
disabled: busy,
|
|
2357
|
+
onChange: () => toggle(entry.kind),
|
|
2358
|
+
}),
|
|
2359
|
+
jsx('span', { className: 'dsx-rev__name', children: entry.label }),
|
|
2360
|
+
],
|
|
2361
|
+
}),
|
|
2362
|
+
jsxs('div', {
|
|
2363
|
+
className: 'dsx-rev__fields',
|
|
2364
|
+
children: [
|
|
2365
|
+
jsxs('label', {
|
|
2366
|
+
className: 'dsx-rev__field',
|
|
2367
|
+
children: [
|
|
2368
|
+
jsx('span', { children: t('review.model') }),
|
|
2369
|
+
selector(entry.kind, 'model', entry.models, choice.model),
|
|
2370
|
+
],
|
|
2371
|
+
}),
|
|
2372
|
+
jsxs('label', {
|
|
2373
|
+
className: 'dsx-rev__field',
|
|
2374
|
+
children: [
|
|
2375
|
+
jsx('span', { children: t('review.effort') }),
|
|
2376
|
+
selector(entry.kind, 'effort', entry.efforts, choice.effort),
|
|
2377
|
+
],
|
|
2378
|
+
}),
|
|
2379
|
+
],
|
|
2380
|
+
}),
|
|
2381
|
+
],
|
|
2382
|
+
}, entry.kind)
|
|
2383
|
+
}),
|
|
2384
|
+
}),
|
|
2385
|
+
settings !== null && settings.enabled.length === 0
|
|
2386
|
+
? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: t('review.none') })
|
|
2387
|
+
: null,
|
|
2388
|
+
jsx('div', { className: 'dsx-subs__note', children: t('review.note') }),
|
|
2389
|
+
],
|
|
2390
|
+
})
|
|
2391
|
+
}
|
|
2220
2392
|
// ── Обновления ───────────────────────────────────────────────────────
|
|
2221
2393
|
//
|
|
2222
2394
|
// Маршрут поднимает host-половина пакета. Путь относительный, токен не
|
|
@@ -2444,6 +2616,21 @@ window.__ModuleLoader__.load({
|
|
|
2444
2616
|
),
|
|
2445
2617
|
)
|
|
2446
2618
|
|
|
2619
|
+
// Ревизоры — соседний раздел: и подписки, и обзор про то, чем и как
|
|
2620
|
+
// работает агент, а не про внешний вид.
|
|
2621
|
+
ctx.slots.inject('settings.section', () =>
|
|
2622
|
+
ctx.slots.register(
|
|
2623
|
+
{
|
|
2624
|
+
name: 'settings.section',
|
|
2625
|
+
id: 'tensorgrid-review',
|
|
2626
|
+
order: 13,
|
|
2627
|
+
locale: LOCALE_NS,
|
|
2628
|
+
label: () => ctx.locale.translate(LOCALE_NS, 'review.nav'),
|
|
2629
|
+
},
|
|
2630
|
+
ReviewRow,
|
|
2631
|
+
),
|
|
2632
|
+
)
|
|
2633
|
+
|
|
2447
2634
|
ctx.slots.inject('settings.general.item', () =>
|
|
2448
2635
|
ctx.slots.register(
|
|
2449
2636
|
{ 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,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Настройки обзора: кого звать, какой моделью и с каким усилием.
|
|
3
|
+
*
|
|
4
|
+
* Хранятся файлом рядом с профилем, а не в браузере. Причина простая:
|
|
5
|
+
* выбирает человек в интерфейсе, а читает Host в момент вызова инструмента —
|
|
6
|
+
* значит хранилище должно быть общим для обеих сторон. Память браузера для
|
|
7
|
+
* этого не годится, Host её не видит.
|
|
8
|
+
*
|
|
9
|
+
* Файл лежит там же, где отметка об установке, и ищется тем же подъёмом
|
|
10
|
+
* вверх: так путь не зависит от того, насколько глубоко pnpm разложил пакет.
|
|
11
|
+
*
|
|
12
|
+
* Импортируются только встроенные модули Node.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, 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
|
+
* означал бы, что половина находок не появится у того, кто в настройки не
|
|
29
|
+
* заглядывал. Модель и усилие пустые — берутся собственные настройки
|
|
30
|
+
* продукта, то есть то, чем человек уже пользуется.
|
|
31
|
+
*/
|
|
32
|
+
export function defaults() {
|
|
33
|
+
return {
|
|
34
|
+
enabled: [...KNOWN],
|
|
35
|
+
choices: { 'claude-code': { model: null, effort: null }, codex: { model: null, effort: null } },
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Каталог профиля: поднимаемся, пока не найдём отметку об установке.
|
|
41
|
+
* @param startDir - откуда начинать подъём.
|
|
42
|
+
*/
|
|
43
|
+
function findProfileRoot(startDir) {
|
|
44
|
+
let dir = startDir
|
|
45
|
+
for (let depth = 0; depth < 10; depth += 1) {
|
|
46
|
+
if (existsSync(join(dir, 'tensorgrid-installed.json'))) return dir
|
|
47
|
+
const parent = dirname(dir)
|
|
48
|
+
if (parent === dir) break
|
|
49
|
+
dir = parent
|
|
50
|
+
}
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Где лежит файл настроек.
|
|
56
|
+
*
|
|
57
|
+
* Сначала ищем от собственного расположения — так работает установленный
|
|
58
|
+
* пакет. Если отметки нет (запуск из репозитория, проверка), кладём рядом с
|
|
59
|
+
* рабочей папкой, чтобы проверяемый код вёл себя как настоящий.
|
|
60
|
+
*
|
|
61
|
+
* @param cwd - рабочая папка сессии, запасной ориентир.
|
|
62
|
+
*/
|
|
63
|
+
export function settingsPath(cwd) {
|
|
64
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
65
|
+
const root = findProfileRoot(here) ?? (typeof cwd === 'string' && cwd !== '' ? findProfileRoot(cwd) : null)
|
|
66
|
+
return root === null ? null : join(root, FILE_NAME)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Привести прочитанное к ожидаемой форме: файл правит человек, и правит с ошибками. */
|
|
70
|
+
export function normalise(raw) {
|
|
71
|
+
const base = defaults()
|
|
72
|
+
if (raw === null || typeof raw !== 'object') return base
|
|
73
|
+
|
|
74
|
+
const enabled = Array.isArray(raw.enabled) ? raw.enabled.filter((kind) => KNOWN.includes(kind)) : base.enabled
|
|
75
|
+
|
|
76
|
+
const choices = {}
|
|
77
|
+
for (const kind of KNOWN) {
|
|
78
|
+
const source = raw.choices !== null && typeof raw.choices === 'object' ? raw.choices[kind] : undefined
|
|
79
|
+
const pick = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null)
|
|
80
|
+
choices[kind] = {
|
|
81
|
+
model: pick(source?.model),
|
|
82
|
+
effort: pick(source?.effort),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Пустой список допустим только как осознанный выбор «никого не звать»;
|
|
87
|
+
// отличить его от испорченного файла нельзя, поэтому возвращаем как есть.
|
|
88
|
+
return { enabled, choices }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Прочитать настройки. Отсутствие файла — не ошибка, а «ничего не выбрано». */
|
|
92
|
+
export async function readSettings(cwd) {
|
|
93
|
+
const path = settingsPath(cwd)
|
|
94
|
+
if (path === null) return defaults()
|
|
95
|
+
try {
|
|
96
|
+
const text = await readFile(path, 'utf8')
|
|
97
|
+
return normalise(JSON.parse(text.replace(/^\uFEFF/, '')))
|
|
98
|
+
} catch {
|
|
99
|
+
return defaults()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Записать настройки. Возвращает то, что действительно сохранено. */
|
|
104
|
+
export async function writeSettings(cwd, value) {
|
|
105
|
+
const path = settingsPath(cwd)
|
|
106
|
+
if (path === null) throw new Error('не найден каталог профиля — некуда сохранить настройки обзора')
|
|
107
|
+
const normalised = normalise(value)
|
|
108
|
+
await writeFile(path, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
|
|
109
|
+
return normalised
|
|
110
|
+
}
|
package/lib/review.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* «Второе мнение» — независимый обзор работы чужими исполнителями.
|
|
3
|
+
*
|
|
4
|
+
* Это строка АГЕНТ-ПЛОСКОСТИ: инструмент регистрируется в реестр пресета, а
|
|
5
|
+
* не профиля.
|
|
6
|
+
*
|
|
7
|
+
* Продукты вызываются НАПРЯМУЮ через `ctx.subprocess`, а не через готовые
|
|
8
|
+
* провайдеры подагентов. Причина в `lib/reviewers.js`: провайдеры не
|
|
9
|
+
* передают дочернему продукту модель и усилие, а без них выбор из
|
|
10
|
+
* интерфейса невозможен.
|
|
11
|
+
*
|
|
12
|
+
* Зачем это нужно, коротко. Ошибки бывают громкие и тихие. Громкие падают с
|
|
13
|
+
* криком, и их находит первый же пользователь. Тихие проявляются только в
|
|
14
|
+
* определённой последовательности действий — их не находит никто, включая
|
|
15
|
+
* автора, который перечитал свой код и уверен в нём.
|
|
16
|
+
*
|
|
17
|
+
* Проверено на себе: два независимых ревизора нашли восемь настоящих
|
|
18
|
+
* дефектов в коде, который был написан, вычитан и выпущен как готовый. Ни
|
|
19
|
+
* одного из них не нашёл ни автор, ни пользователь.
|
|
20
|
+
*
|
|
21
|
+
* Три решения, взятые из того опыта и не подлежащие упрощению:
|
|
22
|
+
*
|
|
23
|
+
* 1. РЕВИЗОРОВ ДВОЕ. Они находят РАЗНОЕ, а не подстраховывают друг друга:
|
|
24
|
+
* два самых опасных дефекта нашли разные исполнители, по одному у
|
|
25
|
+
* каждого. Один ревизор — гарантированно упущенная половина.
|
|
26
|
+
*
|
|
27
|
+
* 2. ФОРМАТ СТРОГИЙ. Без него приходит поток рассуждений, по которому
|
|
28
|
+
* нельзя работать. С ним — список, где у каждой находки есть место,
|
|
29
|
+
* последствие и способ воспроизвести.
|
|
30
|
+
*
|
|
31
|
+
* 3. НЕУВЕРЕННОСТЬ ЦЕННА. Ревизор, который пишет «возможно, мне не дали
|
|
32
|
+
* этот файл — стоит подтвердить», полезнее уверенного: в том опыте он
|
|
33
|
+
* был прав, файла ему действительно не дали.
|
|
34
|
+
*
|
|
35
|
+
* Зависимость объявлена как peerDependency: так пакет пользуется той же
|
|
36
|
+
* копией `dsh-tools`, что и работающий dsh, а не своей собственной. Импорт
|
|
37
|
+
* ЛЕНИВЫЙ, внутри `apply`, по двум причинам: модуль можно загрузить и
|
|
38
|
+
* проверить там, где dsh не установлен, а отсутствие пакета даёт внятную
|
|
39
|
+
* ошибку одной строки вместо обрушения всего ряда.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** Провайдеры, которых зовём. Порядок влияет только на вывод. */
|
|
43
|
+
const REVIEWERS = ['claude-code', 'codex']
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Задание ревизору.
|
|
47
|
+
*
|
|
48
|
+
* Оно самодостаточно: одноразовые провайдеры не видят нашей переписки, не
|
|
49
|
+
* знают персоны и не помнят прошлого вызова. Всё, что нужно для суждения,
|
|
50
|
+
* должно быть здесь.
|
|
51
|
+
*
|
|
52
|
+
* Формат ответа задан жёстко и с примером. Просьба «найди проблемы» без
|
|
53
|
+
* формата даёт эссе; с форматом — список, пригодный к работе.
|
|
54
|
+
*/
|
|
55
|
+
function buildPrompt({ request, paths, focus }) {
|
|
56
|
+
const lines = []
|
|
57
|
+
|
|
58
|
+
lines.push('Ты — независимый ревизор. Работу делал другой исполнитель, ты её раньше не видел.')
|
|
59
|
+
lines.push('Твоя задача — НАЙТИ ПРОБЛЕМЫ. Не переписывать, не хвалить, не предлагать улучшений стиля.')
|
|
60
|
+
lines.push('')
|
|
61
|
+
lines.push('ЧТО ТРЕБОВАЛОСЬ СДЕЛАТЬ')
|
|
62
|
+
lines.push('')
|
|
63
|
+
lines.push(String(request).trim())
|
|
64
|
+
lines.push('')
|
|
65
|
+
|
|
66
|
+
if (Array.isArray(paths) && paths.length > 0) {
|
|
67
|
+
lines.push('ЧТО СМОТРЕТЬ')
|
|
68
|
+
lines.push('')
|
|
69
|
+
for (const path of paths) lines.push(` ${String(path)}`)
|
|
70
|
+
lines.push('')
|
|
71
|
+
lines.push('Пути указаны от рабочей папки, в которой ты запущен.')
|
|
72
|
+
lines.push('')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof focus === 'string' && focus.trim() !== '') {
|
|
76
|
+
lines.push('НА ЧТО ОБРАТИТЬ ОСОБОЕ ВНИМАНИЕ')
|
|
77
|
+
lines.push('')
|
|
78
|
+
lines.push(focus.trim())
|
|
79
|
+
lines.push('')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
lines.push('ЧТО ИСКАТЬ В ЛЮБОМ СЛУЧАЕ')
|
|
83
|
+
lines.push('')
|
|
84
|
+
lines.push(' 1. Ошибки, из-за которых что-то сломается, зависнет или потеряется')
|
|
85
|
+
lines.push(' 2. Необработанные состояния: гонки, утечки, брошенные обещания, отказы сети')
|
|
86
|
+
lines.push(' 3. Несоответствие тому, что требовалось')
|
|
87
|
+
lines.push(' 4. Безопасность: что можно сделать со стороны, чего не следует')
|
|
88
|
+
lines.push(' 5. Случаи, о которых автор скорее всего не подумал')
|
|
89
|
+
lines.push('')
|
|
90
|
+
lines.push('ФОРМАТ ОТВЕТА — строго такой, без вступления и без заключения.')
|
|
91
|
+
lines.push('Для каждой находки:')
|
|
92
|
+
lines.push('')
|
|
93
|
+
lines.push('### краткое название')
|
|
94
|
+
lines.push('ВАЖНОСТЬ: критично | серьёзно | мелочь')
|
|
95
|
+
lines.push('ГДЕ: файл и строка либо имя функции')
|
|
96
|
+
lines.push('ЧТО НЕ ТАК: одно-два предложения')
|
|
97
|
+
lines.push('ПОЧЕМУ ЭТО ВАЖНО: что произойдёт у пользователя')
|
|
98
|
+
lines.push('КАК ПРОВЕРИТЬ: конкретные действия, по которым это воспроизводится')
|
|
99
|
+
lines.push('')
|
|
100
|
+
lines.push('Если находок нет — напиши ровно: НАХОДОК НЕТ')
|
|
101
|
+
lines.push('')
|
|
102
|
+
lines.push('Если каких-то данных тебе не дали и суждение из-за этого неполное — скажи')
|
|
103
|
+
lines.push('об этом прямо в находке, вместо того чтобы догадываться. Честная')
|
|
104
|
+
lines.push('неуверенность полезнее уверенной ошибки.')
|
|
105
|
+
|
|
106
|
+
return lines.join('\n')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Уровни важности в порядке убывания — по нему сортируется итог. */
|
|
110
|
+
const SEVERITY_ORDER = ['критично', 'серьёзно', 'мелочь']
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Разобрать ответ ревизора в список находок.
|
|
114
|
+
*
|
|
115
|
+
* Разбор намеренно снисходительный: ревизор — языковая модель, и точного
|
|
116
|
+
* соблюдения формата от неё ждать нельзя. Не разобранное не выбрасывается,
|
|
117
|
+
* а попадает в `raw`: потерять находку из-за лишнего пробела хуже, чем
|
|
118
|
+
* показать её неразмеченной.
|
|
119
|
+
*/
|
|
120
|
+
export function parseFindings(text, reviewer) {
|
|
121
|
+
const source = String(text ?? '')
|
|
122
|
+
if (/^\s*НАХОДОК НЕТ\s*$/im.test(source) && !source.includes('###')) return []
|
|
123
|
+
|
|
124
|
+
const findings = []
|
|
125
|
+
const blocks = source.split(/^###\s+/m).slice(1)
|
|
126
|
+
|
|
127
|
+
for (const block of blocks) {
|
|
128
|
+
const [titleLine, ...rest] = block.split('\n')
|
|
129
|
+
const body = rest.join('\n')
|
|
130
|
+
// Поле тянется до следующего заголовка или до конца блока.
|
|
131
|
+
//
|
|
132
|
+
// Флаг `m` здесь недопустим: с ним `$` означает конец СТРОКИ, и
|
|
133
|
+
// объяснение обрывалось на первой же — а ревизор объясняет развёрнуто,
|
|
134
|
+
// и вторая строка обычно и есть самое полезное. Начало строки
|
|
135
|
+
// обеспечивается явным `(?:^|\n)`, конец блока — `$` без `m`.
|
|
136
|
+
const field = (label) => {
|
|
137
|
+
const match = body.match(new RegExp(`(?:^|\\n)${label}:\\s*([\\s\\S]+?)(?=\\n[А-ЯЁ ]{3,}:|$)`))
|
|
138
|
+
return match === null ? null : match[1].trim()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const severityRaw = (field('ВАЖНОСТЬ') ?? '').toLowerCase()
|
|
142
|
+
const severity = SEVERITY_ORDER.find((level) => severityRaw.includes(level)) ?? 'мелочь'
|
|
143
|
+
|
|
144
|
+
findings.push({
|
|
145
|
+
reviewer,
|
|
146
|
+
title: titleLine.trim(),
|
|
147
|
+
severity,
|
|
148
|
+
where: field('ГДЕ'),
|
|
149
|
+
what: field('ЧТО НЕ ТАК'),
|
|
150
|
+
why: field('ПОЧЕМУ ЭТО ВАЖНО'),
|
|
151
|
+
howToCheck: field('КАК ПРОВЕРИТЬ'),
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return findings
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Позвать одного ревизора и разобрать его находки.
|
|
160
|
+
*
|
|
161
|
+
* Отказ одного НЕ отменяет обзор: половина ответа полезнее отказа, и
|
|
162
|
+
* пользователь должен видеть, кто именно не ответил.
|
|
163
|
+
*/
|
|
164
|
+
async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal) {
|
|
165
|
+
const outcome = await runReviewer(ctx, kind, {
|
|
166
|
+
prompt,
|
|
167
|
+
cwd,
|
|
168
|
+
model: choice?.model ?? null,
|
|
169
|
+
effort: choice?.effort ?? null,
|
|
170
|
+
signal,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const used = { model: choice?.model ?? null, effort: choice?.effort ?? null }
|
|
174
|
+
|
|
175
|
+
if (outcome.failed !== null) {
|
|
176
|
+
return { provider: kind, failed: outcome.failed, findings: [], ...used }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { provider: kind, failed: null, findings: parseFindings(outcome.answer, kind), ...used }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
|
|
183
|
+
function ordered(findings) {
|
|
184
|
+
return [...findings].sort((a, b) => {
|
|
185
|
+
const bySeverity = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
|
|
186
|
+
return bySeverity !== 0 ? bySeverity : a.reviewer.localeCompare(b.reviewer)
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Человекочитаемый вид итога. */
|
|
191
|
+
export function render(value) {
|
|
192
|
+
const lines = []
|
|
193
|
+
|
|
194
|
+
if (value.reviewers.length === 0) {
|
|
195
|
+
return 'Ни один ревизор не доступен: провайдеры подагентов не смонтированы.'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const failed = value.reviewers.filter((r) => r.failed !== null)
|
|
199
|
+
const worked = value.reviewers.filter((r) => r.failed === null)
|
|
200
|
+
|
|
201
|
+
lines.push(`Ревизоров опрошено: ${worked.length} из ${value.reviewers.length}. Находок: ${value.findings.length}.`)
|
|
202
|
+
for (const entry of failed) lines.push(` ✗ ${entry.provider} — ${entry.failed}`)
|
|
203
|
+
lines.push('')
|
|
204
|
+
|
|
205
|
+
if (value.findings.length === 0) {
|
|
206
|
+
lines.push(worked.length === 0 ? 'Судить не по чему: не ответил ни один ревизор.' : 'Находок нет.')
|
|
207
|
+
return lines.join('\n')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Сортируем здесь, а не полагаемся на вызывающего: опасное должно быть
|
|
211
|
+
// сверху при любом способе получить эти данные.
|
|
212
|
+
for (const finding of ordered(value.findings)) {
|
|
213
|
+
lines.push(`### ${finding.title} [${finding.severity}] — ${finding.reviewer}`)
|
|
214
|
+
if (finding.where) lines.push(`ГДЕ: ${finding.where}`)
|
|
215
|
+
if (finding.what) lines.push(`ЧТО НЕ ТАК: ${finding.what}`)
|
|
216
|
+
if (finding.why) lines.push(`ПОЧЕМУ ЭТО ВАЖНО: ${finding.why}`)
|
|
217
|
+
if (finding.howToCheck) lines.push(`КАК ПРОВЕРИТЬ: ${finding.howToCheck}`)
|
|
218
|
+
lines.push('')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Совпадение двух независимых ревизоров — сильный признак: они друг друга
|
|
222
|
+
// не видели. Расхождение тоже полезно, но его надо проверять самому.
|
|
223
|
+
const byReviewer = new Map()
|
|
224
|
+
for (const finding of value.findings) {
|
|
225
|
+
byReviewer.set(finding.reviewer, (byReviewer.get(finding.reviewer) ?? 0) + 1)
|
|
226
|
+
}
|
|
227
|
+
lines.push(`По ревизорам: ${[...byReviewer].map(([name, count]) => `${name} — ${count}`).join(', ')}.`)
|
|
228
|
+
lines.push('Находки не проверены: это мнение со стороны, а не приговор. Подтвердите каждую прежде чем чинить.')
|
|
229
|
+
|
|
230
|
+
return lines.join('\n')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const name = 'tensorgrid-second-opinion'
|
|
234
|
+
export const inject = ['tools', 'subprocess']
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param ctx - контекст агент-плоскости со службами `tools` и `subprocess`.
|
|
238
|
+
*/
|
|
239
|
+
export async function apply(ctx) {
|
|
240
|
+
const { defineTool } = await import('@deepseek-ai/dsh-tools')
|
|
241
|
+
|
|
242
|
+
ctx.tools.register(
|
|
243
|
+
defineTool({
|
|
244
|
+
name: 'second_opinion',
|
|
245
|
+
description:
|
|
246
|
+
'Ask independent reviewers (Claude Code and Codex) to find problems in work that was just done. ' +
|
|
247
|
+
'They run as separate products in the same workspace, do not see this conversation, and return only findings — ' +
|
|
248
|
+
'severity, location, what is wrong, why it matters, how to reproduce. ' +
|
|
249
|
+
'Use after non-trivial work, before declaring it finished: quiet defects that only appear in a specific sequence ' +
|
|
250
|
+
'are exactly what the author cannot see. Do not use for trivial edits — each review costs a full run of another product.',
|
|
251
|
+
parameters: {
|
|
252
|
+
request: {
|
|
253
|
+
type: 'string',
|
|
254
|
+
required: true,
|
|
255
|
+
description:
|
|
256
|
+
'What was originally asked for, restated so someone who never saw this conversation can judge whether it was met.',
|
|
257
|
+
},
|
|
258
|
+
paths: {
|
|
259
|
+
type: 'array',
|
|
260
|
+
items: { type: 'string' },
|
|
261
|
+
description: 'Files or directories to examine, relative to the workspace.',
|
|
262
|
+
},
|
|
263
|
+
focus: {
|
|
264
|
+
type: 'string',
|
|
265
|
+
description: 'Optional: what deserves particular attention, such as a risky area or a known uncertainty.',
|
|
266
|
+
},
|
|
267
|
+
reviewers: {
|
|
268
|
+
type: 'array',
|
|
269
|
+
items: { type: 'string', enum: REVIEWERS },
|
|
270
|
+
description:
|
|
271
|
+
'Which reviewers to ask. Defaults to both — they find different things, and asking one guarantees missing half.',
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
output: {
|
|
275
|
+
schema: {
|
|
276
|
+
type: 'object',
|
|
277
|
+
additionalProperties: true,
|
|
278
|
+
properties: {
|
|
279
|
+
findings: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
280
|
+
reviewers: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
render: (_args, value) => [{ type: 'text', text: render(value) }],
|
|
284
|
+
},
|
|
285
|
+
async execute(args, exec) {
|
|
286
|
+
const { runReviewer } = await import('./reviewers.js')
|
|
287
|
+
const { readSettings } = await import('./review-settings.js')
|
|
288
|
+
|
|
289
|
+
// Рабочая папка берётся у сессии, как это делают поставочные
|
|
290
|
+
// провайдеры: ревизор должен смотреть тот же код, что и автор.
|
|
291
|
+
const cwd = exec.agent?.session?.header?.cwd
|
|
292
|
+
if (typeof cwd !== 'string' || cwd === '') {
|
|
293
|
+
return { findings: [], reviewers: [], problem: 'у сессии нет рабочей папки — обзору нечего смотреть' }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Выбор пользователя из настроек; вызов может его перекрыть.
|
|
297
|
+
const settings = await readSettings(cwd)
|
|
298
|
+
const wanted = Array.isArray(args.reviewers) && args.reviewers.length > 0
|
|
299
|
+
? args.reviewers
|
|
300
|
+
: settings.enabled
|
|
301
|
+
|
|
302
|
+
if (wanted.length === 0) {
|
|
303
|
+
return { findings: [], reviewers: [], problem: 'в настройках не выбран ни один ревизор' }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const prompt = buildPrompt(args)
|
|
307
|
+
|
|
308
|
+
// Спрашиваем ОБОИХ разом: они независимы, и последовательный опрос
|
|
309
|
+
// удвоил бы ожидание без всякой пользы.
|
|
310
|
+
const answers = await Promise.all(
|
|
311
|
+
wanted.map((kind) => askReviewer(ctx, runReviewer, kind, prompt, cwd, settings.choices[kind], exec.signal)),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
findings: ordered(answers.flatMap((entry) => entry.findings)),
|
|
316
|
+
reviewers: answers.map((entry) => ({
|
|
317
|
+
provider: entry.provider,
|
|
318
|
+
failed: entry.failed,
|
|
319
|
+
found: entry.findings.length,
|
|
320
|
+
model: entry.model,
|
|
321
|
+
effort: entry.effort,
|
|
322
|
+
})),
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
}),
|
|
326
|
+
)
|
|
327
|
+
}
|
package/lib/reviewers.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
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-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
|
+
args.push('--permission-mode', 'bypassPermissions')
|
|
84
|
+
if (model) args.push('--model', model)
|
|
85
|
+
if (effort) args.push('--effort', effort)
|
|
86
|
+
return args
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (kind === 'codex') {
|
|
90
|
+
args.push('exec')
|
|
91
|
+
// Обзор ничего не меняет: только чтение. Это и защита, и честность —
|
|
92
|
+
// ревизор не должен «починить» по дороге то, что взялся оценивать.
|
|
93
|
+
args.push('--sandbox', 'read-only')
|
|
94
|
+
// Рабочая папка может не быть репозиторием, и это не повод отказываться.
|
|
95
|
+
args.push('--skip-git-repo-check')
|
|
96
|
+
args.push('--output-last-message', answerFile)
|
|
97
|
+
if (model) args.push('-m', model)
|
|
98
|
+
// Значение читается как TOML, поэтому строка в кавычках.
|
|
99
|
+
if (effort) args.push('-c', `model_reasoning_effort="${effort}"`)
|
|
100
|
+
return args
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
throw new Error(`неизвестный ревизор: ${kind}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Запустить одного ревизора и вернуть его ответ.
|
|
108
|
+
*
|
|
109
|
+
* Отказ не бросается наружу: обзор двумя ревизорами не должен пропадать
|
|
110
|
+
* целиком из-за одного неответившего. Возвращается либо ответ, либо причина.
|
|
111
|
+
*
|
|
112
|
+
* @param ctx - контекст со службой `subprocess`.
|
|
113
|
+
* @param kind - имя ревизора.
|
|
114
|
+
* @param options - задание, рабочая папка, модель, усилие, сигнал отмены.
|
|
115
|
+
*/
|
|
116
|
+
export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signal, timeoutMs }) {
|
|
117
|
+
const spec = REVIEWERS[kind]
|
|
118
|
+
if (spec === undefined) return { kind, failed: `неизвестный ревизор: ${kind}`, answer: null }
|
|
119
|
+
|
|
120
|
+
for (const [label, value] of [['модель', model], ['усилие', effort]]) {
|
|
121
|
+
if (value !== undefined && value !== null && value !== '' && !SAFE_VALUE.test(String(value))) {
|
|
122
|
+
return { kind, failed: `недопустимое значение (${label}): ${String(value)}`, answer: null }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let workDir = null
|
|
127
|
+
let handle = null
|
|
128
|
+
// Своя отмена поверх пользовательской: ревизор без ограничения по времени
|
|
129
|
+
// держал бы обзор бесконечно.
|
|
130
|
+
const deadline = new AbortController()
|
|
131
|
+
const timer = setTimeout(() => deadline.abort(), timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
132
|
+
const onOuterAbort = () => deadline.abort()
|
|
133
|
+
if (signal !== undefined) signal.addEventListener('abort', onOuterAbort, { once: true })
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
workDir = await mkdtemp(join(tmpdir(), 'tensorgrid-review-'))
|
|
137
|
+
const answerFile = join(workDir, 'answer.txt')
|
|
138
|
+
|
|
139
|
+
let executable
|
|
140
|
+
try {
|
|
141
|
+
executable = await ctx.subprocess.resolveExecutable(spec.command, undefined, deadline.signal)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return { kind, failed: `${spec.command} не найден: ${String(error?.message ?? error)}`, answer: null }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
handle = ctx.subprocess.spawn({
|
|
147
|
+
argv: [executable, ...buildArgs(kind, { model, effort, answerFile })],
|
|
148
|
+
cwd,
|
|
149
|
+
stdio: {
|
|
150
|
+
// Задание через поток ввода: кавычки и переводы строк проходят как есть.
|
|
151
|
+
stdin: { data: prompt },
|
|
152
|
+
stdout: { maxBytes: STDOUT_CAP },
|
|
153
|
+
stderr: { maxBytes: STDERR_CAP },
|
|
154
|
+
},
|
|
155
|
+
graceMs: 5000,
|
|
156
|
+
signal: deadline.signal,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const outcome = await handle.done
|
|
160
|
+
|
|
161
|
+
// Codex кладёт итог в файл, Claude Code печатает его в вывод.
|
|
162
|
+
let answer = ''
|
|
163
|
+
if (kind === 'codex') {
|
|
164
|
+
try {
|
|
165
|
+
answer = await readFile(answerFile, 'utf8')
|
|
166
|
+
} catch {
|
|
167
|
+
answer = ''
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (answer.trim() === '') {
|
|
171
|
+
answer = (await handle.collected.stdout.readFrom(0)).text
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (answer.trim() === '') {
|
|
175
|
+
const stderr = (await handle.collected.stderr.readFrom(0)).text
|
|
176
|
+
const tail = stderr.trim().split('\n').slice(-3).join(' ')
|
|
177
|
+
const code = outcome.exitCode === null ? `сигнал ${String(outcome.signal)}` : `код ${outcome.exitCode}`
|
|
178
|
+
return { kind, failed: `пустой ответ (${code})${tail === '' ? '' : `: ${tail}`}`, answer: null }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { kind, failed: null, answer, model: model ?? null, effort: effort ?? null }
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const reason = deadline.signal.aborted && signal?.aborted !== true ? 'превышено время ожидания' : String(error?.message ?? error)
|
|
184
|
+
return { kind, failed: reason, answer: null }
|
|
185
|
+
} finally {
|
|
186
|
+
clearTimeout(timer)
|
|
187
|
+
if (signal !== undefined) signal.removeEventListener('abort', onOuterAbort)
|
|
188
|
+
if (handle !== null) {
|
|
189
|
+
try {
|
|
190
|
+
handle.terminate()
|
|
191
|
+
} catch {
|
|
192
|
+
// Уборка не должна подменять результат.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (workDir !== null) {
|
|
196
|
+
await rm(workDir, { recursive: true, force: true }).catch(() => {})
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tensorgrid-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -11,11 +11,22 @@
|
|
|
11
11
|
"./client": {
|
|
12
12
|
"default": "./lib/client.js"
|
|
13
13
|
},
|
|
14
|
+
"./review": {
|
|
15
|
+
"default": "./lib/review.js"
|
|
16
|
+
},
|
|
14
17
|
"./package.json": "./package.json"
|
|
15
18
|
},
|
|
16
19
|
"files": [
|
|
17
20
|
"lib"
|
|
18
21
|
],
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@deepseek-ai/dsh-tools": "*"
|
|
24
|
+
},
|
|
25
|
+
"peerDependenciesMeta": {
|
|
26
|
+
"@deepseek-ai/dsh-tools": {
|
|
27
|
+
"optional": true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
19
30
|
"publishConfig": {
|
|
20
31
|
"registry": "https://registry.npmjs.org"
|
|
21
32
|
},
|