tensorgrid-ui 1.1.3 → 1.1.5

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/README.md CHANGED
@@ -12,7 +12,10 @@
12
12
  | Файл | Роль |
13
13
  |---|---|
14
14
  | `package.json` | секция `dsh.client` — по ней рантайм понимает, что у пакета есть браузерная половина |
15
- | `lib/index.js` | host-половина: пустая по существу, нужна как строка композиции |
15
+ | `lib/index.js` | стабильная host-оболочка: маршруты, иконки, манифест и получение Cordis-служб |
16
+ | `lib/http-runtime.js` | перечитываемые HTTP-обработчики входа и обновлений |
17
+ | `lib/auth-bridge.js` | состояние разговора входа |
18
+ | `lib/update-check.js` | проверка версий и установка из реестра |
16
19
  | `lib/client.js` | браузерная половина: палитра, стили, компонент слоя |
17
20
 
18
21
  `lib/client.js` написан руками в том же формате, что и поставочные
@@ -155,22 +158,26 @@ git push # 6. выпустить
155
158
  выпуском. Она прогоняет всё в правильном порядке и **останавливается на
156
159
  первом провале**: сверяет версию dsh со снимком, переснимает окружение,
157
160
  обновляет эталон строк, проверяет связность перевода, пересобирает
158
- словари, гоняет 143 проверки контракта и смотрит, всё ли зафиксировано.
161
+ словари, гоняет 172 проверки контракта и смотрит, всё ли зафиксировано.
159
162
 
160
163
  Порядок шагов не случаен: снимок снимается **до** проверки контракта,
161
164
  потому что тест сверяется именно с ним.
162
165
 
163
166
  ### Обновление без новой версии dsh
164
167
 
165
- Автоматического обновления нет ни у dsh, ни у продукта, и никто не
166
- уведомляет о новых версиях это проверено, а не предположено.
168
+ Продукт сам сверяет установленную версию с меткой `latest` в реестре и
169
+ показывает результат в настройках. Кнопка «Обновить» ставит новую версию,
170
+ после чего просит либо обновить страницу, либо перезапустить приложение —
171
+ решение принимается по отпечатку host-половины, а не наугад.
172
+
173
+ То же самое из консоли:
167
174
 
168
175
  ```powershell
169
- git pull # забрать новую версию продукта
170
- .\install.ps1 # применить; профиль и настройки не трогаются
176
+ dsh plugin --profile tensorgrid add tensorgrid-ui@latest
171
177
  ```
172
178
 
173
- Обновление самого dsh отдельно и вручную:
179
+ **У самого dsh автообновления нет, и никто о новых версиях не уведомляет** —
180
+ это проверено, а не предположено. Обновлять его нужно вручную:
174
181
 
175
182
  ```powershell
176
183
  npm install -g "@deepseek-ai/dsh@<версия>"
@@ -195,12 +202,19 @@ npm install -g "@deepseek-ai/dsh@<версия>"
195
202
  dsh --profile tensorgrid --from-default-profile web --dump-config
196
203
  ```
197
204
 
198
- 2. Скопировать пакет (каталоги разработки в рантайме не нужны):
205
+ 2. Поставить пакет из реестра штатной командой dsh — она пробрасывает
206
+ установку в pnpm внутри профиля:
199
207
 
200
- ```powershell
201
- robocopy <источник> "$env:USERPROFILE\.dsh\profiles\tensorgrid\packages\dsh-ui-obsidian-ion" `
202
- /MIR /XD locale test tools /NFL /NDL /NJH /NJS
203
208
  ```
209
+ dsh plugin --profile tensorgrid add tensorgrid-ui@<версия>
210
+ ```
211
+
212
+ Версию задаёт манифест репозитория, а не метка `latest`: у всех должно
213
+ встать ровно то, что проверено, а не то, что оказалось свежим в момент
214
+ установки.
215
+
216
+ Предупреждение «declares no `dsh.bundle`» здесь нормально: пакет не
217
+ бандл, а один плагин, который монтируется своей строкой ниже.
204
218
 
205
219
  3. Положить патч-слой профиля — готовый файл лежит в репозитории:
206
220
 
@@ -212,15 +226,22 @@ npm install -g "@deepseek-ai/dsh@<версия>"
212
226
 
213
227
  ```yaml
214
228
  - insert:
215
- - id: ui-obsidian-ion
216
- name: './packages/dsh-ui-obsidian-ion/lib/index.js'
229
+ - id: authorization
230
+ name: '@deepseek-ai/dsh-authorization'
231
+
232
+ - id: ui-tensorgrid
233
+ name: 'tensorgrid-ui'
217
234
  ```
218
235
 
219
- Указывать нужно **файл host-половины, а не каталог**: спецификатор
220
- разрешается штатным ESM-резолвом, а тот не умеет импортировать каталог.
221
- Прецедент такого подключения есть и в поставке — профиль `claude`
222
- монтирует локальный пакет строкой
223
- `name: './packages/dsh-claude-subscription/index.mjs'`.
236
+ Пакет монтируется **по имени**: спецификатор разрешается штатным
237
+ ESM-резолвом из `node_modules` профиля, куда его кладёт `dsh plugin add`,
238
+ а манифест `dsh.client` находится подъёмом до ближайшего `package.json`.
239
+ Регистрировать пакет где-то ещё не нужно.
240
+
241
+ Строка `authorization` — стойка входа. Ни один поставочный профиль её не
242
+ монтирует, а без неё `llm-pi-ai` не регистрирует ни одного потока входа,
243
+ и в настройках остаются только ключи API. Подробнее — в разделе «Вход по
244
+ подписке».
224
245
 
225
246
  4. Положить агент-пресет:
226
247
 
@@ -241,6 +262,48 @@ npm install -g "@deepseek-ai/dsh@<версия>"
241
262
  Пресет выбирается в интерфейсе, в настройках агента: в списке появится
242
263
  «TENSOR GRID» рядом с поставочными.
243
264
 
265
+ ## Вход по подписке
266
+
267
+ Провайдеры моделей приходят из библиотеки `pi-ai`, и у части из них есть
268
+ вход по подписке, а не только ключ API. Потоки входа регистрирует
269
+ `llm-pi-ai`, но **только если смонтирована стойка `dsh-authorization`** —
270
+ ни один поставочный профиль её не монтирует, поэтому в стоковом интерфейсе
271
+ видны одни ключи. Одна строка в патч-слое включает все 39 потоков, шесть из
272
+ которых с подпиской:
273
+
274
+ | Провайдер | Ключ | Способы |
275
+ |---|---|---|
276
+ | Anthropic | `llm-pi-ai/anthropic` | Claude Pro/Max, ключ API |
277
+ | OpenAI Codex | `llm-pi-ai/openai-codex` | ChatGPT Plus/Pro |
278
+ | GitHub Copilot | `llm-pi-ai/github-copilot` | вход, токен |
279
+ | Kimi For Coding | `llm-pi-ai/kimi-coding` | вход, ключ API |
280
+ | OpenRouter | `llm-pi-ai/openrouter` | вход, ключ API |
281
+ | xAI | `llm-pi-ai/xai` | SuperGrok или X Premium, ключ API |
282
+
283
+ Учётные данные ложатся в `~/.dsh/.credentials.yaml` — **вне профилей**,
284
+ поэтому вход, сделанный однажды, работает во всех, а подписка у каждого
285
+ сотрудника своя.
286
+
287
+ Строка настроек ведёт разговор входа целиком: показывает ссылку и код,
288
+ задаёт вопросы с выбором и вводом, позволяет отменить попытку. Отмена не
289
+ украшение — стойка допускает одну попытку на ключ, и осиротевшая заявка
290
+ иначе держит ключ занятым до перезапуска.
291
+
292
+ ### Три ошибки, на которых это строилось
293
+
294
+ Чтение службы из Cordis оказалось не тем, чем выглядит, и стоило трёх
295
+ выпусков подряд:
296
+
297
+ 1. `ctx.authorization` как свойство — запрещено без объявления в `inject`;
298
+ 2. `ctx.get('authorization')` возвращает службу, но **её методы всё равно
299
+ охраняются** контекстом, который службу не объявил;
300
+ 3. объявить стойку в `inject` всего ряда нельзя — она необязательна, и
301
+ профиль без неё остался бы без палитры, айдентики и перевода.
302
+
303
+ Работает только четвёртый вариант: **вложенный `ctx.inject`**, который ждёт
304
+ службы, не задерживая остальное, и отдаёт ссылки, связанные с контекстом,
305
+ их объявившим. Правило закреплено проверками в дымовом тесте.
306
+
244
307
  ### Проверка установки
245
308
 
246
309
  Профиль можно поднять на свободном порту, не мешая уже запущенному:
@@ -288,7 +351,7 @@ dsh --profile tensorgrid --port 3081 --no-open
288
351
  5. `exports["./client"]` принимается и строкой, и объектом с `default`;
289
352
  6. путь бандла = `dirname(package.json) + exports["./client"]`.
290
353
 
291
- - **исполнение бандла** — `node test/smoke.mjs`, 47 проверок. Секция про
354
+ - **исполнение бандла** — `node test/smoke.mjs`, 172 проверки. Секция про
292
355
  манифест проверена негативным контролем: при `platform: "node"` и снятом
293
356
  `exports["./client"]` тест валится на четырёх проверках.
294
357
 
@@ -390,23 +453,39 @@ cd C:\тест\dsh-ui-obsidian-ion
390
453
  node tools/extract-locales.mjs # обновить эталон, увидеть новые ключи
391
454
  node tools/audit-ru.mjs # связность перевода
392
455
  node tools/build-ru.mjs # пересобрать словари
393
- node test/smoke.mjs # 139 проверок контракта
456
+ node test/smoke.mjs # 172 проверки контракта
394
457
  ```
395
458
 
396
459
  Новые строки интерфейса, которых нет в русском словаре, покажутся
397
460
  по-английски — это штатный фолбэк, а не поломка.
398
461
 
399
- ### Синхронизация в профиль
462
+ ### Доставка изменений и перезапуск
400
463
 
401
- ```powershell
402
- robocopy C:\тест\dsh-ui-obsidian-ion `
403
- "$env:USERPROFILE\.dsh\profiles\web\packages\dsh-ui-obsidian-ion" `
404
- /MIR /XD locale test tools /NFL /NDL /NJH /NJS
405
- ```
464
+ Пакет приезжает из реестра; копирования файлов больше нет нигде.
465
+
466
+ Что чем подхватывается:
467
+
468
+ | Изменилось | Достаточно |
469
+ |---|---|
470
+ | `lib/client.js` | обновить страницу |
471
+ | `lib/http-runtime.js`, `auth-bridge.js`, `update-check.js` | ничего — следующий запрос возьмёт новую версию |
472
+ | `lib/index.js`, состав маршрутов, зависимости | перезапустить приложение |
473
+
474
+ Так стало с 1.1.4. До неё обработчики жили в `index.js`, а он читается
475
+ только при старте — поэтому каждый выпуск требовал перезапуска, и
476
+ пользователь трижды подряд на это натыкался.
477
+
478
+ **Переход на 1.1.4 сам требует одного перезапуска**: старая оболочка про
479
+ новый модуль ещё не знает.
480
+
481
+ Две честные границы. Это не горячая перезагрузка всего приложения — всё,
482
+ что регистрируется в Cordis, по-прежнему живёт до перезапуска. И обновление
483
+ во время идущего входа обрывает разговор: состояние попытки привязано к
484
+ экземпляру модуля, а замена файла создаёт новый.
406
485
 
407
- Каталоги `locale`, `test` и `tools` в рантайме не нужны и исключены.
408
- Правка `lib/client.js` подхватывается обновлением страницы; правка
409
- `lib/index.js` (host-половина) требует перезапуска `dsh` — модуль кешируется.
486
+ Проверено запуском, а не рассуждением: правка ответа обработчика доехала
487
+ до следующего запроса, возврат исходного текста — тоже, PID процесса не
488
+ менялся.
410
489
 
411
490
  ## Цикл правок
412
491
 
@@ -13,12 +13,11 @@
13
13
  * Импортируются только встроенные модули Node — строка композиции грузится
14
14
  * раньше, чем о пакете что-либо известно.
15
15
  *
16
- * Функции принимают САМИ СЛУЖБЫ, а не контекст. В Cordis чтение
17
- * `ctx.authorization` как свойства требует объявить службу в `inject`, а наш
18
- * ряд объявляет только `webServer`: стойка входа необязательна и может
19
- * отсутствовать вовсе. Читать её полагается через `ctx.get(...)`, и дальше
20
- * передавать результат иначе внутри снова окажется запрещённое обращение,
21
- * и Cordis ответит «cannot get property "authorization" without inject».
16
+ * Функции принимают САМИ СЛУЖБЫ, а не контекст. В Cordis служба должна быть
17
+ * получена из вложенного `ctx.inject(...)`: чтение через `ctx.get(...)`
18
+ * возвращает значение, но вызов его методов всё равно охраняется контекстом,
19
+ * который службу не объявил. Передача самого контекста или результата `get`
20
+ * уже дала две разные ошибки «without inject» подряд.
22
21
  */
23
22
 
24
23
  /** Текущая попытка. null, пока никто не входил. */
package/lib/client.js CHANGED
@@ -1946,6 +1946,18 @@ window.__ModuleLoader__.load({
1946
1946
  const [busy, setBusy] = React.useState(false)
1947
1947
  const [draft, setDraft] = React.useState('')
1948
1948
 
1949
+ // Функция перевода приходит от рантайма НОВОЙ при каждом рендере —
1950
+ // в поставочном коде это обычная стрелка, не мемоизированная. Если
1951
+ // поставить её в зависимости запроса, цепочка t → send → refresh
1952
+ // пересоздаётся каждый раз, эффект с [refresh] срабатывает снова, и
1953
+ // строка уходит в бесконечный цикл рендеров и обращений к Host.
1954
+ // Такой цикл подвешивает всю вкладку, а не только настройки.
1955
+ //
1956
+ // Поэтому перевод берётся через ссылку: она всегда указывает на
1957
+ // свежую функцию, но сама себя не меняет.
1958
+ const translate = React.useRef(t)
1959
+ translate.current = t
1960
+
1949
1961
  const send = React.useCallback(async (body) => {
1950
1962
  const response = body === undefined
1951
1963
  ? await fetch(AUTH_PATH)
@@ -1961,16 +1973,17 @@ window.__ModuleLoader__.load({
1961
1973
  // не JSON. Без этой проверки строка молча показывала пустоту.
1962
1974
  const type = response.headers.get('content-type') || ''
1963
1975
  if (!type.includes('application/json')) {
1976
+ const say = translate.current
1964
1977
  return {
1965
1978
  available: true,
1966
1979
  providers: [],
1967
1980
  problem: response.status === 401 || response.status === 404
1968
- ? t('auth.needsRestart')
1969
- : t('auth.badResponse').replace('{status}', String(response.status)),
1981
+ ? say('auth.needsRestart')
1982
+ : say('auth.badResponse').replace('{status}', String(response.status)),
1970
1983
  }
1971
1984
  }
1972
1985
  return response.json()
1973
- }, [t])
1986
+ }, [])
1974
1987
 
1975
1988
  const refresh = React.useCallback(async (body) => {
1976
1989
  setBusy(true)
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Горячо перечитываемые HTTP-обработчики TENSOR GRID.
3
+ *
4
+ * `lib/index.js` регистрируется Cordis один раз при старте и после
5
+ * обновления остаётся в ESM-кеше. Если бизнес-логика живёт там, любая её
6
+ * правка требует перезапуска всего приложения — именно поэтому во время
7
+ * разработки входа пользователь перезапускал Host после каждого выпуска.
8
+ *
9
+ * Index теперь только держит стабильные маршруты и связанные Cordis-службы,
10
+ * а каждый маршрут передаёт запрос сюда. Модуль загружается с ключом по
11
+ * времени правки файла, поэтому новая версия начинает работать со
12
+ * следующего запроса. Перезапуск нужен лишь когда меняется сама оболочка:
13
+ * новый маршрут, новая служба или статический ресурс.
14
+ */
15
+
16
+ /** Отдать компактный JSON без кеширования. */
17
+ function reply(res, code, value) {
18
+ const body = JSON.stringify(value)
19
+ res.writeHead(code, {
20
+ 'content-type': 'application/json; charset=utf-8',
21
+ 'cache-control': 'no-store',
22
+ })
23
+ res.end(body)
24
+ }
25
+
26
+ /**
27
+ * Принять маленькое JSON-тело.
28
+ *
29
+ * Здесь всегда только действие, ключ провайдера или ответ на вопрос.
30
+ * Ограничение 16 КБ не даёт случайному клиенту держать в памяти тело без
31
+ * практической причины.
32
+ */
33
+ function readJsonBody(req) {
34
+ return new Promise((resolve) => {
35
+ let text = ''
36
+ let tooBig = false
37
+ req.on('data', (chunk) => {
38
+ if (tooBig) return
39
+ text += chunk
40
+ if (text.length > 16384) {
41
+ tooBig = true
42
+ resolve(null)
43
+ }
44
+ })
45
+ req.on('end', () => {
46
+ if (tooBig) return
47
+ try {
48
+ resolve(JSON.parse(text === '' ? '{}' : text))
49
+ } catch {
50
+ resolve(null)
51
+ }
52
+ })
53
+ req.on('error', () => resolve(null))
54
+ })
55
+ }
56
+
57
+ /** JSON-заголовок заставляет чужую страницу пройти CORS preflight. */
58
+ function acceptsJson(req) {
59
+ return String(req.headers['content-type'] ?? '').includes('application/json')
60
+ }
61
+
62
+ /** Обновить кеш состояния версий. Вызывается и фоновым таймером. */
63
+ export async function refreshUpdate(state, loadModule) {
64
+ const module = await loadModule('./update-check.js')
65
+ state.cached = await module.collectStatus()
66
+ return state.cached
67
+ }
68
+
69
+ /** Обслужить `/api/tensorgrid.update`. */
70
+ export async function handleUpdate(req, res, state, loadModule) {
71
+ try {
72
+ if (req.method === 'GET') {
73
+ // force=1 обходит кеш: кнопка «Проверить» должна спросить реестр заново.
74
+ const fresh = String(req.url ?? '').includes('force=1')
75
+ const value = fresh || state.cached === null
76
+ ? await refreshUpdate(state, loadModule)
77
+ : state.cached
78
+ reply(res, 200, value)
79
+ return
80
+ }
81
+
82
+ if (req.method === 'POST') {
83
+ if (!acceptsJson(req)) {
84
+ reply(res, 415, { ok: false, problem: 'ожидается content-type: application/json' })
85
+ return
86
+ }
87
+ const module = await loadModule('./update-check.js')
88
+ const result = await module.applyUpdate()
89
+ state.cached = result.status ?? state.cached
90
+ reply(res, result.ok ? 200 : 409, result)
91
+ return
92
+ }
93
+
94
+ reply(res, 405, { ok: false, problem: 'поддерживаются GET и POST' })
95
+ } catch (error) {
96
+ reply(res, 500, { ok: false, problem: String(error?.message ?? error) })
97
+ }
98
+ }
99
+
100
+ /** Обслужить `/api/tensorgrid.auth`. */
101
+ export async function handleAuth(req, res, services, loadModule) {
102
+ try {
103
+ if (services.authorization === null) {
104
+ reply(res, 200, { available: false, problem: 'стойка входа не смонтирована' })
105
+ return
106
+ }
107
+
108
+ const bridge = await loadModule('./auth-bridge.js')
109
+
110
+ if (req.method === 'GET') {
111
+ reply(res, 200, {
112
+ available: true,
113
+ ...(await bridge.listProviders(services.authorization, services.credentials)),
114
+ })
115
+ return
116
+ }
117
+
118
+ if (req.method !== 'POST') {
119
+ reply(res, 405, { problem: 'поддерживаются GET и POST' })
120
+ return
121
+ }
122
+
123
+ if (!acceptsJson(req)) {
124
+ reply(res, 415, { problem: 'ожидается content-type: application/json' })
125
+ return
126
+ }
127
+
128
+ const body = await readJsonBody(req)
129
+ if (body === null) {
130
+ reply(res, 400, { problem: 'тело запроса не разобрано' })
131
+ return
132
+ }
133
+
134
+ let result
135
+ if (body.action === 'begin') {
136
+ result = bridge.begin(services.authorization, String(body.key), body.method)
137
+ } else if (body.action === 'answer') {
138
+ result = bridge.answer(body.value)
139
+ } else if (body.action === 'cancel') {
140
+ result = bridge.cancel(services.authorization, body.key)
141
+ } else if (body.action === 'poll') {
142
+ result = bridge.poll()
143
+ } else {
144
+ reply(res, 400, { problem: `неизвестное действие: ${String(body.action)}` })
145
+ return
146
+ }
147
+
148
+ reply(res, 200, { available: true, ...result })
149
+ } catch (error) {
150
+ reply(res, 500, { problem: String(error?.message ?? error) })
151
+ }
152
+ }
package/lib/index.js CHANGED
@@ -149,17 +149,14 @@ export function apply(ctx) {
149
149
  }),
150
150
  )
151
151
 
152
- // Состояние обновлений держим в памяти: проверка ходит в сеть и в git,
153
- // и дёргать её на каждый рендер настроек незачем.
154
- let cached = null
155
-
156
- // Модуль проверки грузится с ключом по времени правки файла.
157
- //
158
- // Без этого обновление не может обновить само себя: ESM кеширует
159
- // модуль по адресу, и после замены файла Host продолжал бы работать по
160
- // старой логике до перезапуска. Ключ меняется только когда файл
161
- // действительно изменился, поэтому лишних копий в памяти не остаётся.
162
- async function loadModule(name = './update-check.js') {
152
+ // Стабильная оболочка маршрутов. Вся бизнес-логика живёт в
153
+ // `http-runtime.js`, который перечитывается по времени правки файла.
154
+ // Поэтому исправление обработчика доезжает со следующим запросом — без
155
+ // перезапуска приложения. Перезапуск остаётся нужен только когда меняется
156
+ // сама оболочка: новый маршрут, новая Cordis-служба или статический ресурс.
157
+ const updateState = { cached: null }
158
+
159
+ async function loadModule(name) {
163
160
  const path = new URL(name, import.meta.url)
164
161
  let version = ''
165
162
  try {
@@ -171,44 +168,48 @@ export function apply(ctx) {
171
168
  return import(`${path.href}${version}`)
172
169
  }
173
170
 
174
- async function refresh() {
175
- const module = await loadModule()
176
- cached = await module.collectStatus()
177
- return cached
171
+ /** Минимальный запасной ответ, если горячий модуль не загрузился вовсе. */
172
+ function fail(res, error) {
173
+ const body = JSON.stringify({ problem: `обработчик не загрузился: ${String(error?.message ?? error)}` })
174
+ res.writeHead(500, {
175
+ 'content-type': 'application/json; charset=utf-8',
176
+ 'cache-control': 'no-store',
177
+ })
178
+ res.end(body)
178
179
  }
179
180
 
180
- // Проверка при старте — отложенная. Поднимать приложение она не
181
- // задерживает, а к моменту, когда пользователь откроет настройки,
182
- // ответ уже готов.
183
- const timer = setTimeout(() => {
184
- refresh().catch((error) => {
185
- cached = { problem: `проверка не удалась: ${String(error?.message ?? error)}`, checkedAt: new Date().toISOString() }
186
- })
181
+ // Проверка версий при старте — отложенная. Подъём приложения она не
182
+ // задерживает, а к открытию настроек ответ обычно уже готов.
183
+ const timer = setTimeout(async () => {
184
+ try {
185
+ const runtime = await loadModule('./http-runtime.js')
186
+ await runtime.refreshUpdate(updateState, loadModule)
187
+ } catch (error) {
188
+ updateState.cached = {
189
+ problem: `проверка не удалась: ${String(error?.message ?? error)}`,
190
+ checkedAt: new Date().toISOString(),
191
+ }
192
+ }
187
193
  }, 8000)
188
194
  web.effect(() => () => clearTimeout(timer))
189
195
 
190
- // ── Вход по подписке ────────────────────────────────────────────────
191
- //
192
- // Стойка входа монтируется отдельной строкой профиля, и её может не быть
193
- // вовсе.
194
- //
195
- // Прочитать её через ctx.get мало: возвращённая служба остаётся под
196
- // охраной контекста, который её не объявил, и первое же обращение к
197
- // методу отвечает «cannot get property "list" without inject». Объявить
198
- // стойку в inject всего ряда тоже нельзя — тогда без неё не
199
- // смонтируется ни палитра, ни перевод.
200
- //
201
- // Поэтому службы забираются ВЛОЖЕННЫМ рядом и запоминаются: он ждёт их
202
- // появления, не задерживая остальное, и ссылки остаются пригодными,
203
- // потому что связаны с контекстом, который их объявил. Маршрут при этом
204
- // регистрируется всегда — профиль без стойки получает внятный ответ, а
205
- // не молчание.
206
- let authorization = null
207
- let credentials = null
196
+ // Стойка входа и хранилище необязательны. Вложенный ряд ждёт их, не
197
+ // задерживая палитру, айдентику и перевод. Ссылки должны быть получены
198
+ // именно из контекста, который объявил службы: ctx.get возвращает
199
+ // значение, но его методы всё ещё охраняются Cordis.
200
+ const authServices = { authorization: null, credentials: null, generation: null }
208
201
  web.inject(['authorization', 'credentials'], (bound) => {
209
- authorization = bound.authorization
210
- credentials = bound.credentials
211
- bound.effect(() => () => { authorization = null; credentials = null })
202
+ const generation = {}
203
+ authServices.authorization = bound.authorization
204
+ authServices.credentials = bound.credentials
205
+ authServices.generation = generation
206
+ bound.effect(() => () => {
207
+ // Старый Fiber не должен стереть уже активированную новую пару.
208
+ if (authServices.generation !== generation) return
209
+ authServices.authorization = null
210
+ authServices.credentials = null
211
+ authServices.generation = null
212
+ })
212
213
  })
213
214
 
214
215
  web.effect(() =>
@@ -217,115 +218,25 @@ export function apply(ctx) {
217
218
  path: '/api/tensorgrid.auth',
218
219
  async handler(req, res) {
219
220
  try {
220
- if (authorization === null) {
221
- reply(res, 200, { available: false, problem: 'стойка входа не смонтирована' })
222
- return
223
- }
224
-
225
- const bridge = await loadModule('./auth-bridge.js')
226
-
227
- if (req.method === 'GET') {
228
- reply(res, 200, { available: true, ...(await bridge.listProviders(authorization, credentials)) })
229
- return
230
- }
231
-
232
- if (req.method !== 'POST') {
233
- reply(res, 405, { problem: 'поддерживаются GET и POST' })
234
- return
235
- }
236
-
237
- // Тот же заслон, что у обновлений: браузер не отправит запрос с
238
- // JSON-заголовком с чужой страницы без предварительной проверки.
239
- if (!String(req.headers['content-type'] ?? '').includes('application/json')) {
240
- reply(res, 415, { problem: 'ожидается content-type: application/json' })
241
- return
242
- }
243
-
244
- const body = await readJsonBody(req)
245
- if (body === null) {
246
- reply(res, 400, { problem: 'тело запроса не разобрано' })
247
- return
248
- }
249
-
250
- let result
251
- if (body.action === 'begin') result = bridge.begin(authorization, String(body.key), body.method)
252
- else if (body.action === 'answer') result = bridge.answer(body.value)
253
- else if (body.action === 'cancel') result = bridge.cancel(authorization, body.key)
254
- else if (body.action === 'poll') result = bridge.poll()
255
- else { reply(res, 400, { problem: `неизвестное действие: ${String(body.action)}` }); return }
256
-
257
- reply(res, 200, { available: true, ...result })
221
+ const runtime = await loadModule('./http-runtime.js')
222
+ await runtime.handleAuth(req, res, authServices, loadModule)
258
223
  } catch (error) {
259
- reply(res, 500, { problem: String(error?.message ?? error) })
224
+ fail(res, error)
260
225
  }
261
226
  },
262
227
  }),
263
228
  )
264
229
 
265
- function reply(res, code, value) {
266
- const body = JSON.stringify(value)
267
- res.writeHead(code, {
268
- 'content-type': 'application/json; charset=utf-8',
269
- 'cache-control': 'no-store',
270
- })
271
- res.end(body)
272
- }
273
-
274
- /**
275
- * Читает тело запроса как JSON. Размер ограничен: тело здесь всегда
276
- * крошечное — действие, ключ, ответ на вопрос, — и принимать больше
277
- * значило бы держать открытой дверь без нужды.
278
- */
279
- function readJsonBody(req) {
280
- return new Promise((resolve) => {
281
- let text = ''
282
- let tooBig = false
283
- req.on('data', (chunk) => {
284
- if (tooBig) return
285
- text += chunk
286
- if (text.length > 16384) { tooBig = true; resolve(null) }
287
- })
288
- req.on('end', () => {
289
- if (tooBig) return
290
- try { resolve(JSON.parse(text === '' ? '{}' : text)) } catch { resolve(null) }
291
- })
292
- req.on('error', () => resolve(null))
293
- })
294
- }
295
-
296
230
  web.effect(() =>
297
231
  web.webServer.register({
298
232
  kind: 'exact',
299
233
  path: '/api/tensorgrid.update',
300
234
  async handler(req, res) {
301
235
  try {
302
- if (req.method === 'GET') {
303
- // force=1 обходит кеш: кнопка «проверить» должна спрашивать заново.
304
- const fresh = String(req.url ?? '').includes('force=1')
305
- reply(res, 200, fresh || cached === null ? await refresh() : cached)
306
- return
307
- }
308
-
309
- if (req.method === 'POST') {
310
- // Запись требует JSON-заголовка: браузер не отправит такой
311
- // запрос с чужой страницы без предварительной проверки CORS,
312
- // а она на этот маршрут не настроена. Для инструмента,
313
- // слушающего только петлевой интерфейс, этого достаточно.
314
- const contentType = String(req.headers['content-type'] ?? '')
315
- if (!contentType.includes('application/json')) {
316
- reply(res, 415, { ok: false, problem: 'ожидается content-type: application/json' })
317
- return
318
- }
319
- const module = await loadModule()
320
- const result = await module.applyUpdate()
321
- cached = result.status ?? cached
322
- reply(res, result.ok ? 200 : 409, result)
323
- return
324
- }
325
-
326
- reply(res, 405, { ok: false, problem: 'поддерживаются GET и POST' })
236
+ const runtime = await loadModule('./http-runtime.js')
237
+ await runtime.handleUpdate(req, res, updateState, loadModule)
327
238
  } catch (error) {
328
- reply(res, 500, { ok: false, problem: String(error?.message ?? error) })
239
+ fail(res, error)
329
240
  }
330
241
  },
331
242
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tensorgrid-ui",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",