tensorgrid-ui 2.1.1 → 2.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.
Files changed (2) hide show
  1. package/lib/client.js +2159 -1786
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -1,329 +1,341 @@
1
- /**
2
- * Client-половина пакета Obsidian / Ion.
3
- *
4
- * Формат файла — не результат сборки, а тот же контракт, который используют
5
- * поставочные пакеты `@deepseek-ai/dsh-client-ui-*`: регистрация модуля в
6
- * `window.__ModuleLoader__`, зависимости через колбэк `require`, экспорт
7
- * `apply` и `inject`. Поэтому пакет пишется и правится руками, без tsdown.
8
- *
9
- * Пакет занимает только аддитивные слоты (`replaceRisk: none`) и публичные
10
- * методы сервисов. Ни один поставочный компонент не замещается, поэтому
11
- * обновления dsh продолжают доезжать до интерфейса.
12
- */
13
- window.__ModuleLoader__.load({
14
- id: 'tensorgrid-ui',
15
- factory: (require) => {
16
- var module = { exports: {} }
17
- var exports = module.exports
18
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
19
-
20
- const React = require('react')
21
- const { jsx, jsxs } = require('react/jsx-runtime')
22
-
23
- // ── Палитра ──────────────────────────────────────────────────────────
24
- //
25
- // Доставляется слоем переопределения, а не зарегистрированной темой.
26
- // Причина не в удобстве: `ThemeDefinition` пиннит одну `colorScheme`,
27
- // то есть выбор такой темы ломает режим «следовать системе». Слой же
28
- // несёт пару значений на каждый токен и переживает переключение схемы
29
- // сам. Плюс поставочная строка «Оформление» умеет ровно три значения
30
- // (light / dark / system) и отрисовать стороннюю тему не может.
31
- const SOURCE = 'dsx-obsidian-ion'
32
-
33
- /** Токены, не зависящие от выбранного акцента. */
34
- const BASE_TOKENS = {
35
- '--dsw-alias-bg-base': { dark: '#06080f', light: '#f6f8fc' },
36
- '--dsw-alias-bg-layer-1': { dark: '#0c0f19', light: '#ffffff' },
37
- '--dsw-alias-bg-layer-2': { dark: '#141927', light: '#eef2f9' },
38
- '--dsw-alias-bg-overlay': { dark: '#171d2e', light: '#ffffff' },
39
- '--dsw-alias-border-l1': { dark: '#222a3d', light: '#e3e9f3' },
40
- '--dsw-alias-border-l2': { dark: '#334059', light: '#cad4e4' },
41
- '--dsw-alias-label-primary': { dark: '#e9eff9', light: '#0a1120' },
42
- '--dsw-alias-label-secondary': { dark: '#8b97ad', light: '#5a6780' },
43
- '--dsw-alias-state-error-primary': { dark: '#ff5c72', light: '#d92d43' },
44
- '--dsw-alias-state-success-primary': { dark: '#3ddc9a', light: '#0f9d63' },
45
- '--dsw-alias-state-warn-primary': { dark: '#ffb454', light: '#b8720a' },
46
- '--dsw-specific-sidebar-fill': { dark: '#090c14', light: '#f0f4fa' },
47
- }
48
-
49
- // ── Акцент ───────────────────────────────────────────────────────────
50
- //
51
- // Каждый пресет несёт пару: ведущий тон и контрапункт. Свечение строится
52
- // на обоих, поэтому цветовое поле остаётся глубоким при любом выборе.
53
- // Светлый вариант тона отдельный: на белом фоне тот же цвет не читается.
54
- const ACCENTS = [
55
- { id: 'ion', label: 'Ion', dark: '#5ad9f5', light: '#0b8fad', rgb: '90,217,245', rgb2: '138,108,255' },
56
- { id: 'violet', label: 'Violet', dark: '#a78bfa', light: '#6d38d6', rgb: '167,139,250', rgb2: '90,217,245' },
57
- { id: 'emerald', label: 'Emerald', dark: '#4ade9a', light: '#0c8f5e', rgb: '74,222,154', rgb2: '56,189,248' },
58
- { id: 'amber', label: 'Amber', dark: '#ffc46b', light: '#b3720a', rgb: '255,196,107', rgb2: '255,110,180' },
59
- ]
60
- const DEFAULT_ACCENT = 'ion'
61
-
62
- function accentOf(id) {
63
- for (let i = 0; i < ACCENTS.length; i += 1) if (ACCENTS[i].id === id) return ACCENTS[i]
64
- return null
65
- }
66
-
67
- /** Полный слой токенов для выбранного акцента. */
68
- function tokensFor(accent) {
69
- const tokens = { '--dsw-alias-brand-primary': { dark: accent.dark, light: accent.light } }
70
- for (const name of Object.keys(BASE_TOKENS)) tokens[name] = BASE_TOKENS[name]
71
- return tokens
72
- }
73
-
74
- // ── Пользовательские настройки ───────────────────────────────────────
75
- // Ключи хранения намеренно сохраняют прежнее имя пакета. Это не
76
- // идентичность, а данные: переименование молча сбросило бы у каждого
77
- // пользователя выбранную интенсивность и акцент.
78
- const STORAGE_KEY = 'dsh-ui-obsidian-ion:intensity'
79
- const ACCENT_STORAGE_KEY = 'dsh-ui-obsidian-ion:accent'
80
- const INTENSITY_VAR = '--dsx-intensity'
81
- const ACTIVITY_VAR = '--dsx-activity'
82
- const ACCENT_VAR = '--dsx-accent-rgb'
83
- const ACCENT2_VAR = '--dsx-accent2-rgb'
84
-
85
- const LEVELS = [
86
- { id: 'off', key: 'intensity.off', value: 0 },
87
- { id: 'quiet', key: 'intensity.quiet', value: 0.5 },
88
- { id: 'normal', key: 'intensity.normal', value: 1 },
89
- { id: 'vivid', key: 'intensity.vivid', value: 1.7 },
90
- ]
91
- const DEFAULT_LEVEL = 'normal'
92
-
93
- /**
94
- * Сервис темы, пойманный в apply(). Слой переопределения переписывается
95
- * при смене акцента — документация службы прямо разрешает это: повторный
96
- * вызов с тем же источником заменяет весь слой целиком.
97
- */
98
- let themeService = null
99
-
100
- function levelOf(id) {
101
- for (let i = 0; i < LEVELS.length; i += 1) if (LEVELS[i].id === id) return LEVELS[i]
102
- return null
103
- }
104
-
105
- function readLevelId() {
106
- try {
107
- const raw = window.localStorage.getItem(STORAGE_KEY)
108
- if (levelOf(raw) !== null) return raw
109
- } catch (error) {
110
- // Приватный режим и отключённое хранилище — не повод падать.
111
- }
112
- return DEFAULT_LEVEL
113
- }
114
-
115
- function writeLevelId(id) {
116
- try {
117
- window.localStorage.setItem(STORAGE_KEY, id)
118
- } catch (error) {
119
- // см. выше
120
- }
121
- }
122
-
123
- function applyLevelId(id) {
124
- const level = levelOf(id) ?? levelOf(DEFAULT_LEVEL)
125
- if (typeof document === 'undefined') return
126
- document.documentElement.style.setProperty(INTENSITY_VAR, String(level.value))
127
- }
128
-
129
- function readAccentId() {
130
- try {
131
- const raw = window.localStorage.getItem(ACCENT_STORAGE_KEY)
132
- if (accentOf(raw) !== null) return raw
133
- } catch (error) {
134
- // Приватный режим и отключённое хранилище — не повод падать.
135
- }
136
- return DEFAULT_ACCENT
137
- }
138
-
139
- function writeAccentId(id) {
140
- try {
141
- window.localStorage.setItem(ACCENT_STORAGE_KEY, id)
142
- } catch (error) {
143
- // см. выше
144
- }
145
- }
146
-
147
- /**
148
- * Применяет акцент к обеим сторонам: свечение ведут CSS-переменные, а
149
- * бренд-токен интерфейса переписывается через слой переопределения.
150
- */
151
- function applyAccentId(id) {
152
- const accent = accentOf(id) ?? accentOf(DEFAULT_ACCENT)
153
- if (typeof document !== 'undefined') {
154
- const style = document.documentElement.style
155
- style.setProperty(ACCENT_VAR, accent.rgb)
156
- style.setProperty(ACCENT2_VAR, accent.rgb2)
157
- }
158
- if (themeService !== null) themeService.overrideTokens(SOURCE, tokensFor(accent))
159
- }
160
-
161
- /** Выбранное название. Имя собственное — не переводится. */
162
- const BRAND_NAME = 'TENSOR GRID'
163
-
164
- // ── Локализация ──────────────────────────────────────────────────────
165
- //
166
- // Ни одна видимая строка не зашита в разметку: всё идёт через `t()`.
167
- // Иначе русский текст вылезал бы в английском и китайском интерфейсе.
168
- const LOCALE_NS = 'obsidian-ion'
169
- const RU = 'ru'
170
-
171
- const EN_DICT = {
172
- 'intensity.title': 'Obsidian / Ion atmosphere',
173
- 'intensity.hint': 'Strength of the living glow over the interface',
174
- 'intensity.off': 'Off',
175
- 'intensity.quiet': 'Quiet',
176
- 'intensity.normal': 'Normal',
177
- 'intensity.vivid': 'Vivid',
178
- 'accent.title': 'Obsidian / Ion accent',
179
- 'accent.hint': 'Leading tone of the interface and the glow',
180
- 'review.effortName': 'effort',
181
- 'review.other': 'another…',
182
- 'review.modelName': 'model name',
183
- 'review.fromList': 'back to the list',
184
- 'review.objectFalse': 'I disagree — this is real',
185
- 'review.accuracy': 'How often each reviewer turned out to be right',
186
- 'review.notChecked': 'not verified yet',
187
- 'review.byAgent': 'checked by the assistant',
188
- 'review.humanEvidence': 'The user disagreed with the assistant\'s verdict, having looked at the finding.',
189
- 'review.lastReview': 'Last review',
190
- 'review.objectReal': 'I disagree — this is wrong',
191
- 'review.of': 'of',
192
- 'review.byHuman': 'your call',
193
- 'review.needsProduct': 'Works only if that product is installed separately',
194
- 'review.add': 'Add a reviewer:',
195
- 'review.participates': 'Take part in reviews',
196
- 'review.noneActive': 'Every reviewer is switched off — a review will find nothing.',
197
- 'review.remove': 'Remove this reviewer',
198
- 'review.bySubscription': 'subscription',
199
- 'review.limit': 'That is as many reviewers as one review can hold.',
200
- 'review.byApiKey': 'paid per token',
201
- 'review.groupSubscription': 'Included in a subscription',
202
- 'review.groupPaid': 'Paid separately \u2014 per token',
203
- 'review.nav': 'Reviewers',
204
- 'review.title': 'Second opinion',
205
- 'review.hint': 'Independent reviewers examine finished work and report what is wrong. Choose how many and which ones: different models find different things.',
206
- 'review.model': 'Model',
207
- 'review.effort': 'Effort',
208
- 'review.asConfigured': 'as configured',
209
- 'review.none': 'No reviewer is selected — a review will find nothing.',
210
- '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.',
211
- 'auth.nav': 'Subscriptions',
212
- 'auth.title': 'Provider sign-in',
213
- '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.',
214
- 'auth.hint': 'Sign in with a subscription instead of an API key. The account stays on this computer.',
215
- 'auth.signIn': 'Sign in',
216
- 'auth.again': 'Sign in again',
217
- 'auth.connected': 'connected',
218
- 'auth.openLink': 'Open the sign-in page',
219
- 'auth.send': 'Send',
220
- 'auth.cancel': 'Cancel',
221
- 'auth.done': 'Signed in.',
222
- 'auth.cancelled': 'Sign-in cancelled.',
223
- 'auth.unavailable': 'Sign-in is unavailable: the authorization row is not mounted in this profile.',
224
- 'auth.needsRestart': 'Restart the application: the browser already has the new version, the server does not yet.',
225
- 'auth.badResponse': 'The server answered {status} instead of data.',
226
- 'auth.empty': 'No provider offers subscription sign-in.',
227
- 'update.title': 'Updates',
228
- 'update.check': 'Check',
229
- 'update.apply': 'Update',
230
- 'update.working': 'Working…',
231
- 'update.checking': 'Checking…',
232
- 'update.upToDate': 'Latest version installed',
233
- 'update.available': 'Version {version} is available',
234
- 'update.availableUnknown': 'A new version is available',
235
- 'update.installed': 'Installed {version}, verified on dsh {dsh}',
236
- 'update.doneReload': 'Updated. Reload the page.',
237
- 'update.doneRestart': 'Updated. Restart the application.',
238
- 'update.failed': 'Update failed',
239
- 'update.problem.noStamp': 'No installation record — reinstall',
240
- 'update.problem.noPackage': 'Installed package not found — reinstall',
241
- 'update.problem.registryStatus': 'The registry refused the request',
242
- 'update.problem.registryUnreachable': 'Could not reach the registry',
243
- 'update.problem.badVersion': 'The registry returned no valid version',
244
- 'update.problem.noProfile': 'The installation record has no profile — reinstall',
245
- 'update.problem.installFailed': 'Installing from the registry failed',
246
- 'update.dshMismatch': 'Warning: dsh {actual} is installed, but this version was verified on {expected}',
247
- }
248
-
249
- const ZH_DICT = {
250
- 'intensity.title': 'Obsidian / Ion 氛围',
251
- 'intensity.hint': '界面上方光晕的强度',
252
- 'intensity.off': '关闭',
253
- 'intensity.quiet': '轻微',
254
- 'intensity.normal': '标准',
255
- 'intensity.vivid': '强烈',
256
- 'accent.title': 'Obsidian / Ion 主色',
257
- 'accent.hint': '界面与光晕的主色调',
258
- 'review.effortName': '强度',
259
- 'review.other': '其他…',
260
- 'review.modelName': '模型名称',
261
- 'review.fromList': '返回列表',
262
- 'review.objectFalse': '我不同意——这是真的',
263
- 'review.accuracy': '各审阅者的正确率',
264
- 'review.notChecked': '尚未核实',
265
- 'review.byAgent': '由助手核实',
266
- 'review.humanEvidence': '用户查看该发现后,不同意助手的判定。',
267
- 'review.lastReview': '上次审阅',
268
- 'review.objectReal': '我不同意——这是错的',
269
- 'review.of': '',
270
- 'review.byHuman': '你的判断',
271
- 'review.needsProduct': '仅在单独安装该产品后可用',
272
- 'review.add': '添加审阅者:',
273
- 'review.participates': '参与审阅',
274
- 'review.noneActive': '所有审阅者都已关闭——审阅将一无所获。',
275
- 'review.remove': '移除该审阅者',
276
- 'review.bySubscription': '订阅',
277
- 'review.limit': '一次审阅最多只能有这么多审阅者。',
278
- 'review.byApiKey': '按量计费',
279
- 'review.groupSubscription': '包含在订阅中',
280
- 'review.groupPaid': '单独计费 \u2014 按量',
281
- 'review.nav': '审阅者',
282
- 'review.title': '第二意见',
283
- 'review.hint': '独立审阅者检查已完成的工作并报告问题。自行决定数量与人选:不同模型发现的问题不同。',
284
- 'review.model': '模型',
285
- 'review.effort': '推理强度',
286
- 'review.asConfigured': '按产品设置',
287
- 'review.none': '未选择任何审阅者——审阅将一无所获。',
288
- 'review.note': '默认启用两位审阅者,因为他们发现的问题不同:只用一位必然漏掉一半。模型留空则沿用该产品自身的设置。',
289
- 'auth.nav': '订阅',
290
- 'auth.title': '登录服务商',
291
- 'auth.others': '这里只列出提供订阅登录的服务商。其余(包括 Z.AI)只提供 API 密钥,请在「模型」中配置。',
292
- 'auth.hint': '使用订阅登录,而非 API 密钥。账号仅保存在本机。',
293
- 'auth.signIn': '登录',
294
- 'auth.again': '重新登录',
295
- 'auth.connected': '已连接',
296
- 'auth.openLink': '打开登录页面',
297
- 'auth.send': '发送',
298
- 'auth.cancel': '取消',
299
- 'auth.done': '已登录。',
300
- 'auth.cancelled': '登录已取消。',
301
- 'auth.unavailable': '无法登录:此配置文件未挂载授权行。',
302
- 'auth.needsRestart': '请重启应用:浏览器已是新版本,服务端还不是。',
303
- 'auth.badResponse': '服务端返回 {status},而不是数据。',
304
- 'auth.empty': '没有服务商提供订阅登录。',
305
- 'update.title': '更新',
306
- 'update.check': '检查',
307
- 'update.apply': '更新',
308
- 'update.working': '处理中…',
309
- 'update.checking': '检查中…',
310
- 'update.upToDate': '已是最新版本',
311
- 'update.available': '有新版本 {version}',
312
- 'update.availableUnknown': '有新版本可用',
313
- 'update.installed': '已安装 {version},在 dsh {dsh} 上验证',
314
- 'update.doneReload': '已更新。请刷新页面。',
315
- 'update.doneRestart': '已更新。请重启应用。',
316
- 'update.failed': '更新失败',
317
- 'update.problem.noStamp': '没有安装记录 — 请重新安装',
318
- 'update.problem.noPackage': '未找到已安装的包 — 请重新安装',
319
- 'update.problem.registryStatus': '注册表拒绝了请求',
320
- 'update.problem.registryUnreachable': '无法连接到注册表',
321
- 'update.problem.badVersion': '注册表未返回有效版本',
322
- 'update.problem.noProfile': '安装记录中没有配置文件 — 请重新安装',
323
- 'update.problem.installFailed': '从注册表安装失败',
324
- 'update.dshMismatch': '注意:已安装 dsh {actual},但此版本在 {expected} 上验证',
325
- }
326
-
1
+ /**
2
+ * Client-половина пакета Obsidian / Ion.
3
+ *
4
+ * Формат файла — не результат сборки, а тот же контракт, который используют
5
+ * поставочные пакеты `@deepseek-ai/dsh-client-ui-*`: регистрация модуля в
6
+ * `window.__ModuleLoader__`, зависимости через колбэк `require`, экспорт
7
+ * `apply` и `inject`. Поэтому пакет пишется и правится руками, без tsdown.
8
+ *
9
+ * Пакет занимает только аддитивные слоты (`replaceRisk: none`) и публичные
10
+ * методы сервисов. Ни один поставочный компонент не замещается, поэтому
11
+ * обновления dsh продолжают доезжать до интерфейса.
12
+ */
13
+ window.__ModuleLoader__.load({
14
+ id: 'tensorgrid-ui',
15
+ factory: (require) => {
16
+ var module = { exports: {} }
17
+ var exports = module.exports
18
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
19
+
20
+ const React = require('react')
21
+ const { jsx, jsxs } = require('react/jsx-runtime')
22
+
23
+ // ── Палитра ──────────────────────────────────────────────────────────
24
+ //
25
+ // Доставляется слоем переопределения, а не зарегистрированной темой.
26
+ // Причина не в удобстве: `ThemeDefinition` пиннит одну `colorScheme`,
27
+ // то есть выбор такой темы ломает режим «следовать системе». Слой же
28
+ // несёт пару значений на каждый токен и переживает переключение схемы
29
+ // сам. Плюс поставочная строка «Оформление» умеет ровно три значения
30
+ // (light / dark / system) и отрисовать стороннюю тему не может.
31
+ const SOURCE = 'dsx-obsidian-ion'
32
+
33
+ /** Токены, не зависящие от выбранного акцента. */
34
+ const BASE_TOKENS = {
35
+ '--dsw-alias-bg-base': { dark: '#06080f', light: '#f6f8fc' },
36
+ '--dsw-alias-bg-layer-1': { dark: '#0c0f19', light: '#ffffff' },
37
+ '--dsw-alias-bg-layer-2': { dark: '#141927', light: '#eef2f9' },
38
+ '--dsw-alias-bg-overlay': { dark: '#171d2e', light: '#ffffff' },
39
+ '--dsw-alias-border-l1': { dark: '#222a3d', light: '#e3e9f3' },
40
+ '--dsw-alias-border-l2': { dark: '#334059', light: '#cad4e4' },
41
+ '--dsw-alias-label-primary': { dark: '#e9eff9', light: '#0a1120' },
42
+ '--dsw-alias-label-secondary': { dark: '#8b97ad', light: '#5a6780' },
43
+ '--dsw-alias-state-error-primary': { dark: '#ff5c72', light: '#d92d43' },
44
+ '--dsw-alias-state-success-primary': { dark: '#3ddc9a', light: '#0f9d63' },
45
+ '--dsw-alias-state-warn-primary': { dark: '#ffb454', light: '#b8720a' },
46
+ '--dsw-specific-sidebar-fill': { dark: '#090c14', light: '#f0f4fa' },
47
+ }
48
+
49
+ // ── Акцент ───────────────────────────────────────────────────────────
50
+ //
51
+ // Каждый пресет несёт пару: ведущий тон и контрапункт. Свечение строится
52
+ // на обоих, поэтому цветовое поле остаётся глубоким при любом выборе.
53
+ // Светлый вариант тона отдельный: на белом фоне тот же цвет не читается.
54
+ const ACCENTS = [
55
+ { id: 'ion', label: 'Ion', dark: '#5ad9f5', light: '#0b8fad', rgb: '90,217,245', rgb2: '138,108,255' },
56
+ { id: 'violet', label: 'Violet', dark: '#a78bfa', light: '#6d38d6', rgb: '167,139,250', rgb2: '90,217,245' },
57
+ { id: 'emerald', label: 'Emerald', dark: '#4ade9a', light: '#0c8f5e', rgb: '74,222,154', rgb2: '56,189,248' },
58
+ { id: 'amber', label: 'Amber', dark: '#ffc46b', light: '#b3720a', rgb: '255,196,107', rgb2: '255,110,180' },
59
+ ]
60
+ const DEFAULT_ACCENT = 'ion'
61
+
62
+ function accentOf(id) {
63
+ for (let i = 0; i < ACCENTS.length; i += 1) if (ACCENTS[i].id === id) return ACCENTS[i]
64
+ return null
65
+ }
66
+
67
+ /** Полный слой токенов для выбранного акцента. */
68
+ function tokensFor(accent) {
69
+ const tokens = { '--dsw-alias-brand-primary': { dark: accent.dark, light: accent.light } }
70
+ for (const name of Object.keys(BASE_TOKENS)) tokens[name] = BASE_TOKENS[name]
71
+ return tokens
72
+ }
73
+
74
+ // ── Пользовательские настройки ───────────────────────────────────────
75
+ // Ключи хранения намеренно сохраняют прежнее имя пакета. Это не
76
+ // идентичность, а данные: переименование молча сбросило бы у каждого
77
+ // пользователя выбранную интенсивность и акцент.
78
+ const STORAGE_KEY = 'dsh-ui-obsidian-ion:intensity'
79
+ const ACCENT_STORAGE_KEY = 'dsh-ui-obsidian-ion:accent'
80
+ const INTENSITY_VAR = '--dsx-intensity'
81
+ const ACTIVITY_VAR = '--dsx-activity'
82
+ const ACCENT_VAR = '--dsx-accent-rgb'
83
+ const ACCENT2_VAR = '--dsx-accent2-rgb'
84
+
85
+ const LEVELS = [
86
+ { id: 'off', key: 'intensity.off', value: 0 },
87
+ { id: 'quiet', key: 'intensity.quiet', value: 0.5 },
88
+ { id: 'normal', key: 'intensity.normal', value: 1 },
89
+ { id: 'vivid', key: 'intensity.vivid', value: 1.7 },
90
+ ]
91
+ const DEFAULT_LEVEL = 'normal'
92
+
93
+ /**
94
+ * Сервис темы, пойманный в apply(). Слой переопределения переписывается
95
+ * при смене акцента — документация службы прямо разрешает это: повторный
96
+ * вызов с тем же источником заменяет весь слой целиком.
97
+ */
98
+ let themeService = null
99
+
100
+ function levelOf(id) {
101
+ for (let i = 0; i < LEVELS.length; i += 1) if (LEVELS[i].id === id) return LEVELS[i]
102
+ return null
103
+ }
104
+
105
+ function readLevelId() {
106
+ try {
107
+ const raw = window.localStorage.getItem(STORAGE_KEY)
108
+ if (levelOf(raw) !== null) return raw
109
+ } catch (error) {
110
+ // Приватный режим и отключённое хранилище — не повод падать.
111
+ }
112
+ return DEFAULT_LEVEL
113
+ }
114
+
115
+ function writeLevelId(id) {
116
+ try {
117
+ window.localStorage.setItem(STORAGE_KEY, id)
118
+ } catch (error) {
119
+ // см. выше
120
+ }
121
+ }
122
+
123
+ function applyLevelId(id) {
124
+ const level = levelOf(id) ?? levelOf(DEFAULT_LEVEL)
125
+ if (typeof document === 'undefined') return
126
+ document.documentElement.style.setProperty(INTENSITY_VAR, String(level.value))
127
+ }
128
+
129
+ function readAccentId() {
130
+ try {
131
+ const raw = window.localStorage.getItem(ACCENT_STORAGE_KEY)
132
+ if (accentOf(raw) !== null) return raw
133
+ } catch (error) {
134
+ // Приватный режим и отключённое хранилище — не повод падать.
135
+ }
136
+ return DEFAULT_ACCENT
137
+ }
138
+
139
+ function writeAccentId(id) {
140
+ try {
141
+ window.localStorage.setItem(ACCENT_STORAGE_KEY, id)
142
+ } catch (error) {
143
+ // см. выше
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Применяет акцент к обеим сторонам: свечение ведут CSS-переменные, а
149
+ * бренд-токен интерфейса переписывается через слой переопределения.
150
+ */
151
+ function applyAccentId(id) {
152
+ const accent = accentOf(id) ?? accentOf(DEFAULT_ACCENT)
153
+ if (typeof document !== 'undefined') {
154
+ const style = document.documentElement.style
155
+ style.setProperty(ACCENT_VAR, accent.rgb)
156
+ style.setProperty(ACCENT2_VAR, accent.rgb2)
157
+ }
158
+ if (themeService !== null) themeService.overrideTokens(SOURCE, tokensFor(accent))
159
+ }
160
+
161
+ /** Выбранное название. Имя собственное — не переводится. */
162
+ const BRAND_NAME = 'TENSOR GRID'
163
+
164
+ // ── Локализация ──────────────────────────────────────────────────────
165
+ //
166
+ // Ни одна видимая строка не зашита в разметку: всё идёт через `t()`.
167
+ // Иначе русский текст вылезал бы в английском и китайском интерфейсе.
168
+ const LOCALE_NS = 'obsidian-ion'
169
+ const RU = 'ru'
170
+
171
+ const EN_DICT = {
172
+ 'intensity.title': 'Obsidian / Ion atmosphere',
173
+ 'intensity.hint': 'Strength of the living glow over the interface',
174
+ 'intensity.off': 'Off',
175
+ 'intensity.quiet': 'Quiet',
176
+ 'intensity.normal': 'Normal',
177
+ 'intensity.vivid': 'Vivid',
178
+ 'accent.title': 'Obsidian / Ion accent',
179
+ 'accent.hint': 'Leading tone of the interface and the glow',
180
+ 'review.effortName': 'effort',
181
+ 'review.other': 'another…',
182
+ 'review.modelName': 'model name',
183
+ 'review.fromList': 'back to the list',
184
+ 'review.objectFalse': 'I disagree — this is real',
185
+ 'review.accuracy': 'How often each reviewer turned out to be right',
186
+ 'review.notChecked': 'not verified yet',
187
+ 'review.byAgent': 'checked by the assistant',
188
+ 'review.humanEvidence': 'The user disagreed with the assistant\'s verdict, having looked at the finding.',
189
+ 'review.lastReview': 'Last review',
190
+ 'review.objectReal': 'I disagree — this is wrong',
191
+ 'review.of': 'of',
192
+ 'review.byHuman': 'your call',
193
+ 'review.participates': 'Take part in reviews',
194
+ 'review.noneActive': 'Every reviewer is switched off — a review will find nothing.',
195
+ 'review.remove': 'Remove this reviewer',
196
+ 'review.bySubscription': 'subscription',
197
+ 'review.limit': 'That is as many reviewers as one review can hold.',
198
+ 'review.byApiKey': 'paid per token',
199
+ 'review.nothing': 'Nothing matches',
200
+ 'review.more': '…and {n} more — narrow the search',
201
+ 'review.separateProduct': 'separate product',
202
+ 'review.add': 'Add a reviewer',
203
+ 'review.search': 'Search by name or provider',
204
+ 'switcher.title': 'Model for this conversation — with search',
205
+ 'switcher.search': 'Search models',
206
+ 'switcher.more': '…and {n} more — narrow the search',
207
+ 'switcher.nothing': 'Nothing matches',
208
+ 'switcher.none': 'Model',
209
+ 'review.nav': 'Reviewers',
210
+ 'review.title': 'Second opinion',
211
+ 'review.hint': 'Independent reviewers examine finished work and report what is wrong. Choose how many and which ones: different models find different things.',
212
+ 'review.model': 'Model',
213
+ 'review.effort': 'Effort',
214
+ 'review.asConfigured': 'as configured',
215
+ 'review.none': 'No reviewer is selected — a review will find nothing.',
216
+ '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.',
217
+ 'auth.nav': 'Subscriptions',
218
+ 'auth.title': 'Provider sign-in',
219
+ '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.',
220
+ 'auth.hint': 'Sign in with a subscription instead of an API key. The account stays on this computer.',
221
+ 'auth.signIn': 'Sign in',
222
+ 'auth.again': 'Sign in again',
223
+ 'auth.connected': 'connected',
224
+ 'auth.openLink': 'Open the sign-in page',
225
+ 'auth.send': 'Send',
226
+ 'auth.cancel': 'Cancel',
227
+ 'auth.done': 'Signed in.',
228
+ 'auth.cancelled': 'Sign-in cancelled.',
229
+ 'auth.unavailable': 'Sign-in is unavailable: the authorization row is not mounted in this profile.',
230
+ 'auth.needsRestart': 'Restart the application: the browser already has the new version, the server does not yet.',
231
+ 'auth.badResponse': 'The server answered {status} instead of data.',
232
+ 'auth.empty': 'No provider offers subscription sign-in.',
233
+ 'update.title': 'Updates',
234
+ 'update.check': 'Check',
235
+ 'update.apply': 'Update',
236
+ 'update.working': 'Working…',
237
+ 'update.checking': 'Checking…',
238
+ 'update.upToDate': 'Latest version installed',
239
+ 'update.available': 'Version {version} is available',
240
+ 'update.availableUnknown': 'A new version is available',
241
+ 'update.installed': 'Installed {version}, verified on dsh {dsh}',
242
+ 'update.doneReload': 'Updated. Reload the page.',
243
+ 'update.doneRestart': 'Updated. Restart the application.',
244
+ 'update.failed': 'Update failed',
245
+ 'update.problem.noStamp': 'No installation record reinstall',
246
+ 'update.problem.noPackage': 'Installed package not found reinstall',
247
+ 'update.problem.registryStatus': 'The registry refused the request',
248
+ 'update.problem.registryUnreachable': 'Could not reach the registry',
249
+ 'update.problem.badVersion': 'The registry returned no valid version',
250
+ 'update.problem.noProfile': 'The installation record has no profile — reinstall',
251
+ 'update.problem.installFailed': 'Installing from the registry failed',
252
+ 'update.dshMismatch': 'Warning: dsh {actual} is installed, but this version was verified on {expected}',
253
+ }
254
+
255
+ const ZH_DICT = {
256
+ 'intensity.title': 'Obsidian / Ion 氛围',
257
+ 'intensity.hint': '界面上方光晕的强度',
258
+ 'intensity.off': '关闭',
259
+ 'intensity.quiet': '轻微',
260
+ 'intensity.normal': '标准',
261
+ 'intensity.vivid': '强烈',
262
+ 'accent.title': 'Obsidian / Ion 主色',
263
+ 'accent.hint': '界面与光晕的主色调',
264
+ 'review.effortName': '强度',
265
+ 'review.other': '其他…',
266
+ 'review.modelName': '模型名称',
267
+ 'review.fromList': '返回列表',
268
+ 'review.objectFalse': '我不同意——这是真的',
269
+ 'review.accuracy': '各审阅者的正确率',
270
+ 'review.notChecked': '尚未核实',
271
+ 'review.byAgent': '由助手核实',
272
+ 'review.humanEvidence': '用户查看该发现后,不同意助手的判定。',
273
+ 'review.lastReview': '上次审阅',
274
+ 'review.objectReal': '我不同意——这是错的',
275
+ 'review.of': '',
276
+ 'review.byHuman': '你的判断',
277
+ 'review.participates': '参与审阅',
278
+ 'review.noneActive': '所有审阅者都已关闭——审阅将一无所获。',
279
+ 'review.remove': '移除该审阅者',
280
+ 'review.bySubscription': '订阅',
281
+ 'review.limit': '一次审阅最多只能有这么多审阅者。',
282
+ 'review.byApiKey': '按量计费',
283
+ 'review.nothing': '没有匹配项',
284
+ 'review.more': '……还有 {n} 个 — 请细化搜索',
285
+ 'review.separateProduct': '独立产品',
286
+ 'review.add': '添加审阅者',
287
+ 'review.search': '按名称或提供方搜索',
288
+ 'switcher.title': '本次对话的模型 — 可搜索',
289
+ 'switcher.search': '搜索模型',
290
+ 'switcher.more': '……还有 {n} 个 — 请细化搜索',
291
+ 'switcher.nothing': '没有匹配项',
292
+ 'switcher.none': '模型',
293
+ 'review.nav': '审阅者',
294
+ 'review.title': '第二意见',
295
+ 'review.hint': '独立审阅者检查已完成的工作并报告问题。自行决定数量与人选:不同模型发现的问题不同。',
296
+ 'review.model': '模型',
297
+ 'review.effort': '推理强度',
298
+ 'review.asConfigured': '按产品设置',
299
+ 'review.none': '未选择任何审阅者——审阅将一无所获。',
300
+ 'review.note': '默认启用两位审阅者,因为他们发现的问题不同:只用一位必然漏掉一半。模型留空则沿用该产品自身的设置。',
301
+ 'auth.nav': '订阅',
302
+ 'auth.title': '登录服务商',
303
+ 'auth.others': '这里只列出提供订阅登录的服务商。其余(包括 Z.AI)只提供 API 密钥,请在「模型」中配置。',
304
+ 'auth.hint': '使用订阅登录,而非 API 密钥。账号仅保存在本机。',
305
+ 'auth.signIn': '登录',
306
+ 'auth.again': '重新登录',
307
+ 'auth.connected': '已连接',
308
+ 'auth.openLink': '打开登录页面',
309
+ 'auth.send': '发送',
310
+ 'auth.cancel': '取消',
311
+ 'auth.done': '已登录。',
312
+ 'auth.cancelled': '登录已取消。',
313
+ 'auth.unavailable': '无法登录:此配置文件未挂载授权行。',
314
+ 'auth.needsRestart': '请重启应用:浏览器已是新版本,服务端还不是。',
315
+ 'auth.badResponse': '服务端返回 {status},而不是数据。',
316
+ 'auth.empty': '没有服务商提供订阅登录。',
317
+ 'update.title': '更新',
318
+ 'update.check': '检查',
319
+ 'update.apply': '更新',
320
+ 'update.working': '处理中…',
321
+ 'update.checking': '检查中…',
322
+ 'update.upToDate': '已是最新版本',
323
+ 'update.available': '有新版本 {version}',
324
+ 'update.availableUnknown': '有新版本可用',
325
+ 'update.installed': '已安装 {version},在 dsh {dsh} 上验证',
326
+ 'update.doneReload': '已更新。请刷新页面。',
327
+ 'update.doneRestart': '已更新。请重启应用。',
328
+ 'update.failed': '更新失败',
329
+ 'update.problem.noStamp': '没有安装记录 — 请重新安装',
330
+ 'update.problem.noPackage': '未找到已安装的包 — 请重新安装',
331
+ 'update.problem.registryStatus': '注册表拒绝了请求',
332
+ 'update.problem.registryUnreachable': '无法连接到注册表',
333
+ 'update.problem.badVersion': '注册表未返回有效版本',
334
+ 'update.problem.noProfile': '安装记录中没有配置文件 — 请重新安装',
335
+ 'update.problem.installFailed': '从注册表安装失败',
336
+ 'update.dshMismatch': '注意:已安装 dsh {actual},但此版本在 {expected} 上验证',
337
+ }
338
+
327
339
  // ── RU-PACK:BEGIN — генерируется tools/build-ru.mjs, руками не править ──
328
340
  //
329
341
  // Русские словари ПОСТАВОЧНЫХ пространств имён. В браузере файлы не
@@ -1619,1463 +1631,1824 @@ window.__ModuleLoader__.load({
1619
1631
  "time.ago": "{t} назад"
1620
1632
  }
1621
1633
  }
1622
- // ── RU-PACK:END ──
1623
-
1624
- const RU_DICT = {
1625
- 'intensity.title': 'Атмосфера Obsidian / Ion',
1626
- 'intensity.hint': 'Насыщенность живого свечения поверх интерфейса',
1627
- 'intensity.off': 'Выкл',
1628
- 'intensity.quiet': 'Тихо',
1629
- 'intensity.normal': 'Обычно',
1630
- 'intensity.vivid': 'Ярко',
1631
- 'accent.title': 'Акцент Obsidian / Ion',
1632
- 'accent.hint': 'Ведущий тон интерфейса и свечения',
1633
- 'review.effortName': 'усилие',
1634
- 'review.other': 'другая…',
1635
- 'review.modelName': 'имя модели',
1636
- 'review.fromList': 'вернуться к списку',
1637
- 'review.objectFalse': 'Не согласен — это настоящая',
1638
- 'review.accuracy': 'Как часто каждый ревизор оказывался прав',
1639
- 'review.notChecked': 'ещё не проверено',
1640
- 'review.byAgent': 'проверил помощник',
1641
- 'review.humanEvidence': 'Пользователь не согласился с приговором помощника, посмотрев находку.',
1642
- 'review.lastReview': 'Последний обзор',
1643
- 'review.objectReal': 'Не согласен — это ложная',
1644
- 'review.of': 'из',
1645
- 'review.byHuman': 'ваше решение',
1646
- 'review.needsProduct': 'Работает, только если этот продукт установлен отдельно',
1647
- 'review.add': 'Добавить ревизора:',
1648
- 'review.participates': 'Участвует в обзорах',
1649
- 'review.noneActive': 'Все ревизоры выключены — обзор ничего не найдёт.',
1650
- 'review.remove': 'Убрать этого ревизора',
1651
- 'review.bySubscription': 'по подписке',
1652
- 'review.limit': 'Больше ревизоров в один обзор не поместится.',
1653
- 'review.byApiKey': 'по токенам',
1654
- 'review.groupSubscription': 'Входят в подписку',
1655
- 'review.groupPaid': 'Оплата отдельно \u2014 по токенам',
1656
- 'review.nav': 'Ревизоры',
1657
- 'review.title': 'Второе мнение',
1658
- 'review.hint': 'Независимые ревизоры смотрят готовую работу и сообщают, что в ней не так. Сколько их и какие — выбираете вы: разные модели находят разное.',
1659
- 'review.model': 'Модель',
1660
- 'review.effort': 'Усилие',
1661
- 'review.asConfigured': 'как настроено',
1662
- 'review.none': 'Не выбран ни один ревизор — обзор ничего не найдёт.',
1663
- 'review.note': 'По умолчанию включены оба: они находят разное, и один гарантированно упускает половину. Пустая модель означает «как настроено у самого продукта».',
1664
- 'auth.nav': 'Подписки',
1665
- 'auth.title': 'Вход к провайдерам',
1666
- 'auth.others': 'Здесь только провайдеры, у которых есть вход по подписке. Остальные — в том числе Z.AI — предлагают лишь ключ API; их место в разделе «Модели».',
1667
- 'auth.hint': 'Войти по подписке вместо ключа API. Учётная запись остаётся на этом компьютере.',
1668
- 'auth.signIn': 'Войти',
1669
- 'auth.again': 'Войти заново',
1670
- 'auth.connected': 'подключено',
1671
- 'auth.openLink': 'Открыть страницу входа',
1672
- 'auth.send': 'Отправить',
1673
- 'auth.cancel': 'Отменить',
1674
- 'auth.done': 'Вход выполнен.',
1675
- 'auth.cancelled': 'Вход отменён.',
1676
- 'auth.unavailable': 'Вход недоступен: в профиле не смонтирована строка авторизации.',
1677
- 'auth.needsRestart': 'Перезапустите приложение: у браузера уже новая версия, у сервера ещё нет.',
1678
- 'auth.badResponse': 'Сервер ответил {status} вместо данных.',
1679
- 'auth.empty': 'Ни один провайдер не предлагает вход по подписке.',
1680
- 'update.title': 'Обновления',
1681
- 'update.check': 'Проверить',
1682
- 'update.apply': 'Обновить',
1683
- 'update.working': 'Работаю…',
1684
- 'update.checking': 'Проверяю…',
1685
- 'update.upToDate': 'Установлена последняя версия',
1686
- 'update.available': 'Доступна версия {version}',
1687
- 'update.availableUnknown': 'Доступна новая версия',
1688
- 'update.installed': 'Установлена {version}, проверено на dsh {dsh}',
1689
- 'update.doneReload': 'Обновлено. Обновите страницу.',
1690
- 'update.doneRestart': 'Обновлено. Перезапустите приложение.',
1691
- 'update.failed': 'Обновить не удалось',
1692
- 'update.problem.noStamp': 'Нет отметки об установке — переустановите',
1693
- 'update.problem.noPackage': 'Установленный пакет не найден — переустановите',
1694
- 'update.problem.registryStatus': 'Реестр отклонил запрос',
1695
- 'update.problem.registryUnreachable': 'Не удалось связаться с реестром',
1696
- 'update.problem.badVersion': 'Реестр не сообщил корректную версию',
1697
- 'update.problem.noProfile': 'В отметке об установке нет профиля — переустановите',
1698
- 'update.problem.installFailed': 'Не удалось установить из реестра',
1699
- 'update.dshMismatch': 'Внимание: установлен dsh {actual}, а эта версия проверялась на {expected}',
1700
- }
1701
-
1702
- // ── Стили ────────────────────────────────────────────────────────────
1703
- //
1704
- // Про режимы наложения. Контейнер слота shell.overlay объявлен как
1705
- // `z-index:20`, то есть создаёт изолированный контекст наложения.
1706
- // `mix-blend-mode` внутри него смешивается не с фоном приложения, а с
1707
- // прозрачным фоном самого контейнера — то есть не работает. Поэтому
1708
- // здесь его нет вовсе, а разница между темами берётся оттуда, откуда её
1709
- // и следует брать: из атрибута `data-ds-dark-theme` на body. Его ставит
1710
- // презентер по полю `colorScheme` активной темы это объявленный
1711
- // контракт, а не внутреннее имя класса.
1712
- //
1713
- // Каждая непрозрачность умножается на `--dsx-intensity`, поэтому одна
1714
- // переменная управляет всей атмосферой, включая полное выключение.
1715
- const GRAIN =
1716
- 'data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27220%27 height=%27220%27%3E' +
1717
- '%3Cfilter id=%27g%27%3E' +
1718
- '%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%270.9%27 numOctaves=%273%27 stitchTiles=%27stitch%27/%3E' +
1719
- '%3CfeColorMatrix type=%27saturate%27 values=%270%27/%3E' +
1720
- '%3C/filter%3E' +
1721
- '%3Crect width=%27220%27 height=%27220%27 filter=%27url(%23g)%27/%3E%3C/svg%3E'
1722
-
1723
- const css = [
1724
- // --dsx-activity ведёт живой отклик на работу агента: 0 в покое, 1 пока
1725
- // идёт ход. Значение ставит невидимый драйвер из сессионного слота,
1726
- // а читают его слои в корневом shell.overlay — переменная на
1727
- // documentElement единственный способ связать два разных поддерева.
1728
- ':root{' + INTENSITY_VAR + ':1;' + ACTIVITY_VAR + ':0;' + ACCENT_VAR + ':90,217,245;' + ACCENT2_VAR + ':138,108,255;}',
1729
-
1730
- // `display:contents` критичен: контейнер слота shell.overlay возвращает
1731
- // прямым детям `pointer-events:auto`. Обёртка без собственного бокса
1732
- // не перехватывает клики, а каждый слой ниже гасит события явно.
1733
- '.dsx-root{display:contents;}',
1734
-
1735
- '.dsx-ambient{position:fixed;inset:0;overflow:hidden;pointer-events:none;contain:layout paint style;}',
1736
- '.dsx-ambient__orb{position:absolute;border-radius:50%;filter:blur(100px);will-change:transform;}',
1737
-
1738
- // ── светлая тема: присутствие есть, доминирования нет ──
1739
- '.dsx-ambient__orb--lead{width:46vw;height:46vw;left:-15vw;top:-14vw;opacity:calc(.13 * var(' + INTENSITY_VAR + '));background:radial-gradient(circle,rgba(var(' + ACCENT_VAR + '),.88) 0%,rgba(var(' + ACCENT_VAR + '),0) 70%);animation:dsx-drift-a 34s ease-in-out infinite;}',
1740
- '.dsx-ambient__orb--counter{width:54vw;height:54vw;right:-20vw;bottom:-24vw;opacity:calc(.12 * var(' + INTENSITY_VAR + '));background:radial-gradient(circle,rgba(var(' + ACCENT2_VAR + '),.84) 0%,rgba(var(' + ACCENT2_VAR + '),0) 70%);animation:dsx-drift-b 46s ease-in-out infinite;}',
1741
- '.dsx-ambient__orb--deep{width:36vw;height:36vw;right:8vw;top:-18vw;opacity:calc(.10 * var(' + INTENSITY_VAR + '));background:radial-gradient(circle,rgba(56,120,255,.78) 0%,rgba(56,120,255,0) 70%);animation:dsx-drift-c 54s ease-in-out infinite;}',
1742
- '.dsx-ambient__orb--rose{width:30vw;height:30vw;left:18vw;bottom:-18vw;opacity:calc(.08 * var(' + INTENSITY_VAR + '));background:radial-gradient(circle,rgba(255,110,180,.70) 0%,rgba(255,110,180,0) 70%);animation:dsx-drift-d 62s ease-in-out infinite;}',
1743
-
1744
- // ── тёмная тема: полная интенсивность ──
1745
- 'body[data-ds-dark-theme] .dsx-ambient__orb--lead{opacity:calc(.48 * var(' + INTENSITY_VAR + '));}',
1746
- 'body[data-ds-dark-theme] .dsx-ambient__orb--counter{opacity:calc(.44 * var(' + INTENSITY_VAR + '));}',
1747
- 'body[data-ds-dark-theme] .dsx-ambient__orb--deep{opacity:calc(.34 * var(' + INTENSITY_VAR + '));}',
1748
- 'body[data-ds-dark-theme] .dsx-ambient__orb--rose{opacity:calc(.26 * var(' + INTENSITY_VAR + '));}',
1749
-
1750
- '.dsx-ambient__sweep{position:absolute;top:0;bottom:0;left:-45%;width:42%;filter:blur(34px);opacity:calc(.35 * var(' + INTENSITY_VAR + '));background:linear-gradient(100deg,rgba(255,255,255,0) 0%,rgba(140,200,255,.055) 42%,rgba(200,160,255,.075) 58%,rgba(255,255,255,0) 100%);animation:dsx-sweep 28s cubic-bezier(.45,0,.25,1) infinite;}',
1751
- 'body[data-ds-dark-theme] .dsx-ambient__sweep{opacity:calc(1 * var(' + INTENSITY_VAR + '));}',
1752
-
1753
- // Верхняя кромка: постоянная тусклая линия плюс бегущий по ней блик.
1754
- '.dsx-ambient__seam{position:absolute;top:0;left:0;right:0;height:1px;overflow:hidden;opacity:calc(1 * var(' + INTENSITY_VAR + '));background:linear-gradient(90deg,rgba(120,160,220,0) 0%,rgba(120,160,220,.14) 20%,rgba(120,160,220,.14) 80%,rgba(120,160,220,0) 100%);}',
1755
- '.dsx-ambient__seam::after{content:"";position:absolute;top:0;left:-30%;width:30%;height:100%;opacity:.45;background:linear-gradient(90deg,rgba(var(' + ACCENT_VAR + '),0) 0%,rgba(var(' + ACCENT_VAR + '),.9) 45%,rgba(var(' + ACCENT2_VAR + '),.9) 55%,rgba(var(' + ACCENT2_VAR + '),0) 100%);animation:dsx-seam-travel 14s cubic-bezier(.5,0,.5,1) infinite;}',
1756
- 'body[data-ds-dark-theme] .dsx-ambient__seam::after{opacity:1;}',
1757
-
1758
- '.dsx-grain{position:fixed;inset:-60px;pointer-events:none;opacity:calc(.030 * var(' + INTENSITY_VAR + '));background-image:url("' + GRAIN + '");background-size:220px 220px;will-change:transform;animation:dsx-grain 1.2s steps(5) infinite;}',
1759
- 'body[data-ds-dark-theme] .dsx-grain{opacity:calc(.045 * var(' + INTENSITY_VAR + '));}',
1760
-
1761
- '.dsx-vignette{position:fixed;inset:0;pointer-events:none;opacity:calc(1 * var(' + INTENSITY_VAR + '));background:radial-gradient(125% 95% at 50% 42%,rgba(0,0,0,0) 55%,rgba(20,40,80,.05) 100%);}',
1762
- 'body[data-ds-dark-theme] .dsx-vignette{background:radial-gradient(125% 95% at 50% 42%,rgba(0,0,0,0) 52%,rgba(2,4,10,.30) 100%);}',
1763
-
1764
- // ── отклик на работу агента ──
1765
- // Слой целиком гаснет в покое, поэтому в простое он не стоит ни кадра
1766
- // композитинга. Появление и уход — через transition, а не анимацию,
1767
- // чтобы переход был плавным в обе стороны.
1768
- '.dsx-pulse{position:fixed;inset:0;overflow:hidden;pointer-events:none;opacity:calc(var(' + ACTIVITY_VAR + ') * var(' + INTENSITY_VAR + '));transition:opacity .55s ease;contain:layout paint style;}',
1769
- '.dsx-pulse__beam{position:absolute;top:0;left:0;right:0;height:2px;overflow:hidden;background:linear-gradient(90deg,rgba(var(' + ACCENT_VAR + '),0) 0%,rgba(var(' + ACCENT_VAR + '),.45) 35%,rgba(var(' + ACCENT2_VAR + '),.45) 65%,rgba(var(' + ACCENT2_VAR + '),0) 100%);}',
1770
- '.dsx-pulse__beam::after{content:"";position:absolute;top:0;bottom:0;left:-25%;width:25%;background:linear-gradient(90deg,rgba(255,255,255,0) 0%,rgba(255,255,255,.95) 50%,rgba(255,255,255,0) 100%);animation:dsx-beam 1.6s linear infinite;}',
1771
- '.dsx-pulse__breath{position:absolute;left:50%;bottom:-24vh;width:72vw;height:46vh;margin-left:-36vw;border-radius:50%;filter:blur(90px);background:radial-gradient(ellipse at center,rgba(var(' + ACCENT_VAR + '),.45) 0%,rgba(var(' + ACCENT_VAR + '),0) 70%);animation:dsx-breath 2.8s ease-in-out infinite;}',
1772
-
1773
- // Периоды дрейфа взаимно непропорциональны, поэтому световой рисунок
1774
- // не повторяется: 34 / 46 / 54 / 62 секунды.
1775
- '@keyframes dsx-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1)}50%{transform:translate3d(7vw,5vh,0) scale(1.14)}}',
1776
- '@keyframes dsx-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.05)}50%{transform:translate3d(-6vw,-4vh,0) scale(.92)}}',
1777
- '@keyframes dsx-drift-c{0%,100%{transform:translate3d(0,0,0) scale(.95)}50%{transform:translate3d(-5vw,6vh,0) scale(1.10)}}',
1778
- '@keyframes dsx-drift-d{0%,100%{transform:translate3d(0,0,0) scale(1)}50%{transform:translate3d(9vw,-7vh,0) scale(1.18)}}',
1779
- '@keyframes dsx-sweep{0%{transform:translateX(0)}55%,100%{transform:translateX(340%)}}',
1780
- // -30% + 433% от собственной ширины (30% кадра) = ровно правый край.
1781
- '@keyframes dsx-seam-travel{0%{transform:translateX(0)}70%,100%{transform:translateX(433%)}}',
1782
- '@keyframes dsx-grain{0%{transform:translate3d(0,0,0)}20%{transform:translate3d(-14px,7px,0)}40%{transform:translate3d(11px,-12px,0)}60%{transform:translate3d(-7px,14px,0)}80%{transform:translate3d(13px,5px,0)}100%{transform:translate3d(0,0,0)}}',
1783
-
1784
- '@keyframes dsx-beam{0%{transform:translateX(0)}100%{transform:translateX(500%)}}',
1785
- '@keyframes dsx-breath{0%,100%{opacity:.35;transform:scale(.94)}50%{opacity:.80;transform:scale(1.06)}}',
1786
- '@media (prefers-reduced-motion: reduce){.dsx-ambient__orb,.dsx-ambient__seam::after,.dsx-ambient__sweep,.dsx-grain,.dsx-pulse__beam::after,.dsx-pulse__breath{animation:none!important}.dsx-pulse{transition:none}}',
1787
-
1788
- // ── айдентика ──
1789
- // Контур один, цвет ведёт `currentColor`. Пара значений взята прямо из
1790
- // присланных файлов: белый на тёмной теме, #0B0D12 на светлой. Ключ —
1791
- // атрибут темы приложения, а не системная схема, поэтому знак следует
1792
- // ручному переключению.
1793
- '.dsx-brand{display:block;color:#0B0D12;}',
1794
- 'body[data-ds-dark-theme] .dsx-brand{color:#FFFFFF;}',
1795
- '.dsx-brand--hero{filter:drop-shadow(0 0 24px rgba(var(' + ACCENT_VAR + '),.35));}',
1796
-
1797
- // В свёрнутой рейке места по горизонтали мало, а знак широкий, поэтому
1798
- // там он ужимается. Состояние читается из атрибута `data-sidebar-collapsed`
1799
- // на фрейме. Оговорка: это НАБЛЮДАЕМЫЙ атрибут, а не объявленный
1800
- // контракт вроде `data-ds-dark-theme`. Если он однажды исчезнет, знак
1801
- // просто останется полной высоты деградация мягкая, без поломки.
1802
- '.dsx-brand--sidebar{height:24px;width:auto;}',
1803
- '[data-sidebar-collapsed] .dsx-brand--sidebar{height:16px;}',
1804
-
1805
- '.dsx-brandname{font-size:15px;font-weight:600;letter-spacing:.14em;color:var(--dsw-alias-label-primary);white-space:nowrap;}',
1806
-
1807
- // ── строка настроек ──
1808
- // Собственные цвета не выдумываются: всё берётся из токенов темы,
1809
- // поэтому строка остаётся согласованной при любой палитре.
1810
- '.dsx-setting{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;}',
1811
- '.dsx-setting__title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;}',
1812
- '.dsx-rev__panel{margin-top:14px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1)}.dsx-rev__panel-head{display:flex;align-items:center;gap:8px;margin-bottom:8px}.dsx-rev__count{padding:1px 7px;border-radius:99px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary);font-size:11px}.dsx-rev__findings{display:flex;flex-direction:column;gap:8px}.dsx-rev__finding{padding:9px 11px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2)}.dsx-rev__finding--false{opacity:.55}.dsx-rev__finding-head{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap}.dsx-rev__finding-title{font-weight:600;color:var(--dsw-alias-label-primary);font-size:13px;flex:1}.dsx-rev__sev{padding:1px 6px;border-radius:4px;font-size:10px;text-transform:uppercase;letter-spacing:.03em}.dsx-rev__sev--crit{background:#5c1f22;color:#ffb4b4}.dsx-rev__sev--high{background:#5a3f16;color:#f0cd8a}.dsx-rev__sev--unknown{background:#3a3a44;color:#c9c9d4}.dsx-rev__sev--low{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary)}.dsx-rev__who{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dsx-rev__where{margin-top:4px;font-family:ui-monospace,monospace;font-size:11px;color:var(--dsw-alias-label-tertiary)}.dsx-rev__what{margin-top:4px;font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.45}.dsx-rev__verdict{display:flex;align-items:flex-start;gap:6px;margin-top:7px;padding-top:7px;border-top:1px dashed var(--dsw-alias-border-l1);font-size:11px;color:var(--dsw-alias-label-secondary)}.dsx-rev__verdict-mark{font-weight:700}.dsx-rev__verdict-text{flex:1;line-height:1.4}.dsx-rev__by{color:var(--dsw-alias-label-tertiary);white-space:nowrap}.dsx-rev__object{margin-top:5px}.dsx-rev__object-btn{border:0;background:none;padding:0;color:var(--dsw-alias-label-tertiary);font-size:11px;cursor:pointer;text-decoration:underline dotted}.dsx-rev__object-btn:hover{color:var(--dsw-alias-label-primary)}.dsx-rev__stats{margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}.dsx-rev__stat{display:flex;justify-content:space-between;gap:10px;margin-top:4px;font-size:12px}.dsx-rev__stat-name{color:var(--dsw-alias-label-secondary)}.dsx-rev__stat-rate{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}.dsx-rev__tag{margin-left:6px;padding:1px 6px;border-radius:99px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary);font-size:10px}.dsx-rev__custom{display:inline-flex;align-items:center;gap:4px}.dsx-rev__input{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;width:170px}.dsx-rev__back{border:0;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;line-height:1;padding:0 2px}.dsx-rev__remove{margin-left:auto;border:0;background:none;color:var(--dsw-alias-label-tertiary);font-size:16px;line-height:1;padding:0 4px;cursor:pointer}.dsx-rev__remove:hover{color:#ffb4b4}.dsx-rev__add{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:12px}.dsx-rev__add-label{color:var(--dsw-alias-label-secondary);font-size:12px;margin-right:2px}.dsx-rev__add-btn{padding:4px 11px;border:1px dashed var(--dsw-alias-border-l2);border-radius:99px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;cursor:pointer;font-family:inherit}.dsx-rev__add-btn:hover:not(:disabled){border-style:solid;color:var(--dsw-alias-label-primary)}.dsx-rev__add-btn:disabled{opacity:.4;cursor:default}.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;}',
1813
- '.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;}',
1814
- '.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;}',
1815
- '.dsx-seg:hover{color:var(--dsw-alias-label-primary);}',
1816
- '.dsx-seg--on{background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);box-shadow:0 0 0 1px var(--dsw-alias-border-l2),0 0 12px -4px var(--dsw-alias-brand-primary);}',
1817
- '@media (prefers-reduced-motion: reduce){.dsx-seg{transition:none}}',
1818
-
1819
- // Кружок акцента показывает сам цвет, поэтому подпись ему не нужна —
1820
- // но имя остаётся в aria-label, иначе кнопка была бы безымянной.
1821
- '.dsx-swatch{appearance:none;cursor:pointer;width:22px;height:22px;padding:0;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);background:rgb(var(--dsx-swatch));transition:transform .16s ease,box-shadow .16s ease;}',
1822
- '.dsx-swatch:hover{transform:scale(1.12);}',
1823
- '.dsx-swatch--on{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2),0 0 0 4px rgb(var(--dsx-swatch)),0 0 14px -2px rgb(var(--dsx-swatch));}',
1824
- '.dsx-setting__swatches{display:inline-flex;gap:10px;align-items:center;flex:none;}',
1825
- '@media (prefers-reduced-motion: reduce){.dsx-swatch{transition:none}}',
1826
- ].join('\n')
1827
-
1828
- // Тот же приём вставки стилей, что и у поставочных пакетов:
1829
- // тег помечается data-plugin-css и не дублируется при повторной загрузке.
1830
- const tagId = 'tensorgrid-ui/atmosphere.css'
1831
- if (
1832
- typeof document !== 'undefined' &&
1833
- document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null
1834
- ) {
1835
- const tag = document.createElement('style')
1836
- tag.dataset.plugin = 'tensorgrid-ui'
1837
- tag.dataset.pluginCss = tagId
1838
- tag.textContent = css
1839
- document.head.appendChild(tag)
1840
- }
1841
-
1842
- // Сохранённый выбор применяется сразу, ещё до монтирования React,
1843
- // иначе на каждой загрузке был бы кадр с чужими значениями.
1844
- applyLevelId(readLevelId())
1845
- applyAccentId(readAccentId())
1846
-
1847
- // ── Компоненты ───────────────────────────────────────────────────────
1848
- function Atmosphere() {
1849
- return jsxs('div', {
1850
- className: 'dsx-root',
1851
- 'aria-hidden': 'true',
1852
- children: [
1853
- jsxs('div', {
1854
- className: 'dsx-ambient',
1855
- children: [
1856
- jsx('div', { className: 'dsx-ambient__orb dsx-ambient__orb--lead' }),
1857
- jsx('div', { className: 'dsx-ambient__orb dsx-ambient__orb--counter' }),
1858
- jsx('div', { className: 'dsx-ambient__orb dsx-ambient__orb--deep' }),
1859
- jsx('div', { className: 'dsx-ambient__orb dsx-ambient__orb--rose' }),
1860
- jsx('div', { className: 'dsx-ambient__sweep' }),
1861
- jsx('div', { className: 'dsx-ambient__seam' }),
1862
- ],
1863
- }),
1864
- jsxs('div', {
1865
- className: 'dsx-pulse',
1866
- children: [
1867
- jsx('div', { className: 'dsx-pulse__beam' }),
1868
- jsx('div', { className: 'dsx-pulse__breath' }),
1869
- ],
1870
- }),
1871
- jsx('div', { className: 'dsx-grain' }),
1872
- jsx('div', { className: 'dsx-vignette' }),
1873
- ],
1874
- })
1875
- }
1876
-
1877
- /**
1878
- * Невидимый драйвер отклика: живёт в сессионном слоте, ничего не рисует и
1879
- * лишь переносит состояние хода в CSS-переменную на documentElement.
1880
- *
1881
- * Разделение вынужденное и осознанное: ambient-слой сидит в корневом
1882
- * shell.overlay, у которого нет сессии, а `useSession` раздаётся только
1883
- * сессионным слотам. Переменная на корне — единственный мост между двумя
1884
- * поддеревьями, не требующий трогать чужой DOM.
1885
- * @param props - стандартные пропсы сессионного слота.
1886
- */
1887
- function ActivityDriver({ useSession }) {
1888
- const running = useSession((session) => session.running) ?? false
1889
-
1890
- React.useEffect(() => {
1891
- if (typeof document === 'undefined') return undefined
1892
- const style = document.documentElement.style
1893
- style.setProperty(ACTIVITY_VAR, running ? '1' : '0')
1894
- return () => {
1895
- style.setProperty(ACTIVITY_VAR, '0')
1896
- }
1897
- }, [running])
1898
-
1899
- return null
1900
- }
1901
-
1902
- function AccentRow({ t }) {
1903
- const [current, setCurrent] = React.useState(readAccentId)
1904
-
1905
- React.useEffect(() => {
1906
- applyAccentId(current)
1907
- writeAccentId(current)
1908
- }, [current])
1909
-
1910
- return jsxs('div', {
1911
- className: 'dsx-setting',
1912
- children: [
1913
- jsxs('div', {
1914
- children: [
1915
- jsx('div', { className: 'dsx-setting__title', children: t('accent.title') }),
1916
- jsx('div', { className: 'dsx-setting__hint', children: t('accent.hint') }),
1917
- ],
1918
- }),
1919
- jsx('div', {
1920
- className: 'dsx-setting__swatches',
1921
- role: 'group',
1922
- children: ACCENTS.map((accent) =>
1923
- jsx(
1924
- 'button',
1925
- {
1926
- type: 'button',
1927
- className: 'dsx-swatch' + (accent.id === current ? ' dsx-swatch--on' : ''),
1928
- style: { '--dsx-swatch': accent.rgb },
1929
- 'aria-label': accent.label,
1930
- 'aria-pressed': accent.id === current,
1931
- onClick: () => setCurrent(accent.id),
1932
- },
1933
- accent.id,
1934
- ),
1935
- ),
1936
- }),
1937
- ],
1938
- })
1939
- }
1940
-
1941
- // ── Айдентика ────────────────────────────────────────────────────────
1942
- //
1943
- // Присланные tg-white.svg и tg-black.svg побайтово совпадают всюду, кроме
1944
- // заливки, поэтому здесь один контур, а цвет ведёт `currentColor`.
1945
- const BRAND_VIEWBOX = '245 230 1047 566'
1946
- const BRAND_RATIO = 566 / 1047
1947
- const BRAND_PATHS = [
1948
- 'M315 278H929L766 404H626Q620 404 620 410V724C620 737 610 748 597 748H516C503 748 493 738 493 725V411Q493 404 486 404H317C303 404 293 394 293 380V302C293 288 303 278 315 278Z',
1949
- 'M968 278H1126C1190 278 1244 333 1244 402V421Q1244 435 1230 435H1128Q1117 435 1117 424Q1117 404 1099 404H889C867 404 849 422 849 444V604C849 625 866 638 887 638H1086C1103 638 1115 627 1115 611V572Q1115 564 1107 564H954C943 564 935 556 935 546V493C935 482 943 474 954 474H1225C1236 474 1244 483 1244 494V619C1244 691 1192 748 1121 748H847C778 748 724 696 724 625V510C724 475 733 455 755 438L968 278Z',
1950
- ]
1951
-
1952
- /**
1953
- * Знак TG. Ширина задаётся снаружи, высота считается из пропорции 1047:566,
1954
- * поэтому знак никогда не переполняет узкую рейку сайдбара.
1955
- * @param width - желаемая ширина в пикселях.
1956
- * @param extraClass - дополнительный класс оформления.
1957
- */
1958
- function brandSvg(width, extraClass) {
1959
- return jsx('svg', {
1960
- className: extraClass === undefined ? 'dsx-brand' : 'dsx-brand ' + extraClass,
1961
- viewBox: BRAND_VIEWBOX,
1962
- width: width,
1963
- height: Math.round(width * BRAND_RATIO),
1964
- role: 'img',
1965
- 'aria-label': 'TG',
1966
- children: jsx('g', {
1967
- fill: 'currentColor',
1968
- children: BRAND_PATHS.map((d, index) => jsx('path', { d: d }, String(index))),
1969
- }),
1970
- })
1971
- }
1972
-
1973
- /**
1974
- * Знак в сайдбаре.
1975
- *
1976
- * Слот передаёт `size: 24` — высоту полосы `.brandIdentity`, которую
1977
- * поставочная квадратная рыба занимает целиком. Знак TG вдвое шире, и
1978
- * если принять `size` за ширину, высота выходит ~13px: рядом с надписью
1979
- * в 18px он читается мелким. Поэтому `size` трактуется как ВЫСОТА, а
1980
- * ширина считается из пропорции.
1981
- * @param size - высота, которую отводит слот.
1982
- */
1983
- function BrandMark({ size }) {
1984
- const box = typeof size === 'number' && size > 0 ? size : 24
1985
- return brandSvg(Math.round(box / BRAND_RATIO), 'dsx-brand--sidebar')
1986
- }
1987
-
1988
- /** Знак на экране пустой сессии — крупнее и со свечением акцента. */
1989
- function HeroMark() {
1990
- return brandSvg(96, 'dsx-brand--hero')
1991
- }
1992
-
1993
- /** Надпись рядом со знаком. */
1994
- function BrandName() {
1995
- return jsx('span', { className: 'dsx-brandname', children: BRAND_NAME })
1996
- }
1997
-
1998
- function IntensityRow({ t }) {
1999
- const [current, setCurrent] = React.useState(readLevelId)
2000
-
2001
- React.useEffect(() => {
2002
- applyLevelId(current)
2003
- writeLevelId(current)
2004
- }, [current])
2005
-
2006
- return jsxs('div', {
2007
- className: 'dsx-setting',
2008
- children: [
2009
- jsxs('div', {
2010
- children: [
2011
- jsx('div', { className: 'dsx-setting__title', children: t('intensity.title') }),
2012
- jsx('div', { className: 'dsx-setting__hint', children: t('intensity.hint') }),
2013
- ],
2014
- }),
2015
- jsx('div', {
2016
- className: 'dsx-setting__control',
2017
- role: 'group',
2018
- children: LEVELS.map((level) =>
2019
- jsx(
2020
- 'button',
2021
- {
2022
- type: 'button',
2023
- className: 'dsx-seg' + (level.id === current ? ' dsx-seg--on' : ''),
2024
- 'aria-pressed': level.id === current,
2025
- onClick: () => setCurrent(level.id),
2026
- children: t(level.key),
2027
- },
2028
- level.id,
2029
- ),
2030
- ),
2031
- }),
2032
- ],
2033
- })
2034
- }
2035
-
2036
- // ── Вход по подписке ─────────────────────────────────────────────────
2037
- //
2038
- // Стойка входа ведёт разговор: показывает ссылку, иногда просит код,
2039
- // иногда задаёт вопрос с выбором. Разговор длится минуты, поэтому
2040
- // браузер начинает попытку и затем опрашивает состояние.
2041
- const AUTH_PATH = '/api/tensorgrid.auth'
2042
-
2043
- /**
2044
- * Пропустить только настоящий веб-адрес.
2045
- *
2046
- * Ссылка приходит от стойки входа, то есть в конечном счёте от внешнего
2047
- * провайдера. Подставлять её в `href` как есть нельзя: схема
2048
- * `javascript:` превратила бы «Открыть страницу входа» в запуск чужого
2049
- * кода прямо в приложении. Разрешаем http и https, остальное прячем.
2050
- *
2051
- * @param value - адрес из уведомления.
2052
- * @returns адрес, пригодный для ссылки, или null.
2053
- */
2054
- function safeUrl(value) {
2055
- if (typeof value !== 'string' || value === '') return null
2056
- try {
2057
- const parsed = new URL(value)
2058
- return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? value : null
2059
- } catch {
2060
- return null
2061
- }
2062
- }
2063
-
2064
- function AuthRow({ t }) {
2065
- const [state, setState] = React.useState(null)
2066
- // Занятость от ДЕЙСТВИЯ пользователя и фоновый опрос — разные вещи.
2067
- //
2068
- // Раньше опрос тоже поднимал этот признак, а поле ввода и кнопки на
2069
- // нём завязаны: каждые две секунды поле гасло прямо под руками. Это
2070
- // ломало ровно тот случай, ради которого опрос и заведён, — ввод кода.
2071
- const [busy, setBusy] = React.useState(false)
2072
- const [draft, setDraft] = React.useState('')
2073
- // Ответы приходят не в том порядке, в каком ушли: медленный опрос
2074
- // может вернуться после быстрого ответа и откатить состояние назад.
2075
- // Применяем только тот ответ, который свежее уже показанного.
2076
- const seq = React.useRef(0)
2077
- const applied = React.useRef(0)
2078
-
2079
- // Функция перевода приходит от рантайма НОВОЙ при каждом рендере
2080
- // в поставочном коде это обычная стрелка, не мемоизированная. Если
2081
- // поставить её в зависимости запроса, цепочка t → send → refresh
2082
- // пересоздаётся каждый раз, эффект с [refresh] срабатывает снова, и
2083
- // строка уходит в бесконечный цикл рендеров и обращений к Host.
2084
- // Такой цикл подвешивает всю вкладку, а не только настройки.
2085
- //
2086
- // Поэтому перевод берётся через ссылку: она всегда указывает на
2087
- // свежую функцию, но сама себя не меняет.
2088
- const translate = React.useRef(t)
2089
- translate.current = t
2090
-
2091
- const send = React.useCallback(async (body) => {
2092
- const response = body === undefined
2093
- ? await fetch(AUTH_PATH)
2094
- : await fetch(AUTH_PATH, {
2095
- method: 'POST',
2096
- headers: { 'content-type': 'application/json' },
2097
- body: JSON.stringify(body),
2098
- })
2099
-
2100
- // Маршрут поднимает host-половина, а она читается только при старте
2101
- // приложения. После обновления браузер получает новый код раньше
2102
- // Host-а, запрос уходит в общий заслон и возвращает 401 страницей, а
2103
- // не JSON. Без этой проверки строка молча показывала пустоту.
2104
- const type = response.headers.get('content-type') || ''
2105
- if (!type.includes('application/json')) {
2106
- const say = translate.current
2107
- return {
2108
- available: true,
2109
- providers: [],
2110
- problem: response.status === 401 || response.status === 404
2111
- ? say('auth.needsRestart')
2112
- : say('auth.badResponse').replace('{status}', String(response.status)),
2113
- }
2114
- }
2115
- return response.json()
2116
- }, [])
2117
-
2118
- /**
2119
- * Спросить Host и показать ответ.
2120
- *
2121
- * @param body - тело действия; без него идёт GET за списком.
2122
- * @param background - фоновый опрос: не блокирует поля и не стирает
2123
- * показанное состояние, если сеть отвалилась. Пользователь в этот
2124
- * момент вводит код, и мигание под руками недопустимо.
2125
- */
2126
- const refresh = React.useCallback(async (body, background) => {
2127
- const ticket = ++seq.current
2128
- if (!background) setBusy(true)
2129
- try {
2130
- const next = await send(body)
2131
- // Обгонять уже применённый ответ нельзя: иначе запоздавший опрос
2132
- // вернёт вопрос, на который только что ответили.
2133
- if (ticket > applied.current) {
2134
- applied.current = ticket
2135
- setState(next)
2136
- }
2137
- } catch (error) {
2138
- // Сбой фонового опроса состояние НЕ трогает. Раньше он затирал его
2139
- // объектом без попытки, из-за чего опрос останавливался навсегда:
2140
- // интернет возвращался, а строка молчала до перезагрузки.
2141
- if (!background && ticket > applied.current) {
2142
- applied.current = ticket
2143
- setState({ available: true, problem: String(error && error.message ? error.message : error) })
2144
- }
2145
- } finally {
2146
- if (!background) setBusy(false)
2147
- }
2148
- }, [send])
2149
-
2150
- React.useEffect(() => { refresh() }, [refresh])
2151
-
2152
- // Пока попытка идёт, состояние спрашивается повторно: поток отвечает
2153
- // не сразу, а ссылка и вопросы приходят по ходу дела.
2154
- const active = state !== null && state.attempt !== undefined && state.attempt !== null && state.attempt.active === true
2155
- React.useEffect(() => {
2156
- if (!active) return undefined
2157
- const id = setInterval(() => { refresh({ action: 'poll' }, true) }, 2000)
2158
- return () => clearInterval(id)
2159
- }, [active, refresh])
2160
-
2161
- if (state !== null && state.available === false) {
2162
- return jsx('div', {
2163
- className: 'dsx-setting',
2164
- children: jsxs('div', {
2165
- children: [
2166
- jsx('div', { className: 'dsx-setting__title', children: t('auth.title') }),
2167
- jsx('div', { className: 'dsx-setting__hint', children: t('auth.unavailable') }),
2168
- ],
2169
- }),
2170
- })
2171
- }
2172
-
2173
- const attempt = state === null || !state.attempt ? null : state.attempt
2174
- const providers = state === null || !Array.isArray(state.providers) ? [] : state.providers
2175
- // Показываем только вход по подписке: ключи API у настроек моделей свои.
2176
- const subscription = providers.filter((p) => p.methods.some((m) => m.id === 'oauth'))
2177
-
2178
- const rows = []
2179
-
2180
- if (attempt !== null && (attempt.active || attempt.outcome || attempt.problem)) {
2181
- for (const notice of attempt.notices || []) {
2182
- rows.push(jsxs('div', {
2183
- className: 'dsx-auth__notice',
2184
- children: [
2185
- jsx('div', { children: notice.message }),
2186
- safeUrl(notice.url) === null ? null : jsx('a', {
2187
- className: 'dsx-auth__link',
2188
- href: safeUrl(notice.url),
2189
- target: '_blank',
2190
- rel: 'noreferrer',
2191
- children: t('auth.openLink'),
2192
- }),
2193
- notice.code === null ? null : jsx('code', { className: 'dsx-auth__code', children: notice.code }),
2194
- ],
2195
- }, 'notice-' + rows.length))
2196
- }
2197
-
2198
- if (attempt.prompt !== null) {
2199
- const prompt = attempt.prompt
2200
- rows.push(jsxs('div', {
2201
- className: 'dsx-auth__prompt',
2202
- children: [
2203
- jsx('div', { children: prompt.message }),
2204
- prompt.options !== null
2205
- ? jsx('div', {
2206
- className: 'dsx-setting__control',
2207
- children: prompt.options.map((option) =>
2208
- jsx('button', {
2209
- type: 'button',
2210
- className: 'dsx-seg',
2211
- disabled: busy,
2212
- onClick: () => refresh({ action: 'answer', value: option.id }),
2213
- children: option.label,
2214
- }, option.id),
2215
- ),
2216
- })
2217
- : jsxs('div', {
2218
- className: 'dsx-setting__control',
2219
- children: [
2220
- jsx('input', {
2221
- className: 'dsx-auth__input',
2222
- type: prompt.kind === 'secret' ? 'password' : 'text',
2223
- value: draft,
2224
- disabled: busy,
2225
- onChange: (event) => setDraft(event.target.value),
2226
- }),
2227
- jsx('button', {
2228
- type: 'button',
2229
- className: 'dsx-seg dsx-seg--on',
2230
- disabled: busy || draft === '',
2231
- onClick: () => { refresh({ action: 'answer', value: draft }); setDraft('') },
2232
- children: t('auth.send'),
2233
- }),
2234
- ],
2235
- }),
2236
- ],
2237
- }, 'prompt'))
2238
- }
2239
-
2240
- if (attempt.outcome) {
2241
- rows.push(jsx('div', {
2242
- className: 'dsx-setting__hint dsx-setting__hint--strong',
2243
- children: attempt.outcome === 'authorized' ? t('auth.done') : t('auth.cancelled'),
2244
- }, 'outcome'))
2245
- }
2246
- if (attempt.problem) {
2247
- rows.push(jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: attempt.problem }, 'problem'))
2248
- }
2249
- }
2250
-
2251
- return jsxs('div', {
2252
- className: 'dsx-subs',
2253
- children: [
2254
- jsxs('div', {
2255
- className: 'dsx-subs__head',
2256
- children: [
2257
- jsx('div', { className: 'dsx-subs__title', children: t('auth.title') }),
2258
- jsx('div', { className: 'dsx-setting__hint', children: t('auth.hint') }),
2259
- ],
2260
- }),
2261
- state !== null && state.problem && (attempt === null || !attempt.problem)
2262
- ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: state.problem })
2263
- : null,
2264
- state !== null && !state.problem && subscription.length === 0
2265
- ? jsx('div', { className: 'dsx-setting__hint', children: t('auth.empty') })
2266
- : null,
2267
- jsx('div', {
2268
- className: 'dsx-auth__list',
2269
- children: subscription.map((provider) =>
2270
- jsxs('div', {
2271
- className: 'dsx-auth__row',
2272
- children: [
2273
- jsxs('div', {
2274
- children: [
2275
- jsx('span', { className: 'dsx-auth__name', children: provider.label }),
2276
- provider.configured
2277
- ? jsx('span', { className: 'dsx-auth__badge', children: t('auth.connected') })
2278
- : null,
2279
- ],
2280
- }),
2281
- jsx('button', {
2282
- type: 'button',
2283
- className: 'dsx-seg',
2284
- disabled: busy || (attempt !== null && attempt.active),
2285
- onClick: () => refresh({ action: 'begin', key: provider.key, method: 'oauth' }),
2286
- children: provider.configured ? t('auth.again') : t('auth.signIn'),
2287
- }),
2288
- ],
2289
- }, provider.key),
2290
- ),
2291
- }),
2292
- rows.length === 0 ? null : jsx('div', { className: 'dsx-auth__panel', children: rows }),
2293
- attempt !== null && attempt.active
2294
- ? jsx('div', {
2295
- className: 'dsx-setting__control',
2296
- children: jsx('button', {
2297
- type: 'button',
2298
- className: 'dsx-seg',
2299
- onClick: () => refresh({ action: 'cancel' }),
2300
- children: t('auth.cancel'),
2301
- }),
2302
- })
2303
- : null,
2304
- // Здесь только провайдеры, у которых есть вход по подписке. У
2305
- // остальных включая Z.AI поставщик предлагает единственный
2306
- // способ, ключ API, и его место в разделе «Модели». Без этой
2307
- // строки отсутствие знакомого имени выглядит как недоработка.
2308
- jsx('div', { className: 'dsx-subs__note', children: t('auth.others') }),
2309
- ],
2310
- })
2311
- }
2312
-
2313
- /** Класс важности: имена уровней приходят с Host по-русски. */
2314
- function severityClass(severity) {
2315
- if (severity === 'критично') return 'crit'
2316
- if (severity === 'серьёзно') return 'high'
2317
- if (severity === 'не указана') return 'unknown'
2318
- return 'low'
2319
- }
2320
-
2321
- /** Знак приговора. Пустой кружок — проверки ещё не было. */
2322
- function verdictMark(verdict) {
2323
- if (verdict === 'confirmed') return ''
2324
- if (verdict === 'false') return ''
2325
- if (verdict === 'deferred') return '⋯'
2326
- return '○'
2327
- }
2328
- // ── Ревизоры ─────────────────────────────────────────────────────────
2329
- //
2330
- // Раздел настроек, где выбирают, кто смотрит работу вторым мнением,
2331
- // какой моделью и с каким усилием.
2332
- //
2333
- // Справочник моделей приходит с Host, а не зашит здесь: иначе список
2334
- // расширят в одной половине и забудут в другой. Пустой выбор модели
2335
- // означает «как настроено у самого продукта» — человек уже настроил
2336
- // свой Claude Code и Codex, и навязывать поверх нечего.
2337
- const REVIEW_PATH = '/api/tensorgrid.review'
2338
-
2339
- /**
2340
- * Выбор значения: известные варианты плюс «своё».
2341
- *
2342
- * `groups` разносит варианты по подписям. Для моделей это принципиально:
2343
- * один и тот же `claude-opus-5` доступен и по подписке, и через
2344
- * OpenRouter по токенам, и вперемешку они неразличимы — а разница в том,
2345
- * придёт ли за них счёт.
2346
- */
2347
- function ValuePicker({ t, value, options, disabled, onPick, placeholder, groups }) {
2348
- const known = options.some((option) => option.value === value)
2349
- const [custom, setCustom] = React.useState(value !== null && !known)
2350
-
2351
- if (custom) {
2352
- return jsxs('span', {
2353
- className: 'dsx-rev__custom',
2354
- children: [
2355
- jsx('input', {
2356
- className: 'dsx-rev__input',
2357
- type: 'text',
2358
- value: value === null ? '' : value,
2359
- placeholder: placeholder,
2360
- disabled: disabled,
2361
- onChange: (event) => onPick(event.target.value.trim() === '' ? null : event.target.value.trim()),
2362
- }),
2363
- jsx('button', {
2364
- type: 'button',
2365
- className: 'dsx-rev__back',
2366
- disabled: disabled,
2367
- title: t('review.fromList'),
2368
- onClick: () => { setCustom(false); onPick(null) },
2369
- children: '\u00d7',
2370
- }),
2371
- ],
2372
- })
2373
- }
2374
-
2375
- // Группы это `optgroup`, а не подписи-разделители внутри списка:
2376
- // браузер сам не даст выбрать заголовок и показывает его иначе.
2377
- const body = groups === undefined
2378
- ? options.map((option) => jsx('option', { value: option.value, children: option.label }, option.value))
2379
- : groups
2380
- .filter((group) => group.options.length > 0)
2381
- .map((group) => jsx('optgroup', {
2382
- label: group.label,
2383
- children: group.options.map((option) => jsx('option', { value: option.value, children: option.label }, option.value)),
2384
- }, group.label))
2385
-
2386
- return jsxs('select', {
2387
- className: 'dsx-rev__select',
2388
- value: value === null ? '' : value,
2389
- disabled: disabled,
2390
- onChange: (event) => {
2391
- if (event.target.value === '\u0000custom') { setCustom(true); return }
2392
- onPick(event.target.value === '' ? null : event.target.value)
2393
- },
2394
- children: [
2395
- jsx('option', { value: '', children: t('review.asConfigured') }, ''),
2396
- ...body,
2397
- jsx('option', { value: '\u0000custom', children: t('review.other') }, 'custom'),
2398
- ],
2399
- })
2400
- }
2401
- /** Класс важности: имена уровней приходят с Host по-русски. */
2402
- function severityClass(severity) {
2403
- if (severity === 'критично') return 'crit'
2404
- if (severity === 'серьёзно') return 'high'
2405
- if (severity === 'не указана') return 'unknown'
2406
- return 'low'
2407
- }
2408
-
2409
- /** Знак приговора. Пустой кружок — проверки ещё не было. */
2410
- function verdictMark(verdict) {
2411
- if (verdict === 'confirmed') return '✓'
2412
- if (verdict === 'false') return '✗'
2413
- if (verdict === 'deferred') return '⋯'
2414
- return '○'
2415
- }
2416
- // ── Ревизоры ─────────────────────────────────────────────────────────
2417
- //
2418
- // Свободный список, а не готовые режимы.
2419
- //
2420
- // Режимы «быстрый, обычный, глубокий» отвечали за человека на вопрос, на
2421
- // который он вполне способен ответить сам, и при этом не давали позвать,
2422
- // скажем, три разные модели одного семейства. Теперь: сколько угодно
2423
- // записей, у каждой свой вид, модель и усилие.
2424
- //
2425
- // Рядом с каждой моделью показано, чем она оплачена. Это не украшение:
2426
- // подписка безлимитна, а ключ API считается по токенам, и человек,
2427
- // собирающий состав, должен видеть разницу ДО того, как получит счёт.
2428
- function ReviewRow({ t }) {
2429
- const [state, setState] = React.useState(null)
2430
- const [busy, setBusy] = React.useState(false)
2431
-
2432
- const translate = React.useRef(t)
2433
- translate.current = t
2434
-
2435
- const send = React.useCallback(async (body) => {
2436
- const response = body === undefined
2437
- ? await fetch(REVIEW_PATH)
2438
- : await fetch(REVIEW_PATH, {
2439
- method: 'POST',
2440
- headers: { 'content-type': 'application/json' },
2441
- body: JSON.stringify(body),
2442
- })
2443
- const type = response.headers.get('content-type') || ''
2444
- if (!type.includes('application/json')) {
2445
- const say = translate.current
2446
- return {
2447
- catalogue: [],
2448
- problem: response.status === 401 || response.status === 404
2449
- ? say('auth.needsRestart')
2450
- : say('auth.badResponse').replace('{status}', String(response.status)),
2451
- }
2452
- }
2453
- return response.json()
2454
- }, [])
2455
-
2456
- const refresh = React.useCallback(async (body) => {
2457
- setBusy(true)
2458
- try {
2459
- const next = await send(body)
2460
- // Ответ без каталога — это отказ, а не новое состояние. Замена
2461
- // состояния целиком убирала все карточки после одного неудачного
2462
- // нажатия: ни повторить, ни увидеть выбранное было нельзя.
2463
- setState((previous) => {
2464
- const complete = Array.isArray(next.catalogue) && next.catalogue.length > 0
2465
- if (complete) return next
2466
- return previous === null ? next : { ...previous, problem: next.problem ?? 'сохранить не удалось' }
2467
- })
2468
- } catch (error) {
2469
- const text = String(error && error.message ? error.message : error)
2470
- setState((previous) => (previous === null ? { catalogue: [], problem: text } : { ...previous, problem: text }))
2471
- } finally {
2472
- setBusy(false)
2473
- }
2474
- }, [send])
2475
-
2476
- React.useEffect(() => { refresh() }, [refresh])
2477
-
2478
- const catalogue = state !== null && Array.isArray(state.catalogue) ? state.catalogue : []
2479
- const journal = state !== null && state.journal ? state.journal : null
2480
- const effortMap = state !== null && state.efforts ? state.efforts : {}
2481
-
2482
- /**
2483
- * Уровни усилия для записи.
2484
- *
2485
- * У наших ревизоров приходят с Host по выбранной модели; у внешних
2486
- * продуктов остаются зашитыми — спросить у них нечем.
2487
- */
2488
- const effortsFor = (entry) => {
2489
- const spec = catalogue.find((item) => item.kind === entry.kind)
2490
- if (spec && !spec.internal) return spec.efforts || []
2491
- if (entry.provider === null || entry.model === null) return []
2492
- const info = effortMap[entry.provider + '/' + entry.model]
2493
- return info && Array.isArray(info.efforts) ? info.efforts : []
2494
- }
2495
- const reviewers = state !== null && state.settings && Array.isArray(state.settings.reviewers)
2496
- ? state.settings.reviewers
2497
- : []
2498
-
2499
- // Список уходит ЦЕЛИКОМ: удаление записи иначе было бы невыразимо —
2500
- // слияние на стороне Host всегда возвращало бы удалённое обратно.
2501
- const save = React.useCallback((next) => { refresh({ reviewers: next }) }, [refresh])
2502
-
2503
- const patch = (id, changes) =>
2504
- save(reviewers.map((entry) => (entry.id === id ? { ...entry, ...changes } : entry)))
2505
-
2506
- const remove = (id) => save(reviewers.filter((entry) => entry.id !== id))
2507
-
2508
- const add = (kind) => {
2509
- const spec = catalogue.find((entry) => entry.kind === kind)
2510
- save([...reviewers, {
2511
- kind,
2512
- // Умолчание берётся из справочника: пустая модель означала бы
2513
- // умолчание маршрута, то есть копию уже имеющегося ревизора.
2514
- provider: spec && spec.internal ? spec.defaultProvider : null,
2515
- model: spec ? spec.defaultModel : null,
2516
- effort: null,
2517
- enabled: true,
2518
- }])
2519
- }
2520
-
2521
- /**
2522
- * Отметка на карточке: по подписке, за отдельные деньги или молчание.
2523
- *
2524
- * ТРИ состояния, и третье обязательно. Прежде их было два, и всё, что
2525
- * не подписка, объявлялось платным включая случай «сказать нечего».
2526
- * Получалось, что человек видел «по токенам» на модели, которая ему
2527
- * давно оплачена, а внешний продукт с собственной подпиской получал
2528
- * ту же метку.
2529
- *
2530
- * Молчание честнее догадки: отсутствие отметки означает «не знаю», и
2531
- * это ровно то, что есть на самом деле.
2532
- */
2533
- const billingLabel = (kind) => {
2534
- if (kind === 'subscription') return t('review.bySubscription')
2535
- if (kind === 'api-key') return t('review.byApiKey')
2536
- return null
2537
- }
2538
-
2539
- const atLimit = reviewers.length >= 8
2540
- const activeCount = reviewers.filter((entry) => entry.enabled).length
2541
-
2542
- return jsxs('div', {
2543
- className: 'dsx-subs',
2544
- children: [
2545
- jsxs('div', {
2546
- className: 'dsx-subs__head',
2547
- children: [
2548
- jsx('div', { className: 'dsx-subs__title', children: t('review.title') }),
2549
- jsx('div', { className: 'dsx-setting__hint', children: t('review.hint') }),
2550
- ],
2551
- }),
2552
- state !== null && state.problem
2553
- ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: state.problem })
2554
- : null,
2555
-
2556
- jsx('div', {
2557
- className: 'dsx-rev__list',
2558
- children: reviewers.map((entry) => {
2559
- const spec = catalogue.find((item) => item.kind === entry.kind)
2560
- const models = spec && Array.isArray(spec.models) ? spec.models : []
2561
- const chosen = models.find((m) => m.id === entry.model)
2562
- const billing = billingLabel(chosen ? chosen.billing : null)
2563
-
2564
- return jsxs('div', {
2565
- className: 'dsx-rev__card' + (entry.enabled ? ' dsx-rev__card--on' : ''),
2566
- children: [
2567
- jsxs('div', {
2568
- className: 'dsx-rev__head',
2569
- children: [
2570
- jsx('input', {
2571
- type: 'checkbox',
2572
- checked: entry.enabled,
2573
- disabled: busy,
2574
- title: t('review.participates'),
2575
- onChange: () => patch(entry.id, { enabled: !entry.enabled }),
2576
- }),
2577
- jsx('span', { className: 'dsx-rev__name', children: spec ? spec.label : entry.kind }),
2578
- billing === null ? null : jsx('span', { className: 'dsx-rev__tag', children: billing }),
2579
- jsx('button', {
2580
- type: 'button',
2581
- className: 'dsx-rev__remove',
2582
- disabled: busy,
2583
- title: t('review.remove'),
2584
- onClick: () => remove(entry.id),
2585
- children: '\u00d7',
2586
- }),
2587
- ],
2588
- }),
2589
- jsxs('div', {
2590
- className: 'dsx-rev__fields',
2591
- children: [
2592
- jsxs('label', {
2593
- className: 'dsx-rev__field',
2594
- children: [
2595
- jsx('span', { children: t('review.model') }),
2596
- jsx(ValuePicker, {
2597
- t: t,
2598
- value: entry.model,
2599
- disabled: busy,
2600
- placeholder: t('review.modelName'),
2601
- options: models.map((m) => ({ value: m.id, label: m.id })),
2602
- // РАЗДЕЛЕНИЕ ПО ОПЛАТЕ.
2603
- //
2604
- // Один и тот же claude-opus-5 доступен и по
2605
- // подписке, и через OpenRouter по токенам. В общем
2606
- // списке они неразличимы, а разница в том, придёт
2607
- // ли за них счёт. Провайдер стоит в названии: у
2608
- // OpenRouter сотни моделей, и без него не понять,
2609
- // чья она.
2610
- // Групп ДВЕ, а не три. Различие, которое важно
2611
- // человеку, одно: придёт ли за эту модель
2612
- // отдельный счёт. Ключ, сохранённый в dsh, и
2613
- // ключ из окружения — с этой стороны одно и то
2614
- // же, а третья группа оказывалась почти пустой и
2615
- // только мешала выбирать.
2616
- // Группы появляются, ТОЛЬКО когда признак оплаты
2617
- // действительно известен. Иначе — простой список:
2618
- // деление на «подписка» и «по токенам», в котором
2619
- // первая корзина пуста, а вторая содержит всё,
2620
- // сообщает неправду, а не отсутствие сведений.
2621
- groups: models.some((m) => m.billing !== null && m.billing !== undefined)
2622
- ? [
2623
- {
2624
- label: t('review.groupSubscription'),
2625
- options: models.filter((m) => m.billing === 'subscription')
2626
- .map((m) => ({ value: m.id, label: m.provider + ' \u00b7 ' + m.id })),
2627
- },
2628
- {
2629
- label: t('review.groupPaid'),
2630
- options: models.filter((m) => m.billing !== 'subscription')
2631
- .map((m) => ({ value: m.id, label: (m.provider === null ? '' : m.provider + ' \u00b7 ') + m.id })),
2632
- },
2633
- ]
2634
- : undefined,
2635
- onPick: (value) => {
2636
- const found = models.find((m) => m.id === value)
2637
- patch(entry.id, {
2638
- provider: value === null ? null : (found ? found.provider : entry.provider),
2639
- model: value,
2640
- })
2641
- },
2642
- }),
2643
- ],
2644
- }),
2645
- jsxs('label', {
2646
- className: 'dsx-rev__field',
2647
- children: [
2648
- jsx('span', { children: t('review.effort') }),
2649
- jsx(ValuePicker, {
2650
- t: t,
2651
- value: entry.effort,
2652
- disabled: busy,
2653
- placeholder: t('review.effortName'),
2654
- // Уровни у КАЖДОЙ МОДЕЛИ свои, и зашитый список
2655
- // врал почти везде. Проверено: у claude-opus-5 их
2656
- // шесть, у deepseek-v4-pro — три, а одна и та же
2657
- // модель через подписку и через OpenRouter даёт
2658
- // разные наборы.
2659
- //
2660
- // Пока модель не выбрана, спрашивать нечего:
2661
- // остаётся ввод своего значения.
2662
- options: effortsFor(entry).map((e) => ({ value: e, label: e })),
2663
- onPick: (value) => patch(entry.id, { effort: value }),
2664
- }),
2665
- ],
2666
- }),
2667
- ],
2668
- }),
2669
- ],
2670
- }, entry.id)
2671
- }),
2672
- }),
2673
-
2674
- // Добавление: по кнопке на каждый вид ревизора. Внешние продукты
2675
- // помечены они работают, только если установлены отдельно.
2676
- jsxs('div', {
2677
- className: 'dsx-rev__add',
2678
- children: [
2679
- jsx('span', { className: 'dsx-rev__add-label', children: t('review.add') }),
2680
- ...catalogue.map((spec) => jsx('button', {
2681
- type: 'button',
2682
- className: 'dsx-rev__add-btn',
2683
- disabled: busy || atLimit,
2684
- title: spec.optional ? t('review.needsProduct') : '',
2685
- onClick: () => add(spec.kind),
2686
- children: spec.label + (spec.optional ? ' \u2217' : ''),
2687
- }, spec.kind)),
2688
- ],
2689
- }),
2690
- atLimit ? jsx('div', { className: 'dsx-setting__hint', children: t('review.limit') }) : null,
2691
-
2692
- reviewers.length === 0
2693
- ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: t('review.none') })
2694
- : activeCount === 0
2695
- ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: t('review.noneActive') })
2696
- : null,
2697
- // ── Панель находок ──────────────────────────────────────────
2698
- //
2699
- // Показывает последний обзор: что нашли ревизоры и что из этого
2700
- // подтвердилось проверкой.
2701
- //
2702
- // Судит НЕ тот, кто смотрит. Приговор выносит агент и обязан
2703
- // приложить доказательство — что именно он запустил или прочитал.
2704
- // Просить человека, не разбирающегося в предмете, решить
2705
- // «настоящая это ошибка или нет» значит просить невозможного и
2706
- // получить случайные нажатия, которые ещё и будут выглядеть как
2707
- // данные.
2708
- //
2709
- // Возразить можно, но не нужно: возражение человека перекрывает
2710
- // приговор агента и обратно уже не отменяется.
2711
- journal === null || journal.latest === null ? null : jsxs('div', {
2712
- className: 'dsx-rev__panel',
2713
- children: [
2714
- jsxs('div', {
2715
- className: 'dsx-rev__panel-head',
2716
- children: [
2717
- jsx('span', { className: 'dsx-subs__title', children: t('review.lastReview') }),
2718
- jsx('span', { className: 'dsx-rev__count', children: journal.latest.findings.length }),
2719
- ],
2720
- }),
2721
- jsx('div', {
2722
- className: 'dsx-rev__findings',
2723
- children: journal.latest.findings.map((finding) => jsxs('div', {
2724
- className: 'dsx-rev__finding dsx-rev__finding--' + finding.verdict,
2725
- children: [
2726
- jsxs('div', {
2727
- className: 'dsx-rev__finding-head',
2728
- children: [
2729
- jsx('span', { className: 'dsx-rev__sev dsx-rev__sev--' + severityClass(finding.severity), children: finding.severity }),
2730
- jsx('span', { className: 'dsx-rev__finding-title', children: finding.title }),
2731
- jsx('span', { className: 'dsx-rev__who', children: finding.reviewer }),
2732
- ],
2733
- }),
2734
- finding.where === null ? null : jsx('div', { className: 'dsx-rev__where', children: finding.where }),
2735
- finding.what === null ? null : jsx('div', { className: 'dsx-rev__what', children: finding.what }),
2736
- jsxs('div', {
2737
- className: 'dsx-rev__verdict',
2738
- children: [
2739
- jsx('span', {
2740
- className: 'dsx-rev__verdict-mark',
2741
- children: verdictMark(finding.verdict),
2742
- }),
2743
- jsx('span', {
2744
- className: 'dsx-rev__verdict-text',
2745
- children: finding.evidence === ''
2746
- ? t('review.notChecked')
2747
- : finding.evidence,
2748
- }),
2749
- finding.by === null ? null : jsx('span', {
2750
- className: 'dsx-rev__by',
2751
- children: finding.by === 'human' ? t('review.byHuman') : t('review.byAgent'),
2752
- }),
2753
- ],
2754
- }),
2755
- jsx('div', {
2756
- className: 'dsx-rev__object',
2757
- children: jsx('button', {
2758
- type: 'button',
2759
- className: 'dsx-rev__object-btn',
2760
- disabled: busy,
2761
- onClick: () => save({
2762
- findingId: finding.id,
2763
- verdict: finding.verdict === 'confirmed' ? 'false' : 'confirmed',
2764
- evidence: t('review.humanEvidence'),
2765
- }),
2766
- children: finding.verdict === 'confirmed' ? t('review.objectFalse') : t('review.objectReal'),
2767
- }),
2768
- }),
2769
- ],
2770
- }, finding.id)),
2771
- }),
2772
- journal.accuracy.length === 0 ? null : jsxs('div', {
2773
- className: 'dsx-rev__stats',
2774
- children: [
2775
- jsx('div', { className: 'dsx-setting__hint', children: t('review.accuracy') }),
2776
- ...journal.accuracy.map((stat) => jsxs('div', {
2777
- className: 'dsx-rev__stat',
2778
- children: [
2779
- jsx('span', { className: 'dsx-rev__stat-name', children: stat.reviewer }),
2780
- jsx('span', {
2781
- className: 'dsx-rev__stat-rate',
2782
- // Доля без числа находок ничего не значит: «100%» по
2783
- // одной проверенной находке выглядит как надёжность.
2784
- children: Math.round(stat.rate * 100) + '% ' + t('review.of') + ' ' + stat.judged,
2785
- }),
2786
- ],
2787
- }, stat.reviewer)),
2788
- ],
2789
- }),
2790
- ],
2791
- }),
2792
- jsx('div', { className: 'dsx-subs__note', children: t('review.note') }),
2793
- ],
2794
- })
2795
- }
2796
- // ── Обновления ───────────────────────────────────────────────────────
2797
- //
2798
- // Маршрут поднимает host-половина пакета. Путь относительный, токен не
2799
- // нужен: страница уже авторизована сессией, как и у поставочных
2800
- // пакетов, которые ходят на /api/ обычным fetch.
2801
- const UPDATE_PATH = '/api/tensorgrid.update'
2802
-
2803
- function UpdateRow({ t }) {
2804
- const [state, setState] = React.useState(null)
2805
- const [busy, setBusy] = React.useState(false)
2806
- const [result, setResult] = React.useState(null)
2807
-
2808
- const load = React.useCallback(async (force) => {
2809
- setBusy(true)
2810
- setResult(null)
2811
- try {
2812
- const response = await fetch(UPDATE_PATH + (force ? '?force=1' : ''))
2813
- setState(response.ok ? await response.json() : { problem: 'сервер ответил ' + response.status })
2814
- } catch (error) {
2815
- setState({ problem: String(error && error.message ? error.message : error) })
2816
- } finally {
2817
- setBusy(false)
2818
- }
2819
- }, [])
2820
-
2821
- // Первый показ берёт готовый ответ: host проверяет при старте, так что
2822
- // ждать сети обычно не приходится.
2823
- React.useEffect(() => { load(false) }, [load])
2824
-
2825
- const apply = React.useCallback(async () => {
2826
- setBusy(true)
2827
- setResult(null)
2828
- try {
2829
- const response = await fetch(UPDATE_PATH, {
2830
- method: 'POST',
2831
- headers: { 'content-type': 'application/json' },
2832
- body: '{}',
2833
- })
2834
- const value = await response.json()
2835
- setResult(value)
2836
- if (value.status) setState(value.status)
2837
- } catch (error) {
2838
- setResult({ ok: false, problem: String(error && error.message ? error.message : error) })
2839
- } finally {
2840
- setBusy(false)
2841
- }
2842
- }, [])
2843
-
2844
- // Host сообщает и код проблемы, и её текст. Код переводится, текст
2845
- // остаётся запасным вариантом: сообщение с Host всегда по-русски, и
2846
- // без кода англоязычный пользователь увидел бы кириллицу.
2847
- const problemText = (value) => {
2848
- if (!value.problemCode) return value.problem
2849
- const key = 'update.problem.' + value.problemCode
2850
- const translated = t(key)
2851
- return translated === key ? value.problem : translated
2852
- }
2853
-
2854
- // Подставляем значение, только если оно действительно пришло. Иначе
2855
- // берём формулировку без него.
2856
- //
2857
- // Это не перестраховка: при обновлении Host перечитывает свою
2858
- // половину сразу, а браузерный бандл — только после перезагрузки
2859
- // страницы. В этом промежутке старый клиент получает ответ новой
2860
- // формы, и слепая подстановка выдавала пользователю «undefined».
2861
- const fill = (key, fallbackKey, name, value) =>
2862
- typeof value === 'string' && value !== ''
2863
- ? t(key).replace('{' + name + '}', value)
2864
- : t(fallbackKey)
2865
-
2866
- let summary = t('update.checking')
2867
- if (state !== null) {
2868
- if (state.problem) summary = problemText(state)
2869
- else if (state.updateAvailable) summary = fill('update.available', 'update.availableUnknown', 'version', state.latestVersion)
2870
- else summary = t('update.upToDate')
2871
- }
2872
-
2873
- const version = state === null || typeof state.installedVersion !== 'string'
2874
- ? ''
2875
- : t('update.installed')
2876
- .replace('{version}', state.installedVersion)
2877
- .replace('{dsh}', typeof state.dshExpected === 'string' ? state.dshExpected : '—')
2878
-
2879
- // Расхождение версий dsh — самый тихий способ всё сломать: пакет
2880
- // опирается на контракты, а проверяли его на другой версии. Пользователь
2881
- // об этом узнать ниоткуда не может, поэтому говорим прямо.
2882
- const dshMismatch = state !== null
2883
- && state.dshExpected
2884
- && state.dshActual
2885
- && state.dshExpected !== state.dshActual
2886
- ? t('update.dshMismatch').replace('{expected}', state.dshExpected).replace('{actual}', state.dshActual)
2887
- : null
2888
-
2889
- return jsxs('div', {
2890
- className: 'dsx-setting',
2891
- children: [
2892
- jsxs('div', {
2893
- children: [
2894
- jsx('div', { className: 'dsx-setting__title', children: t('update.title') }),
2895
- jsx('div', { className: 'dsx-setting__hint', children: summary }),
2896
- version === '' ? null : jsx('div', { className: 'dsx-setting__hint', children: version }),
2897
- dshMismatch === null ? null : jsx('div', {
2898
- className: 'dsx-setting__hint dsx-setting__hint--warn',
2899
- children: dshMismatch,
2900
- }),
2901
- result === null ? null : jsx('div', {
2902
- className: 'dsx-setting__hint dsx-setting__hint--strong',
2903
- children: result.ok
2904
- ? (result.needsRestart ? t('update.doneRestart') : t('update.doneReload'))
2905
- : (problemText(result) || t('update.failed')),
2906
- }),
2907
- ],
2908
- }),
2909
- jsxs('div', {
2910
- className: 'dsx-setting__control',
2911
- role: 'group',
2912
- children: [
2913
- jsx('button', {
2914
- type: 'button',
2915
- className: 'dsx-seg',
2916
- disabled: busy,
2917
- onClick: () => load(true),
2918
- children: busy ? t('update.working') : t('update.check'),
2919
- }),
2920
- state !== null && state.canUpdate
2921
- ? jsx('button', {
2922
- type: 'button',
2923
- className: 'dsx-seg dsx-seg--on',
2924
- disabled: busy,
2925
- onClick: apply,
2926
- children: t('update.apply'),
2927
- })
2928
- : null,
2929
- ],
2930
- }),
2931
- ],
2932
- })
2933
- }
2934
-
2935
- // ── Регистрация ──────────────────────────────────────────────────────
2936
- /** Жёсткие зависимости клиентской половины. */
2937
- const inject = ['slots', 'theme', 'locale']
2938
-
2939
- /**
2940
- * Кладёт слой токенов и занимает пять аддитивных мест: ambient-слой над
2941
- * фреймом, две строки настроек, невидимый драйвер отклика в сессионном
2942
- * слоте и знак на экране пустой сессии. Каждая регистрация возвращает
2943
- * диспозер, поэтому снятие строки композиции убирает пакет без следов.
2944
- * @param ctx - Корневой клиентский контекст.
2945
- */
2946
- function apply(ctx) {
2947
- // Ссылка живёт ровно столько же, сколько слой: её берёт строка акцента,
2948
- // чтобы переписать слой при смене тона.
2949
- ctx.effect(() => {
2950
- themeService = ctx.theme
2951
- const dispose = ctx.theme.overrideTokens(SOURCE, tokensFor(accentOf(readAccentId()) ?? accentOf(DEFAULT_ACCENT)))
2952
- return () => {
2953
- themeService = null
2954
- dispose()
2955
- }
2956
- })
2957
-
2958
- // Русский как язык-пакет. Цепочка запасных вариантов обязана дойти до
2959
- // английского, и это ровно то, что делает перевод безопасным: любой
2960
- // ключ без русского значения — включая строки, которые появятся в
2961
- // будущих версиях dsh, сам покажется по-английски. Сломаться нечему.
2962
- ctx.effect(() => {
2963
- const disposers = []
2964
-
2965
- const known = ctx.locale.getLocale().locales.some((entry) => entry.id === RU)
2966
- if (!known) {
2967
- try {
2968
- disposers.push(ctx.locale.addLanguage({ id: RU, label: 'Русский', fallback: 'en' }))
2969
- } catch (error) {
2970
- console.error('addLanguage failed', error)
2971
- }
2972
- }
2973
-
2974
- disposers.push(ctx.locale.register(LOCALE_NS, { en: EN_DICT, zh: ZH_DICT }))
2975
- disposers.push(ctx.locale.register(LOCALE_NS, RU, RU_DICT))
2976
-
2977
- // Русские словари чужих пространств имён. Трёхаргументная форма
2978
- // register принимает произвольное имя, поэтому язык-пакет может
2979
- // дополнять словари пакетов, которые ему не принадлежат, ничего в
2980
- // них не замещая: английский и китайский остаются нетронутыми.
2981
- for (const ns of Object.keys(RU_PACK)) {
2982
- disposers.push(ctx.locale.register(ns, RU, RU_PACK[ns]))
2983
- }
2984
-
2985
- return () => {
2986
- for (const dispose of disposers) dispose()
2987
- }
2988
- }, 'obsidian-ion: language pack')
2989
-
2990
- ctx.slots.inject('shell.overlay', () =>
2991
- ctx.slots.register(
2992
- { name: 'shell.overlay', id: 'obsidian-ion-ambient', order: -1000 },
2993
- Atmosphere,
2994
- ),
2995
- )
2996
-
2997
- ctx.slots.inject('settings.general.item', () =>
2998
- ctx.slots.register(
2999
- { name: 'settings.general.item', id: 'obsidian-ion-intensity', order: 12, locale: LOCALE_NS },
3000
- IntensityRow,
3001
- ),
3002
- )
3003
-
3004
- // Подписки — собственный раздел настроек, а не строка среди прочих.
3005
- //
3006
- // `settings.section` аддитивный список: своя запись добавляет свою
3007
- // вкладку и ничего не замещает. Порядок 12 ставит её между «Моделями»
3008
- // (10) и «Плагинами» (15): вход к провайдерам — сосед моделей, а не
3009
- // оформления.
3010
- ctx.slots.inject('settings.section', () =>
3011
- ctx.slots.register(
3012
- {
3013
- name: 'settings.section',
3014
- id: 'tensorgrid-subscriptions',
3015
- order: 12,
3016
- locale: LOCALE_NS,
3017
- label: () => ctx.locale.translate(LOCALE_NS, 'auth.nav'),
3018
- },
3019
- AuthRow,
3020
- ),
3021
- )
3022
-
3023
- // Ревизоры — соседний раздел: и подписки, и обзор про то, чем и как
3024
- // работает агент, а не про внешний вид.
3025
- ctx.slots.inject('settings.section', () =>
3026
- ctx.slots.register(
3027
- {
3028
- name: 'settings.section',
3029
- id: 'tensorgrid-review',
3030
- order: 13,
3031
- locale: LOCALE_NS,
3032
- label: () => ctx.locale.translate(LOCALE_NS, 'review.nav'),
3033
- },
3034
- ReviewRow,
3035
- ),
3036
- )
3037
-
3038
- ctx.slots.inject('settings.general.item', () =>
3039
- ctx.slots.register(
3040
- { name: 'settings.general.item', id: 'obsidian-ion-update', order: 11, locale: LOCALE_NS },
3041
- UpdateRow,
3042
- ),
3043
- )
3044
-
3045
- ctx.slots.inject('settings.general.item', () =>
3046
- ctx.slots.register(
3047
- { name: 'settings.general.item', id: 'obsidian-ion-accent', order: 13, locale: LOCALE_NS },
3048
- AccentRow,
3049
- ),
3050
- )
3051
-
3052
- ctx.slots.inject('conversation.input.dock', () =>
3053
- ctx.slots.register(
3054
- { name: 'conversation.input.dock', id: 'obsidian-ion-activity', order: 1000 },
3055
- ActivityDriver,
3056
- ),
3057
- )
3058
-
3059
- ctx.slots.inject('conversation.hero.brand.mark', () =>
3060
- ctx.slots.register({ name: 'conversation.hero.brand.mark' }, HeroMark),
3061
- )
3062
-
3063
- // Два слота ниже помечены `shadows-shipped-ui`, и это единственное
3064
- // осознанное исключение из правила пакета. Заменяемые компоненты —
3065
- // чистая графика (`FishLogo` и `BrandWordmark`): ни дочерних слотов,
3066
- // ни поведения, ни будущих функций, которые мы бы пропустили. Потерять
3067
- // поставочную айдентику здесь и есть цель.
3068
- ctx.slots.inject('sidebar.brand.mark', () =>
3069
- ctx.slots.register({ name: 'sidebar.brand.mark' }, BrandMark),
3070
- )
3071
-
3072
- ctx.slots.inject('sidebar.brand.name', () =>
3073
- ctx.slots.register({ name: 'sidebar.brand.name' }, BrandName),
3074
- )
3075
- }
3076
-
3077
- exports.apply = apply
3078
- exports.inject = inject
3079
- return module.exports
3080
- },
3081
- })
1634
+ // ── RU-PACK:END ──
1635
+
1636
+ const RU_DICT = {
1637
+ 'intensity.title': 'Атмосфера Obsidian / Ion',
1638
+ 'intensity.hint': 'Насыщенность живого свечения поверх интерфейса',
1639
+ 'intensity.off': 'Выкл',
1640
+ 'intensity.quiet': 'Тихо',
1641
+ 'intensity.normal': 'Обычно',
1642
+ 'intensity.vivid': 'Ярко',
1643
+ 'accent.title': 'Акцент Obsidian / Ion',
1644
+ 'accent.hint': 'Ведущий тон интерфейса и свечения',
1645
+ 'review.effortName': 'усилие',
1646
+ 'review.other': 'другая…',
1647
+ 'review.modelName': 'имя модели',
1648
+ 'review.fromList': 'вернуться к списку',
1649
+ 'review.objectFalse': 'Не согласен — это настоящая',
1650
+ 'review.accuracy': 'Как часто каждый ревизор оказывался прав',
1651
+ 'review.notChecked': 'ещё не проверено',
1652
+ 'review.byAgent': 'проверил помощник',
1653
+ 'review.humanEvidence': 'Пользователь не согласился с приговором помощника, посмотрев находку.',
1654
+ 'review.lastReview': 'Последний обзор',
1655
+ 'review.objectReal': 'Не согласен — это ложная',
1656
+ 'review.of': 'из',
1657
+ 'review.byHuman': 'ваше решение',
1658
+ 'review.participates': 'Участвует в обзорах',
1659
+ 'review.noneActive': 'Все ревизоры выключены — обзор ничего не найдёт.',
1660
+ 'review.remove': 'Убрать этого ревизора',
1661
+ 'review.bySubscription': 'по подписке',
1662
+ 'review.limit': 'Больше ревизоров в один обзор не поместится.',
1663
+ 'review.byApiKey': 'по токенам',
1664
+ 'review.nothing': 'Ничего не нашлось',
1665
+ 'review.more': '…и ещё {n} — уточните поиск',
1666
+ 'review.add': 'Добавить ревизора',
1667
+ 'review.separateProduct': 'отдельный продукт',
1668
+ 'review.search': 'Поиск по названию или провайдеру',
1669
+ 'switcher.title': 'Модель для этого диалога — с поиском',
1670
+ 'switcher.search': 'Поиск моделей',
1671
+ 'switcher.more': '…и ещё {n} — уточните поиск',
1672
+ 'switcher.nothing': 'Ничего не нашлось',
1673
+ 'switcher.none': 'Модель',
1674
+ 'review.nav': 'Ревизоры',
1675
+ 'review.title': 'Второе мнение',
1676
+ 'review.hint': 'Независимые ревизоры смотрят готовую работу и сообщают, что в ней не так. Сколько их и какие — выбираете вы: разные модели находят разное.',
1677
+ 'review.model': 'Модель',
1678
+ 'review.effort': 'Усилие',
1679
+ 'review.asConfigured': 'как настроено',
1680
+ 'review.none': 'Не выбран ни один ревизор — обзор ничего не найдёт.',
1681
+ 'review.note': 'По умолчанию включены оба: они находят разное, и один гарантированно упускает половину. Пустая модель означает «как настроено у самого продукта».',
1682
+ 'auth.nav': 'Подписки',
1683
+ 'auth.title': 'Вход к провайдерам',
1684
+ 'auth.others': 'Здесь только провайдеры, у которых есть вход по подписке. Остальные — в том числе Z.AI — предлагают лишь ключ API; их место в разделе «Модели».',
1685
+ 'auth.hint': 'Войти по подписке вместо ключа API. Учётная запись остаётся на этом компьютере.',
1686
+ 'auth.signIn': 'Войти',
1687
+ 'auth.again': 'Войти заново',
1688
+ 'auth.connected': 'подключено',
1689
+ 'auth.openLink': 'Открыть страницу входа',
1690
+ 'auth.send': 'Отправить',
1691
+ 'auth.cancel': 'Отменить',
1692
+ 'auth.done': 'Вход выполнен.',
1693
+ 'auth.cancelled': 'Вход отменён.',
1694
+ 'auth.unavailable': 'Вход недоступен: в профиле не смонтирована строка авторизации.',
1695
+ 'auth.needsRestart': 'Перезапустите приложение: у браузера уже новая версия, у сервера ещё нет.',
1696
+ 'auth.badResponse': 'Сервер ответил {status} вместо данных.',
1697
+ 'auth.empty': 'Ни один провайдер не предлагает вход по подписке.',
1698
+ 'update.title': 'Обновления',
1699
+ 'update.check': 'Проверить',
1700
+ 'update.apply': 'Обновить',
1701
+ 'update.working': 'Работаю…',
1702
+ 'update.checking': 'Проверяю…',
1703
+ 'update.upToDate': 'Установлена последняя версия',
1704
+ 'update.available': 'Доступна версия {version}',
1705
+ 'update.availableUnknown': 'Доступна новая версия',
1706
+ 'update.installed': 'Установлена {version}, проверено на dsh {dsh}',
1707
+ 'update.doneReload': 'Обновлено. Обновите страницу.',
1708
+ 'update.doneRestart': 'Обновлено. Перезапустите приложение.',
1709
+ 'update.failed': 'Обновить не удалось',
1710
+ 'update.problem.noStamp': 'Нет отметки об установке — переустановите',
1711
+ 'update.problem.noPackage': 'Установленный пакет не найден переустановите',
1712
+ 'update.problem.registryStatus': 'Реестр отклонил запрос',
1713
+ 'update.problem.registryUnreachable': 'Не удалось связаться с реестром',
1714
+ 'update.problem.badVersion': 'Реестр не сообщил корректную версию',
1715
+ 'update.problem.noProfile': 'В отметке об установке нет профиля — переустановите',
1716
+ 'update.problem.installFailed': 'Не удалось установить из реестра',
1717
+ 'update.dshMismatch': 'Внимание: установлен dsh {actual}, а эта версия проверялась на {expected}',
1718
+ }
1719
+
1720
+ // ── Стили ────────────────────────────────────────────────────────────
1721
+ //
1722
+ // Про режимы наложения. Контейнер слота shell.overlay объявлен как
1723
+ // `z-index:20`, то есть создаёт изолированный контекст наложения.
1724
+ // `mix-blend-mode` внутри него смешивается не с фоном приложения, а с
1725
+ // прозрачным фоном самого контейнера то есть не работает. Поэтому
1726
+ // здесь его нет вовсе, а разница между темами берётся оттуда, откуда её
1727
+ // и следует брать: из атрибута `data-ds-dark-theme` на body. Его ставит
1728
+ // презентер по полю `colorScheme` активной темы — это объявленный
1729
+ // контракт, а не внутреннее имя класса.
1730
+ //
1731
+ // Каждая непрозрачность умножается на `--dsx-intensity`, поэтому одна
1732
+ // переменная управляет всей атмосферой, включая полное выключение.
1733
+ const GRAIN =
1734
+ 'data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27220%27 height=%27220%27%3E' +
1735
+ '%3Cfilter id=%27g%27%3E' +
1736
+ '%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%270.9%27 numOctaves=%273%27 stitchTiles=%27stitch%27/%3E' +
1737
+ '%3CfeColorMatrix type=%27saturate%27 values=%270%27/%3E' +
1738
+ '%3C/filter%3E' +
1739
+ '%3Crect width=%27220%27 height=%27220%27 filter=%27url(%23g)%27/%3E%3C/svg%3E'
1740
+
1741
+ const css = [
1742
+ // --dsx-activity ведёт живой отклик на работу агента: 0 в покое, 1 пока
1743
+ // идёт ход. Значение ставит невидимый драйвер из сессионного слота,
1744
+ // а читают его слои в корневом shell.overlay переменная на
1745
+ // documentElement единственный способ связать два разных поддерева.
1746
+ ':root{' + INTENSITY_VAR + ':1;' + ACTIVITY_VAR + ':0;' + ACCENT_VAR + ':90,217,245;' + ACCENT2_VAR + ':138,108,255;}',
1747
+
1748
+ // `display:contents` критичен: контейнер слота shell.overlay возвращает
1749
+ // прямым детям `pointer-events:auto`. Обёртка без собственного бокса
1750
+ // не перехватывает клики, а каждый слой ниже гасит события явно.
1751
+ '.dsx-root{display:contents;}',
1752
+
1753
+ '.dsx-ambient{position:fixed;inset:0;overflow:hidden;pointer-events:none;contain:layout paint style;}',
1754
+ '.dsx-ambient__orb{position:absolute;border-radius:50%;filter:blur(100px);will-change:transform;}',
1755
+
1756
+ // ── светлая тема: присутствие есть, доминирования нет ──
1757
+ '.dsx-ambient__orb--lead{width:46vw;height:46vw;left:-15vw;top:-14vw;opacity:calc(.13 * var(' + INTENSITY_VAR + '));background:radial-gradient(circle,rgba(var(' + ACCENT_VAR + '),.88) 0%,rgba(var(' + ACCENT_VAR + '),0) 70%);animation:dsx-drift-a 34s ease-in-out infinite;}',
1758
+ '.dsx-ambient__orb--counter{width:54vw;height:54vw;right:-20vw;bottom:-24vw;opacity:calc(.12 * var(' + INTENSITY_VAR + '));background:radial-gradient(circle,rgba(var(' + ACCENT2_VAR + '),.84) 0%,rgba(var(' + ACCENT2_VAR + '),0) 70%);animation:dsx-drift-b 46s ease-in-out infinite;}',
1759
+ '.dsx-ambient__orb--deep{width:36vw;height:36vw;right:8vw;top:-18vw;opacity:calc(.10 * var(' + INTENSITY_VAR + '));background:radial-gradient(circle,rgba(56,120,255,.78) 0%,rgba(56,120,255,0) 70%);animation:dsx-drift-c 54s ease-in-out infinite;}',
1760
+ '.dsx-ambient__orb--rose{width:30vw;height:30vw;left:18vw;bottom:-18vw;opacity:calc(.08 * var(' + INTENSITY_VAR + '));background:radial-gradient(circle,rgba(255,110,180,.70) 0%,rgba(255,110,180,0) 70%);animation:dsx-drift-d 62s ease-in-out infinite;}',
1761
+
1762
+ // ── тёмная тема: полная интенсивность ──
1763
+ 'body[data-ds-dark-theme] .dsx-ambient__orb--lead{opacity:calc(.48 * var(' + INTENSITY_VAR + '));}',
1764
+ 'body[data-ds-dark-theme] .dsx-ambient__orb--counter{opacity:calc(.44 * var(' + INTENSITY_VAR + '));}',
1765
+ 'body[data-ds-dark-theme] .dsx-ambient__orb--deep{opacity:calc(.34 * var(' + INTENSITY_VAR + '));}',
1766
+ 'body[data-ds-dark-theme] .dsx-ambient__orb--rose{opacity:calc(.26 * var(' + INTENSITY_VAR + '));}',
1767
+
1768
+ '.dsx-ambient__sweep{position:absolute;top:0;bottom:0;left:-45%;width:42%;filter:blur(34px);opacity:calc(.35 * var(' + INTENSITY_VAR + '));background:linear-gradient(100deg,rgba(255,255,255,0) 0%,rgba(140,200,255,.055) 42%,rgba(200,160,255,.075) 58%,rgba(255,255,255,0) 100%);animation:dsx-sweep 28s cubic-bezier(.45,0,.25,1) infinite;}',
1769
+ 'body[data-ds-dark-theme] .dsx-ambient__sweep{opacity:calc(1 * var(' + INTENSITY_VAR + '));}',
1770
+
1771
+ // Верхняя кромка: постоянная тусклая линия плюс бегущий по ней блик.
1772
+ '.dsx-ambient__seam{position:absolute;top:0;left:0;right:0;height:1px;overflow:hidden;opacity:calc(1 * var(' + INTENSITY_VAR + '));background:linear-gradient(90deg,rgba(120,160,220,0) 0%,rgba(120,160,220,.14) 20%,rgba(120,160,220,.14) 80%,rgba(120,160,220,0) 100%);}',
1773
+ '.dsx-ambient__seam::after{content:"";position:absolute;top:0;left:-30%;width:30%;height:100%;opacity:.45;background:linear-gradient(90deg,rgba(var(' + ACCENT_VAR + '),0) 0%,rgba(var(' + ACCENT_VAR + '),.9) 45%,rgba(var(' + ACCENT2_VAR + '),.9) 55%,rgba(var(' + ACCENT2_VAR + '),0) 100%);animation:dsx-seam-travel 14s cubic-bezier(.5,0,.5,1) infinite;}',
1774
+ 'body[data-ds-dark-theme] .dsx-ambient__seam::after{opacity:1;}',
1775
+
1776
+ '.dsx-grain{position:fixed;inset:-60px;pointer-events:none;opacity:calc(.030 * var(' + INTENSITY_VAR + '));background-image:url("' + GRAIN + '");background-size:220px 220px;will-change:transform;animation:dsx-grain 1.2s steps(5) infinite;}',
1777
+ 'body[data-ds-dark-theme] .dsx-grain{opacity:calc(.045 * var(' + INTENSITY_VAR + '));}',
1778
+
1779
+ '.dsx-vignette{position:fixed;inset:0;pointer-events:none;opacity:calc(1 * var(' + INTENSITY_VAR + '));background:radial-gradient(125% 95% at 50% 42%,rgba(0,0,0,0) 55%,rgba(20,40,80,.05) 100%);}',
1780
+ 'body[data-ds-dark-theme] .dsx-vignette{background:radial-gradient(125% 95% at 50% 42%,rgba(0,0,0,0) 52%,rgba(2,4,10,.30) 100%);}',
1781
+
1782
+ // ── отклик на работу агента ──
1783
+ // Слой целиком гаснет в покое, поэтому в простое он не стоит ни кадра
1784
+ // композитинга. Появление и уход — через transition, а не анимацию,
1785
+ // чтобы переход был плавным в обе стороны.
1786
+ '.dsx-pulse{position:fixed;inset:0;overflow:hidden;pointer-events:none;opacity:calc(var(' + ACTIVITY_VAR + ') * var(' + INTENSITY_VAR + '));transition:opacity .55s ease;contain:layout paint style;}',
1787
+ '.dsx-pulse__beam{position:absolute;top:0;left:0;right:0;height:2px;overflow:hidden;background:linear-gradient(90deg,rgba(var(' + ACCENT_VAR + '),0) 0%,rgba(var(' + ACCENT_VAR + '),.45) 35%,rgba(var(' + ACCENT2_VAR + '),.45) 65%,rgba(var(' + ACCENT2_VAR + '),0) 100%);}',
1788
+ '.dsx-pulse__beam::after{content:"";position:absolute;top:0;bottom:0;left:-25%;width:25%;background:linear-gradient(90deg,rgba(255,255,255,0) 0%,rgba(255,255,255,.95) 50%,rgba(255,255,255,0) 100%);animation:dsx-beam 1.6s linear infinite;}',
1789
+ '.dsx-pulse__breath{position:absolute;left:50%;bottom:-24vh;width:72vw;height:46vh;margin-left:-36vw;border-radius:50%;filter:blur(90px);background:radial-gradient(ellipse at center,rgba(var(' + ACCENT_VAR + '),.45) 0%,rgba(var(' + ACCENT_VAR + '),0) 70%);animation:dsx-breath 2.8s ease-in-out infinite;}',
1790
+
1791
+ // Периоды дрейфа взаимно непропорциональны, поэтому световой рисунок
1792
+ // не повторяется: 34 / 46 / 54 / 62 секунды.
1793
+ '@keyframes dsx-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1)}50%{transform:translate3d(7vw,5vh,0) scale(1.14)}}',
1794
+ '@keyframes dsx-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.05)}50%{transform:translate3d(-6vw,-4vh,0) scale(.92)}}',
1795
+ '@keyframes dsx-drift-c{0%,100%{transform:translate3d(0,0,0) scale(.95)}50%{transform:translate3d(-5vw,6vh,0) scale(1.10)}}',
1796
+ '@keyframes dsx-drift-d{0%,100%{transform:translate3d(0,0,0) scale(1)}50%{transform:translate3d(9vw,-7vh,0) scale(1.18)}}',
1797
+ '@keyframes dsx-sweep{0%{transform:translateX(0)}55%,100%{transform:translateX(340%)}}',
1798
+ // -30% + 433% от собственной ширины (30% кадра) = ровно правый край.
1799
+ '@keyframes dsx-seam-travel{0%{transform:translateX(0)}70%,100%{transform:translateX(433%)}}',
1800
+ '@keyframes dsx-grain{0%{transform:translate3d(0,0,0)}20%{transform:translate3d(-14px,7px,0)}40%{transform:translate3d(11px,-12px,0)}60%{transform:translate3d(-7px,14px,0)}80%{transform:translate3d(13px,5px,0)}100%{transform:translate3d(0,0,0)}}',
1801
+
1802
+ '@keyframes dsx-beam{0%{transform:translateX(0)}100%{transform:translateX(500%)}}',
1803
+ '@keyframes dsx-breath{0%,100%{opacity:.35;transform:scale(.94)}50%{opacity:.80;transform:scale(1.06)}}',
1804
+ '@media (prefers-reduced-motion: reduce){.dsx-ambient__orb,.dsx-ambient__seam::after,.dsx-ambient__sweep,.dsx-grain,.dsx-pulse__beam::after,.dsx-pulse__breath{animation:none!important}.dsx-pulse{transition:none}}',
1805
+
1806
+ // ── айдентика ──
1807
+ // Контур один, цвет ведёт `currentColor`. Пара значений взята прямо из
1808
+ // присланных файлов: белый на тёмной теме, #0B0D12 на светлой. Ключ —
1809
+ // атрибут темы приложения, а не системная схема, поэтому знак следует
1810
+ // ручному переключению.
1811
+ '.dsx-brand{display:block;color:#0B0D12;}',
1812
+ 'body[data-ds-dark-theme] .dsx-brand{color:#FFFFFF;}',
1813
+ '.dsx-brand--hero{filter:drop-shadow(0 0 24px rgba(var(' + ACCENT_VAR + '),.35));}',
1814
+
1815
+ // В свёрнутой рейке места по горизонтали мало, а знак широкий, поэтому
1816
+ // там он ужимается. Состояние читается из атрибута `data-sidebar-collapsed`
1817
+ // на фрейме. Оговорка: это НАБЛЮДАЕМЫЙ атрибут, а не объявленный
1818
+ // контракт вроде `data-ds-dark-theme`. Если он однажды исчезнет, знак
1819
+ // просто останется полной высоты — деградация мягкая, без поломки.
1820
+ '.dsx-brand--sidebar{height:24px;width:auto;}',
1821
+ '[data-sidebar-collapsed] .dsx-brand--sidebar{height:16px;}',
1822
+
1823
+ '.dsx-brandname{font-size:15px;font-weight:600;letter-spacing:.14em;color:var(--dsw-alias-label-primary);white-space:nowrap;}',
1824
+
1825
+ // ── строка настроек ──
1826
+ // Собственные цвета не выдумываются: всё берётся из токенов темы,
1827
+ // поэтому строка остаётся согласованной при любой палитре.
1828
+ '.dsx-setting{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;}',
1829
+ '.dsx-setting__title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;}',
1830
+ '.dsx-rev__panel{margin-top:14px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1)}.dsx-rev__panel-head{display:flex;align-items:center;gap:8px;margin-bottom:8px}.dsx-rev__count{padding:1px 7px;border-radius:99px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary);font-size:11px}.dsx-rev__findings{display:flex;flex-direction:column;gap:8px}.dsx-rev__finding{padding:9px 11px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2)}.dsx-rev__finding--false{opacity:.55}.dsx-rev__finding-head{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap}.dsx-rev__finding-title{font-weight:600;color:var(--dsw-alias-label-primary);font-size:13px;flex:1}.dsx-rev__sev{padding:1px 6px;border-radius:4px;font-size:10px;text-transform:uppercase;letter-spacing:.03em}.dsx-rev__sev--crit{background:#5c1f22;color:#ffb4b4}.dsx-rev__sev--high{background:#5a3f16;color:#f0cd8a}.dsx-rev__sev--unknown{background:#3a3a44;color:#c9c9d4}.dsx-rev__sev--low{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary)}.dsx-rev__who{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dsx-rev__where{margin-top:4px;font-family:ui-monospace,monospace;font-size:11px;color:var(--dsw-alias-label-tertiary)}.dsx-rev__what{margin-top:4px;font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.45}.dsx-rev__verdict{display:flex;align-items:flex-start;gap:6px;margin-top:7px;padding-top:7px;border-top:1px dashed var(--dsw-alias-border-l1);font-size:11px;color:var(--dsw-alias-label-secondary)}.dsx-rev__verdict-mark{font-weight:700}.dsx-rev__verdict-text{flex:1;line-height:1.4}.dsx-rev__by{color:var(--dsw-alias-label-tertiary);white-space:nowrap}.dsx-rev__object{margin-top:5px}.dsx-rev__object-btn{border:0;background:none;padding:0;color:var(--dsw-alias-label-tertiary);font-size:11px;cursor:pointer;text-decoration:underline dotted}.dsx-rev__object-btn:hover{color:var(--dsw-alias-label-primary)}.dsx-rev__stats{margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}.dsx-rev__stat{display:flex;justify-content:space-between;gap:10px;margin-top:4px;font-size:12px}.dsx-rev__stat-name{color:var(--dsw-alias-label-secondary)}.dsx-rev__stat-rate{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}.dsx-rev__tag{margin-left:6px;padding:1px 6px;border-radius:99px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary);font-size:10px}.dsx-rev__custom{display:inline-flex;align-items:center;gap:4px}.dsx-rev__input{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;width:170px}.dsx-rev__back{border:0;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;line-height:1;padding:0 2px}.dsx-rev__remove{margin-left:auto;border:0;background:none;color:var(--dsw-alias-label-tertiary);font-size:16px;line-height:1;padding:0 4px;cursor:pointer}.dsx-rev__remove:hover{color:#ffb4b4}.dsx-rev__add{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:12px}.dsx-rev__add-btn{padding:4px 11px;border:1px dashed var(--dsw-alias-border-l2);border-radius:99px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;cursor:pointer;font-family:inherit}.dsx-rev__add-btn:hover:not(:disabled){border-style:solid;color:var(--dsw-alias-label-primary)}.dsx-rev__add-btn:disabled{opacity:.4;cursor:default}.dsx-mdl{position:relative;display:inline-flex}.dsx-mdl__trigger{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:3px 9px;border:1px solid var(--dsw-alias-border-l2);border-radius:99px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;font-family:inherit;cursor:pointer}.dsx-mdl__trigger:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l1)}.dsx-mdl__trigger:disabled{opacity:.5;cursor:default}.dsx-mdl__pop{position:absolute;z-index:60;bottom:calc(100% + 6px);left:0;min-width:300px;max-width:min(440px,84vw);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-l1);box-shadow:0 12px 34px rgba(0,0,0,.5);overflow:hidden}.dsx-mdl__search{width:100%;padding:9px 11px;border:0;border-bottom:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);font-size:12px;font-family:inherit;outline:none}.dsx-mdl__options{max-height:300px;overflow-y:auto}.dsx-mdl__option{display:flex;align-items:baseline;gap:8px;width:100%;padding:6px 11px;border:0;background:transparent;color:var(--dsw-alias-label-primary);font-size:12px;font-family:inherit;text-align:left;cursor:pointer}.dsx-mdl__option:hover{background:var(--dsw-alias-bg-l2)}.dsx-mdl__option--on{background:var(--dsw-alias-bg-l2);color:var(--dsw-accent-ion,#7fb2ff)}.dsx-mdl__option-provider{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px}.dsx-mdl__option-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dsx-mdl__more{padding:8px 11px;color:var(--dsw-alias-label-tertiary);font-size:11px}.dsx-rev__picker{position:relative;display:inline-block}.dsx-rev__trigger{width:100%;min-width:200px;text-align:left;padding:5px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-l2);color:var(--dsw-alias-label-primary);font-size:12px;font-family:inherit;cursor:pointer}.dsx-rev__trigger:hover:not(:disabled){border-color:var(--dsw-alias-border-l1)}.dsx-rev__trigger:disabled{opacity:.5;cursor:default}.dsx-rev__pop{position:absolute;z-index:40;top:calc(100% + 4px);left:0;min-width:320px;max-width:min(460px,86vw);border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-l1);box-shadow:0 10px 30px rgba(0,0,0,.45);overflow:hidden}.dsx-rev__search{width:100%;padding:8px 10px;border:0;border-bottom:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);font-size:12px;font-family:inherit;outline:none}.dsx-rev__options{max-height:280px;overflow-y:auto}.dsx-rev__option{display:flex;align-items:center;gap:8px;width:100%;padding:6px 10px;border:0;background:transparent;color:var(--dsw-alias-label-primary);font-size:12px;font-family:inherit;text-align:left;cursor:pointer}.dsx-rev__option:hover{background:var(--dsw-alias-bg-l2)}.dsx-rev__option--on{background:var(--dsw-alias-bg-l2)}.dsx-rev__option-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dsx-rev__option-provider{color:var(--dsw-alias-label-tertiary);margin-right:5px}.dsx-rev__tag--sub{border-color:rgba(127,178,255,.4);color:#9ec7ff}.dsx-rev__tag--paid{border-color:rgba(232,194,122,.4);color:#e8c27a}.dsx-rev__tag--ext{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary)}.dsx-rev__more{padding:7px 10px;color:var(--dsw-alias-label-tertiary);font-size:11px}.dsx-rev__own{width:100%;padding:7px 10px;border:0;border-top:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;font-family:inherit;text-align:left;cursor:pointer}.dsx-rev__own:hover{color:var(--dsw-alias-label-primary)}.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-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;}',
1831
+ '.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;}',
1832
+ '.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;}',
1833
+ '.dsx-seg:hover{color:var(--dsw-alias-label-primary);}',
1834
+ '.dsx-seg--on{background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);box-shadow:0 0 0 1px var(--dsw-alias-border-l2),0 0 12px -4px var(--dsw-alias-brand-primary);}',
1835
+ '@media (prefers-reduced-motion: reduce){.dsx-seg{transition:none}}',
1836
+
1837
+ // Кружок акцента показывает сам цвет, поэтому подпись ему не нужна —
1838
+ // но имя остаётся в aria-label, иначе кнопка была бы безымянной.
1839
+ '.dsx-swatch{appearance:none;cursor:pointer;width:22px;height:22px;padding:0;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);background:rgb(var(--dsx-swatch));transition:transform .16s ease,box-shadow .16s ease;}',
1840
+ '.dsx-swatch:hover{transform:scale(1.12);}',
1841
+ '.dsx-swatch--on{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-2),0 0 0 4px rgb(var(--dsx-swatch)),0 0 14px -2px rgb(var(--dsx-swatch));}',
1842
+ '.dsx-setting__swatches{display:inline-flex;gap:10px;align-items:center;flex:none;}',
1843
+ '@media (prefers-reduced-motion: reduce){.dsx-swatch{transition:none}}',
1844
+ ].join('\n')
1845
+
1846
+ // Тот же приём вставки стилей, что и у поставочных пакетов:
1847
+ // тег помечается data-plugin-css и не дублируется при повторной загрузке.
1848
+ const tagId = 'tensorgrid-ui/atmosphere.css'
1849
+ if (
1850
+ typeof document !== 'undefined' &&
1851
+ document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null
1852
+ ) {
1853
+ const tag = document.createElement('style')
1854
+ tag.dataset.plugin = 'tensorgrid-ui'
1855
+ tag.dataset.pluginCss = tagId
1856
+ tag.textContent = css
1857
+ document.head.appendChild(tag)
1858
+ }
1859
+
1860
+ // Сохранённый выбор применяется сразу, ещё до монтирования React,
1861
+ // иначе на каждой загрузке был бы кадр с чужими значениями.
1862
+ applyLevelId(readLevelId())
1863
+ applyAccentId(readAccentId())
1864
+
1865
+ // ── Компоненты ───────────────────────────────────────────────────────
1866
+ function Atmosphere() {
1867
+ return jsxs('div', {
1868
+ className: 'dsx-root',
1869
+ 'aria-hidden': 'true',
1870
+ children: [
1871
+ jsxs('div', {
1872
+ className: 'dsx-ambient',
1873
+ children: [
1874
+ jsx('div', { className: 'dsx-ambient__orb dsx-ambient__orb--lead' }),
1875
+ jsx('div', { className: 'dsx-ambient__orb dsx-ambient__orb--counter' }),
1876
+ jsx('div', { className: 'dsx-ambient__orb dsx-ambient__orb--deep' }),
1877
+ jsx('div', { className: 'dsx-ambient__orb dsx-ambient__orb--rose' }),
1878
+ jsx('div', { className: 'dsx-ambient__sweep' }),
1879
+ jsx('div', { className: 'dsx-ambient__seam' }),
1880
+ ],
1881
+ }),
1882
+ jsxs('div', {
1883
+ className: 'dsx-pulse',
1884
+ children: [
1885
+ jsx('div', { className: 'dsx-pulse__beam' }),
1886
+ jsx('div', { className: 'dsx-pulse__breath' }),
1887
+ ],
1888
+ }),
1889
+ jsx('div', { className: 'dsx-grain' }),
1890
+ jsx('div', { className: 'dsx-vignette' }),
1891
+ ],
1892
+ })
1893
+ }
1894
+
1895
+ /**
1896
+ * Невидимый драйвер отклика: живёт в сессионном слоте, ничего не рисует и
1897
+ * лишь переносит состояние хода в CSS-переменную на documentElement.
1898
+ *
1899
+ * Разделение вынужденное и осознанное: ambient-слой сидит в корневом
1900
+ * shell.overlay, у которого нет сессии, а `useSession` раздаётся только
1901
+ * сессионным слотам. Переменная на корне — единственный мост между двумя
1902
+ * поддеревьями, не требующий трогать чужой DOM.
1903
+ * @param props - стандартные пропсы сессионного слота.
1904
+ */
1905
+ function ActivityDriver({ useSession }) {
1906
+ const running = useSession((session) => session.running) ?? false
1907
+
1908
+ React.useEffect(() => {
1909
+ if (typeof document === 'undefined') return undefined
1910
+ const style = document.documentElement.style
1911
+ style.setProperty(ACTIVITY_VAR, running ? '1' : '0')
1912
+ return () => {
1913
+ style.setProperty(ACTIVITY_VAR, '0')
1914
+ }
1915
+ }, [running])
1916
+
1917
+ return null
1918
+ }
1919
+
1920
+ function AccentRow({ t }) {
1921
+ const [current, setCurrent] = React.useState(readAccentId)
1922
+
1923
+ React.useEffect(() => {
1924
+ applyAccentId(current)
1925
+ writeAccentId(current)
1926
+ }, [current])
1927
+
1928
+ return jsxs('div', {
1929
+ className: 'dsx-setting',
1930
+ children: [
1931
+ jsxs('div', {
1932
+ children: [
1933
+ jsx('div', { className: 'dsx-setting__title', children: t('accent.title') }),
1934
+ jsx('div', { className: 'dsx-setting__hint', children: t('accent.hint') }),
1935
+ ],
1936
+ }),
1937
+ jsx('div', {
1938
+ className: 'dsx-setting__swatches',
1939
+ role: 'group',
1940
+ children: ACCENTS.map((accent) =>
1941
+ jsx(
1942
+ 'button',
1943
+ {
1944
+ type: 'button',
1945
+ className: 'dsx-swatch' + (accent.id === current ? ' dsx-swatch--on' : ''),
1946
+ style: { '--dsx-swatch': accent.rgb },
1947
+ 'aria-label': accent.label,
1948
+ 'aria-pressed': accent.id === current,
1949
+ onClick: () => setCurrent(accent.id),
1950
+ },
1951
+ accent.id,
1952
+ ),
1953
+ ),
1954
+ }),
1955
+ ],
1956
+ })
1957
+ }
1958
+
1959
+ // ── Айдентика ────────────────────────────────────────────────────────
1960
+ //
1961
+ // Присланные tg-white.svg и tg-black.svg побайтово совпадают всюду, кроме
1962
+ // заливки, поэтому здесь один контур, а цвет ведёт `currentColor`.
1963
+ const BRAND_VIEWBOX = '245 230 1047 566'
1964
+ const BRAND_RATIO = 566 / 1047
1965
+ const BRAND_PATHS = [
1966
+ 'M315 278H929L766 404H626Q620 404 620 410V724C620 737 610 748 597 748H516C503 748 493 738 493 725V411Q493 404 486 404H317C303 404 293 394 293 380V302C293 288 303 278 315 278Z',
1967
+ 'M968 278H1126C1190 278 1244 333 1244 402V421Q1244 435 1230 435H1128Q1117 435 1117 424Q1117 404 1099 404H889C867 404 849 422 849 444V604C849 625 866 638 887 638H1086C1103 638 1115 627 1115 611V572Q1115 564 1107 564H954C943 564 935 556 935 546V493C935 482 943 474 954 474H1225C1236 474 1244 483 1244 494V619C1244 691 1192 748 1121 748H847C778 748 724 696 724 625V510C724 475 733 455 755 438L968 278Z',
1968
+ ]
1969
+
1970
+ /**
1971
+ * Знак TG. Ширина задаётся снаружи, высота считается из пропорции 1047:566,
1972
+ * поэтому знак никогда не переполняет узкую рейку сайдбара.
1973
+ * @param width - желаемая ширина в пикселях.
1974
+ * @param extraClass - дополнительный класс оформления.
1975
+ */
1976
+ function brandSvg(width, extraClass) {
1977
+ return jsx('svg', {
1978
+ className: extraClass === undefined ? 'dsx-brand' : 'dsx-brand ' + extraClass,
1979
+ viewBox: BRAND_VIEWBOX,
1980
+ width: width,
1981
+ height: Math.round(width * BRAND_RATIO),
1982
+ role: 'img',
1983
+ 'aria-label': 'TG',
1984
+ children: jsx('g', {
1985
+ fill: 'currentColor',
1986
+ children: BRAND_PATHS.map((d, index) => jsx('path', { d: d }, String(index))),
1987
+ }),
1988
+ })
1989
+ }
1990
+
1991
+ /**
1992
+ * Знак в сайдбаре.
1993
+ *
1994
+ * Слот передаёт `size: 24` — высоту полосы `.brandIdentity`, которую
1995
+ * поставочная квадратная рыба занимает целиком. Знак TG вдвое шире, и
1996
+ * если принять `size` за ширину, высота выходит ~13px: рядом с надписью
1997
+ * в 18px он читается мелким. Поэтому `size` трактуется как ВЫСОТА, а
1998
+ * ширина считается из пропорции.
1999
+ * @param size - высота, которую отводит слот.
2000
+ */
2001
+ function BrandMark({ size }) {
2002
+ const box = typeof size === 'number' && size > 0 ? size : 24
2003
+ return brandSvg(Math.round(box / BRAND_RATIO), 'dsx-brand--sidebar')
2004
+ }
2005
+
2006
+ /** Знак на экране пустой сессии — крупнее и со свечением акцента. */
2007
+ function HeroMark() {
2008
+ return brandSvg(96, 'dsx-brand--hero')
2009
+ }
2010
+
2011
+ /** Надпись рядом со знаком. */
2012
+ function BrandName() {
2013
+ return jsx('span', { className: 'dsx-brandname', children: BRAND_NAME })
2014
+ }
2015
+
2016
+ function IntensityRow({ t }) {
2017
+ const [current, setCurrent] = React.useState(readLevelId)
2018
+
2019
+ React.useEffect(() => {
2020
+ applyLevelId(current)
2021
+ writeLevelId(current)
2022
+ }, [current])
2023
+
2024
+ return jsxs('div', {
2025
+ className: 'dsx-setting',
2026
+ children: [
2027
+ jsxs('div', {
2028
+ children: [
2029
+ jsx('div', { className: 'dsx-setting__title', children: t('intensity.title') }),
2030
+ jsx('div', { className: 'dsx-setting__hint', children: t('intensity.hint') }),
2031
+ ],
2032
+ }),
2033
+ jsx('div', {
2034
+ className: 'dsx-setting__control',
2035
+ role: 'group',
2036
+ children: LEVELS.map((level) =>
2037
+ jsx(
2038
+ 'button',
2039
+ {
2040
+ type: 'button',
2041
+ className: 'dsx-seg' + (level.id === current ? ' dsx-seg--on' : ''),
2042
+ 'aria-pressed': level.id === current,
2043
+ onClick: () => setCurrent(level.id),
2044
+ children: t(level.key),
2045
+ },
2046
+ level.id,
2047
+ ),
2048
+ ),
2049
+ }),
2050
+ ],
2051
+ })
2052
+ }
2053
+
2054
+ // ── Вход по подписке ─────────────────────────────────────────────────
2055
+ //
2056
+ // Стойка входа ведёт разговор: показывает ссылку, иногда просит код,
2057
+ // иногда задаёт вопрос с выбором. Разговор длится минуты, поэтому
2058
+ // браузер начинает попытку и затем опрашивает состояние.
2059
+ const AUTH_PATH = '/api/tensorgrid.auth'
2060
+
2061
+ /**
2062
+ * Пропустить только настоящий веб-адрес.
2063
+ *
2064
+ * Ссылка приходит от стойки входа, то есть в конечном счёте от внешнего
2065
+ * провайдера. Подставлять её в `href` как есть нельзя: схема
2066
+ * `javascript:` превратила бы «Открыть страницу входа» в запуск чужого
2067
+ * кода прямо в приложении. Разрешаем http и https, остальное прячем.
2068
+ *
2069
+ * @param value - адрес из уведомления.
2070
+ * @returns адрес, пригодный для ссылки, или null.
2071
+ */
2072
+ function safeUrl(value) {
2073
+ if (typeof value !== 'string' || value === '') return null
2074
+ try {
2075
+ const parsed = new URL(value)
2076
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? value : null
2077
+ } catch {
2078
+ return null
2079
+ }
2080
+ }
2081
+
2082
+ function AuthRow({ t }) {
2083
+ const [state, setState] = React.useState(null)
2084
+ // Занятость от ДЕЙСТВИЯ пользователя и фоновый опрос — разные вещи.
2085
+ //
2086
+ // Раньше опрос тоже поднимал этот признак, а поле ввода и кнопки на
2087
+ // нём завязаны: каждые две секунды поле гасло прямо под руками. Это
2088
+ // ломало ровно тот случай, ради которого опрос и заведён, — ввод кода.
2089
+ const [busy, setBusy] = React.useState(false)
2090
+ const [draft, setDraft] = React.useState('')
2091
+ // Ответы приходят не в том порядке, в каком ушли: медленный опрос
2092
+ // может вернуться после быстрого ответа и откатить состояние назад.
2093
+ // Применяем только тот ответ, который свежее уже показанного.
2094
+ const seq = React.useRef(0)
2095
+ const applied = React.useRef(0)
2096
+
2097
+ // Функция перевода приходит от рантайма НОВОЙ при каждом рендере —
2098
+ // в поставочном коде это обычная стрелка, не мемоизированная. Если
2099
+ // поставить её в зависимости запроса, цепочка t → send → refresh
2100
+ // пересоздаётся каждый раз, эффект с [refresh] срабатывает снова, и
2101
+ // строка уходит в бесконечный цикл рендеров и обращений к Host.
2102
+ // Такой цикл подвешивает всю вкладку, а не только настройки.
2103
+ //
2104
+ // Поэтому перевод берётся через ссылку: она всегда указывает на
2105
+ // свежую функцию, но сама себя не меняет.
2106
+ const translate = React.useRef(t)
2107
+ translate.current = t
2108
+
2109
+ const send = React.useCallback(async (body) => {
2110
+ const response = body === undefined
2111
+ ? await fetch(AUTH_PATH)
2112
+ : await fetch(AUTH_PATH, {
2113
+ method: 'POST',
2114
+ headers: { 'content-type': 'application/json' },
2115
+ body: JSON.stringify(body),
2116
+ })
2117
+
2118
+ // Маршрут поднимает host-половина, а она читается только при старте
2119
+ // приложения. После обновления браузер получает новый код раньше
2120
+ // Host-а, запрос уходит в общий заслон и возвращает 401 страницей, а
2121
+ // не JSON. Без этой проверки строка молча показывала пустоту.
2122
+ const type = response.headers.get('content-type') || ''
2123
+ if (!type.includes('application/json')) {
2124
+ const say = translate.current
2125
+ return {
2126
+ available: true,
2127
+ providers: [],
2128
+ problem: response.status === 401 || response.status === 404
2129
+ ? say('auth.needsRestart')
2130
+ : say('auth.badResponse').replace('{status}', String(response.status)),
2131
+ }
2132
+ }
2133
+ return response.json()
2134
+ }, [])
2135
+
2136
+ /**
2137
+ * Спросить Host и показать ответ.
2138
+ *
2139
+ * @param body - тело действия; без него идёт GET за списком.
2140
+ * @param background - фоновый опрос: не блокирует поля и не стирает
2141
+ * показанное состояние, если сеть отвалилась. Пользователь в этот
2142
+ * момент вводит код, и мигание под руками недопустимо.
2143
+ */
2144
+ const refresh = React.useCallback(async (body, background) => {
2145
+ const ticket = ++seq.current
2146
+ if (!background) setBusy(true)
2147
+ try {
2148
+ const next = await send(body)
2149
+ // Обгонять уже применённый ответ нельзя: иначе запоздавший опрос
2150
+ // вернёт вопрос, на который только что ответили.
2151
+ if (ticket > applied.current) {
2152
+ applied.current = ticket
2153
+ setState(next)
2154
+ }
2155
+ } catch (error) {
2156
+ // Сбой фонового опроса состояние НЕ трогает. Раньше он затирал его
2157
+ // объектом без попытки, из-за чего опрос останавливался навсегда:
2158
+ // интернет возвращался, а строка молчала до перезагрузки.
2159
+ if (!background && ticket > applied.current) {
2160
+ applied.current = ticket
2161
+ setState({ available: true, problem: String(error && error.message ? error.message : error) })
2162
+ }
2163
+ } finally {
2164
+ if (!background) setBusy(false)
2165
+ }
2166
+ }, [send])
2167
+
2168
+ React.useEffect(() => { refresh() }, [refresh])
2169
+
2170
+ // Пока попытка идёт, состояние спрашивается повторно: поток отвечает
2171
+ // не сразу, а ссылка и вопросы приходят по ходу дела.
2172
+ const active = state !== null && state.attempt !== undefined && state.attempt !== null && state.attempt.active === true
2173
+ React.useEffect(() => {
2174
+ if (!active) return undefined
2175
+ const id = setInterval(() => { refresh({ action: 'poll' }, true) }, 2000)
2176
+ return () => clearInterval(id)
2177
+ }, [active, refresh])
2178
+
2179
+ if (state !== null && state.available === false) {
2180
+ return jsx('div', {
2181
+ className: 'dsx-setting',
2182
+ children: jsxs('div', {
2183
+ children: [
2184
+ jsx('div', { className: 'dsx-setting__title', children: t('auth.title') }),
2185
+ jsx('div', { className: 'dsx-setting__hint', children: t('auth.unavailable') }),
2186
+ ],
2187
+ }),
2188
+ })
2189
+ }
2190
+
2191
+ const attempt = state === null || !state.attempt ? null : state.attempt
2192
+ const providers = state === null || !Array.isArray(state.providers) ? [] : state.providers
2193
+ // Показываем только вход по подписке: ключи API у настроек моделей свои.
2194
+ const subscription = providers.filter((p) => p.methods.some((m) => m.id === 'oauth'))
2195
+
2196
+ const rows = []
2197
+
2198
+ if (attempt !== null && (attempt.active || attempt.outcome || attempt.problem)) {
2199
+ for (const notice of attempt.notices || []) {
2200
+ rows.push(jsxs('div', {
2201
+ className: 'dsx-auth__notice',
2202
+ children: [
2203
+ jsx('div', { children: notice.message }),
2204
+ safeUrl(notice.url) === null ? null : jsx('a', {
2205
+ className: 'dsx-auth__link',
2206
+ href: safeUrl(notice.url),
2207
+ target: '_blank',
2208
+ rel: 'noreferrer',
2209
+ children: t('auth.openLink'),
2210
+ }),
2211
+ notice.code === null ? null : jsx('code', { className: 'dsx-auth__code', children: notice.code }),
2212
+ ],
2213
+ }, 'notice-' + rows.length))
2214
+ }
2215
+
2216
+ if (attempt.prompt !== null) {
2217
+ const prompt = attempt.prompt
2218
+ rows.push(jsxs('div', {
2219
+ className: 'dsx-auth__prompt',
2220
+ children: [
2221
+ jsx('div', { children: prompt.message }),
2222
+ prompt.options !== null
2223
+ ? jsx('div', {
2224
+ className: 'dsx-setting__control',
2225
+ children: prompt.options.map((option) =>
2226
+ jsx('button', {
2227
+ type: 'button',
2228
+ className: 'dsx-seg',
2229
+ disabled: busy,
2230
+ onClick: () => refresh({ action: 'answer', value: option.id }),
2231
+ children: option.label,
2232
+ }, option.id),
2233
+ ),
2234
+ })
2235
+ : jsxs('div', {
2236
+ className: 'dsx-setting__control',
2237
+ children: [
2238
+ jsx('input', {
2239
+ className: 'dsx-auth__input',
2240
+ type: prompt.kind === 'secret' ? 'password' : 'text',
2241
+ value: draft,
2242
+ disabled: busy,
2243
+ onChange: (event) => setDraft(event.target.value),
2244
+ }),
2245
+ jsx('button', {
2246
+ type: 'button',
2247
+ className: 'dsx-seg dsx-seg--on',
2248
+ disabled: busy || draft === '',
2249
+ onClick: () => { refresh({ action: 'answer', value: draft }); setDraft('') },
2250
+ children: t('auth.send'),
2251
+ }),
2252
+ ],
2253
+ }),
2254
+ ],
2255
+ }, 'prompt'))
2256
+ }
2257
+
2258
+ if (attempt.outcome) {
2259
+ rows.push(jsx('div', {
2260
+ className: 'dsx-setting__hint dsx-setting__hint--strong',
2261
+ children: attempt.outcome === 'authorized' ? t('auth.done') : t('auth.cancelled'),
2262
+ }, 'outcome'))
2263
+ }
2264
+ if (attempt.problem) {
2265
+ rows.push(jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: attempt.problem }, 'problem'))
2266
+ }
2267
+ }
2268
+
2269
+ return jsxs('div', {
2270
+ className: 'dsx-subs',
2271
+ children: [
2272
+ jsxs('div', {
2273
+ className: 'dsx-subs__head',
2274
+ children: [
2275
+ jsx('div', { className: 'dsx-subs__title', children: t('auth.title') }),
2276
+ jsx('div', { className: 'dsx-setting__hint', children: t('auth.hint') }),
2277
+ ],
2278
+ }),
2279
+ state !== null && state.problem && (attempt === null || !attempt.problem)
2280
+ ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: state.problem })
2281
+ : null,
2282
+ state !== null && !state.problem && subscription.length === 0
2283
+ ? jsx('div', { className: 'dsx-setting__hint', children: t('auth.empty') })
2284
+ : null,
2285
+ jsx('div', {
2286
+ className: 'dsx-auth__list',
2287
+ children: subscription.map((provider) =>
2288
+ jsxs('div', {
2289
+ className: 'dsx-auth__row',
2290
+ children: [
2291
+ jsxs('div', {
2292
+ children: [
2293
+ jsx('span', { className: 'dsx-auth__name', children: provider.label }),
2294
+ provider.configured
2295
+ ? jsx('span', { className: 'dsx-auth__badge', children: t('auth.connected') })
2296
+ : null,
2297
+ ],
2298
+ }),
2299
+ jsx('button', {
2300
+ type: 'button',
2301
+ className: 'dsx-seg',
2302
+ disabled: busy || (attempt !== null && attempt.active),
2303
+ onClick: () => refresh({ action: 'begin', key: provider.key, method: 'oauth' }),
2304
+ children: provider.configured ? t('auth.again') : t('auth.signIn'),
2305
+ }),
2306
+ ],
2307
+ }, provider.key),
2308
+ ),
2309
+ }),
2310
+ rows.length === 0 ? null : jsx('div', { className: 'dsx-auth__panel', children: rows }),
2311
+ attempt !== null && attempt.active
2312
+ ? jsx('div', {
2313
+ className: 'dsx-setting__control',
2314
+ children: jsx('button', {
2315
+ type: 'button',
2316
+ className: 'dsx-seg',
2317
+ onClick: () => refresh({ action: 'cancel' }),
2318
+ children: t('auth.cancel'),
2319
+ }),
2320
+ })
2321
+ : null,
2322
+ // Здесь только провайдеры, у которых есть вход по подписке. У
2323
+ // остальных — включая Z.AI — поставщик предлагает единственный
2324
+ // способ, ключ API, и его место в разделе «Модели». Без этой
2325
+ // строки отсутствие знакомого имени выглядит как недоработка.
2326
+ jsx('div', { className: 'dsx-subs__note', children: t('auth.others') }),
2327
+ ],
2328
+ })
2329
+ }
2330
+
2331
+ /** Класс важности: имена уровней приходят с Host по-русски. */
2332
+ function severityClass(severity) {
2333
+ if (severity === 'критично') return 'crit'
2334
+ if (severity === 'серьёзно') return 'high'
2335
+ if (severity === 'не указана') return 'unknown'
2336
+ return 'low'
2337
+ }
2338
+
2339
+ /** Знак приговора. Пустой кружок — проверки ещё не было. */
2340
+ function verdictMark(verdict) {
2341
+ if (verdict === 'confirmed') return '✓'
2342
+ if (verdict === 'false') return '✗'
2343
+ if (verdict === 'deferred') return '⋯'
2344
+ return '○'
2345
+ }
2346
+ // ── Ревизоры ─────────────────────────────────────────────────────────
2347
+ //
2348
+ // Раздел настроек, где выбирают, кто смотрит работу вторым мнением,
2349
+ // какой моделью и с каким усилием.
2350
+ //
2351
+ // Справочник моделей приходит с Host, а не зашит здесь: иначе список
2352
+ // расширят в одной половине и забудут в другой. Пустой выбор модели
2353
+ // означает «как настроено у самого продукта» — человек уже настроил
2354
+ // свой Claude Code и Codex, и навязывать поверх нечего.
2355
+ const REVIEW_PATH = '/api/tensorgrid.review'
2356
+
2357
+ /**
2358
+ * Выбор значения: известные варианты плюс «своё».
2359
+ *
2360
+ * `groups` разносит варианты по подписям. Для моделей это принципиально:
2361
+ * один и тот же `claude-opus-5` доступен и по подписке, и через
2362
+ * OpenRouter по токенам, и вперемешку они неразличимы — а разница в том,
2363
+ * придёт ли за них счёт.
2364
+ */
2365
+ /**
2366
+ * Выбор значения с поиском.
2367
+ *
2368
+ * Родной `select` здесь не годится: моделей под четыреста, и пролистать
2369
+ * их невозможно — это первое, что сказал пользователь. Он не умеет ни
2370
+ * искать, ни показывать отметку рядом со строкой.
2371
+ *
2372
+ * Групп нет намеренно. Заголовок группы виден, только пока список не
2373
+ * прокручен, а вопрос «придёт ли за это счёт» задаётся про строку под
2374
+ * курсором. Поэтому отметка стоит У КАЖДОЙ позиции.
2375
+ */
2376
+ function SearchPicker({ t, value, options, disabled, onPick, placeholder, emptyLabel }) {
2377
+ const [open, setOpen] = React.useState(false)
2378
+ const [query, setQuery] = React.useState('')
2379
+ const [custom, setCustom] = React.useState(false)
2380
+ const boxRef = React.useRef(null)
2381
+ const inputRef = React.useRef(null)
2382
+
2383
+ // Закрытие по щелчку вне и по Esc: иначе список висит поверх соседних
2384
+ // карточек и мешает работать с ними.
2385
+ React.useEffect(() => {
2386
+ if (!open) return undefined
2387
+ const onDown = (event) => {
2388
+ if (boxRef.current !== null && !boxRef.current.contains(event.target)) setOpen(false)
2389
+ }
2390
+ const onKey = (event) => { if (event.key === 'Escape') setOpen(false) }
2391
+ document.addEventListener('mousedown', onDown)
2392
+ document.addEventListener('keydown', onKey)
2393
+ return () => {
2394
+ document.removeEventListener('mousedown', onDown)
2395
+ document.removeEventListener('keydown', onKey)
2396
+ }
2397
+ }, [open])
2398
+
2399
+ React.useEffect(() => {
2400
+ if (open && inputRef.current !== null) inputRef.current.focus()
2401
+ }, [open])
2402
+
2403
+ if (custom) {
2404
+ return jsxs('span', {
2405
+ className: 'dsx-rev__custom',
2406
+ children: [
2407
+ jsx('input', {
2408
+ className: 'dsx-rev__input',
2409
+ type: 'text',
2410
+ value: value === null ? '' : value,
2411
+ placeholder: placeholder,
2412
+ disabled: disabled,
2413
+ onChange: (event) => onPick(event.target.value.trim() === '' ? null : event.target.value.trim()),
2414
+ }),
2415
+ jsx('button', {
2416
+ type: 'button',
2417
+ className: 'dsx-rev__back',
2418
+ disabled: disabled,
2419
+ title: t('review.fromList'),
2420
+ onClick: () => { setCustom(false); onPick(null) },
2421
+ children: '\u00d7',
2422
+ }),
2423
+ ],
2424
+ })
2425
+ }
2426
+
2427
+ const needle = query.trim().toLowerCase()
2428
+ // Ищем и по названию, и по провайдеру: «openrouter» должно находить всё
2429
+ // подключённое через него, а «opus» — все его выпуски.
2430
+ const matched = needle === ''
2431
+ ? options
2432
+ : options.filter((option) =>
2433
+ option.value.toLowerCase().includes(needle) ||
2434
+ (typeof option.provider === 'string' && option.provider.toLowerCase().includes(needle)))
2435
+
2436
+ // Показываем не всё: четыреста строк в разметке заметно тормозят
2437
+ // открытие, а искать глазами по четырёмстам всё равно нельзя для того
2438
+ // и поиск. Сколько скрыто, сказано прямо, чтобы это не выглядело как
2439
+ // «модели пропали».
2440
+ const LIMIT = 60
2441
+ const shown = matched.slice(0, LIMIT)
2442
+ const hidden = matched.length - shown.length
2443
+
2444
+ const current = options.find((option) => option.value === value)
2445
+ const title = current !== undefined
2446
+ ? (typeof current.provider === 'string' ? current.provider + ' \u00b7 ' : '') + current.value
2447
+ : (value === null ? (emptyLabel ?? t('review.asConfigured')) : value)
2448
+
2449
+ return jsxs('span', {
2450
+ className: 'dsx-rev__picker',
2451
+ ref: boxRef,
2452
+ children: [
2453
+ jsx('button', {
2454
+ type: 'button',
2455
+ className: 'dsx-rev__trigger',
2456
+ disabled: disabled,
2457
+ onClick: () => { setOpen(!open); setQuery('') },
2458
+ children: title,
2459
+ }),
2460
+ !open ? null : jsxs('div', {
2461
+ className: 'dsx-rev__pop',
2462
+ children: [
2463
+ jsx('input', {
2464
+ ref: inputRef,
2465
+ className: 'dsx-rev__search',
2466
+ type: 'text',
2467
+ value: query,
2468
+ placeholder: t('review.search'),
2469
+ onChange: (event) => setQuery(event.target.value),
2470
+ }),
2471
+ jsxs('div', {
2472
+ className: 'dsx-rev__options',
2473
+ children: [
2474
+ jsx('button', {
2475
+ type: 'button',
2476
+ className: 'dsx-rev__option',
2477
+ onClick: () => { onPick(null); setOpen(false) },
2478
+ children: emptyLabel ?? t('review.asConfigured'),
2479
+ }, 'none'),
2480
+ ...shown.map((option) => jsxs('button', {
2481
+ type: 'button',
2482
+ className: 'dsx-rev__option' + (option.value === value ? ' dsx-rev__option--on' : ''),
2483
+ onClick: () => { onPick(option.value); setOpen(false) },
2484
+ children: [
2485
+ jsxs('span', {
2486
+ className: 'dsx-rev__option-name',
2487
+ children: [
2488
+ typeof option.provider !== 'string'
2489
+ ? null
2490
+ : jsx('span', { className: 'dsx-rev__option-provider', children: option.provider }),
2491
+ option.value,
2492
+ ],
2493
+ }),
2494
+ typeof option.tag !== 'string'
2495
+ ? null
2496
+ : jsx('span', {
2497
+ className: 'dsx-rev__tag dsx-rev__tag--' + (option.tagKind ?? 'plain'),
2498
+ children: option.tag,
2499
+ }),
2500
+ ],
2501
+ }, String(option.provider ?? '') + '/' + option.value)),
2502
+ hidden <= 0 ? null : jsx('div', {
2503
+ className: 'dsx-rev__more',
2504
+ children: t('review.more').replace('{n}', String(hidden)),
2505
+ }),
2506
+ matched.length !== 0 ? null : jsx('div', { className: 'dsx-rev__more', children: t('review.nothing') }),
2507
+ ],
2508
+ }),
2509
+ jsx('button', {
2510
+ type: 'button',
2511
+ className: 'dsx-rev__own',
2512
+ onClick: () => { setCustom(true); setOpen(false) },
2513
+ children: t('review.other'),
2514
+ }),
2515
+ ],
2516
+ }),
2517
+ ],
2518
+ })
2519
+ }
2520
+ /** Класс важности: имена уровней приходят с Host по-русски. */
2521
+ function severityClass(severity) {
2522
+ if (severity === 'критично') return 'crit'
2523
+ if (severity === 'серьёзно') return 'high'
2524
+ if (severity === 'не указана') return 'unknown'
2525
+ return 'low'
2526
+ }
2527
+
2528
+ /** Знак приговора. Пустой кружок — проверки ещё не было. */
2529
+ function verdictMark(verdict) {
2530
+ if (verdict === 'confirmed') return '✓'
2531
+ if (verdict === 'false') return '✗'
2532
+ if (verdict === 'deferred') return '⋯'
2533
+ return '○'
2534
+ }
2535
+ // ── Ревизоры ─────────────────────────────────────────────────────────
2536
+ //
2537
+ // Свободный список, а не готовые режимы.
2538
+ //
2539
+ // Режимы «быстрый, обычный, глубокий» отвечали за человека на вопрос, на
2540
+ // который он вполне способен ответить сам, и при этом не давали позвать,
2541
+ // скажем, три разные модели одного семейства. Теперь: сколько угодно
2542
+ // записей, у каждой свой вид, модель и усилие.
2543
+ //
2544
+ // Рядом с каждой моделью показано, чем она оплачена. Это не украшение:
2545
+ // подписка безлимитна, а ключ API считается по токенам, и человек,
2546
+ // собирающий состав, должен видеть разницу ДО того, как получит счёт.
2547
+ function ReviewRow({ t }) {
2548
+ const [state, setState] = React.useState(null)
2549
+ const [busy, setBusy] = React.useState(false)
2550
+
2551
+ const translate = React.useRef(t)
2552
+ translate.current = t
2553
+
2554
+ const send = React.useCallback(async (body) => {
2555
+ const response = body === undefined
2556
+ ? await fetch(REVIEW_PATH)
2557
+ : await fetch(REVIEW_PATH, {
2558
+ method: 'POST',
2559
+ headers: { 'content-type': 'application/json' },
2560
+ body: JSON.stringify(body),
2561
+ })
2562
+ const type = response.headers.get('content-type') || ''
2563
+ if (!type.includes('application/json')) {
2564
+ const say = translate.current
2565
+ return {
2566
+ catalogue: [],
2567
+ problem: response.status === 401 || response.status === 404
2568
+ ? say('auth.needsRestart')
2569
+ : say('auth.badResponse').replace('{status}', String(response.status)),
2570
+ }
2571
+ }
2572
+ return response.json()
2573
+ }, [])
2574
+
2575
+ const refresh = React.useCallback(async (body) => {
2576
+ setBusy(true)
2577
+ try {
2578
+ const next = await send(body)
2579
+ // Ответ без каталога — это отказ, а не новое состояние. Замена
2580
+ // состояния целиком убирала все карточки после одного неудачного
2581
+ // нажатия: ни повторить, ни увидеть выбранное было нельзя.
2582
+ setState((previous) => {
2583
+ const complete = Array.isArray(next.catalogue) && next.catalogue.length > 0
2584
+ if (complete) return next
2585
+ return previous === null ? next : { ...previous, problem: next.problem ?? 'сохранить не удалось' }
2586
+ })
2587
+ } catch (error) {
2588
+ const text = String(error && error.message ? error.message : error)
2589
+ setState((previous) => (previous === null ? { catalogue: [], problem: text } : { ...previous, problem: text }))
2590
+ } finally {
2591
+ setBusy(false)
2592
+ }
2593
+ }, [send])
2594
+
2595
+ React.useEffect(() => { refresh() }, [refresh])
2596
+
2597
+ const catalogue = state !== null && Array.isArray(state.catalogue) ? state.catalogue : []
2598
+ const journal = state !== null && state.journal ? state.journal : null
2599
+ const effortMap = state !== null && state.efforts ? state.efforts : {}
2600
+
2601
+ /**
2602
+ * Уровни усилия для записи.
2603
+ *
2604
+ * У наших ревизоров приходят с Host по выбранной модели; у внешних
2605
+ * продуктов остаются зашитыми — спросить у них нечем.
2606
+ */
2607
+ /**
2608
+ * ЕДИНЫЙ список того, чем может работать ревизор.
2609
+ *
2610
+ * Модели маршрута и внешние продукты в одном перечне. Прежде вид
2611
+ * выбирался отдельными кнопками, а модель отдельным списком — деление
2612
+ * было лишним: в списке и так видно, что за модель и чем она оплачена.
2613
+ *
2614
+ * Отметка стоит у КАЖДОЙ строки, а не заголовком группы: заголовок
2615
+ * виден, только пока список не прокручен, а вопрос «придёт ли счёт»
2616
+ * задаётся про строку под курсором.
2617
+ */
2618
+ const modelChoices = React.useMemo(() => {
2619
+ // Перевод берётся ЧЕРЕЗ ССЫЛКУ, а не из зависимостей.
2620
+ //
2621
+ // Поставочная функция перевода не мемоизирована: на каждой отрисовке
2622
+ // это новое значение. Поставь её в зависимости — и список из
2623
+ // четырёхсот строк пересобирался бы при любом нажатии, ради чего хук
2624
+ // и не нужен вовсе. В строке входа такой цикл уже подвешивал вкладку.
2625
+ const say = translate.current
2626
+ const out = []
2627
+ for (const spec of catalogue) {
2628
+ for (const model of spec.models || []) {
2629
+ out.push({
2630
+ kind: spec.kind,
2631
+ value: model.id,
2632
+ provider: spec.internal ? model.provider : spec.label,
2633
+ // Молчание при неизвестности обязательно: незнание не повод
2634
+ // объявлять модель платной.
2635
+ tag: spec.internal
2636
+ ? (model.billing === 'subscription'
2637
+ ? say('review.bySubscription')
2638
+ : model.billing === 'api-key' ? say('review.byApiKey') : undefined)
2639
+ : say('review.separateProduct'),
2640
+ tagKind: spec.internal
2641
+ ? (model.billing === 'subscription' ? 'sub' : model.billing === 'api-key' ? 'paid' : 'plain')
2642
+ : 'ext',
2643
+ })
2644
+ }
2645
+ }
2646
+ return out
2647
+ }, [catalogue])
2648
+
2649
+ const effortsFor = (entry) => {
2650
+ const spec = catalogue.find((item) => item.kind === entry.kind)
2651
+ if (spec && !spec.internal) return spec.efforts || []
2652
+ if (entry.provider === null || entry.model === null) return []
2653
+ const info = effortMap[entry.provider + '/' + entry.model]
2654
+ return info && Array.isArray(info.efforts) ? info.efforts : []
2655
+ }
2656
+ const reviewers = state !== null && state.settings && Array.isArray(state.settings.reviewers)
2657
+ ? state.settings.reviewers
2658
+ : []
2659
+
2660
+ // Список уходит ЦЕЛИКОМ: удаление записи иначе было бы невыразимо —
2661
+ // слияние на стороне Host всегда возвращало бы удалённое обратно.
2662
+ const save = React.useCallback((next) => { refresh({ reviewers: next }) }, [refresh])
2663
+
2664
+ const patch = (id, changes) =>
2665
+ save(reviewers.map((entry) => (entry.id === id ? { ...entry, ...changes } : entry)))
2666
+
2667
+ const remove = (id) => save(reviewers.filter((entry) => entry.id !== id))
2668
+
2669
+ const add = (kind) => {
2670
+ const spec = catalogue.find((entry) => entry.kind === kind)
2671
+ save([...reviewers, {
2672
+ kind,
2673
+ // Умолчание берётся из справочника: пустая модель означала бы
2674
+ // умолчание маршрута, то есть копию уже имеющегося ревизора.
2675
+ provider: spec && spec.internal ? spec.defaultProvider : null,
2676
+ model: spec ? spec.defaultModel : null,
2677
+ effort: null,
2678
+ enabled: true,
2679
+ }])
2680
+ }
2681
+
2682
+ /**
2683
+ * Отметка на карточке: по подписке, за отдельные деньги или молчание.
2684
+ *
2685
+ * ТРИ состояния, и третье обязательно. Прежде их было два, и всё, что
2686
+ * не подписка, объявлялось платным включая случай «сказать нечего».
2687
+ * Получалось, что человек видел «по токенам» на модели, которая ему
2688
+ * давно оплачена, а внешний продукт с собственной подпиской получал
2689
+ * ту же метку.
2690
+ *
2691
+ * Молчание честнее догадки: отсутствие отметки означает «не знаю», и
2692
+ * это ровно то, что есть на самом деле.
2693
+ */
2694
+ const billingLabel = (kind) => {
2695
+ if (kind === 'subscription') return t('review.bySubscription')
2696
+ if (kind === 'api-key') return t('review.byApiKey')
2697
+ return null
2698
+ }
2699
+
2700
+ const atLimit = reviewers.length >= 8
2701
+ const activeCount = reviewers.filter((entry) => entry.enabled).length
2702
+
2703
+ return jsxs('div', {
2704
+ className: 'dsx-subs',
2705
+ children: [
2706
+ jsxs('div', {
2707
+ className: 'dsx-subs__head',
2708
+ children: [
2709
+ jsx('div', { className: 'dsx-subs__title', children: t('review.title') }),
2710
+ jsx('div', { className: 'dsx-setting__hint', children: t('review.hint') }),
2711
+ ],
2712
+ }),
2713
+ state !== null && state.problem
2714
+ ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: state.problem })
2715
+ : null,
2716
+
2717
+ jsx('div', {
2718
+ className: 'dsx-rev__list',
2719
+ children: reviewers.map((entry) => {
2720
+ const spec = catalogue.find((item) => item.kind === entry.kind)
2721
+ const models = spec && Array.isArray(spec.models) ? spec.models : []
2722
+ const chosen = models.find((m) => m.id === entry.model)
2723
+ const billing = billingLabel(chosen ? chosen.billing : null)
2724
+
2725
+ return jsxs('div', {
2726
+ className: 'dsx-rev__card' + (entry.enabled ? ' dsx-rev__card--on' : ''),
2727
+ children: [
2728
+ jsxs('div', {
2729
+ className: 'dsx-rev__head',
2730
+ children: [
2731
+ jsx('input', {
2732
+ type: 'checkbox',
2733
+ checked: entry.enabled,
2734
+ disabled: busy,
2735
+ title: t('review.participates'),
2736
+ onChange: () => patch(entry.id, { enabled: !entry.enabled }),
2737
+ }),
2738
+ jsx('span', { className: 'dsx-rev__name', children: spec ? spec.label : entry.kind }),
2739
+ billing === null ? null : jsx('span', { className: 'dsx-rev__tag', children: billing }),
2740
+ jsx('button', {
2741
+ type: 'button',
2742
+ className: 'dsx-rev__remove',
2743
+ disabled: busy,
2744
+ title: t('review.remove'),
2745
+ onClick: () => remove(entry.id),
2746
+ children: '\u00d7',
2747
+ }),
2748
+ ],
2749
+ }),
2750
+ jsxs('div', {
2751
+ className: 'dsx-rev__fields',
2752
+ children: [
2753
+ jsxs('label', {
2754
+ className: 'dsx-rev__field',
2755
+ children: [
2756
+ jsx('span', { children: t('review.model') }),
2757
+ jsx(SearchPicker, {
2758
+ t: t,
2759
+ value: entry.model,
2760
+ disabled: busy,
2761
+ placeholder: t('review.modelName'),
2762
+ emptyLabel: t('review.asConfigured'),
2763
+ // ОДИН СПИСОК НА ВСЁ.
2764
+ //
2765
+ // Прежде вид ревизора выбирался отдельными
2766
+ // кнопками — «наш», «вторая модель», «Codex»,
2767
+ // «Claude Code», — а модель отдельным списком.
2768
+ // Деление было лишним: в списке и так видно, что
2769
+ // за модель и чем она оплачена. Теперь выбор
2770
+ // один, и внешние продукты стоят в нём наравне.
2771
+ options: modelChoices,
2772
+ onPick: (value) => {
2773
+ const found = modelChoices.find((option) => option.value === value)
2774
+ if (found === undefined) {
2775
+ // Вписанное вручную: вид ревизора не меняем,
2776
+ // человек сам знает, к чему оно относится.
2777
+ patch(entry.id, { model: value })
2778
+ return
2779
+ }
2780
+ patch(entry.id, {
2781
+ kind: found.kind,
2782
+ provider: found.provider ?? null,
2783
+ model: found.value,
2784
+ // Уровни усилия у каждой модели свои, и
2785
+ // прежний перестаёт существовать вместе с
2786
+ // прежней моделью. Оставить его значило бы
2787
+ // послать несуществующее значение.
2788
+ effort: null,
2789
+ })
2790
+ },
2791
+ }),
2792
+ ],
2793
+ }),
2794
+ jsxs('label', {
2795
+ className: 'dsx-rev__field',
2796
+ children: [
2797
+ jsx('span', { children: t('review.effort') }),
2798
+ jsx(SearchPicker, {
2799
+ t: t,
2800
+ value: entry.effort,
2801
+ disabled: busy,
2802
+ placeholder: t('review.effortName'),
2803
+ emptyLabel: t('review.asConfigured'),
2804
+ // Уровни у КАЖДОЙ МОДЕЛИ свои, и зашитый список
2805
+ // врал почти везде. Проверено: у claude-opus-5 их
2806
+ // шесть, у deepseek-v4-pro — три, а одна и та же
2807
+ // модель через подписку и через OpenRouter даёт
2808
+ // разные наборы.
2809
+ options: effortsFor(entry).map((level) => ({ value: level })),
2810
+ onPick: (value) => patch(entry.id, { effort: value }),
2811
+ }),
2812
+ ],
2813
+ }),
2814
+ ],
2815
+ }),
2816
+ ],
2817
+ }, entry.id)
2818
+ }),
2819
+ }),
2820
+
2821
+ // Добавление: по кнопке на каждый вид ревизора. Внешние продукты
2822
+ // помечены — они работают, только если установлены отдельно.
2823
+ // ОДНА кнопка вместо кнопки на каждый вид.
2824
+ //
2825
+ // Прежде их было четыре «наш», «вторая модель», «Codex»,
2826
+ // «Claude Code». Выбирать вид до модели незачем: он определяется
2827
+ // тем, что выбрано в списке, и меняется вместе с ним.
2828
+ jsx('div', {
2829
+ className: 'dsx-rev__add',
2830
+ children: jsx('button', {
2831
+ type: 'button',
2832
+ className: 'dsx-rev__add-btn',
2833
+ disabled: busy || atLimit,
2834
+ onClick: () => add(),
2835
+ children: t('review.add'),
2836
+ }),
2837
+ }),
2838
+ atLimit ? jsx('div', { className: 'dsx-setting__hint', children: t('review.limit') }) : null,
2839
+
2840
+ reviewers.length === 0
2841
+ ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: t('review.none') })
2842
+ : activeCount === 0
2843
+ ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: t('review.noneActive') })
2844
+ : null,
2845
+ // ── Панель находок ──────────────────────────────────────────
2846
+ //
2847
+ // Показывает последний обзор: что нашли ревизоры и что из этого
2848
+ // подтвердилось проверкой.
2849
+ //
2850
+ // Судит НЕ тот, кто смотрит. Приговор выносит агент и обязан
2851
+ // приложить доказательство — что именно он запустил или прочитал.
2852
+ // Просить человека, не разбирающегося в предмете, решить
2853
+ // «настоящая это ошибка или нет» значит просить невозможного и
2854
+ // получить случайные нажатия, которые ещё и будут выглядеть как
2855
+ // данные.
2856
+ //
2857
+ // Возразить можно, но не нужно: возражение человека перекрывает
2858
+ // приговор агента и обратно уже не отменяется.
2859
+ journal === null || journal.latest === null ? null : jsxs('div', {
2860
+ className: 'dsx-rev__panel',
2861
+ children: [
2862
+ jsxs('div', {
2863
+ className: 'dsx-rev__panel-head',
2864
+ children: [
2865
+ jsx('span', { className: 'dsx-subs__title', children: t('review.lastReview') }),
2866
+ jsx('span', { className: 'dsx-rev__count', children: journal.latest.findings.length }),
2867
+ ],
2868
+ }),
2869
+ jsx('div', {
2870
+ className: 'dsx-rev__findings',
2871
+ children: journal.latest.findings.map((finding) => jsxs('div', {
2872
+ className: 'dsx-rev__finding dsx-rev__finding--' + finding.verdict,
2873
+ children: [
2874
+ jsxs('div', {
2875
+ className: 'dsx-rev__finding-head',
2876
+ children: [
2877
+ jsx('span', { className: 'dsx-rev__sev dsx-rev__sev--' + severityClass(finding.severity), children: finding.severity }),
2878
+ jsx('span', { className: 'dsx-rev__finding-title', children: finding.title }),
2879
+ jsx('span', { className: 'dsx-rev__who', children: finding.reviewer }),
2880
+ ],
2881
+ }),
2882
+ finding.where === null ? null : jsx('div', { className: 'dsx-rev__where', children: finding.where }),
2883
+ finding.what === null ? null : jsx('div', { className: 'dsx-rev__what', children: finding.what }),
2884
+ jsxs('div', {
2885
+ className: 'dsx-rev__verdict',
2886
+ children: [
2887
+ jsx('span', {
2888
+ className: 'dsx-rev__verdict-mark',
2889
+ children: verdictMark(finding.verdict),
2890
+ }),
2891
+ jsx('span', {
2892
+ className: 'dsx-rev__verdict-text',
2893
+ children: finding.evidence === ''
2894
+ ? t('review.notChecked')
2895
+ : finding.evidence,
2896
+ }),
2897
+ finding.by === null ? null : jsx('span', {
2898
+ className: 'dsx-rev__by',
2899
+ children: finding.by === 'human' ? t('review.byHuman') : t('review.byAgent'),
2900
+ }),
2901
+ ],
2902
+ }),
2903
+ jsx('div', {
2904
+ className: 'dsx-rev__object',
2905
+ children: jsx('button', {
2906
+ type: 'button',
2907
+ className: 'dsx-rev__object-btn',
2908
+ disabled: busy,
2909
+ onClick: () => save({
2910
+ findingId: finding.id,
2911
+ verdict: finding.verdict === 'confirmed' ? 'false' : 'confirmed',
2912
+ evidence: t('review.humanEvidence'),
2913
+ }),
2914
+ children: finding.verdict === 'confirmed' ? t('review.objectFalse') : t('review.objectReal'),
2915
+ }),
2916
+ }),
2917
+ ],
2918
+ }, finding.id)),
2919
+ }),
2920
+ journal.accuracy.length === 0 ? null : jsxs('div', {
2921
+ className: 'dsx-rev__stats',
2922
+ children: [
2923
+ jsx('div', { className: 'dsx-setting__hint', children: t('review.accuracy') }),
2924
+ ...journal.accuracy.map((stat) => jsxs('div', {
2925
+ className: 'dsx-rev__stat',
2926
+ children: [
2927
+ jsx('span', { className: 'dsx-rev__stat-name', children: stat.reviewer }),
2928
+ jsx('span', {
2929
+ className: 'dsx-rev__stat-rate',
2930
+ // Доля без числа находок ничего не значит: «100%» по
2931
+ // одной проверенной находке выглядит как надёжность.
2932
+ children: Math.round(stat.rate * 100) + '% ' + t('review.of') + ' ' + stat.judged,
2933
+ }),
2934
+ ],
2935
+ }, stat.reviewer)),
2936
+ ],
2937
+ }),
2938
+ ],
2939
+ }),
2940
+ jsx('div', { className: 'dsx-subs__note', children: t('review.note') }),
2941
+ ],
2942
+ })
2943
+ }
2944
+ // ── Переключатель модели с поиском ───────────────────────────────────
2945
+ //
2946
+ // ПОЧЕМУ ОН ВООБЩЕ ЕСТЬ
2947
+ //
2948
+ // В поставочном выборе модели поиска нет, а моделей под четыреста:
2949
+ // пролистать их невозможно. Это первое, на что пожаловался пользователь.
2950
+ //
2951
+ // ПОЧЕМУ ДОБАВЛЕН, А НЕ ЗАМЕНЁН
2952
+ //
2953
+ // Поставочный выбор живёт в слоте `conversation.input.model`, помеченном
2954
+ // `shadows-shipped-ui`: занять его значит переписать чужой компонент
2955
+ // целиком, вместе с загрузкой каталога, ошибками провайдеров, подменю
2956
+ // глубины рассуждений и доступностью с клавиатуры, и чинить это после
2957
+ // каждого обновления dsh.
2958
+ //
2959
+ // Мы весь проект держим правило: поставочный интерфейс не подменять.
2960
+ // Оно уже окупилось при разборе 0.1.6 все семь занятых слотов уцелели
2961
+ // именно потому, что мы ничего не заменяли.
2962
+ //
2963
+ // Поэтому переключатель встаёт в `conversation.input.left` аддитивный
2964
+ // список. Поставочный остаётся на месте и работает; наш даёт поиск.
2965
+ //
2966
+ // ОТКУДА ДАННЫЕ
2967
+ //
2968
+ // `modelDirectories.directoryFor(sessionId)` — та же служба, которой
2969
+ // пользуется поставочный выбор. Проверено на живом приложении:
2970
+ //
2971
+ // store.getSnapshot() -> { current: {provider, model, reasoningEffort},
2972
+ // groups: [{id, name, models:[{id, name}]}],
2973
+ // status, error }
2974
+ // select({provider, model}) -> меняет модель сессии
2975
+ //
2976
+ // Служба необязательна: профиль может не монтировать выбор моделей, и
2977
+ // тогда переключатель просто не рисуется.
2978
+ function ModelSwitcher({ t, sessionId, directoryFor }) {
2979
+ const [snapshot, setSnapshot] = React.useState(null)
2980
+ const [open, setOpen] = React.useState(false)
2981
+ const [query, setQuery] = React.useState('')
2982
+ const [busy, setBusy] = React.useState(false)
2983
+ const boxRef = React.useRef(null)
2984
+ const inputRef = React.useRef(null)
2985
+
2986
+ const directory = React.useMemo(() => {
2987
+ const service = ctx.get('modelDirectories')
2988
+ if (service === undefined || service === null || typeof service.directoryFor !== 'function') return null
2989
+ try {
2990
+ return service.directoryFor(sessionId)
2991
+ } catch {
2992
+ return null
2993
+ }
2994
+ }, [sessionId])
2995
+
2996
+ // Подписка на СЛУЖБУ, а не собственное состояние: модель может смениться
2997
+ // поставочным выбором, командой или другой вкладкой, и показывать при
2998
+ // этом своё устаревшее значение хуже, чем не показывать ничего.
2999
+ React.useEffect(() => {
3000
+ if (directory === null) return undefined
3001
+ const read = () => {
3002
+ try {
3003
+ setSnapshot(directory.store.getSnapshot())
3004
+ } catch {
3005
+ setSnapshot(null)
3006
+ }
3007
+ }
3008
+ read()
3009
+ try {
3010
+ return directory.store.subscribe(read)
3011
+ } catch {
3012
+ return undefined
3013
+ }
3014
+ }, [directory])
3015
+
3016
+ React.useEffect(() => {
3017
+ if (!open) return undefined
3018
+ const onDown = (event) => {
3019
+ if (boxRef.current !== null && !boxRef.current.contains(event.target)) setOpen(false)
3020
+ }
3021
+ const onKey = (event) => { if (event.key === 'Escape') setOpen(false) }
3022
+ document.addEventListener('mousedown', onDown)
3023
+ document.addEventListener('keydown', onKey)
3024
+ return () => {
3025
+ document.removeEventListener('mousedown', onDown)
3026
+ document.removeEventListener('keydown', onKey)
3027
+ }
3028
+ }, [open])
3029
+
3030
+ React.useEffect(() => {
3031
+ if (open && inputRef.current !== null) inputRef.current.focus()
3032
+ }, [open])
3033
+
3034
+ if (directory === null || snapshot === null) return null
3035
+
3036
+ const groups = Array.isArray(snapshot.groups) ? snapshot.groups : []
3037
+ const rows = []
3038
+ for (const group of groups) {
3039
+ for (const model of group.models ?? []) {
3040
+ rows.push({
3041
+ provider: group.id,
3042
+ providerName: group.name ?? group.id,
3043
+ id: model.id,
3044
+ name: model.name ?? model.id,
3045
+ })
3046
+ }
3047
+ }
3048
+
3049
+ const needle = query.trim().toLowerCase()
3050
+ // Ищем по трём полям: имя модели, её показываемое название и провайдер.
3051
+ // «openrouter» должно находить всё подключённое через него, «opus» — все
3052
+ // его выпуски, «Клод» по русскому названию группы, если оно такое.
3053
+ const matched = needle === ''
3054
+ ? rows
3055
+ : rows.filter((row) =>
3056
+ row.id.toLowerCase().includes(needle) ||
3057
+ row.name.toLowerCase().includes(needle) ||
3058
+ row.providerName.toLowerCase().includes(needle) ||
3059
+ row.provider.toLowerCase().includes(needle))
3060
+
3061
+ const LIMIT = 60
3062
+ const shown = matched.slice(0, LIMIT)
3063
+ const hidden = matched.length - shown.length
3064
+
3065
+ const current = snapshot.current ?? null
3066
+ const label = current === null ? t('switcher.none') : current.model
3067
+
3068
+ const choose = async (row) => {
3069
+ setBusy(true)
3070
+ setOpen(false)
3071
+ try {
3072
+ // Глубина рассуждений НЕ передаётся намеренно: у каждой модели свои
3073
+ // уровни, и перенос чужого значения дал бы несуществующий. Пусть
3074
+ // служба применит своё умолчание, а тонкая настройка остаётся за
3075
+ // поставочным выбором он для того и оставлен на месте.
3076
+ await directory.select({ provider: row.provider, model: row.id })
3077
+ } catch {
3078
+ // Отказ виден в поставочном выборе: он подписан на ту же службу и
3079
+ // покажет и ошибку, и причину. Дублировать её здесь значило бы
3080
+ // спорить с ним за одно и то же место.
3081
+ } finally {
3082
+ setBusy(false)
3083
+ }
3084
+ }
3085
+
3086
+ return jsxs('span', {
3087
+ className: 'dsx-mdl',
3088
+ ref: boxRef,
3089
+ children: [
3090
+ jsx('button', {
3091
+ type: 'button',
3092
+ className: 'dsx-mdl__trigger',
3093
+ disabled: busy,
3094
+ title: t('switcher.title'),
3095
+ onClick: () => { setOpen(!open); setQuery('') },
3096
+ children: label,
3097
+ }),
3098
+ !open ? null : jsxs('div', {
3099
+ className: 'dsx-mdl__pop',
3100
+ children: [
3101
+ jsx('input', {
3102
+ ref: inputRef,
3103
+ className: 'dsx-mdl__search',
3104
+ type: 'text',
3105
+ value: query,
3106
+ placeholder: t('switcher.search'),
3107
+ onChange: (event) => setQuery(event.target.value),
3108
+ }),
3109
+ jsxs('div', {
3110
+ className: 'dsx-mdl__options',
3111
+ children: [
3112
+ ...shown.map((row) => jsxs('button', {
3113
+ type: 'button',
3114
+ className: 'dsx-mdl__option'
3115
+ + (current !== null && current.provider === row.provider && current.model === row.id
3116
+ ? ' dsx-mdl__option--on' : ''),
3117
+ onClick: () => choose(row),
3118
+ children: [
3119
+ jsx('span', { className: 'dsx-mdl__option-provider', children: row.providerName }),
3120
+ jsx('span', { className: 'dsx-mdl__option-name', children: row.name }),
3121
+ ],
3122
+ }, row.provider + '/' + row.id)),
3123
+ hidden <= 0 ? null : jsx('div', {
3124
+ className: 'dsx-mdl__more',
3125
+ children: t('switcher.more').replace('{n}', String(hidden)),
3126
+ }),
3127
+ matched.length !== 0 ? null : jsx('div', {
3128
+ className: 'dsx-mdl__more',
3129
+ children: t('switcher.nothing'),
3130
+ }),
3131
+ ],
3132
+ }),
3133
+ ],
3134
+ }),
3135
+ ],
3136
+ })
3137
+ }
3138
+
3139
+ // ── Обновления ───────────────────────────────────────────────────────
3140
+ //
3141
+ // Маршрут поднимает host-половина пакета. Путь относительный, токен не
3142
+ // нужен: страница уже авторизована сессией, как и у поставочных
3143
+ // пакетов, которые ходят на /api/ обычным fetch.
3144
+ const UPDATE_PATH = '/api/tensorgrid.update'
3145
+
3146
+ function UpdateRow({ t }) {
3147
+ const [state, setState] = React.useState(null)
3148
+ const [busy, setBusy] = React.useState(false)
3149
+ const [result, setResult] = React.useState(null)
3150
+
3151
+ const load = React.useCallback(async (force) => {
3152
+ setBusy(true)
3153
+ setResult(null)
3154
+ try {
3155
+ const response = await fetch(UPDATE_PATH + (force ? '?force=1' : ''))
3156
+ setState(response.ok ? await response.json() : { problem: 'сервер ответил ' + response.status })
3157
+ } catch (error) {
3158
+ setState({ problem: String(error && error.message ? error.message : error) })
3159
+ } finally {
3160
+ setBusy(false)
3161
+ }
3162
+ }, [])
3163
+
3164
+ // Первый показ берёт готовый ответ: host проверяет при старте, так что
3165
+ // ждать сети обычно не приходится.
3166
+ React.useEffect(() => { load(false) }, [load])
3167
+
3168
+ const apply = React.useCallback(async () => {
3169
+ setBusy(true)
3170
+ setResult(null)
3171
+ try {
3172
+ const response = await fetch(UPDATE_PATH, {
3173
+ method: 'POST',
3174
+ headers: { 'content-type': 'application/json' },
3175
+ body: '{}',
3176
+ })
3177
+ const value = await response.json()
3178
+ setResult(value)
3179
+ if (value.status) setState(value.status)
3180
+ } catch (error) {
3181
+ setResult({ ok: false, problem: String(error && error.message ? error.message : error) })
3182
+ } finally {
3183
+ setBusy(false)
3184
+ }
3185
+ }, [])
3186
+
3187
+ // Host сообщает и код проблемы, и её текст. Код переводится, текст
3188
+ // остаётся запасным вариантом: сообщение с Host всегда по-русски, и
3189
+ // без кода англоязычный пользователь увидел бы кириллицу.
3190
+ const problemText = (value) => {
3191
+ if (!value.problemCode) return value.problem
3192
+ const key = 'update.problem.' + value.problemCode
3193
+ const translated = t(key)
3194
+ return translated === key ? value.problem : translated
3195
+ }
3196
+
3197
+ // Подставляем значение, только если оно действительно пришло. Иначе
3198
+ // берём формулировку без него.
3199
+ //
3200
+ // Это не перестраховка: при обновлении Host перечитывает свою
3201
+ // половину сразу, а браузерный бандл — только после перезагрузки
3202
+ // страницы. В этом промежутке старый клиент получает ответ новой
3203
+ // формы, и слепая подстановка выдавала пользователю «undefined».
3204
+ const fill = (key, fallbackKey, name, value) =>
3205
+ typeof value === 'string' && value !== ''
3206
+ ? t(key).replace('{' + name + '}', value)
3207
+ : t(fallbackKey)
3208
+
3209
+ let summary = t('update.checking')
3210
+ if (state !== null) {
3211
+ if (state.problem) summary = problemText(state)
3212
+ else if (state.updateAvailable) summary = fill('update.available', 'update.availableUnknown', 'version', state.latestVersion)
3213
+ else summary = t('update.upToDate')
3214
+ }
3215
+
3216
+ const version = state === null || typeof state.installedVersion !== 'string'
3217
+ ? ''
3218
+ : t('update.installed')
3219
+ .replace('{version}', state.installedVersion)
3220
+ .replace('{dsh}', typeof state.dshExpected === 'string' ? state.dshExpected : '—')
3221
+
3222
+ // Расхождение версий dsh — самый тихий способ всё сломать: пакет
3223
+ // опирается на контракты, а проверяли его на другой версии. Пользователь
3224
+ // об этом узнать ниоткуда не может, поэтому говорим прямо.
3225
+ const dshMismatch = state !== null
3226
+ && state.dshExpected
3227
+ && state.dshActual
3228
+ && state.dshExpected !== state.dshActual
3229
+ ? t('update.dshMismatch').replace('{expected}', state.dshExpected).replace('{actual}', state.dshActual)
3230
+ : null
3231
+
3232
+ return jsxs('div', {
3233
+ className: 'dsx-setting',
3234
+ children: [
3235
+ jsxs('div', {
3236
+ children: [
3237
+ jsx('div', { className: 'dsx-setting__title', children: t('update.title') }),
3238
+ jsx('div', { className: 'dsx-setting__hint', children: summary }),
3239
+ version === '' ? null : jsx('div', { className: 'dsx-setting__hint', children: version }),
3240
+ dshMismatch === null ? null : jsx('div', {
3241
+ className: 'dsx-setting__hint dsx-setting__hint--warn',
3242
+ children: dshMismatch,
3243
+ }),
3244
+ result === null ? null : jsx('div', {
3245
+ className: 'dsx-setting__hint dsx-setting__hint--strong',
3246
+ children: result.ok
3247
+ ? (result.needsRestart ? t('update.doneRestart') : t('update.doneReload'))
3248
+ : (problemText(result) || t('update.failed')),
3249
+ }),
3250
+ ],
3251
+ }),
3252
+ jsxs('div', {
3253
+ className: 'dsx-setting__control',
3254
+ role: 'group',
3255
+ children: [
3256
+ jsx('button', {
3257
+ type: 'button',
3258
+ className: 'dsx-seg',
3259
+ disabled: busy,
3260
+ onClick: () => load(true),
3261
+ children: busy ? t('update.working') : t('update.check'),
3262
+ }),
3263
+ state !== null && state.canUpdate
3264
+ ? jsx('button', {
3265
+ type: 'button',
3266
+ className: 'dsx-seg dsx-seg--on',
3267
+ disabled: busy,
3268
+ onClick: apply,
3269
+ children: t('update.apply'),
3270
+ })
3271
+ : null,
3272
+ ],
3273
+ }),
3274
+ ],
3275
+ })
3276
+ }
3277
+
3278
+ // ── Регистрация ──────────────────────────────────────────────────────
3279
+ /** Жёсткие зависимости клиентской половины. */
3280
+ const inject = ['slots', 'theme', 'locale']
3281
+
3282
+ /**
3283
+ * Кладёт слой токенов и занимает пять аддитивных мест: ambient-слой над
3284
+ * фреймом, две строки настроек, невидимый драйвер отклика в сессионном
3285
+ * слоте и знак на экране пустой сессии. Каждая регистрация возвращает
3286
+ * диспозер, поэтому снятие строки композиции убирает пакет без следов.
3287
+ * @param ctx - Корневой клиентский контекст.
3288
+ */
3289
+ function apply(ctx) {
3290
+ // Ссылка живёт ровно столько же, сколько слой: её берёт строка акцента,
3291
+ // чтобы переписать слой при смене тона.
3292
+ ctx.effect(() => {
3293
+ themeService = ctx.theme
3294
+ const dispose = ctx.theme.overrideTokens(SOURCE, tokensFor(accentOf(readAccentId()) ?? accentOf(DEFAULT_ACCENT)))
3295
+ return () => {
3296
+ themeService = null
3297
+ dispose()
3298
+ }
3299
+ })
3300
+
3301
+ // Русский как язык-пакет. Цепочка запасных вариантов обязана дойти до
3302
+ // английского, и это ровно то, что делает перевод безопасным: любой
3303
+ // ключ без русского значения — включая строки, которые появятся в
3304
+ // будущих версиях dsh, — сам покажется по-английски. Сломаться нечему.
3305
+ ctx.effect(() => {
3306
+ const disposers = []
3307
+
3308
+ const known = ctx.locale.getLocale().locales.some((entry) => entry.id === RU)
3309
+ if (!known) {
3310
+ try {
3311
+ disposers.push(ctx.locale.addLanguage({ id: RU, label: 'Русский', fallback: 'en' }))
3312
+ } catch (error) {
3313
+ console.error('addLanguage failed', error)
3314
+ }
3315
+ }
3316
+
3317
+ disposers.push(ctx.locale.register(LOCALE_NS, { en: EN_DICT, zh: ZH_DICT }))
3318
+ disposers.push(ctx.locale.register(LOCALE_NS, RU, RU_DICT))
3319
+
3320
+ // Русские словари чужих пространств имён. Трёхаргументная форма
3321
+ // register принимает произвольное имя, поэтому язык-пакет может
3322
+ // дополнять словари пакетов, которые ему не принадлежат, ничего в
3323
+ // них не замещая: английский и китайский остаются нетронутыми.
3324
+ for (const ns of Object.keys(RU_PACK)) {
3325
+ disposers.push(ctx.locale.register(ns, RU, RU_PACK[ns]))
3326
+ }
3327
+
3328
+ return () => {
3329
+ for (const dispose of disposers) dispose()
3330
+ }
3331
+ }, 'obsidian-ion: language pack')
3332
+
3333
+ ctx.slots.inject('shell.overlay', () =>
3334
+ ctx.slots.register(
3335
+ { name: 'shell.overlay', id: 'obsidian-ion-ambient', order: -1000 },
3336
+ Atmosphere,
3337
+ ),
3338
+ )
3339
+
3340
+ ctx.slots.inject('settings.general.item', () =>
3341
+ ctx.slots.register(
3342
+ { name: 'settings.general.item', id: 'obsidian-ion-intensity', order: 12, locale: LOCALE_NS },
3343
+ IntensityRow,
3344
+ ),
3345
+ )
3346
+
3347
+ // Подписки — собственный раздел настроек, а не строка среди прочих.
3348
+ //
3349
+ // `settings.section` — аддитивный список: своя запись добавляет свою
3350
+ // вкладку и ничего не замещает. Порядок 12 ставит её между «Моделями»
3351
+ // (10) и «Плагинами» (15): вход к провайдерам — сосед моделей, а не
3352
+ // оформления.
3353
+ ctx.slots.inject('settings.section', () =>
3354
+ ctx.slots.register(
3355
+ {
3356
+ name: 'settings.section',
3357
+ id: 'tensorgrid-subscriptions',
3358
+ order: 12,
3359
+ locale: LOCALE_NS,
3360
+ label: () => ctx.locale.translate(LOCALE_NS, 'auth.nav'),
3361
+ },
3362
+ AuthRow,
3363
+ ),
3364
+ )
3365
+
3366
+ // Ревизоры — соседний раздел: и подписки, и обзор про то, чем и как
3367
+ // работает агент, а не про внешний вид.
3368
+ ctx.slots.inject('settings.section', () =>
3369
+ ctx.slots.register(
3370
+ {
3371
+ name: 'settings.section',
3372
+ id: 'tensorgrid-review',
3373
+ order: 13,
3374
+ locale: LOCALE_NS,
3375
+ label: () => ctx.locale.translate(LOCALE_NS, 'review.nav'),
3376
+ },
3377
+ ReviewRow,
3378
+ ),
3379
+ )
3380
+
3381
+ ctx.slots.inject('settings.general.item', () =>
3382
+ ctx.slots.register(
3383
+ { name: 'settings.general.item', id: 'obsidian-ion-update', order: 11, locale: LOCALE_NS },
3384
+ UpdateRow,
3385
+ ),
3386
+ )
3387
+
3388
+ ctx.slots.inject('settings.general.item', () =>
3389
+ ctx.slots.register(
3390
+ { name: 'settings.general.item', id: 'obsidian-ion-accent', order: 13, locale: LOCALE_NS },
3391
+ AccentRow,
3392
+ ),
3393
+ )
3394
+
3395
+ // Переключатель модели с поиском — ДОБАВЛЯЕТСЯ, а не подменяет.
3396
+ //
3397
+ // `conversation.input.left` — аддитивный список: поставочный выбор
3398
+ // модели остаётся на месте и продолжает работать, наш встаёт рядом и
3399
+ // даёт то, чего в нём нет, — поиск по четырёмстам моделям.
3400
+ ctx.slots.inject('conversation.input.left', () =>
3401
+ ctx.slots.register(
3402
+ { name: 'conversation.input.left', id: 'obsidian-ion-model', order: -50, locale: LOCALE_NS },
3403
+ // Служба передаётся ОТСЮДА, свойством.
3404
+ //
3405
+ // Компонент объявлен на уровне модуля, а `ctx` существует только
3406
+ // внутри `apply`. Обращение к нему из компонента роняло отрисовку —
3407
+ // это поймала проверка «компонент рендерится без исключения», ещё
3408
+ // до того, как код попал в браузер.
3409
+ //
3410
+ // Читается при каждой отрисовке намеренно: ряд выбора моделей может
3411
+ // подняться и опуститься на ходу, а сохранённая ссылка пережила бы
3412
+ // его и указывала в пустоту.
3413
+ (props) => jsx(ModelSwitcher, {
3414
+ ...props,
3415
+ directoryFor: (id) => {
3416
+ const service = ctx.get('modelDirectories')
3417
+ if (service === undefined || service === null) return null
3418
+ if (typeof service.directoryFor !== 'function') return null
3419
+ return service.directoryFor(id)
3420
+ },
3421
+ }),
3422
+ ),
3423
+ )
3424
+
3425
+ ctx.slots.inject('conversation.input.dock', () =>
3426
+ ctx.slots.register(
3427
+ { name: 'conversation.input.dock', id: 'obsidian-ion-activity', order: 1000 },
3428
+ ActivityDriver,
3429
+ ),
3430
+ )
3431
+
3432
+ ctx.slots.inject('conversation.hero.brand.mark', () =>
3433
+ ctx.slots.register({ name: 'conversation.hero.brand.mark' }, HeroMark),
3434
+ )
3435
+
3436
+ // Два слота ниже помечены `shadows-shipped-ui`, и это единственное
3437
+ // осознанное исключение из правила пакета. Заменяемые компоненты —
3438
+ // чистая графика (`FishLogo` и `BrandWordmark`): ни дочерних слотов,
3439
+ // ни поведения, ни будущих функций, которые мы бы пропустили. Потерять
3440
+ // поставочную айдентику здесь и есть цель.
3441
+ ctx.slots.inject('sidebar.brand.mark', () =>
3442
+ ctx.slots.register({ name: 'sidebar.brand.mark' }, BrandMark),
3443
+ )
3444
+
3445
+ ctx.slots.inject('sidebar.brand.name', () =>
3446
+ ctx.slots.register({ name: 'sidebar.brand.name' }, BrandName),
3447
+ )
3448
+ }
3449
+
3450
+ exports.apply = apply
3451
+ exports.inject = inject
3452
+ return module.exports
3453
+ },
3454
+ })