tensorgrid-ui 1.3.0 → 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 +49 -51
- package/lib/reviewers.js +199 -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,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
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
|
* криком, и их находит первый же пользователь. Тихие проявляются только в
|
|
@@ -151,47 +155,28 @@ export function parseFindings(text, reviewer) {
|
|
|
151
155
|
return findings
|
|
152
156
|
}
|
|
153
157
|
|
|
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
158
|
/**
|
|
161
|
-
* Позвать одного ревизора и
|
|
159
|
+
* Позвать одного ревизора и разобрать его находки.
|
|
162
160
|
*
|
|
163
161
|
* Отказ одного НЕ отменяет обзор: половина ответа полезнее отказа, и
|
|
164
162
|
* пользователь должен видеть, кто именно не ответил.
|
|
165
163
|
*/
|
|
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
|
-
}
|
|
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
|
+
})
|
|
178
172
|
|
|
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
|
-
}
|
|
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 }
|
|
194
177
|
}
|
|
178
|
+
|
|
179
|
+
return { provider: kind, failed: null, findings: parseFindings(outcome.answer, kind), ...used }
|
|
195
180
|
}
|
|
196
181
|
|
|
197
182
|
/** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
|
|
@@ -246,10 +231,10 @@ export function render(value) {
|
|
|
246
231
|
}
|
|
247
232
|
|
|
248
233
|
export const name = 'tensorgrid-second-opinion'
|
|
249
|
-
export const inject = ['tools', '
|
|
234
|
+
export const inject = ['tools', 'subprocess']
|
|
250
235
|
|
|
251
236
|
/**
|
|
252
|
-
* @param ctx - контекст агент-плоскости
|
|
237
|
+
* @param ctx - контекст агент-плоскости со службами `tools` и `subprocess`.
|
|
253
238
|
*/
|
|
254
239
|
export async function apply(ctx) {
|
|
255
240
|
const { defineTool } = await import('@deepseek-ai/dsh-tools')
|
|
@@ -298,17 +283,24 @@ export async function apply(ctx) {
|
|
|
298
283
|
render: (_args, value) => [{ type: 'text', text: render(value) }],
|
|
299
284
|
},
|
|
300
285
|
async execute(args, exec) {
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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: 'в настройках не выбран ни один ревизор' }
|
|
312
304
|
}
|
|
313
305
|
|
|
314
306
|
const prompt = buildPrompt(args)
|
|
@@ -316,12 +308,18 @@ export async function apply(ctx) {
|
|
|
316
308
|
// Спрашиваем ОБОИХ разом: они независимы, и последовательный опрос
|
|
317
309
|
// удвоил бы ожидание без всякой пользы.
|
|
318
310
|
const answers = await Promise.all(
|
|
319
|
-
|
|
311
|
+
wanted.map((kind) => askReviewer(ctx, runReviewer, kind, prompt, cwd, settings.choices[kind], exec.signal)),
|
|
320
312
|
)
|
|
321
313
|
|
|
322
314
|
return {
|
|
323
315
|
findings: ordered(answers.flatMap((entry) => entry.findings)),
|
|
324
|
-
reviewers: answers.map((entry) => ({
|
|
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
|
+
})),
|
|
325
323
|
}
|
|
326
324
|
},
|
|
327
325
|
}),
|
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