tensorgrid-ui 1.1.5 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/auth-bridge.js +46 -10
- package/lib/client.js +89 -13
- package/lib/http-runtime.js +146 -15
- package/lib/index.js +67 -39
- package/package.json +1 -1
package/lib/auth-bridge.js
CHANGED
|
@@ -120,6 +120,14 @@ export function begin(authorization, key, method) {
|
|
|
120
120
|
if (current.notices.length > MAX_NOTICES) current.notices.shift()
|
|
121
121
|
},
|
|
122
122
|
prompt(prompt) {
|
|
123
|
+
// Поток задал новый вопрос, не дождавшись ответа на прежний. Прежнее
|
|
124
|
+
// обещание нужно закрыть, иначе оно повисает навсегда, а вместе с ним
|
|
125
|
+
// и вся попытка: ключ занят, отвечать уже некому.
|
|
126
|
+
if (current.resolvePrompt !== null) {
|
|
127
|
+
const orphan = current.resolvePrompt
|
|
128
|
+
current.resolvePrompt = null
|
|
129
|
+
orphan('')
|
|
130
|
+
}
|
|
123
131
|
current.prompt = prompt
|
|
124
132
|
return new Promise((resolve) => { current.resolvePrompt = resolve })
|
|
125
133
|
},
|
|
@@ -143,31 +151,59 @@ export function begin(authorization, key, method) {
|
|
|
143
151
|
return { attempt: view() }
|
|
144
152
|
}
|
|
145
153
|
|
|
146
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* Отвечает на вопрос, который задал поток.
|
|
156
|
+
*
|
|
157
|
+
* Значение приводится к строке ДО того, как вопрос снят. Обратный порядок
|
|
158
|
+
* подвешивал разговор: тело вида `{"value":{"toString":null}}` роняло
|
|
159
|
+
* приведение уже после очистки, обещание оставалось без ответа, а функция
|
|
160
|
+
* его разрешения терялась — попытка числилась активной, но вопрос исчезал.
|
|
161
|
+
*/
|
|
147
162
|
export function answer(value) {
|
|
148
163
|
if (attempt === null || attempt.resolvePrompt === null) {
|
|
149
164
|
return { problem: 'поток ничего не спрашивает', attempt: view() }
|
|
150
165
|
}
|
|
166
|
+
|
|
167
|
+
let text
|
|
168
|
+
try {
|
|
169
|
+
text = typeof value === 'string' ? value : String(value ?? '')
|
|
170
|
+
} catch {
|
|
171
|
+
return { problem: 'ответ не приводится к тексту', attempt: view() }
|
|
172
|
+
}
|
|
173
|
+
|
|
151
174
|
const resolve = attempt.resolvePrompt
|
|
152
175
|
attempt.resolvePrompt = null
|
|
153
176
|
attempt.prompt = null
|
|
154
|
-
resolve(
|
|
177
|
+
resolve(text)
|
|
155
178
|
return { attempt: view() }
|
|
156
179
|
}
|
|
157
180
|
|
|
158
181
|
/**
|
|
159
|
-
* Снимает попытку. Нужно не только по желанию пользователя: стойка
|
|
160
|
-
* ключ занятым, и осиротевшая попытка иначе блокирует повторный вход
|
|
161
|
-
* перезапуска.
|
|
182
|
+
* Снимает текущую попытку. Нужно не только по желанию пользователя: стойка
|
|
183
|
+
* держит ключ занятым, и осиротевшая попытка иначе блокирует повторный вход
|
|
184
|
+
* до перезапуска.
|
|
185
|
+
*
|
|
186
|
+
* Ключ из запроса НЕ принимается: снимать можно только то, что идёт сейчас.
|
|
187
|
+
* Иначе вторая вкладка — или посторонний запрос — снимала бы чужую попытку
|
|
188
|
+
* по произвольному имени.
|
|
162
189
|
*/
|
|
163
190
|
export function cancel(authorization, key) {
|
|
164
|
-
|
|
165
|
-
|
|
191
|
+
if (attempt === null) return { problem: 'нечего отменять', attempt: view() }
|
|
192
|
+
|
|
193
|
+
const target = attempt.key
|
|
194
|
+
if (typeof key === 'string' && key !== '' && key !== target) {
|
|
195
|
+
return { problem: 'идёт попытка для другого провайдера', attempt: view() }
|
|
196
|
+
}
|
|
197
|
+
|
|
166
198
|
authorization.cancel(target)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
199
|
+
attempt.problem = attempt.problem ?? 'отменено'
|
|
200
|
+
attempt.prompt = null
|
|
201
|
+
if (attempt.resolvePrompt !== null) {
|
|
202
|
+
// Ожидающее обещание закрываем сами: иначе поток останется висеть на
|
|
203
|
+
// вопросе, на который уже никто не ответит.
|
|
204
|
+
const pending = attempt.resolvePrompt
|
|
170
205
|
attempt.resolvePrompt = null
|
|
206
|
+
pending('')
|
|
171
207
|
}
|
|
172
208
|
return { attempt: view() }
|
|
173
209
|
}
|
package/lib/client.js
CHANGED
|
@@ -177,7 +177,9 @@ window.__ModuleLoader__.load({
|
|
|
177
177
|
'intensity.vivid': 'Vivid',
|
|
178
178
|
'accent.title': 'Obsidian / Ion accent',
|
|
179
179
|
'accent.hint': 'Leading tone of the interface and the glow',
|
|
180
|
+
'auth.nav': 'Subscriptions',
|
|
180
181
|
'auth.title': 'Provider sign-in',
|
|
182
|
+
'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.',
|
|
181
183
|
'auth.hint': 'Sign in with a subscription instead of an API key. The account stays on this computer.',
|
|
182
184
|
'auth.signIn': 'Sign in',
|
|
183
185
|
'auth.again': 'Sign in again',
|
|
@@ -222,7 +224,9 @@ window.__ModuleLoader__.load({
|
|
|
222
224
|
'intensity.vivid': '强烈',
|
|
223
225
|
'accent.title': 'Obsidian / Ion 主色',
|
|
224
226
|
'accent.hint': '界面与光晕的主色调',
|
|
227
|
+
'auth.nav': '订阅',
|
|
225
228
|
'auth.title': '登录服务商',
|
|
229
|
+
'auth.others': '这里只列出提供订阅登录的服务商。其余(包括 Z.AI)只提供 API 密钥,请在「模型」中配置。',
|
|
226
230
|
'auth.hint': '使用订阅登录,而非 API 密钥。账号仅保存在本机。',
|
|
227
231
|
'auth.signIn': '登录',
|
|
228
232
|
'auth.again': '重新登录',
|
|
@@ -1564,7 +1568,9 @@ window.__ModuleLoader__.load({
|
|
|
1564
1568
|
'intensity.vivid': 'Ярко',
|
|
1565
1569
|
'accent.title': 'Акцент Obsidian / Ion',
|
|
1566
1570
|
'accent.hint': 'Ведущий тон интерфейса и свечения',
|
|
1571
|
+
'auth.nav': 'Подписки',
|
|
1567
1572
|
'auth.title': 'Вход к провайдерам',
|
|
1573
|
+
'auth.others': 'Здесь только провайдеры, у которых есть вход по подписке. Остальные — в том числе Z.AI — предлагают лишь ключ API; их место в разделе «Модели».',
|
|
1568
1574
|
'auth.hint': 'Войти по подписке вместо ключа API. Учётная запись остаётся на этом компьютере.',
|
|
1569
1575
|
'auth.signIn': 'Войти',
|
|
1570
1576
|
'auth.again': 'Войти заново',
|
|
@@ -1710,7 +1716,7 @@ window.__ModuleLoader__.load({
|
|
|
1710
1716
|
// поэтому строка остаётся согласованной при любой палитре.
|
|
1711
1717
|
'.dsx-setting{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;}',
|
|
1712
1718
|
'.dsx-setting__title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;}',
|
|
1713
|
-
'.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;}',
|
|
1719
|
+
'.dsx-subs{display:flex;flex-direction:column;gap:14px;padding:4px 0}.dsx-subs__head{display:flex;flex-direction:column;gap:4px}.dsx-subs__title{font-weight:600;font-size:15px;color:var(--dsw-alias-label-primary)}.dsx-subs__note{padding-top:6px;border-top:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}.dsx-setting--stack{flex-direction:column;align-items:stretch;gap:10px}.dsx-auth__list{display:flex;flex-direction:column;gap:6px}.dsx-auth__row{display:flex;align-items:center;justify-content:space-between;gap:12px}.dsx-auth__name{color:var(--dsw-alias-label-primary)}.dsx-auth__badge{margin-left:8px;padding:1px 6px;border-radius:999px;font-size:11px;color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-border-l2)}.dsx-auth__panel{display:flex;flex-direction:column;gap:8px;padding:10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1)}.dsx-auth__notice,.dsx-auth__prompt{display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-label-secondary)}.dsx-auth__link{color:var(--dsw-alias-brand-primary);word-break:break-all}.dsx-auth__code{font-family:ui-monospace,monospace;letter-spacing:.08em;color:var(--dsw-alias-label-primary)}.dsx-auth__input{flex:1;min-width:0;padding:4px 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dsx-setting__hint--strong{color:var(--dsw-alias-label-primary)}.dsx-setting__hint--warn{color:var(--dsw-alias-state-warn-primary)}.dsx-setting__hint{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:2px;}',
|
|
1714
1720
|
'.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;}',
|
|
1715
1721
|
'.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;}',
|
|
1716
1722
|
'.dsx-seg:hover{color:var(--dsw-alias-label-primary);}',
|
|
@@ -1941,10 +1947,41 @@ window.__ModuleLoader__.load({
|
|
|
1941
1947
|
// браузер начинает попытку и затем опрашивает состояние.
|
|
1942
1948
|
const AUTH_PATH = '/api/tensorgrid.auth'
|
|
1943
1949
|
|
|
1950
|
+
/**
|
|
1951
|
+
* Пропустить только настоящий веб-адрес.
|
|
1952
|
+
*
|
|
1953
|
+
* Ссылка приходит от стойки входа, то есть в конечном счёте от внешнего
|
|
1954
|
+
* провайдера. Подставлять её в `href` как есть нельзя: схема
|
|
1955
|
+
* `javascript:` превратила бы «Открыть страницу входа» в запуск чужого
|
|
1956
|
+
* кода прямо в приложении. Разрешаем http и https, остальное прячем.
|
|
1957
|
+
*
|
|
1958
|
+
* @param value - адрес из уведомления.
|
|
1959
|
+
* @returns адрес, пригодный для ссылки, или null.
|
|
1960
|
+
*/
|
|
1961
|
+
function safeUrl(value) {
|
|
1962
|
+
if (typeof value !== 'string' || value === '') return null
|
|
1963
|
+
try {
|
|
1964
|
+
const parsed = new URL(value)
|
|
1965
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? value : null
|
|
1966
|
+
} catch {
|
|
1967
|
+
return null
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1944
1971
|
function AuthRow({ t }) {
|
|
1945
1972
|
const [state, setState] = React.useState(null)
|
|
1973
|
+
// Занятость от ДЕЙСТВИЯ пользователя и фоновый опрос — разные вещи.
|
|
1974
|
+
//
|
|
1975
|
+
// Раньше опрос тоже поднимал этот признак, а поле ввода и кнопки на
|
|
1976
|
+
// нём завязаны: каждые две секунды поле гасло прямо под руками. Это
|
|
1977
|
+
// ломало ровно тот случай, ради которого опрос и заведён, — ввод кода.
|
|
1946
1978
|
const [busy, setBusy] = React.useState(false)
|
|
1947
1979
|
const [draft, setDraft] = React.useState('')
|
|
1980
|
+
// Ответы приходят не в том порядке, в каком ушли: медленный опрос
|
|
1981
|
+
// может вернуться после быстрого ответа и откатить состояние назад.
|
|
1982
|
+
// Применяем только тот ответ, который свежее уже показанного.
|
|
1983
|
+
const seq = React.useRef(0)
|
|
1984
|
+
const applied = React.useRef(0)
|
|
1948
1985
|
|
|
1949
1986
|
// Функция перевода приходит от рантайма НОВОЙ при каждом рендере —
|
|
1950
1987
|
// в поставочном коде это обычная стрелка, не мемоизированная. Если
|
|
@@ -1985,14 +2022,35 @@ window.__ModuleLoader__.load({
|
|
|
1985
2022
|
return response.json()
|
|
1986
2023
|
}, [])
|
|
1987
2024
|
|
|
1988
|
-
|
|
1989
|
-
|
|
2025
|
+
/**
|
|
2026
|
+
* Спросить Host и показать ответ.
|
|
2027
|
+
*
|
|
2028
|
+
* @param body - тело действия; без него идёт GET за списком.
|
|
2029
|
+
* @param background - фоновый опрос: не блокирует поля и не стирает
|
|
2030
|
+
* показанное состояние, если сеть отвалилась. Пользователь в этот
|
|
2031
|
+
* момент вводит код, и мигание под руками недопустимо.
|
|
2032
|
+
*/
|
|
2033
|
+
const refresh = React.useCallback(async (body, background) => {
|
|
2034
|
+
const ticket = ++seq.current
|
|
2035
|
+
if (!background) setBusy(true)
|
|
1990
2036
|
try {
|
|
1991
|
-
|
|
2037
|
+
const next = await send(body)
|
|
2038
|
+
// Обгонять уже применённый ответ нельзя: иначе запоздавший опрос
|
|
2039
|
+
// вернёт вопрос, на который только что ответили.
|
|
2040
|
+
if (ticket > applied.current) {
|
|
2041
|
+
applied.current = ticket
|
|
2042
|
+
setState(next)
|
|
2043
|
+
}
|
|
1992
2044
|
} catch (error) {
|
|
1993
|
-
|
|
2045
|
+
// Сбой фонового опроса состояние НЕ трогает. Раньше он затирал его
|
|
2046
|
+
// объектом без попытки, из-за чего опрос останавливался навсегда:
|
|
2047
|
+
// интернет возвращался, а строка молчала до перезагрузки.
|
|
2048
|
+
if (!background && ticket > applied.current) {
|
|
2049
|
+
applied.current = ticket
|
|
2050
|
+
setState({ available: true, problem: String(error && error.message ? error.message : error) })
|
|
2051
|
+
}
|
|
1994
2052
|
} finally {
|
|
1995
|
-
setBusy(false)
|
|
2053
|
+
if (!background) setBusy(false)
|
|
1996
2054
|
}
|
|
1997
2055
|
}, [send])
|
|
1998
2056
|
|
|
@@ -2003,7 +2061,7 @@ window.__ModuleLoader__.load({
|
|
|
2003
2061
|
const active = state !== null && state.attempt !== undefined && state.attempt !== null && state.attempt.active === true
|
|
2004
2062
|
React.useEffect(() => {
|
|
2005
2063
|
if (!active) return undefined
|
|
2006
|
-
const id = setInterval(() => { refresh({ action: 'poll' }) }, 2000)
|
|
2064
|
+
const id = setInterval(() => { refresh({ action: 'poll' }, true) }, 2000)
|
|
2007
2065
|
return () => clearInterval(id)
|
|
2008
2066
|
}, [active, refresh])
|
|
2009
2067
|
|
|
@@ -2032,9 +2090,9 @@ window.__ModuleLoader__.load({
|
|
|
2032
2090
|
className: 'dsx-auth__notice',
|
|
2033
2091
|
children: [
|
|
2034
2092
|
jsx('div', { children: notice.message }),
|
|
2035
|
-
notice.url === null ? null : jsx('a', {
|
|
2093
|
+
safeUrl(notice.url) === null ? null : jsx('a', {
|
|
2036
2094
|
className: 'dsx-auth__link',
|
|
2037
|
-
href: notice.url,
|
|
2095
|
+
href: safeUrl(notice.url),
|
|
2038
2096
|
target: '_blank',
|
|
2039
2097
|
rel: 'noreferrer',
|
|
2040
2098
|
children: t('auth.openLink'),
|
|
@@ -2098,11 +2156,12 @@ window.__ModuleLoader__.load({
|
|
|
2098
2156
|
}
|
|
2099
2157
|
|
|
2100
2158
|
return jsxs('div', {
|
|
2101
|
-
className: 'dsx-
|
|
2159
|
+
className: 'dsx-subs',
|
|
2102
2160
|
children: [
|
|
2103
2161
|
jsxs('div', {
|
|
2162
|
+
className: 'dsx-subs__head',
|
|
2104
2163
|
children: [
|
|
2105
|
-
jsx('div', { className: 'dsx-
|
|
2164
|
+
jsx('div', { className: 'dsx-subs__title', children: t('auth.title') }),
|
|
2106
2165
|
jsx('div', { className: 'dsx-setting__hint', children: t('auth.hint') }),
|
|
2107
2166
|
],
|
|
2108
2167
|
}),
|
|
@@ -2149,6 +2208,11 @@ window.__ModuleLoader__.load({
|
|
|
2149
2208
|
}),
|
|
2150
2209
|
})
|
|
2151
2210
|
: null,
|
|
2211
|
+
// Здесь только провайдеры, у которых есть вход по подписке. У
|
|
2212
|
+
// остальных — включая Z.AI — поставщик предлагает единственный
|
|
2213
|
+
// способ, ключ API, и его место в разделе «Модели». Без этой
|
|
2214
|
+
// строки отсутствие знакомого имени выглядит как недоработка.
|
|
2215
|
+
jsx('div', { className: 'dsx-subs__note', children: t('auth.others') }),
|
|
2152
2216
|
],
|
|
2153
2217
|
})
|
|
2154
2218
|
}
|
|
@@ -2361,9 +2425,21 @@ window.__ModuleLoader__.load({
|
|
|
2361
2425
|
),
|
|
2362
2426
|
)
|
|
2363
2427
|
|
|
2364
|
-
|
|
2428
|
+
// Подписки — собственный раздел настроек, а не строка среди прочих.
|
|
2429
|
+
//
|
|
2430
|
+
// `settings.section` — аддитивный список: своя запись добавляет свою
|
|
2431
|
+
// вкладку и ничего не замещает. Порядок 12 ставит её между «Моделями»
|
|
2432
|
+
// (10) и «Плагинами» (15): вход к провайдерам — сосед моделей, а не
|
|
2433
|
+
// оформления.
|
|
2434
|
+
ctx.slots.inject('settings.section', () =>
|
|
2365
2435
|
ctx.slots.register(
|
|
2366
|
-
{
|
|
2436
|
+
{
|
|
2437
|
+
name: 'settings.section',
|
|
2438
|
+
id: 'tensorgrid-subscriptions',
|
|
2439
|
+
order: 12,
|
|
2440
|
+
locale: LOCALE_NS,
|
|
2441
|
+
label: () => ctx.locale.translate(LOCALE_NS, 'auth.nav'),
|
|
2442
|
+
},
|
|
2367
2443
|
AuthRow,
|
|
2368
2444
|
),
|
|
2369
2445
|
)
|
package/lib/http-runtime.js
CHANGED
|
@@ -27,30 +27,55 @@ function reply(res, code, value) {
|
|
|
27
27
|
* Принять маленькое JSON-тело.
|
|
28
28
|
*
|
|
29
29
|
* Здесь всегда только действие, ключ провайдера или ответ на вопрос.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
*
|
|
31
|
+
* Куски накапливаются БУФЕРАМИ, а не строкой. Склейка строкой вызывает
|
|
32
|
+
* `toString()` на каждом куске, и символ UTF-8, разорванный по границе
|
|
33
|
+
* сетевого пакета, превращается в «замену» — ответ с кириллицей изредка
|
|
34
|
+
* приезжал бы битым. Предел считается в БАЙТАХ по той же причине: длина
|
|
35
|
+
* строки и размер тела — разные величины.
|
|
36
|
+
*
|
|
37
|
+
* Тайм-аут обязателен: клиент, открывший запрос и не закрывший его, иначе
|
|
38
|
+
* держал бы обработчик и сокет бесконечно.
|
|
32
39
|
*/
|
|
33
|
-
function readJsonBody(req) {
|
|
40
|
+
function readJsonBody(req, limitBytes = 16384, timeoutMs = 15000) {
|
|
34
41
|
return new Promise((resolve) => {
|
|
35
|
-
|
|
36
|
-
let
|
|
42
|
+
const chunks = []
|
|
43
|
+
let size = 0
|
|
44
|
+
let settled = false
|
|
45
|
+
|
|
46
|
+
const finish = (value) => {
|
|
47
|
+
if (settled) return
|
|
48
|
+
settled = true
|
|
49
|
+
clearTimeout(timer)
|
|
50
|
+
resolve(value)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
req.destroy()
|
|
55
|
+
finish(null)
|
|
56
|
+
}, timeoutMs)
|
|
57
|
+
|
|
37
58
|
req.on('data', (chunk) => {
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
59
|
+
if (settled) return
|
|
60
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
61
|
+
size += buffer.length
|
|
62
|
+
if (size > limitBytes) {
|
|
63
|
+
req.destroy()
|
|
64
|
+
finish(null)
|
|
65
|
+
return
|
|
43
66
|
}
|
|
67
|
+
chunks.push(buffer)
|
|
44
68
|
})
|
|
45
69
|
req.on('end', () => {
|
|
46
|
-
if (
|
|
70
|
+
if (settled) return
|
|
71
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
47
72
|
try {
|
|
48
|
-
|
|
73
|
+
finish(JSON.parse(text === '' ? '{}' : text))
|
|
49
74
|
} catch {
|
|
50
|
-
|
|
75
|
+
finish(null)
|
|
51
76
|
}
|
|
52
77
|
})
|
|
53
|
-
req.on('error', () =>
|
|
78
|
+
req.on('error', () => finish(null))
|
|
54
79
|
})
|
|
55
80
|
}
|
|
56
81
|
|
|
@@ -59,6 +84,95 @@ function acceptsJson(req) {
|
|
|
59
84
|
return String(req.headers['content-type'] ?? '').includes('application/json')
|
|
60
85
|
}
|
|
61
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Запрос пришёл со своей же страницы?
|
|
89
|
+
*
|
|
90
|
+
* Межсайтовый POST с нашим типом содержимого браузер и так не пустит без
|
|
91
|
+
* предварительной проверки CORS, которую мы не проходим. Но держать защиту
|
|
92
|
+
* только на чужом поведении неправильно: явная сверка источника дешева и
|
|
93
|
+
* делает намерение видимым в коде.
|
|
94
|
+
*
|
|
95
|
+
* Отсутствие заголовка допускается намеренно: его не шлют не-браузерные
|
|
96
|
+
* клиенты, а локальный процесс и без того читает файл с учётными данными
|
|
97
|
+
* напрямую — запрет ему ничего бы не добавил.
|
|
98
|
+
*/
|
|
99
|
+
function sameOrigin(req) {
|
|
100
|
+
const origin = req.headers.origin
|
|
101
|
+
if (typeof origin !== 'string' || origin === '') return true
|
|
102
|
+
const host = String(req.headers.host ?? '')
|
|
103
|
+
if (host === '') return false
|
|
104
|
+
try {
|
|
105
|
+
return new URL(origin).host === host
|
|
106
|
+
} catch {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Найти поставочный файл фронтенда.
|
|
113
|
+
*
|
|
114
|
+
* Профили делят общий `node_modules`, поэтому от нашего кода до него —
|
|
115
|
+
* подъём на несколько уровней. Ищем перебором вверх, а не по заданному
|
|
116
|
+
* пути: глубина вложения зависит от того, как pnpm разложил пакеты.
|
|
117
|
+
*
|
|
118
|
+
* @param fromDir - каталог, от которого подниматься.
|
|
119
|
+
* @param relative - путь внутри `node_modules`.
|
|
120
|
+
* @returns полный путь или null, если не нашли.
|
|
121
|
+
*/
|
|
122
|
+
async function findShipped(fromDir, relative) {
|
|
123
|
+
const { existsSync } = await import('node:fs')
|
|
124
|
+
const { dirname, join } = await import('node:path')
|
|
125
|
+
let dir = fromDir
|
|
126
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
127
|
+
const candidate = join(dir, 'node_modules', relative)
|
|
128
|
+
if (existsSync(candidate)) return candidate
|
|
129
|
+
const parent = dirname(dir)
|
|
130
|
+
if (parent === dir) break
|
|
131
|
+
dir = parent
|
|
132
|
+
}
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Отдать веб-манифест: поставочный, но с нашим именем и иконкой.
|
|
138
|
+
*
|
|
139
|
+
* Полная подмена была бы заморозкой чужого файла на сегодняшней версии:
|
|
140
|
+
* dsh добавит поле — ярлыки, снимки экрана, обработчики ссылок, — а мы
|
|
141
|
+
* продолжим отдавать форму без него, и установленное приложение молча
|
|
142
|
+
* потеряет возможности.
|
|
143
|
+
*
|
|
144
|
+
* Поэтому поставочный манифест читается и ДОПОЛНЯЕТСЯ. Своими остаются
|
|
145
|
+
* ровно те поля, ради которых всё затевалось: имя и иконка. Всё прочее —
|
|
146
|
+
* `id`, `start_url`, `scope`, `display` и любое будущее — проходит как есть.
|
|
147
|
+
*
|
|
148
|
+
* Если файл не найден, отдаётся наш запасной: лучше своё имя без чужих
|
|
149
|
+
* полей, чем отказ.
|
|
150
|
+
*
|
|
151
|
+
* @param baseDir - каталог нашего модуля, от него ищется поставочный файл.
|
|
152
|
+
* @param overrides - наши поля.
|
|
153
|
+
* @param fallback - что отдать, если поставочного файла нет.
|
|
154
|
+
*/
|
|
155
|
+
export async function handleManifest(req, res, baseDir, overrides, fallback) {
|
|
156
|
+
let shipped = null
|
|
157
|
+
try {
|
|
158
|
+
const path = await findShipped(baseDir, '@deepseek-ai/dsh-web-frontend/dist/manifest.webmanifest')
|
|
159
|
+
if (path !== null) {
|
|
160
|
+
const { readFile } = await import('node:fs/promises')
|
|
161
|
+
shipped = JSON.parse(await readFile(path, 'utf8'))
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// Испорченный или недоступный файл — не повод отказывать.
|
|
165
|
+
shipped = null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const value = shipped === null ? fallback : { ...shipped, ...overrides }
|
|
169
|
+
res.writeHead(200, {
|
|
170
|
+
'content-type': 'application/manifest+json; charset=utf-8',
|
|
171
|
+
'cache-control': 'no-cache',
|
|
172
|
+
})
|
|
173
|
+
res.end(JSON.stringify(value, null, 2))
|
|
174
|
+
}
|
|
175
|
+
|
|
62
176
|
/** Обновить кеш состояния версий. Вызывается и фоновым таймером. */
|
|
63
177
|
export async function refreshUpdate(state, loadModule) {
|
|
64
178
|
const module = await loadModule('./update-check.js')
|
|
@@ -84,6 +198,10 @@ export async function handleUpdate(req, res, state, loadModule) {
|
|
|
84
198
|
reply(res, 415, { ok: false, problem: 'ожидается content-type: application/json' })
|
|
85
199
|
return
|
|
86
200
|
}
|
|
201
|
+
if (!sameOrigin(req)) {
|
|
202
|
+
reply(res, 403, { ok: false, problem: 'запрос с чужой страницы' })
|
|
203
|
+
return
|
|
204
|
+
}
|
|
87
205
|
const module = await loadModule('./update-check.js')
|
|
88
206
|
const result = await module.applyUpdate()
|
|
89
207
|
state.cached = result.status ?? state.cached
|
|
@@ -125,6 +243,11 @@ export async function handleAuth(req, res, services, loadModule) {
|
|
|
125
243
|
return
|
|
126
244
|
}
|
|
127
245
|
|
|
246
|
+
if (!sameOrigin(req)) {
|
|
247
|
+
reply(res, 403, { problem: 'запрос с чужой страницы' })
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
128
251
|
const body = await readJsonBody(req)
|
|
129
252
|
if (body === null) {
|
|
130
253
|
reply(res, 400, { problem: 'тело запроса не разобрано' })
|
|
@@ -145,7 +268,15 @@ export async function handleAuth(req, res, services, loadModule) {
|
|
|
145
268
|
return
|
|
146
269
|
}
|
|
147
270
|
|
|
148
|
-
|
|
271
|
+
// Список провайдеров прикладывается к КАЖДОМУ ответу, а не только к GET.
|
|
272
|
+
//
|
|
273
|
+
// Браузер заменяет состояние ответом целиком, и ответ без списка обнулял
|
|
274
|
+
// его: после первого же нажатия «Войти» кнопки провайдеров исчезали и не
|
|
275
|
+
// возвращались до перезагрузки страницы. Отметка «подключено» вдобавок
|
|
276
|
+
// меняется как раз по итогу входа — значит список и должен приходить
|
|
277
|
+
// вместе с ним.
|
|
278
|
+
const listing = await bridge.listProviders(services.authorization, services.credentials)
|
|
279
|
+
reply(res, 200, { available: true, ...listing, ...result })
|
|
149
280
|
} catch (error) {
|
|
150
281
|
reply(res, 500, { problem: String(error?.message ?? error) })
|
|
151
282
|
}
|
package/lib/index.js
CHANGED
|
@@ -69,33 +69,42 @@ const APP_ICON = (() => {
|
|
|
69
69
|
const APP_ICON_PATH = '/tensorgrid-icon.svg'
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
*
|
|
73
|
-
* приложения — то, что видно в панели задач, меню «Пуск» и заголовке окна.
|
|
74
|
-
* Поставочный называет продукт чужим именем, поэтому манифест свой.
|
|
72
|
+
* Наши поля веб-манифеста — и только они.
|
|
75
73
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* Манифест задаёт не только иконку, но и ИМЯ установленного приложения: то,
|
|
75
|
+
* что видно в панели задач, меню «Пуск» и заголовке окна. Поставочный
|
|
76
|
+
* называет продукт чужим именем, поэтому эти поля мы переопределяем.
|
|
77
|
+
*
|
|
78
|
+
* Остальное НЕ трогаем. Поставочный манифест читается из файла фронтенда и
|
|
79
|
+
* дополняется этими полями, так что `id`, `start_url`, `scope`, `display` и
|
|
80
|
+
* всё, что dsh добавит завтра, проходят как есть. Полная подмена заморозила
|
|
81
|
+
* бы чужой файл на сегодняшней версии.
|
|
78
82
|
*/
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
83
|
+
const MANIFEST_OVERRIDES = {
|
|
84
|
+
name: 'TENSOR GRID',
|
|
85
|
+
short_name: 'TENSOR GRID',
|
|
86
|
+
background_color: '#0B0D12',
|
|
87
|
+
theme_color: '#0B0D12',
|
|
88
|
+
icons: [
|
|
89
|
+
{ src: APP_ICON_PATH, sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
|
|
90
|
+
// maskable разрешает системе обрезать иконку под свою форму. Знак
|
|
91
|
+
// отступает от краёв на 16%, поэтому обрезка его не заденет.
|
|
92
|
+
{ src: APP_ICON_PATH, sizes: 'any', type: 'image/svg+xml', purpose: 'maskable' },
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Запасной манифест — на случай, если поставочного файла не нашлось.
|
|
98
|
+
* Повторяет поля, которые поставка задавала на момент проверки: лучше своё
|
|
99
|
+
* имя без чужих новшеств, чем отказ отдать манифест вовсе.
|
|
100
|
+
*/
|
|
101
|
+
const MANIFEST_FALLBACK = {
|
|
102
|
+
id: '/',
|
|
103
|
+
start_url: '/',
|
|
104
|
+
scope: '/',
|
|
105
|
+
display: 'fullscreen',
|
|
106
|
+
...MANIFEST_OVERRIDES,
|
|
107
|
+
}
|
|
99
108
|
|
|
100
109
|
export const name = 'tensorgrid-ui'
|
|
101
110
|
|
|
@@ -135,20 +144,6 @@ export function apply(ctx) {
|
|
|
135
144
|
}),
|
|
136
145
|
)
|
|
137
146
|
|
|
138
|
-
web.effect(() =>
|
|
139
|
-
web.webServer.register({
|
|
140
|
-
kind: 'exact',
|
|
141
|
-
path: '/manifest.webmanifest',
|
|
142
|
-
handler(_req, res) {
|
|
143
|
-
res.writeHead(200, {
|
|
144
|
-
'content-type': 'application/manifest+json; charset=utf-8',
|
|
145
|
-
'cache-control': 'no-cache',
|
|
146
|
-
})
|
|
147
|
-
res.end(MANIFEST)
|
|
148
|
-
},
|
|
149
|
-
}),
|
|
150
|
-
)
|
|
151
|
-
|
|
152
147
|
// Стабильная оболочка маршрутов. Вся бизнес-логика живёт в
|
|
153
148
|
// `http-runtime.js`, который перечитывается по времени правки файла.
|
|
154
149
|
// Поэтому исправление обработчика доезжает со следующим запросом — без
|
|
@@ -168,6 +163,19 @@ export function apply(ctx) {
|
|
|
168
163
|
return import(`${path.href}${version}`)
|
|
169
164
|
}
|
|
170
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Превратить file-адрес в путь файловой системы без импорта модуля.
|
|
168
|
+
*
|
|
169
|
+
* `node:url` импортировать здесь нельзя: строка композиции грузится
|
|
170
|
+
* раньше, чем о пакете что-либо известно, и host-половина обязана
|
|
171
|
+
* оставаться без импортов. На Windows адрес выглядит как
|
|
172
|
+
* `file:///C:/…`, поэтому ведущая косая убирается.
|
|
173
|
+
*/
|
|
174
|
+
function fileURLToPathSafe(url) {
|
|
175
|
+
const decoded = decodeURIComponent(String(url.pathname))
|
|
176
|
+
return /^\/[A-Za-z]:/.test(decoded) ? decoded.slice(1) : decoded
|
|
177
|
+
}
|
|
178
|
+
|
|
171
179
|
/** Минимальный запасной ответ, если горячий модуль не загрузился вовсе. */
|
|
172
180
|
function fail(res, error) {
|
|
173
181
|
const body = JSON.stringify({ problem: `обработчик не загрузился: ${String(error?.message ?? error)}` })
|
|
@@ -212,6 +220,26 @@ export function apply(ctx) {
|
|
|
212
220
|
})
|
|
213
221
|
})
|
|
214
222
|
|
|
223
|
+
// Манифест собирается в горячем модуле: он ЧИТАЕТ поставочный файл и
|
|
224
|
+
// дополняет его нашими полями, а значит зависит от чужой формы, которая
|
|
225
|
+
// может поменяться. Такую логику правильнее держать там, где её можно
|
|
226
|
+
// поправить без перезапуска.
|
|
227
|
+
web.effect(() =>
|
|
228
|
+
web.webServer.register({
|
|
229
|
+
kind: 'exact',
|
|
230
|
+
path: '/manifest.webmanifest',
|
|
231
|
+
async handler(req, res) {
|
|
232
|
+
try {
|
|
233
|
+
const runtime = await loadModule('./http-runtime.js')
|
|
234
|
+
const here = new URL('.', import.meta.url)
|
|
235
|
+
await runtime.handleManifest(req, res, fileURLToPathSafe(here), MANIFEST_OVERRIDES, MANIFEST_FALLBACK)
|
|
236
|
+
} catch (error) {
|
|
237
|
+
fail(res, error)
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
)
|
|
242
|
+
|
|
215
243
|
web.effect(() =>
|
|
216
244
|
web.webServer.register({
|
|
217
245
|
kind: 'exact',
|
package/package.json
CHANGED