tensorgrid-ui 1.2.0 → 1.3.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/auth-bridge.js +46 -10
- package/lib/client.js +60 -8
- package/lib/http-runtime.js +146 -15
- package/lib/index.js +67 -39
- package/lib/review.js +329 -0
- package/package.json +12 -1
package/lib/auth-bridge.js
CHANGED
|
@@ -120,6 +120,14 @@ export function begin(authorization, key, method) {
|
|
|
120
120
|
if (current.notices.length > MAX_NOTICES) current.notices.shift()
|
|
121
121
|
},
|
|
122
122
|
prompt(prompt) {
|
|
123
|
+
// Поток задал новый вопрос, не дождавшись ответа на прежний. Прежнее
|
|
124
|
+
// обещание нужно закрыть, иначе оно повисает навсегда, а вместе с ним
|
|
125
|
+
// и вся попытка: ключ занят, отвечать уже некому.
|
|
126
|
+
if (current.resolvePrompt !== null) {
|
|
127
|
+
const orphan = current.resolvePrompt
|
|
128
|
+
current.resolvePrompt = null
|
|
129
|
+
orphan('')
|
|
130
|
+
}
|
|
123
131
|
current.prompt = prompt
|
|
124
132
|
return new Promise((resolve) => { current.resolvePrompt = resolve })
|
|
125
133
|
},
|
|
@@ -143,31 +151,59 @@ export function begin(authorization, key, method) {
|
|
|
143
151
|
return { attempt: view() }
|
|
144
152
|
}
|
|
145
153
|
|
|
146
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* Отвечает на вопрос, который задал поток.
|
|
156
|
+
*
|
|
157
|
+
* Значение приводится к строке ДО того, как вопрос снят. Обратный порядок
|
|
158
|
+
* подвешивал разговор: тело вида `{"value":{"toString":null}}` роняло
|
|
159
|
+
* приведение уже после очистки, обещание оставалось без ответа, а функция
|
|
160
|
+
* его разрешения терялась — попытка числилась активной, но вопрос исчезал.
|
|
161
|
+
*/
|
|
147
162
|
export function answer(value) {
|
|
148
163
|
if (attempt === null || attempt.resolvePrompt === null) {
|
|
149
164
|
return { problem: 'поток ничего не спрашивает', attempt: view() }
|
|
150
165
|
}
|
|
166
|
+
|
|
167
|
+
let text
|
|
168
|
+
try {
|
|
169
|
+
text = typeof value === 'string' ? value : String(value ?? '')
|
|
170
|
+
} catch {
|
|
171
|
+
return { problem: 'ответ не приводится к тексту', attempt: view() }
|
|
172
|
+
}
|
|
173
|
+
|
|
151
174
|
const resolve = attempt.resolvePrompt
|
|
152
175
|
attempt.resolvePrompt = null
|
|
153
176
|
attempt.prompt = null
|
|
154
|
-
resolve(
|
|
177
|
+
resolve(text)
|
|
155
178
|
return { attempt: view() }
|
|
156
179
|
}
|
|
157
180
|
|
|
158
181
|
/**
|
|
159
|
-
* Снимает попытку. Нужно не только по желанию пользователя: стойка
|
|
160
|
-
* ключ занятым, и осиротевшая попытка иначе блокирует повторный вход
|
|
161
|
-
* перезапуска.
|
|
182
|
+
* Снимает текущую попытку. Нужно не только по желанию пользователя: стойка
|
|
183
|
+
* держит ключ занятым, и осиротевшая попытка иначе блокирует повторный вход
|
|
184
|
+
* до перезапуска.
|
|
185
|
+
*
|
|
186
|
+
* Ключ из запроса НЕ принимается: снимать можно только то, что идёт сейчас.
|
|
187
|
+
* Иначе вторая вкладка — или посторонний запрос — снимала бы чужую попытку
|
|
188
|
+
* по произвольному имени.
|
|
162
189
|
*/
|
|
163
190
|
export function cancel(authorization, key) {
|
|
164
|
-
|
|
165
|
-
|
|
191
|
+
if (attempt === null) return { problem: 'нечего отменять', attempt: view() }
|
|
192
|
+
|
|
193
|
+
const target = attempt.key
|
|
194
|
+
if (typeof key === 'string' && key !== '' && key !== target) {
|
|
195
|
+
return { problem: 'идёт попытка для другого провайдера', attempt: view() }
|
|
196
|
+
}
|
|
197
|
+
|
|
166
198
|
authorization.cancel(target)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
199
|
+
attempt.problem = attempt.problem ?? 'отменено'
|
|
200
|
+
attempt.prompt = null
|
|
201
|
+
if (attempt.resolvePrompt !== null) {
|
|
202
|
+
// Ожидающее обещание закрываем сами: иначе поток останется висеть на
|
|
203
|
+
// вопросе, на который уже никто не ответит.
|
|
204
|
+
const pending = attempt.resolvePrompt
|
|
170
205
|
attempt.resolvePrompt = null
|
|
206
|
+
pending('')
|
|
171
207
|
}
|
|
172
208
|
return { attempt: view() }
|
|
173
209
|
}
|
package/lib/client.js
CHANGED
|
@@ -1947,10 +1947,41 @@ window.__ModuleLoader__.load({
|
|
|
1947
1947
|
// браузер начинает попытку и затем опрашивает состояние.
|
|
1948
1948
|
const AUTH_PATH = '/api/tensorgrid.auth'
|
|
1949
1949
|
|
|
1950
|
+
/**
|
|
1951
|
+
* Пропустить только настоящий веб-адрес.
|
|
1952
|
+
*
|
|
1953
|
+
* Ссылка приходит от стойки входа, то есть в конечном счёте от внешнего
|
|
1954
|
+
* провайдера. Подставлять её в `href` как есть нельзя: схема
|
|
1955
|
+
* `javascript:` превратила бы «Открыть страницу входа» в запуск чужого
|
|
1956
|
+
* кода прямо в приложении. Разрешаем http и https, остальное прячем.
|
|
1957
|
+
*
|
|
1958
|
+
* @param value - адрес из уведомления.
|
|
1959
|
+
* @returns адрес, пригодный для ссылки, или null.
|
|
1960
|
+
*/
|
|
1961
|
+
function safeUrl(value) {
|
|
1962
|
+
if (typeof value !== 'string' || value === '') return null
|
|
1963
|
+
try {
|
|
1964
|
+
const parsed = new URL(value)
|
|
1965
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? value : null
|
|
1966
|
+
} catch {
|
|
1967
|
+
return null
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1950
1971
|
function AuthRow({ t }) {
|
|
1951
1972
|
const [state, setState] = React.useState(null)
|
|
1973
|
+
// Занятость от ДЕЙСТВИЯ пользователя и фоновый опрос — разные вещи.
|
|
1974
|
+
//
|
|
1975
|
+
// Раньше опрос тоже поднимал этот признак, а поле ввода и кнопки на
|
|
1976
|
+
// нём завязаны: каждые две секунды поле гасло прямо под руками. Это
|
|
1977
|
+
// ломало ровно тот случай, ради которого опрос и заведён, — ввод кода.
|
|
1952
1978
|
const [busy, setBusy] = React.useState(false)
|
|
1953
1979
|
const [draft, setDraft] = React.useState('')
|
|
1980
|
+
// Ответы приходят не в том порядке, в каком ушли: медленный опрос
|
|
1981
|
+
// может вернуться после быстрого ответа и откатить состояние назад.
|
|
1982
|
+
// Применяем только тот ответ, который свежее уже показанного.
|
|
1983
|
+
const seq = React.useRef(0)
|
|
1984
|
+
const applied = React.useRef(0)
|
|
1954
1985
|
|
|
1955
1986
|
// Функция перевода приходит от рантайма НОВОЙ при каждом рендере —
|
|
1956
1987
|
// в поставочном коде это обычная стрелка, не мемоизированная. Если
|
|
@@ -1991,14 +2022,35 @@ window.__ModuleLoader__.load({
|
|
|
1991
2022
|
return response.json()
|
|
1992
2023
|
}, [])
|
|
1993
2024
|
|
|
1994
|
-
|
|
1995
|
-
|
|
2025
|
+
/**
|
|
2026
|
+
* Спросить Host и показать ответ.
|
|
2027
|
+
*
|
|
2028
|
+
* @param body - тело действия; без него идёт GET за списком.
|
|
2029
|
+
* @param background - фоновый опрос: не блокирует поля и не стирает
|
|
2030
|
+
* показанное состояние, если сеть отвалилась. Пользователь в этот
|
|
2031
|
+
* момент вводит код, и мигание под руками недопустимо.
|
|
2032
|
+
*/
|
|
2033
|
+
const refresh = React.useCallback(async (body, background) => {
|
|
2034
|
+
const ticket = ++seq.current
|
|
2035
|
+
if (!background) setBusy(true)
|
|
1996
2036
|
try {
|
|
1997
|
-
|
|
2037
|
+
const next = await send(body)
|
|
2038
|
+
// Обгонять уже применённый ответ нельзя: иначе запоздавший опрос
|
|
2039
|
+
// вернёт вопрос, на который только что ответили.
|
|
2040
|
+
if (ticket > applied.current) {
|
|
2041
|
+
applied.current = ticket
|
|
2042
|
+
setState(next)
|
|
2043
|
+
}
|
|
1998
2044
|
} catch (error) {
|
|
1999
|
-
|
|
2045
|
+
// Сбой фонового опроса состояние НЕ трогает. Раньше он затирал его
|
|
2046
|
+
// объектом без попытки, из-за чего опрос останавливался навсегда:
|
|
2047
|
+
// интернет возвращался, а строка молчала до перезагрузки.
|
|
2048
|
+
if (!background && ticket > applied.current) {
|
|
2049
|
+
applied.current = ticket
|
|
2050
|
+
setState({ available: true, problem: String(error && error.message ? error.message : error) })
|
|
2051
|
+
}
|
|
2000
2052
|
} finally {
|
|
2001
|
-
setBusy(false)
|
|
2053
|
+
if (!background) setBusy(false)
|
|
2002
2054
|
}
|
|
2003
2055
|
}, [send])
|
|
2004
2056
|
|
|
@@ -2009,7 +2061,7 @@ window.__ModuleLoader__.load({
|
|
|
2009
2061
|
const active = state !== null && state.attempt !== undefined && state.attempt !== null && state.attempt.active === true
|
|
2010
2062
|
React.useEffect(() => {
|
|
2011
2063
|
if (!active) return undefined
|
|
2012
|
-
const id = setInterval(() => { refresh({ action: 'poll' }) }, 2000)
|
|
2064
|
+
const id = setInterval(() => { refresh({ action: 'poll' }, true) }, 2000)
|
|
2013
2065
|
return () => clearInterval(id)
|
|
2014
2066
|
}, [active, refresh])
|
|
2015
2067
|
|
|
@@ -2038,9 +2090,9 @@ window.__ModuleLoader__.load({
|
|
|
2038
2090
|
className: 'dsx-auth__notice',
|
|
2039
2091
|
children: [
|
|
2040
2092
|
jsx('div', { children: notice.message }),
|
|
2041
|
-
notice.url === null ? null : jsx('a', {
|
|
2093
|
+
safeUrl(notice.url) === null ? null : jsx('a', {
|
|
2042
2094
|
className: 'dsx-auth__link',
|
|
2043
|
-
href: notice.url,
|
|
2095
|
+
href: safeUrl(notice.url),
|
|
2044
2096
|
target: '_blank',
|
|
2045
2097
|
rel: 'noreferrer',
|
|
2046
2098
|
children: t('auth.openLink'),
|
package/lib/http-runtime.js
CHANGED
|
@@ -27,30 +27,55 @@ function reply(res, code, value) {
|
|
|
27
27
|
* Принять маленькое JSON-тело.
|
|
28
28
|
*
|
|
29
29
|
* Здесь всегда только действие, ключ провайдера или ответ на вопрос.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
*
|
|
31
|
+
* Куски накапливаются БУФЕРАМИ, а не строкой. Склейка строкой вызывает
|
|
32
|
+
* `toString()` на каждом куске, и символ UTF-8, разорванный по границе
|
|
33
|
+
* сетевого пакета, превращается в «замену» — ответ с кириллицей изредка
|
|
34
|
+
* приезжал бы битым. Предел считается в БАЙТАХ по той же причине: длина
|
|
35
|
+
* строки и размер тела — разные величины.
|
|
36
|
+
*
|
|
37
|
+
* Тайм-аут обязателен: клиент, открывший запрос и не закрывший его, иначе
|
|
38
|
+
* держал бы обработчик и сокет бесконечно.
|
|
32
39
|
*/
|
|
33
|
-
function readJsonBody(req) {
|
|
40
|
+
function readJsonBody(req, limitBytes = 16384, timeoutMs = 15000) {
|
|
34
41
|
return new Promise((resolve) => {
|
|
35
|
-
|
|
36
|
-
let
|
|
42
|
+
const chunks = []
|
|
43
|
+
let size = 0
|
|
44
|
+
let settled = false
|
|
45
|
+
|
|
46
|
+
const finish = (value) => {
|
|
47
|
+
if (settled) return
|
|
48
|
+
settled = true
|
|
49
|
+
clearTimeout(timer)
|
|
50
|
+
resolve(value)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
req.destroy()
|
|
55
|
+
finish(null)
|
|
56
|
+
}, timeoutMs)
|
|
57
|
+
|
|
37
58
|
req.on('data', (chunk) => {
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
59
|
+
if (settled) return
|
|
60
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
61
|
+
size += buffer.length
|
|
62
|
+
if (size > limitBytes) {
|
|
63
|
+
req.destroy()
|
|
64
|
+
finish(null)
|
|
65
|
+
return
|
|
43
66
|
}
|
|
67
|
+
chunks.push(buffer)
|
|
44
68
|
})
|
|
45
69
|
req.on('end', () => {
|
|
46
|
-
if (
|
|
70
|
+
if (settled) return
|
|
71
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
47
72
|
try {
|
|
48
|
-
|
|
73
|
+
finish(JSON.parse(text === '' ? '{}' : text))
|
|
49
74
|
} catch {
|
|
50
|
-
|
|
75
|
+
finish(null)
|
|
51
76
|
}
|
|
52
77
|
})
|
|
53
|
-
req.on('error', () =>
|
|
78
|
+
req.on('error', () => finish(null))
|
|
54
79
|
})
|
|
55
80
|
}
|
|
56
81
|
|
|
@@ -59,6 +84,95 @@ function acceptsJson(req) {
|
|
|
59
84
|
return String(req.headers['content-type'] ?? '').includes('application/json')
|
|
60
85
|
}
|
|
61
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Запрос пришёл со своей же страницы?
|
|
89
|
+
*
|
|
90
|
+
* Межсайтовый POST с нашим типом содержимого браузер и так не пустит без
|
|
91
|
+
* предварительной проверки CORS, которую мы не проходим. Но держать защиту
|
|
92
|
+
* только на чужом поведении неправильно: явная сверка источника дешева и
|
|
93
|
+
* делает намерение видимым в коде.
|
|
94
|
+
*
|
|
95
|
+
* Отсутствие заголовка допускается намеренно: его не шлют не-браузерные
|
|
96
|
+
* клиенты, а локальный процесс и без того читает файл с учётными данными
|
|
97
|
+
* напрямую — запрет ему ничего бы не добавил.
|
|
98
|
+
*/
|
|
99
|
+
function sameOrigin(req) {
|
|
100
|
+
const origin = req.headers.origin
|
|
101
|
+
if (typeof origin !== 'string' || origin === '') return true
|
|
102
|
+
const host = String(req.headers.host ?? '')
|
|
103
|
+
if (host === '') return false
|
|
104
|
+
try {
|
|
105
|
+
return new URL(origin).host === host
|
|
106
|
+
} catch {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Найти поставочный файл фронтенда.
|
|
113
|
+
*
|
|
114
|
+
* Профили делят общий `node_modules`, поэтому от нашего кода до него —
|
|
115
|
+
* подъём на несколько уровней. Ищем перебором вверх, а не по заданному
|
|
116
|
+
* пути: глубина вложения зависит от того, как pnpm разложил пакеты.
|
|
117
|
+
*
|
|
118
|
+
* @param fromDir - каталог, от которого подниматься.
|
|
119
|
+
* @param relative - путь внутри `node_modules`.
|
|
120
|
+
* @returns полный путь или null, если не нашли.
|
|
121
|
+
*/
|
|
122
|
+
async function findShipped(fromDir, relative) {
|
|
123
|
+
const { existsSync } = await import('node:fs')
|
|
124
|
+
const { dirname, join } = await import('node:path')
|
|
125
|
+
let dir = fromDir
|
|
126
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
127
|
+
const candidate = join(dir, 'node_modules', relative)
|
|
128
|
+
if (existsSync(candidate)) return candidate
|
|
129
|
+
const parent = dirname(dir)
|
|
130
|
+
if (parent === dir) break
|
|
131
|
+
dir = parent
|
|
132
|
+
}
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Отдать веб-манифест: поставочный, но с нашим именем и иконкой.
|
|
138
|
+
*
|
|
139
|
+
* Полная подмена была бы заморозкой чужого файла на сегодняшней версии:
|
|
140
|
+
* dsh добавит поле — ярлыки, снимки экрана, обработчики ссылок, — а мы
|
|
141
|
+
* продолжим отдавать форму без него, и установленное приложение молча
|
|
142
|
+
* потеряет возможности.
|
|
143
|
+
*
|
|
144
|
+
* Поэтому поставочный манифест читается и ДОПОЛНЯЕТСЯ. Своими остаются
|
|
145
|
+
* ровно те поля, ради которых всё затевалось: имя и иконка. Всё прочее —
|
|
146
|
+
* `id`, `start_url`, `scope`, `display` и любое будущее — проходит как есть.
|
|
147
|
+
*
|
|
148
|
+
* Если файл не найден, отдаётся наш запасной: лучше своё имя без чужих
|
|
149
|
+
* полей, чем отказ.
|
|
150
|
+
*
|
|
151
|
+
* @param baseDir - каталог нашего модуля, от него ищется поставочный файл.
|
|
152
|
+
* @param overrides - наши поля.
|
|
153
|
+
* @param fallback - что отдать, если поставочного файла нет.
|
|
154
|
+
*/
|
|
155
|
+
export async function handleManifest(req, res, baseDir, overrides, fallback) {
|
|
156
|
+
let shipped = null
|
|
157
|
+
try {
|
|
158
|
+
const path = await findShipped(baseDir, '@deepseek-ai/dsh-web-frontend/dist/manifest.webmanifest')
|
|
159
|
+
if (path !== null) {
|
|
160
|
+
const { readFile } = await import('node:fs/promises')
|
|
161
|
+
shipped = JSON.parse(await readFile(path, 'utf8'))
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// Испорченный или недоступный файл — не повод отказывать.
|
|
165
|
+
shipped = null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const value = shipped === null ? fallback : { ...shipped, ...overrides }
|
|
169
|
+
res.writeHead(200, {
|
|
170
|
+
'content-type': 'application/manifest+json; charset=utf-8',
|
|
171
|
+
'cache-control': 'no-cache',
|
|
172
|
+
})
|
|
173
|
+
res.end(JSON.stringify(value, null, 2))
|
|
174
|
+
}
|
|
175
|
+
|
|
62
176
|
/** Обновить кеш состояния версий. Вызывается и фоновым таймером. */
|
|
63
177
|
export async function refreshUpdate(state, loadModule) {
|
|
64
178
|
const module = await loadModule('./update-check.js')
|
|
@@ -84,6 +198,10 @@ export async function handleUpdate(req, res, state, loadModule) {
|
|
|
84
198
|
reply(res, 415, { ok: false, problem: 'ожидается content-type: application/json' })
|
|
85
199
|
return
|
|
86
200
|
}
|
|
201
|
+
if (!sameOrigin(req)) {
|
|
202
|
+
reply(res, 403, { ok: false, problem: 'запрос с чужой страницы' })
|
|
203
|
+
return
|
|
204
|
+
}
|
|
87
205
|
const module = await loadModule('./update-check.js')
|
|
88
206
|
const result = await module.applyUpdate()
|
|
89
207
|
state.cached = result.status ?? state.cached
|
|
@@ -125,6 +243,11 @@ export async function handleAuth(req, res, services, loadModule) {
|
|
|
125
243
|
return
|
|
126
244
|
}
|
|
127
245
|
|
|
246
|
+
if (!sameOrigin(req)) {
|
|
247
|
+
reply(res, 403, { problem: 'запрос с чужой страницы' })
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
128
251
|
const body = await readJsonBody(req)
|
|
129
252
|
if (body === null) {
|
|
130
253
|
reply(res, 400, { problem: 'тело запроса не разобрано' })
|
|
@@ -145,7 +268,15 @@ export async function handleAuth(req, res, services, loadModule) {
|
|
|
145
268
|
return
|
|
146
269
|
}
|
|
147
270
|
|
|
148
|
-
|
|
271
|
+
// Список провайдеров прикладывается к КАЖДОМУ ответу, а не только к GET.
|
|
272
|
+
//
|
|
273
|
+
// Браузер заменяет состояние ответом целиком, и ответ без списка обнулял
|
|
274
|
+
// его: после первого же нажатия «Войти» кнопки провайдеров исчезали и не
|
|
275
|
+
// возвращались до перезагрузки страницы. Отметка «подключено» вдобавок
|
|
276
|
+
// меняется как раз по итогу входа — значит список и должен приходить
|
|
277
|
+
// вместе с ним.
|
|
278
|
+
const listing = await bridge.listProviders(services.authorization, services.credentials)
|
|
279
|
+
reply(res, 200, { available: true, ...listing, ...result })
|
|
149
280
|
} catch (error) {
|
|
150
281
|
reply(res, 500, { problem: String(error?.message ?? error) })
|
|
151
282
|
}
|
package/lib/index.js
CHANGED
|
@@ -69,33 +69,42 @@ const APP_ICON = (() => {
|
|
|
69
69
|
const APP_ICON_PATH = '/tensorgrid-icon.svg'
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
*
|
|
73
|
-
* приложения — то, что видно в панели задач, меню «Пуск» и заголовке окна.
|
|
74
|
-
* Поставочный называет продукт чужим именем, поэтому манифест свой.
|
|
72
|
+
* Наши поля веб-манифеста — и только они.
|
|
75
73
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* Манифест задаёт не только иконку, но и ИМЯ установленного приложения: то,
|
|
75
|
+
* что видно в панели задач, меню «Пуск» и заголовке окна. Поставочный
|
|
76
|
+
* называет продукт чужим именем, поэтому эти поля мы переопределяем.
|
|
77
|
+
*
|
|
78
|
+
* Остальное НЕ трогаем. Поставочный манифест читается из файла фронтенда и
|
|
79
|
+
* дополняется этими полями, так что `id`, `start_url`, `scope`, `display` и
|
|
80
|
+
* всё, что dsh добавит завтра, проходят как есть. Полная подмена заморозила
|
|
81
|
+
* бы чужой файл на сегодняшней версии.
|
|
78
82
|
*/
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
83
|
+
const MANIFEST_OVERRIDES = {
|
|
84
|
+
name: 'TENSOR GRID',
|
|
85
|
+
short_name: 'TENSOR GRID',
|
|
86
|
+
background_color: '#0B0D12',
|
|
87
|
+
theme_color: '#0B0D12',
|
|
88
|
+
icons: [
|
|
89
|
+
{ src: APP_ICON_PATH, sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
|
|
90
|
+
// maskable разрешает системе обрезать иконку под свою форму. Знак
|
|
91
|
+
// отступает от краёв на 16%, поэтому обрезка его не заденет.
|
|
92
|
+
{ src: APP_ICON_PATH, sizes: 'any', type: 'image/svg+xml', purpose: 'maskable' },
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Запасной манифест — на случай, если поставочного файла не нашлось.
|
|
98
|
+
* Повторяет поля, которые поставка задавала на момент проверки: лучше своё
|
|
99
|
+
* имя без чужих новшеств, чем отказ отдать манифест вовсе.
|
|
100
|
+
*/
|
|
101
|
+
const MANIFEST_FALLBACK = {
|
|
102
|
+
id: '/',
|
|
103
|
+
start_url: '/',
|
|
104
|
+
scope: '/',
|
|
105
|
+
display: 'fullscreen',
|
|
106
|
+
...MANIFEST_OVERRIDES,
|
|
107
|
+
}
|
|
99
108
|
|
|
100
109
|
export const name = 'tensorgrid-ui'
|
|
101
110
|
|
|
@@ -135,20 +144,6 @@ export function apply(ctx) {
|
|
|
135
144
|
}),
|
|
136
145
|
)
|
|
137
146
|
|
|
138
|
-
web.effect(() =>
|
|
139
|
-
web.webServer.register({
|
|
140
|
-
kind: 'exact',
|
|
141
|
-
path: '/manifest.webmanifest',
|
|
142
|
-
handler(_req, res) {
|
|
143
|
-
res.writeHead(200, {
|
|
144
|
-
'content-type': 'application/manifest+json; charset=utf-8',
|
|
145
|
-
'cache-control': 'no-cache',
|
|
146
|
-
})
|
|
147
|
-
res.end(MANIFEST)
|
|
148
|
-
},
|
|
149
|
-
}),
|
|
150
|
-
)
|
|
151
|
-
|
|
152
147
|
// Стабильная оболочка маршрутов. Вся бизнес-логика живёт в
|
|
153
148
|
// `http-runtime.js`, который перечитывается по времени правки файла.
|
|
154
149
|
// Поэтому исправление обработчика доезжает со следующим запросом — без
|
|
@@ -168,6 +163,19 @@ export function apply(ctx) {
|
|
|
168
163
|
return import(`${path.href}${version}`)
|
|
169
164
|
}
|
|
170
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Превратить file-адрес в путь файловой системы без импорта модуля.
|
|
168
|
+
*
|
|
169
|
+
* `node:url` импортировать здесь нельзя: строка композиции грузится
|
|
170
|
+
* раньше, чем о пакете что-либо известно, и host-половина обязана
|
|
171
|
+
* оставаться без импортов. На Windows адрес выглядит как
|
|
172
|
+
* `file:///C:/…`, поэтому ведущая косая убирается.
|
|
173
|
+
*/
|
|
174
|
+
function fileURLToPathSafe(url) {
|
|
175
|
+
const decoded = decodeURIComponent(String(url.pathname))
|
|
176
|
+
return /^\/[A-Za-z]:/.test(decoded) ? decoded.slice(1) : decoded
|
|
177
|
+
}
|
|
178
|
+
|
|
171
179
|
/** Минимальный запасной ответ, если горячий модуль не загрузился вовсе. */
|
|
172
180
|
function fail(res, error) {
|
|
173
181
|
const body = JSON.stringify({ problem: `обработчик не загрузился: ${String(error?.message ?? error)}` })
|
|
@@ -212,6 +220,26 @@ export function apply(ctx) {
|
|
|
212
220
|
})
|
|
213
221
|
})
|
|
214
222
|
|
|
223
|
+
// Манифест собирается в горячем модуле: он ЧИТАЕТ поставочный файл и
|
|
224
|
+
// дополняет его нашими полями, а значит зависит от чужой формы, которая
|
|
225
|
+
// может поменяться. Такую логику правильнее держать там, где её можно
|
|
226
|
+
// поправить без перезапуска.
|
|
227
|
+
web.effect(() =>
|
|
228
|
+
web.webServer.register({
|
|
229
|
+
kind: 'exact',
|
|
230
|
+
path: '/manifest.webmanifest',
|
|
231
|
+
async handler(req, res) {
|
|
232
|
+
try {
|
|
233
|
+
const runtime = await loadModule('./http-runtime.js')
|
|
234
|
+
const here = new URL('.', import.meta.url)
|
|
235
|
+
await runtime.handleManifest(req, res, fileURLToPathSafe(here), MANIFEST_OVERRIDES, MANIFEST_FALLBACK)
|
|
236
|
+
} catch (error) {
|
|
237
|
+
fail(res, error)
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
)
|
|
242
|
+
|
|
215
243
|
web.effect(() =>
|
|
216
244
|
web.webServer.register({
|
|
217
245
|
kind: 'exact',
|
package/lib/review.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* «Второе мнение» — независимый обзор работы чужими исполнителями.
|
|
3
|
+
*
|
|
4
|
+
* Это строка АГЕНТ-ПЛОСКОСТИ: инструмент регистрируется в реестр пресета, а
|
|
5
|
+
* не профиля. Провайдеры подагентов живут на Host и монтируются отдельно;
|
|
6
|
+
* наличие провайдера само по себе прав агенту не даёт, их даёт эта строка.
|
|
7
|
+
*
|
|
8
|
+
* Зачем это нужно, коротко. Ошибки бывают громкие и тихие. Громкие падают с
|
|
9
|
+
* криком, и их находит первый же пользователь. Тихие проявляются только в
|
|
10
|
+
* определённой последовательности действий — их не находит никто, включая
|
|
11
|
+
* автора, который перечитал свой код и уверен в нём.
|
|
12
|
+
*
|
|
13
|
+
* Проверено на себе: два независимых ревизора нашли восемь настоящих
|
|
14
|
+
* дефектов в коде, который был написан, вычитан и выпущен как готовый. Ни
|
|
15
|
+
* одного из них не нашёл ни автор, ни пользователь.
|
|
16
|
+
*
|
|
17
|
+
* Три решения, взятые из того опыта и не подлежащие упрощению:
|
|
18
|
+
*
|
|
19
|
+
* 1. РЕВИЗОРОВ ДВОЕ. Они находят РАЗНОЕ, а не подстраховывают друг друга:
|
|
20
|
+
* два самых опасных дефекта нашли разные исполнители, по одному у
|
|
21
|
+
* каждого. Один ревизор — гарантированно упущенная половина.
|
|
22
|
+
*
|
|
23
|
+
* 2. ФОРМАТ СТРОГИЙ. Без него приходит поток рассуждений, по которому
|
|
24
|
+
* нельзя работать. С ним — список, где у каждой находки есть место,
|
|
25
|
+
* последствие и способ воспроизвести.
|
|
26
|
+
*
|
|
27
|
+
* 3. НЕУВЕРЕННОСТЬ ЦЕННА. Ревизор, который пишет «возможно, мне не дали
|
|
28
|
+
* этот файл — стоит подтвердить», полезнее уверенного: в том опыте он
|
|
29
|
+
* был прав, файла ему действительно не дали.
|
|
30
|
+
*
|
|
31
|
+
* Зависимость объявлена как peerDependency: так пакет пользуется той же
|
|
32
|
+
* копией `dsh-tools`, что и работающий dsh, а не своей собственной. Импорт
|
|
33
|
+
* ЛЕНИВЫЙ, внутри `apply`, по двум причинам: модуль можно загрузить и
|
|
34
|
+
* проверить там, где dsh не установлен, а отсутствие пакета даёт внятную
|
|
35
|
+
* ошибку одной строки вместо обрушения всего ряда.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Провайдеры, которых зовём. Порядок влияет только на вывод. */
|
|
39
|
+
const REVIEWERS = ['claude-code', 'codex']
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Задание ревизору.
|
|
43
|
+
*
|
|
44
|
+
* Оно самодостаточно: одноразовые провайдеры не видят нашей переписки, не
|
|
45
|
+
* знают персоны и не помнят прошлого вызова. Всё, что нужно для суждения,
|
|
46
|
+
* должно быть здесь.
|
|
47
|
+
*
|
|
48
|
+
* Формат ответа задан жёстко и с примером. Просьба «найди проблемы» без
|
|
49
|
+
* формата даёт эссе; с форматом — список, пригодный к работе.
|
|
50
|
+
*/
|
|
51
|
+
function buildPrompt({ request, paths, focus }) {
|
|
52
|
+
const lines = []
|
|
53
|
+
|
|
54
|
+
lines.push('Ты — независимый ревизор. Работу делал другой исполнитель, ты её раньше не видел.')
|
|
55
|
+
lines.push('Твоя задача — НАЙТИ ПРОБЛЕМЫ. Не переписывать, не хвалить, не предлагать улучшений стиля.')
|
|
56
|
+
lines.push('')
|
|
57
|
+
lines.push('ЧТО ТРЕБОВАЛОСЬ СДЕЛАТЬ')
|
|
58
|
+
lines.push('')
|
|
59
|
+
lines.push(String(request).trim())
|
|
60
|
+
lines.push('')
|
|
61
|
+
|
|
62
|
+
if (Array.isArray(paths) && paths.length > 0) {
|
|
63
|
+
lines.push('ЧТО СМОТРЕТЬ')
|
|
64
|
+
lines.push('')
|
|
65
|
+
for (const path of paths) lines.push(` ${String(path)}`)
|
|
66
|
+
lines.push('')
|
|
67
|
+
lines.push('Пути указаны от рабочей папки, в которой ты запущен.')
|
|
68
|
+
lines.push('')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof focus === 'string' && focus.trim() !== '') {
|
|
72
|
+
lines.push('НА ЧТО ОБРАТИТЬ ОСОБОЕ ВНИМАНИЕ')
|
|
73
|
+
lines.push('')
|
|
74
|
+
lines.push(focus.trim())
|
|
75
|
+
lines.push('')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
lines.push('ЧТО ИСКАТЬ В ЛЮБОМ СЛУЧАЕ')
|
|
79
|
+
lines.push('')
|
|
80
|
+
lines.push(' 1. Ошибки, из-за которых что-то сломается, зависнет или потеряется')
|
|
81
|
+
lines.push(' 2. Необработанные состояния: гонки, утечки, брошенные обещания, отказы сети')
|
|
82
|
+
lines.push(' 3. Несоответствие тому, что требовалось')
|
|
83
|
+
lines.push(' 4. Безопасность: что можно сделать со стороны, чего не следует')
|
|
84
|
+
lines.push(' 5. Случаи, о которых автор скорее всего не подумал')
|
|
85
|
+
lines.push('')
|
|
86
|
+
lines.push('ФОРМАТ ОТВЕТА — строго такой, без вступления и без заключения.')
|
|
87
|
+
lines.push('Для каждой находки:')
|
|
88
|
+
lines.push('')
|
|
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
|
+
|
|
102
|
+
return lines.join('\n')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Уровни важности в порядке убывания — по нему сортируется итог. */
|
|
106
|
+
const SEVERITY_ORDER = ['критично', 'серьёзно', 'мелочь']
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Разобрать ответ ревизора в список находок.
|
|
110
|
+
*
|
|
111
|
+
* Разбор намеренно снисходительный: ревизор — языковая модель, и точного
|
|
112
|
+
* соблюдения формата от неё ждать нельзя. Не разобранное не выбрасывается,
|
|
113
|
+
* а попадает в `raw`: потерять находку из-за лишнего пробела хуже, чем
|
|
114
|
+
* показать её неразмеченной.
|
|
115
|
+
*/
|
|
116
|
+
export function parseFindings(text, reviewer) {
|
|
117
|
+
const source = String(text ?? '')
|
|
118
|
+
if (/^\s*НАХОДОК НЕТ\s*$/im.test(source) && !source.includes('###')) return []
|
|
119
|
+
|
|
120
|
+
const findings = []
|
|
121
|
+
const blocks = source.split(/^###\s+/m).slice(1)
|
|
122
|
+
|
|
123
|
+
for (const block of blocks) {
|
|
124
|
+
const [titleLine, ...rest] = block.split('\n')
|
|
125
|
+
const body = rest.join('\n')
|
|
126
|
+
// Поле тянется до следующего заголовка или до конца блока.
|
|
127
|
+
//
|
|
128
|
+
// Флаг `m` здесь недопустим: с ним `$` означает конец СТРОКИ, и
|
|
129
|
+
// объяснение обрывалось на первой же — а ревизор объясняет развёрнуто,
|
|
130
|
+
// и вторая строка обычно и есть самое полезное. Начало строки
|
|
131
|
+
// обеспечивается явным `(?:^|\n)`, конец блока — `$` без `m`.
|
|
132
|
+
const field = (label) => {
|
|
133
|
+
const match = body.match(new RegExp(`(?:^|\\n)${label}:\\s*([\\s\\S]+?)(?=\\n[А-ЯЁ ]{3,}:|$)`))
|
|
134
|
+
return match === null ? null : match[1].trim()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const severityRaw = (field('ВАЖНОСТЬ') ?? '').toLowerCase()
|
|
138
|
+
const severity = SEVERITY_ORDER.find((level) => severityRaw.includes(level)) ?? 'мелочь'
|
|
139
|
+
|
|
140
|
+
findings.push({
|
|
141
|
+
reviewer,
|
|
142
|
+
title: titleLine.trim(),
|
|
143
|
+
severity,
|
|
144
|
+
where: field('ГДЕ'),
|
|
145
|
+
what: field('ЧТО НЕ ТАК'),
|
|
146
|
+
why: field('ПОЧЕМУ ЭТО ВАЖНО'),
|
|
147
|
+
howToCheck: field('КАК ПРОВЕРИТЬ'),
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return findings
|
|
152
|
+
}
|
|
153
|
+
|
|
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
|
+
/**
|
|
161
|
+
* Позвать одного ревизора и вернуть его находки.
|
|
162
|
+
*
|
|
163
|
+
* Отказ одного НЕ отменяет обзор: половина ответа полезнее отказа, и
|
|
164
|
+
* пользователь должен видеть, кто именно не ответил.
|
|
165
|
+
*/
|
|
166
|
+
async function askReviewer(ctx, provider, prompt, parent, signal) {
|
|
167
|
+
let run
|
|
168
|
+
try {
|
|
169
|
+
run = await ctx.subagents.start(provider, {
|
|
170
|
+
label: `обзор: ${provider}`,
|
|
171
|
+
prompt: [{ type: 'text', text: prompt }],
|
|
172
|
+
parent,
|
|
173
|
+
signal,
|
|
174
|
+
})
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return { provider, failed: String(error?.message ?? error), findings: [] }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const result = await run.result
|
|
181
|
+
const answer = textOf(result?.output)
|
|
182
|
+
if (answer.trim() === '') {
|
|
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
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
|
|
198
|
+
function ordered(findings) {
|
|
199
|
+
return [...findings].sort((a, b) => {
|
|
200
|
+
const bySeverity = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
|
|
201
|
+
return bySeverity !== 0 ? bySeverity : a.reviewer.localeCompare(b.reviewer)
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Человекочитаемый вид итога. */
|
|
206
|
+
export function render(value) {
|
|
207
|
+
const lines = []
|
|
208
|
+
|
|
209
|
+
if (value.reviewers.length === 0) {
|
|
210
|
+
return 'Ни один ревизор не доступен: провайдеры подагентов не смонтированы.'
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const failed = value.reviewers.filter((r) => r.failed !== null)
|
|
214
|
+
const worked = value.reviewers.filter((r) => r.failed === null)
|
|
215
|
+
|
|
216
|
+
lines.push(`Ревизоров опрошено: ${worked.length} из ${value.reviewers.length}. Находок: ${value.findings.length}.`)
|
|
217
|
+
for (const entry of failed) lines.push(` ✗ ${entry.provider} — ${entry.failed}`)
|
|
218
|
+
lines.push('')
|
|
219
|
+
|
|
220
|
+
if (value.findings.length === 0) {
|
|
221
|
+
lines.push(worked.length === 0 ? 'Судить не по чему: не ответил ни один ревизор.' : 'Находок нет.')
|
|
222
|
+
return lines.join('\n')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Сортируем здесь, а не полагаемся на вызывающего: опасное должно быть
|
|
226
|
+
// сверху при любом способе получить эти данные.
|
|
227
|
+
for (const finding of ordered(value.findings)) {
|
|
228
|
+
lines.push(`### ${finding.title} [${finding.severity}] — ${finding.reviewer}`)
|
|
229
|
+
if (finding.where) lines.push(`ГДЕ: ${finding.where}`)
|
|
230
|
+
if (finding.what) lines.push(`ЧТО НЕ ТАК: ${finding.what}`)
|
|
231
|
+
if (finding.why) lines.push(`ПОЧЕМУ ЭТО ВАЖНО: ${finding.why}`)
|
|
232
|
+
if (finding.howToCheck) lines.push(`КАК ПРОВЕРИТЬ: ${finding.howToCheck}`)
|
|
233
|
+
lines.push('')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Совпадение двух независимых ревизоров — сильный признак: они друг друга
|
|
237
|
+
// не видели. Расхождение тоже полезно, но его надо проверять самому.
|
|
238
|
+
const byReviewer = new Map()
|
|
239
|
+
for (const finding of value.findings) {
|
|
240
|
+
byReviewer.set(finding.reviewer, (byReviewer.get(finding.reviewer) ?? 0) + 1)
|
|
241
|
+
}
|
|
242
|
+
lines.push(`По ревизорам: ${[...byReviewer].map(([name, count]) => `${name} — ${count}`).join(', ')}.`)
|
|
243
|
+
lines.push('Находки не проверены: это мнение со стороны, а не приговор. Подтвердите каждую прежде чем чинить.')
|
|
244
|
+
|
|
245
|
+
return lines.join('\n')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const name = 'tensorgrid-second-opinion'
|
|
249
|
+
export const inject = ['tools', 'subagents']
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @param ctx - контекст агент-плоскости с реестрами `tools` и `subagents`.
|
|
253
|
+
*/
|
|
254
|
+
export async function apply(ctx) {
|
|
255
|
+
const { defineTool } = await import('@deepseek-ai/dsh-tools')
|
|
256
|
+
|
|
257
|
+
ctx.tools.register(
|
|
258
|
+
defineTool({
|
|
259
|
+
name: 'second_opinion',
|
|
260
|
+
description:
|
|
261
|
+
'Ask independent reviewers (Claude Code and Codex) to find problems in work that was just done. ' +
|
|
262
|
+
'They run as separate products in the same workspace, do not see this conversation, and return only findings — ' +
|
|
263
|
+
'severity, location, what is wrong, why it matters, how to reproduce. ' +
|
|
264
|
+
'Use after non-trivial work, before declaring it finished: quiet defects that only appear in a specific sequence ' +
|
|
265
|
+
'are exactly what the author cannot see. Do not use for trivial edits — each review costs a full run of another product.',
|
|
266
|
+
parameters: {
|
|
267
|
+
request: {
|
|
268
|
+
type: 'string',
|
|
269
|
+
required: true,
|
|
270
|
+
description:
|
|
271
|
+
'What was originally asked for, restated so someone who never saw this conversation can judge whether it was met.',
|
|
272
|
+
},
|
|
273
|
+
paths: {
|
|
274
|
+
type: 'array',
|
|
275
|
+
items: { type: 'string' },
|
|
276
|
+
description: 'Files or directories to examine, relative to the workspace.',
|
|
277
|
+
},
|
|
278
|
+
focus: {
|
|
279
|
+
type: 'string',
|
|
280
|
+
description: 'Optional: what deserves particular attention, such as a risky area or a known uncertainty.',
|
|
281
|
+
},
|
|
282
|
+
reviewers: {
|
|
283
|
+
type: 'array',
|
|
284
|
+
items: { type: 'string', enum: REVIEWERS },
|
|
285
|
+
description:
|
|
286
|
+
'Which reviewers to ask. Defaults to both — they find different things, and asking one guarantees missing half.',
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
output: {
|
|
290
|
+
schema: {
|
|
291
|
+
type: 'object',
|
|
292
|
+
additionalProperties: true,
|
|
293
|
+
properties: {
|
|
294
|
+
findings: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
295
|
+
reviewers: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
render: (_args, value) => [{ type: 'text', text: render(value) }],
|
|
299
|
+
},
|
|
300
|
+
async execute(args, exec) {
|
|
301
|
+
const parent = exec.agent
|
|
302
|
+
const available = ctx.subagents.list()
|
|
303
|
+
const wanted = Array.isArray(args.reviewers) && args.reviewers.length > 0 ? args.reviewers : REVIEWERS
|
|
304
|
+
const usable = wanted.filter((provider) => available.includes(provider))
|
|
305
|
+
|
|
306
|
+
if (usable.length === 0) {
|
|
307
|
+
return {
|
|
308
|
+
findings: [],
|
|
309
|
+
reviewers: [],
|
|
310
|
+
problem: `нет доступных ревизоров; провайдеры на Host: ${available.join(', ') || 'нет'}`,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const prompt = buildPrompt(args)
|
|
315
|
+
|
|
316
|
+
// Спрашиваем ОБОИХ разом: они независимы, и последовательный опрос
|
|
317
|
+
// удвоил бы ожидание без всякой пользы.
|
|
318
|
+
const answers = await Promise.all(
|
|
319
|
+
usable.map((provider) => askReviewer(ctx, provider, prompt, parent, exec.signal)),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
findings: ordered(answers.flatMap((entry) => entry.findings)),
|
|
324
|
+
reviewers: answers.map((entry) => ({ provider: entry.provider, failed: entry.failed, found: entry.findings.length })),
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
}),
|
|
328
|
+
)
|
|
329
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tensorgrid-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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
|
},
|