dsh-magpie-connector 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/lib/client/SettingsPage.d.ts +0 -10
- package/lib/client/SettingsPage.js +93 -65
- package/lib/client/SpeedSelect.js +7 -5
- package/lib/client/UsageCard.js +10 -9
- package/lib/client/index.d.ts +9 -0
- package/lib/client/index.js +4 -1
- package/lib/client/latency.d.ts +3 -1
- package/lib/client/latency.js +2 -0
- package/lib/client/locales.d.ts +169 -0
- package/lib/client/locales.js +305 -0
- package/lib/client/view-model.d.ts +0 -5
- package/lib/client/view-model.js +7 -1
- package/lib/client.js +481 -116
- package/lib/host/latency.d.ts +4 -9
- package/lib/host/latency.js +22 -11
- package/lib/host/routes.js +1 -1
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 — 2026-10-31
|
|
4
|
+
|
|
5
|
+
- 设置页与速度选择器支持界面语言:新增 `settings.magpie` 的 zh/en 字典(`src/client/locales.ts`),通过 `ctx.locale` 注册并在 `apply()` 绑定;文案不再硬编码中文,跟随 DSH 界面语言(英文界面下为英文)。未绑定翻译器时(单测、无 locale 服务的宿主)仍回退到中文,行为不变。
|
|
6
|
+
- `dsh.client.inject` 与 peer 依赖补 `@deepseek-ai/dsh-client-locale`。
|
|
7
|
+
- 新增字典键集合一致性与中英切换测试(`test/client-locales.test.ts`)。
|
|
8
|
+
|
|
9
|
+
## 0.4.2
|
|
10
|
+
|
|
11
|
+
- **Only the checked line is re-measured.** 0.4.1 refreshed the whole latency pass after a line check, so every saved row's reading changed even though only one line was checked. `GET /api/dsh-magpie-connector/latency` now accepts `?line=<id>`: the host probes just that saved line and returns only its reading, and the settings page merges it into that row while every other row keeps its previous value. Unknown or unsaved ids measure nothing. The full pass on panel open and after a successful save is unchanged, and there is still no polling.
|
|
12
|
+
|
|
3
13
|
## 0.4.1
|
|
4
14
|
|
|
5
15
|
- **Check refreshes latency.** A completed line check on a **saved** line now also re-runs the settings-page latency measurement (one `GET /api/dsh-magpie-connector/latency` pass), so the signal reading next to that line reflects the same moment as the check instead of the last panel open or save. Unsaved drafts trigger nothing (they have no latency row), the host measurement stays single-flight with its 600 ms deadline, and there is still no polling.
|
package/README.md
CHANGED
|
@@ -70,6 +70,7 @@ Need another method — custom headers, mTLS, another zero-trust gateway? **Issu
|
|
|
70
70
|
- **The transport is deliberately narrow.** Host requests go through a private `undici` dispatcher straight to the configured gateway, never through a global proxy dispatcher. Redirects are rejected; only `GET /models`, `POST /responses`, `POST /chat/completions`, `GET /magpie/quotas/cards` and `POST /magpie/quotas/refresh` are allowed; Magpie GUI `/api/*` management endpoints are never called. Provider egress proxies belong in Magpie.
|
|
71
71
|
- **Not supported:** `stop` sequences fail before any network access (`MAGPIE_STOP_UNSUPPORTED`) because Magpie cannot preserve them across every upstream translation. Usage cards and Codex reset credits are display-only; the plugin never calls account-management or reset endpoints.
|
|
72
72
|
- **Secrets stay out of responses and logs.** Gateway-reflected text is redacted with the current key before it can reach the settings API or logs. Files are written `0600`; the settings API reports only `configured` / `writable`.
|
|
73
|
+
- **The interface follows the DSH UI language.** Since 0.5.0 the settings page and the speed menu ship English and Chinese dictionaries and render in the active DSH language (`ctx.locale`); before 0.5.0 the copy was Chinese-only. A host without a locale service (or a unit test) falls back to the Chinese reference set, so stored settings and behaviour are untouched.
|
|
73
74
|
|
|
74
75
|
### Code map
|
|
75
76
|
|
package/README.zh.md
CHANGED
|
@@ -72,6 +72,7 @@ dsh plugin --profile web add dsh-magpie-connector
|
|
|
72
72
|
- **传输边界刻意收窄。** host 请求经私有 `undici` dispatcher 直连配置的网关,不经过全局代理 dispatcher;拒绝重定向;只允许 `GET /models`、`POST /responses`、`POST /chat/completions`、`GET /magpie/quotas/cards`、`POST /magpie/quotas/refresh`;从不调用 Magpie GUI 的 `/api/*` 管理接口。厂商出站代理在 Magpie 侧配置。
|
|
73
73
|
- **不支持:** 非空 `stop` 序列会在发请求前失败(`MAGPIE_STOP_UNSUPPORTED`),因为 Magpie 无法在所有上游协议转换中保留 stop。用量卡片与 Codex 重置次数只读展示,插件不会调用账号管理或重置端点。
|
|
74
74
|
- **密钥不会进入响应或日志。** 网关反射的文本在进入设置接口或日志之前会用当前 key 脱敏。写盘文件权限 `0600`;设置接口只报告 `configured` / `writable`。
|
|
75
|
+
- **界面跟随 DSH 语言。** 0.5.0 起设置页与速度菜单带中英文字典,按当前 DSH 界面语言渲染(`ctx.locale`);0.5.0 之前文案只有中文。宿主没有 locale 服务(或单测环境)时回退到中文参考集,已保存设置与行为不受影响。
|
|
75
76
|
|
|
76
77
|
### 代码导览
|
|
77
78
|
|
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
import { type Api } from './api.js';
|
|
2
2
|
import { type CheckSnapshot, type SettingsView } from './view-model.js';
|
|
3
|
-
/**
|
|
4
|
-
* Magpie settings section: connection (with an advanced access mode), then
|
|
5
|
-
* per-provider groups (model visibility, DSH-side default effort, read-only
|
|
6
|
-
* usage cards). Cards are read once on open or after a connection write; only
|
|
7
|
-
* user refreshes reread upstream usage — each card refreshes itself, and the
|
|
8
|
-
* connection panel offers "获取模型与用量" only while no card is shown.
|
|
9
|
-
*/
|
|
10
|
-
export declare const EMPTY_TEXT = "Magpie \u672A\u8FD4\u56DE\u53EF\u89C1\u7528\u91CF";
|
|
11
|
-
export declare const RESTRICTED_HINT = "\u53D7\u9650\u7684 gateway key \u65E0\u6CD5\u8BA9 Magpie \u9996\u6B21\u8BFB\u53D6\u7528\u91CF\uFF1A\u9700\u8981 Magpie \u7BA1\u7406\u5458\u5148\u5728 Magpie \u4E2D\u8BFB\u53D6\u4E00\u6B21\uFF0C\u6216\u8005\u6539\u7528\u4E0D\u53D7\u9650\u7684 key\u3002";
|
|
12
|
-
export declare const NOT_REREAD = "Magpie \u672A\u91CD\u65B0\u8BFB\u53D6\uFF08\u53EF\u80FD\u5904\u4E8E\u8282\u6D41\u95F4\u9694\u5185\uFF09";
|
|
13
3
|
/**
|
|
14
4
|
* Does this submitted list touch the active line's identity? Only then the old usage cards must
|
|
15
5
|
* disappear before the request is even sent (the host writes credentials before awaiting its
|
|
@@ -4,25 +4,22 @@ import { errorText, failureText, isOk } from './api.js';
|
|
|
4
4
|
import { lineLatency, parseLatency } from './latency.js';
|
|
5
5
|
import { settingsCss } from './styles.js';
|
|
6
6
|
import { UsageCard } from './UsageCard.js';
|
|
7
|
+
import { t } from './locales.js';
|
|
7
8
|
import { cacheIds, cardKey, checkDiff, checkSignature, formatTime, parseCards, parseCheck, providerOrder, readTime, sameTarget, } from './view-model.js';
|
|
8
9
|
/**
|
|
9
10
|
* Magpie settings section: connection (with an advanced access mode), then
|
|
10
11
|
* per-provider groups (model visibility, DSH-side default effort, read-only
|
|
11
12
|
* usage cards). Cards are read once on open or after a connection write; only
|
|
12
13
|
* user refreshes reread upstream usage — each card refreshes itself, and the
|
|
13
|
-
* connection panel offers "
|
|
14
|
+
* connection panel offers "Get models and usage" only while no card is shown.
|
|
14
15
|
*/
|
|
15
|
-
|
|
16
|
-
export const RESTRICTED_HINT = '受限的 gateway key 无法让 Magpie 首次读取用量:需要 Magpie 管理员先在 Magpie 中读取一次,或者改用不受限的 key。';
|
|
17
|
-
export const NOT_REREAD = 'Magpie 未重新读取(可能处于节流间隔内)';
|
|
18
|
-
const SYNC_KIND = { unauthorized: 'key 无效(401)', forbidden: '无权访问(403)', http: '网关错误', unreachable: '无法连接网关', settings: '设置保存失败', registration: '路由注册失败' };
|
|
16
|
+
const SYNC_KIND = { unauthorized: 'syncKindUnauthorized', forbidden: 'syncKindForbidden', http: 'syncKindHttp', unreachable: 'syncKindUnreachable', settings: 'syncKindSettings', registration: 'syncKindRegistration' };
|
|
19
17
|
/** Human labels for the check kinds the host may report. */
|
|
20
18
|
const CHECK_KIND = {
|
|
21
|
-
ok: '
|
|
22
|
-
unreachable: '
|
|
23
|
-
invalid: '
|
|
19
|
+
ok: 'checkKindOk', unauthorized: 'checkKindUnauthorized', forbidden: 'checkKindForbidden', http: 'checkKindHttp', redirect: 'checkKindRedirect',
|
|
20
|
+
unreachable: 'checkKindUnreachable', timeout: 'checkKindTimeout', 'invalid-catalog': 'checkKindInvalidCatalog', incomplete: 'checkKindIncomplete',
|
|
21
|
+
invalid: 'checkKindInvalid', busy: 'checkKindBusy', pending: 'checkKindPending',
|
|
24
22
|
};
|
|
25
|
-
const CHECK_UNSUPPORTED = '当前宿主版本不支持线路检查';
|
|
26
23
|
const ACCESS_FIELDS = ['accessClientId', 'accessClientSecret'];
|
|
27
24
|
/**
|
|
28
25
|
* Does this submitted list touch the active line's identity? Only then the old usage cards must
|
|
@@ -50,11 +47,14 @@ export function touchesActiveIdentity(lines, view) {
|
|
|
50
47
|
}
|
|
51
48
|
/** One line of the read-only snapshot: kind, model count, diff against the cache and latency. */
|
|
52
49
|
export function describeCheck(snapshot, diff, lastSyncAt) {
|
|
53
|
-
const
|
|
50
|
+
const kindKey = CHECK_KIND[snapshot.kind];
|
|
51
|
+
const parts = [kindKey === undefined ? snapshot.kind : t(kindKey)];
|
|
54
52
|
if (snapshot.kind === 'ok') {
|
|
55
|
-
parts.push(
|
|
53
|
+
parts.push(t('checkModelCount', { n: snapshot.modelCount ?? snapshot.models?.length ?? 0 }));
|
|
56
54
|
if (diff)
|
|
57
|
-
parts.push(diff.added.length === 0 && diff.removed.length === 0
|
|
55
|
+
parts.push(diff.added.length === 0 && diff.removed.length === 0
|
|
56
|
+
? (lastSyncAt ? t('checkMatchesCacheAt', { time: formatTime(lastSyncAt) }) : t('checkMatchesCache'))
|
|
57
|
+
: t('checkDiffCounts', { added: diff.added.length, removed: diff.removed.length }));
|
|
58
58
|
}
|
|
59
59
|
if (snapshot.gatewayStatus !== undefined)
|
|
60
60
|
parts.push(`HTTP ${snapshot.gatewayStatus}`);
|
|
@@ -91,20 +91,49 @@ export function MagpieSettings({ api }) {
|
|
|
91
91
|
setCards({ status: 'error', error: errorText(error), cards: [] });
|
|
92
92
|
}
|
|
93
93
|
};
|
|
94
|
-
/** One measurement per panel open (and after a successful line write); failures stay silent.
|
|
95
|
-
|
|
94
|
+
/** One measurement per panel open (and after a successful line write); failures stay silent.
|
|
95
|
+
* With `only`, just that saved line is re-measured and merged in — the other rows keep their
|
|
96
|
+
* reading instead of all flipping to the measuring state (the line-check refresh). */
|
|
97
|
+
const readLatency = async (only) => {
|
|
96
98
|
const epoch = ++latencyEpoch.current;
|
|
97
|
-
|
|
99
|
+
if (only === undefined)
|
|
100
|
+
setLatency({ phase: 'measuring' });
|
|
101
|
+
else
|
|
102
|
+
setLatency(current => current.phase === 'done' ? { phase: 'done', readings: current.readings, measuring: new Set([only]) } : current);
|
|
103
|
+
const settle = () => {
|
|
104
|
+
// Superseded by a newer pass: drop the stale result but never leave the row stuck on measuring.
|
|
105
|
+
if (only === undefined)
|
|
106
|
+
return;
|
|
107
|
+
setLatency(current => current.phase === 'done' ? { phase: 'done', readings: current.readings } : current);
|
|
108
|
+
};
|
|
98
109
|
try {
|
|
99
|
-
const response = await api({ method: 'GET', path: 'latency' });
|
|
100
|
-
if (epoch !== latencyEpoch.current)
|
|
110
|
+
const response = await api({ method: 'GET', path: 'latency', ...(only === undefined ? {} : { query: { line: only } }) });
|
|
111
|
+
if (epoch !== latencyEpoch.current) {
|
|
112
|
+
settle();
|
|
101
113
|
return;
|
|
114
|
+
}
|
|
102
115
|
const readings = isOk(response) ? parseLatency(response.body) : undefined;
|
|
103
|
-
|
|
116
|
+
if (only === undefined) {
|
|
117
|
+
setLatency(readings ? { phase: 'done', readings } : { phase: 'failed' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
setLatency(current => {
|
|
121
|
+
const merged = new Map(current.phase === 'done' ? current.readings : []);
|
|
122
|
+
if (readings)
|
|
123
|
+
for (const [id, ms] of readings)
|
|
124
|
+
merged.set(id, ms);
|
|
125
|
+
return { phase: 'done', readings: merged };
|
|
126
|
+
});
|
|
104
127
|
}
|
|
105
128
|
catch {
|
|
106
|
-
if (epoch
|
|
129
|
+
if (epoch !== latencyEpoch.current) {
|
|
130
|
+
settle();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (only === undefined)
|
|
107
134
|
setLatency({ phase: 'failed' });
|
|
135
|
+
else
|
|
136
|
+
settle();
|
|
108
137
|
}
|
|
109
138
|
};
|
|
110
139
|
const clearUsage = () => {
|
|
@@ -218,7 +247,7 @@ export function MagpieSettings({ api }) {
|
|
|
218
247
|
return;
|
|
219
248
|
// A failed refresh is not a card result: keep what was shown and say why.
|
|
220
249
|
if (!isOk(response)) {
|
|
221
|
-
setUsageAlert(
|
|
250
|
+
setUsageAlert(t('refreshFailed', { reason: failureText(response) }));
|
|
222
251
|
return;
|
|
223
252
|
}
|
|
224
253
|
const next = parseCards(response.body, response.status);
|
|
@@ -227,7 +256,7 @@ export function MagpieSettings({ api }) {
|
|
|
227
256
|
}
|
|
228
257
|
catch (error) {
|
|
229
258
|
if (epoch === cardsEpoch.current)
|
|
230
|
-
setUsageAlert(
|
|
259
|
+
setUsageAlert(t('refreshFailed', { reason: errorText(error) }));
|
|
231
260
|
}
|
|
232
261
|
finally {
|
|
233
262
|
if (epoch === cardsEpoch.current) {
|
|
@@ -250,9 +279,9 @@ export function MagpieSettings({ api }) {
|
|
|
250
279
|
}
|
|
251
280
|
};
|
|
252
281
|
const groupNote = (provider) => noteScope === provider ? { notice, alert: usageAlert } : {};
|
|
253
|
-
return h('div', { className: 'dmg-settings' }, h('style', null, settingsCss), h('p', { className: 'dmg-intro' }, '
|
|
282
|
+
return h('div', { className: 'dmg-settings' }, h('style', null, settingsCss), h('p', { className: 'dmg-intro' }, t('intro')), settingsError ? h('p', { className: 'dmg-error', role: 'alert' }, t('settingsReadFailed', { error: settingsError })) : null, settings ? h(Connection, { settings, saving: savingSettings, mutateSettings, api, latency, onRefreshLatency: (lineId) => { void readLatency(lineId); },
|
|
254
283
|
usage: h(UsageStatus, { cards, busy, ...(noteScope === undefined ? { notice, alert: usageAlert } : {}), onFetchAll: () => { void fetchAll(); }, saving: savingSettings }) })
|
|
255
|
-
: settingsError ? null : h('p', { role: 'status', className: 'dmg-hint' }, '
|
|
284
|
+
: settingsError ? null : h('p', { role: 'status', className: 'dmg-hint' }, t('readingSettings')), ...providerOrder(groups, cardList).map(provider => h(ProviderGroup, {
|
|
256
285
|
key: provider, provider,
|
|
257
286
|
group: groups.find(g => g.provider === provider),
|
|
258
287
|
settings,
|
|
@@ -280,42 +309,41 @@ function noticeAfter(target, before, next) {
|
|
|
280
309
|
*/
|
|
281
310
|
function UsageStatus({ cards, busy, saving, notice, alert, onFetchAll }) {
|
|
282
311
|
const state = !cards ? 'loading' : cards.status !== 'ok' ? cards.status : cards.cards.length ? 'ok' : 'empty';
|
|
283
|
-
const message = state === 'loading' ? '
|
|
284
|
-
: state === 'forbidden' ? '
|
|
285
|
-
: state === 'unconfigured' ? '
|
|
286
|
-
: state === 'error' ? '
|
|
287
|
-
: state === 'empty' ?
|
|
312
|
+
const message = state === 'loading' ? t('usageReading')
|
|
313
|
+
: state === 'forbidden' ? t('usageForbidden')
|
|
314
|
+
: state === 'unconfigured' ? t('usageUnconfigured')
|
|
315
|
+
: state === 'error' ? t('usageError')
|
|
316
|
+
: state === 'empty' ? t('usageEmpty') : undefined;
|
|
288
317
|
const canFetch = state === 'empty' || state === 'error';
|
|
289
318
|
const lines = [
|
|
290
319
|
message ? h('p', { key: 'm', className: state === 'error' || state === 'forbidden' ? 'dmg-error' : 'dmg-hint', role: 'status' }, message) : null,
|
|
291
320
|
cards?.error && state !== 'ok' ? h('p', { key: 'e', className: 'dmg-error' }, cards.error) : null,
|
|
292
|
-
state === 'empty' && notice === 'restricted' ? h('p', { key: 'r', className: 'dmg-hint' },
|
|
293
|
-
notice === 'not-reread' ? h('p', { key: 'n', className: 'dmg-hint', role: 'status' },
|
|
294
|
-
busy && canFetch ? h('p', { key: 'b', className: 'dmg-hint', role: 'status' }, '
|
|
321
|
+
state === 'empty' && notice === 'restricted' ? h('p', { key: 'r', className: 'dmg-hint' }, t('restrictedHint')) : null,
|
|
322
|
+
notice === 'not-reread' ? h('p', { key: 'n', className: 'dmg-hint', role: 'status' }, t('notReread')) : null,
|
|
323
|
+
busy && canFetch ? h('p', { key: 'b', className: 'dmg-hint', role: 'status' }, t('rereading')) : null,
|
|
295
324
|
alert ? h('p', { key: 'a', className: 'dmg-error', role: 'alert', 'data-usage-alert': '' }, alert) : null,
|
|
296
325
|
].filter(Boolean);
|
|
297
326
|
if (!lines.length && !canFetch)
|
|
298
327
|
return null;
|
|
299
|
-
return h('div', { className: 'dmg-usage', 'data-usage-state': state, 'aria-label': '
|
|
328
|
+
return h('div', { className: 'dmg-usage', 'data-usage-state': state, 'aria-label': t('usageLabel') }, ...lines, canFetch ? h('div', { className: 'dmg-row' }, h('button', { type: 'button', className: 'dmg-button', disabled: busy || saving, onClick: onFetchAll }, t('fetchModelsAndUsage'))) : null);
|
|
300
329
|
}
|
|
301
330
|
async function postSettings(api, body) {
|
|
302
331
|
try {
|
|
303
332
|
const response = await api({ method: 'POST', path: 'settings', body });
|
|
304
333
|
if (isOk(response))
|
|
305
334
|
return { view: response.body };
|
|
306
|
-
return { error: response.status === 409 ? '
|
|
335
|
+
return { error: response.status === 409 ? t('envKeyReadonly') : t('notSaved', { reason: failureText(response) }) };
|
|
307
336
|
}
|
|
308
337
|
catch (error) {
|
|
309
|
-
return { error:
|
|
338
|
+
return { error: t('notSaved', { reason: errorText(error) }) };
|
|
310
339
|
}
|
|
311
340
|
}
|
|
312
|
-
const ACCESS_MODES = [{ id: 'direct',
|
|
313
|
-
const INFO_TEXT = '线路按序号优先尝试,连不上自动试下一条;绿色方块表示当前线路;多条线路应指向同一个 Magpie 网关;延迟是 TCP 建连耗时(含 DNS 解析),不是网关健康检查。';
|
|
341
|
+
const ACCESS_MODES = [{ id: 'direct', labelKey: 'direct' }, { id: 'cloudflare', labelKey: 'cloudflareAccess' }];
|
|
314
342
|
const hostnameOf = (baseUrl) => { try {
|
|
315
343
|
return new URL(baseUrl).hostname || baseUrl;
|
|
316
344
|
}
|
|
317
345
|
catch {
|
|
318
|
-
return baseUrl || '
|
|
346
|
+
return baseUrl || t('hostnameUnset');
|
|
319
347
|
} };
|
|
320
348
|
function lineDrafts(settings) {
|
|
321
349
|
const configured = (access) => access?.clientId?.configured === true || access?.clientSecret?.configured === true;
|
|
@@ -425,7 +453,7 @@ function Connection({ settings, saving, mutateSettings, api, usage, latency, onR
|
|
|
425
453
|
};
|
|
426
454
|
const savedLabel = (view) => {
|
|
427
455
|
const index = (view.lines ?? []).findIndex(line => line.id === view.activeLineId);
|
|
428
|
-
return index >= 0 ?
|
|
456
|
+
return index >= 0 ? t('savedLine', { n: index + 1 }) : t('savedNoActive');
|
|
429
457
|
};
|
|
430
458
|
const save = () => {
|
|
431
459
|
// A save attempt retires every snapshot: the checked draft is about to become the saved one,
|
|
@@ -473,18 +501,18 @@ function Connection({ settings, saving, mutateSettings, api, usage, latency, onR
|
|
|
473
501
|
}
|
|
474
502
|
const snapshot = parseCheck(response.body);
|
|
475
503
|
if (!snapshot) {
|
|
476
|
-
setCheckErrors(current => ({ ...current, [draft.draftId]: isOk(response) ? '
|
|
504
|
+
setCheckErrors(current => ({ ...current, [draft.draftId]: isOk(response) ? t('checkUnparsable') : failureText(response) }));
|
|
477
505
|
return;
|
|
478
506
|
}
|
|
479
507
|
setChecks(current => ({ ...current, [draft.draftId]: { signature, activeLineId: activeId, result: snapshot } }));
|
|
480
|
-
// A completed check is a fresh network sample of this line:
|
|
481
|
-
//
|
|
508
|
+
// A completed check is a fresh network sample of this line: re-measure just that saved
|
|
509
|
+
// line (unsaved drafts have no reading row, so they trigger nothing).
|
|
482
510
|
if (draft.id)
|
|
483
|
-
onRefreshLatency();
|
|
511
|
+
onRefreshLatency(draft.id);
|
|
484
512
|
}
|
|
485
513
|
catch (error) {
|
|
486
514
|
if (epoch === checkEpoch.current)
|
|
487
|
-
setCheckErrors(current => ({ ...current, [draft.draftId]:
|
|
515
|
+
setCheckErrors(current => ({ ...current, [draft.draftId]: t('checkFailed', { reason: errorText(error) }) }));
|
|
488
516
|
}
|
|
489
517
|
finally {
|
|
490
518
|
if (epoch === checkEpoch.current) {
|
|
@@ -499,7 +527,7 @@ function Connection({ settings, saving, mutateSettings, api, usage, latency, onR
|
|
|
499
527
|
setCheckErrors(current => { const next = { ...current }; delete next[draftId]; return next; });
|
|
500
528
|
};
|
|
501
529
|
const catalogIds = cacheIds(settings.groups);
|
|
502
|
-
return h('section', { className: 'dmg-panel', 'aria-label': '
|
|
530
|
+
return h('section', { className: 'dmg-panel', 'aria-label': t('connection') }, h('div', { className: 'dmg-head' }, h('h3', null, t('connection')), h('span', { className: 'dmg-info', role: 'img', tabIndex: 0, 'aria-label': t('infoText'), title: t('infoText') }, 'ⓘ')), drafts.length === 0 ? h('p', { className: 'dmg-hint' }, t('noLines')) : null, h('div', { className: 'dmg-lines' }, ...drafts.map((draft, index) => {
|
|
503
531
|
const entry = checks[draft.draftId];
|
|
504
532
|
const fresh = entry && entry.signature === checkSignature(draft) && entry.activeLineId === activeId ? entry : undefined;
|
|
505
533
|
return h(LineCard, {
|
|
@@ -518,11 +546,11 @@ function Connection({ settings, saving, mutateSettings, api, usage, latency, onR
|
|
|
518
546
|
onCheck: () => { void runCheck(draft); },
|
|
519
547
|
onClearCheck: () => clearCheck(draft.draftId),
|
|
520
548
|
});
|
|
521
|
-
})), h('div', { className: 'dmg-row' }, h('button', { type: 'button', className: 'dmg-button', disabled: saving, onClick: add }, drafts.length ? '
|
|
549
|
+
})), h('div', { className: 'dmg-row' }, h('button', { type: 'button', className: 'dmg-button', disabled: saving, onClick: add }, drafts.length ? t('addBackupLine') : t('addLine'))), h('div', { className: 'dmg-row' }, h('button', { type: 'button', className: 'dmg-button', disabled: saving || !dirty, onClick: save }, saving ? t('saving') : t('save')), h('button', { type: 'button', className: 'dmg-button', disabled: saving, onClick: () => { void post({ resync: true }, t('modelsRefreshed')); } }, t('refreshModels'))), h(SyncStatus, { settings }), message ? h('p', { className: message.failed ? 'dmg-error' : 'dmg-ok', ...(message.failed ? { role: 'alert' } : {}) }, message.text) : null, usage);
|
|
522
550
|
}
|
|
523
551
|
function iconButton(name, icon, lineLabel, disabled, onClick, reason) {
|
|
524
|
-
const action =
|
|
525
|
-
const label = disabled && reason ?
|
|
552
|
+
const action = t('lineAction', { action: name, line: lineLabel });
|
|
553
|
+
const label = disabled && reason ? t('lineActionReason', { action, reason }) : action;
|
|
526
554
|
const button = h('button', { type: 'button', className: 'dmg-icon-button', 'aria-label': label, 'data-action': name, disabled, onClick }, h('span', { className: 'dmg-icon', 'aria-hidden': true }, icon));
|
|
527
555
|
return disabled ? button : h(Tooltip, { label: action, children: button });
|
|
528
556
|
}
|
|
@@ -535,36 +563,36 @@ function LatencySlot({ latency }) {
|
|
|
535
563
|
attrs['data-latency-ms'] = latency.ms === null || latency.ms === undefined ? '' : String(latency.ms);
|
|
536
564
|
}
|
|
537
565
|
const reading = latency.state === 'done' && latency.ms !== null && latency.ms !== undefined;
|
|
538
|
-
const text = latency.state === 'measuring' ? '
|
|
539
|
-
const title = latency.state === 'done' ? (reading ?
|
|
566
|
+
const text = latency.state === 'measuring' ? t('latencyMeasuring') : latency.state === 'failed' ? '—' : latency.state === 'done' ? (reading ? `${latency.ms} ms` : t('latencyTimeout')) : '';
|
|
567
|
+
const title = latency.state === 'done' ? (reading ? t('latencyTitle', { ms: latency.ms ?? 0 }) : t('latencyTitleTimeout')) : undefined;
|
|
540
568
|
return h('span', { ...attrs, ...(title ? { title } : {}) }, reading ? h('span', { className: 'dmg-bars', 'aria-hidden': true, 'data-level': String(level) }, ...[0, 1, 2, 3].map(index => h('i', { key: index, 'data-on': index < level ? 'true' : 'false' }))) : null, h('span', { className: 'dmg-latency-text' }, text));
|
|
541
569
|
}
|
|
542
570
|
function LineCard({ draft, index, total, saving, active, expanded, latency, checking, checkDisabled, check, checkError, diff, lastSyncAt, onToggle, onChange, onMove, onRemove, onCheck, onClearCheck }) {
|
|
543
571
|
const diffParts = diff ? [
|
|
544
|
-
diff.added.length ? h('p', { key: 'added', className: 'dmg-hint' },
|
|
545
|
-
diff.removed.length ? h('p', { key: 'removed', className: 'dmg-hint' },
|
|
572
|
+
diff.added.length ? h('p', { key: 'added', className: 'dmg-hint' }, t('diffAdded', { list: diff.added.join(t('listSeparator')) })) : null,
|
|
573
|
+
diff.removed.length ? h('p', { key: 'removed', className: 'dmg-hint' }, t('diffRemoved', { list: diff.removed.join(t('listSeparator')) })) : null,
|
|
546
574
|
].filter(Boolean) : [];
|
|
547
575
|
const lineLabel = String(index + 1);
|
|
548
576
|
const bodyId = `dmg-line-body-${draft.draftId}`;
|
|
549
|
-
return h('div', { className: 'dmg-line', 'data-line-id': draft.id, 'data-draft': draft.id ? undefined : '', 'data-expanded': expanded ? 'true' : 'false' }, h('div', { className: 'dmg-line-head' }, h('button', { type: 'button', className: 'dmg-line-toggle', 'aria-expanded': expanded, ...(expanded ? { 'aria-controls': bodyId } : {}), 'data-toggle': '', onClick: onToggle }, h('span', { className: 'dmg-chevron', 'aria-hidden': true }, h(IconTriangleRightFillRegular, null)), h('span', { className: 'dmg-line-index' },
|
|
550
|
-
placeholder: draft.keyConfigured ? '
|
|
577
|
+
return h('div', { className: 'dmg-line', 'data-line-id': draft.id, 'data-draft': draft.id ? undefined : '', 'data-expanded': expanded ? 'true' : 'false' }, h('div', { className: 'dmg-line-head' }, h('button', { type: 'button', className: 'dmg-line-toggle', 'aria-expanded': expanded, ...(expanded ? { 'aria-controls': bodyId } : {}), 'data-toggle': '', onClick: onToggle }, h('span', { className: 'dmg-chevron', 'aria-hidden': true }, h(IconTriangleRightFillRegular, null)), h('span', { className: 'dmg-line-index' }, t('lineIndex', { n: lineLabel })), active ? h('span', { className: 'dmg-active', role: 'img', 'aria-label': t('currentLine'), title: t('currentLine'), 'data-active-line': draft.id }) : null, draft.id && !draft.complete ? h('span', { className: 'dmg-unset', 'data-line-incomplete': '' }, t('unconfigured')) : null, h('span', { className: 'dmg-line-title' }, `${hostnameOf(draft.baseUrl)}${draft.id ? '' : t('unsaved')}`), h(LatencySlot, { latency })), h('div', { className: 'dmg-line-actions' }, iconButton(t('moveUp'), h(IconChevronUpOutlineRegular, null), lineLabel, saving || index === 0, () => onMove(-1), t('firstLine')), iconButton(t('moveDown'), h(IconChevronDownOutlineRegular, null), lineLabel, saving || index === total - 1, () => onMove(1), t('lastLine')), iconButton(t('remove'), h(IconTrashOutlineRegular, null), lineLabel, saving || total <= 1, onRemove, t('onlyLine')))), expanded ? h('div', { className: 'dmg-line-body', id: bodyId }, h('label', { className: 'dmg-field' }, h('span', null, t('baseUrlLabel')), h('input', { className: 'dmg-input', value: draft.baseUrl, disabled: saving, placeholder: 'http://127.0.0.1:3425/v1', onChange: (e) => onChange({ baseUrl: e.target.value }) })), h('label', { className: 'dmg-field' }, h('span', null, t('apiKeyLabel', { state: `${draft.keyConfigured ? t('configured') : t('notConfigured')}${draft.keyWritable ? '' : t('readonlyFromEnv')}` })), h('input', { className: 'dmg-input', type: 'password', autoComplete: 'off', value: draft.apiKey, disabled: saving || !draft.keyWritable,
|
|
578
|
+
placeholder: draft.keyConfigured ? t('keepBlank') : t('required'), onChange: (e) => onChange({ apiKey: e.target.value }) })), h('div', { className: 'dmg-check', 'data-check': '' }, h('div', { className: 'dmg-row' }, h('button', {
|
|
551
579
|
type: 'button', className: 'dmg-button', 'data-check-run': '',
|
|
552
580
|
disabled: saving || checkDisabled || draft.baseUrl.trim() === '', onClick: onCheck,
|
|
553
|
-
}, checking ? '
|
|
554
|
-
onChange: (e) => onChange({ accessMode: e.target.value }) }, ...ACCESS_MODES.map(mode => h('option', { key: mode.id, value: mode.id }, mode.
|
|
555
|
-
? h('p', { className: 'dmg-hint' }, draft.savedAccess ? '
|
|
556
|
-
: h('p', { className: 'dmg-hint' }, '
|
|
581
|
+
}, checking ? t('checking') : t('checkLine')), check ? h('button', { type: 'button', className: 'dmg-button', disabled: saving || checking, onClick: onClearCheck }, t('clear')) : null), h('p', { className: 'dmg-hint' }, t('checkReadonly')), checkError ? h('p', { className: 'dmg-error' }, checkError) : null, check ? h('p', { className: 'dmg-check-line', 'data-check-kind': check.kind }, describeCheck(check, diff, lastSyncAt)) : null, check?.reason ? h('p', { className: 'dmg-hint', 'data-check-reason': '' }, check.reason) : null, check?.models?.length ? h('details', { className: 'dmg-check-models' }, h('summary', null, t('modelIds', { n: check.models.length })), ...diffParts, h('p', { className: 'dmg-hint' }, check.models.map(model => model.id).join('、'))) : null), h('details', { className: 'dmg-advanced', open: true, 'data-advanced': '' }, h('summary', null, t('advanced')), h('div', { className: 'dmg-advanced-body' }, h('label', { className: 'dmg-field' }, h('span', null, `${t('accessMode')}${draft.accessWritable ? '' : t('accessReadonly')}`), h('select', { className: 'dmg-select', 'aria-label': t('accessMode'), value: draft.accessMode, disabled: saving || !draft.accessWritable,
|
|
582
|
+
onChange: (e) => onChange({ accessMode: e.target.value }) }, ...ACCESS_MODES.map(mode => h('option', { key: mode.id, value: mode.id }, t(mode.labelKey))))), draft.accessMode === 'direct'
|
|
583
|
+
? h('p', { className: 'dmg-hint' }, draft.savedAccess ? t('directHintClears') : t('directHint'))
|
|
584
|
+
: h('p', { className: 'dmg-hint' }, t('cloudflareHint')), ...(draft.accessMode === 'cloudflare' ? [
|
|
557
585
|
['Access Client ID', draft.accessClientId, 'accessClientId'],
|
|
558
586
|
['Access Client Secret', draft.accessClientSecret, 'accessClientSecret'],
|
|
559
|
-
].map(([label, value, field]) => h('label', { key: label, className: 'dmg-field' }, h('span', null,
|
|
560
|
-
disabled: saving || !draft.accessWritable, placeholder: '
|
|
587
|
+
].map(([label, value, field]) => h('label', { key: label, className: 'dmg-field' }, h('span', null, t('accessFieldLabel', { field: label, state: `${draft.accessConfigured ? t('configured') : t('notConfigured')}${draft.accessWritable ? '' : t('readonlyFromEnv')}` })), h('input', { className: 'dmg-input', type: 'password', autoComplete: 'off', value,
|
|
588
|
+
disabled: saving || !draft.accessWritable, placeholder: t('keepBlank'),
|
|
561
589
|
onChange: (e) => onChange({ [field]: e.target.value }) }))) : [])))) : null);
|
|
562
590
|
}
|
|
563
591
|
function SyncStatus({ settings }) {
|
|
564
592
|
const count = (settings.groups ?? []).reduce((n, g) => n + g.models.length, 0);
|
|
565
593
|
const rejected = settings.rejectedOwners ?? [];
|
|
566
594
|
const registration = settings.routeRegistrationError;
|
|
567
|
-
return h('div', { className: 'dmg-meta', 'data-sync': '' }, settings.lastSyncAt ? h('p', null,
|
|
595
|
+
return h('div', { className: 'dmg-meta', 'data-sync': '' }, settings.lastSyncAt ? h('p', null, t('modelsCount', { n: count }), h('time', { dateTime: settings.lastSyncAt }, formatTime(settings.lastSyncAt))) : null, settings.lastSyncError ? h('p', { className: 'dmg-error' }, t('syncFailed', { kind: settings.lastSyncErrorKind ? t('syncFailedKind', { kind: t(SYNC_KIND[settings.lastSyncErrorKind] ?? 'syncKindHttp') }) : '', error: settings.lastSyncError })) : null, registration ? h('p', { className: 'dmg-error', role: 'alert' }, t('routeRegistrationFailed', { code: registration.code, route: registration.route ? `, ${registration.route}` : '' })) : null, settings.knownOwnersReset ? h('p', { className: 'dmg-hint' }, t('ownersReset')) : null, settings.ownerLimitReached ? h('p', { className: 'dmg-hint' }, t('ownerLimitReached', { list: rejected.join(t('listSeparator')) })) : null);
|
|
568
596
|
}
|
|
569
597
|
function ProviderGroup({ provider, group, settings, cards, busy, saving, mutateSettings, onRefresh, notice, alert }) {
|
|
570
598
|
const [error, setError] = useState('');
|
|
@@ -594,21 +622,21 @@ function ProviderGroup({ provider, group, settings, cards, busy, saving, mutateS
|
|
|
594
622
|
return { visibleModels: rest };
|
|
595
623
|
});
|
|
596
624
|
};
|
|
597
|
-
return h('section', { className: 'dmg-panel', 'data-provider': provider, 'aria-label': provider }, h('div', { className: 'dmg-head' }, h('h3', null, provider), group && !following ? h('button', { type: 'button', className: 'dmg-button', disabled: saving, onClick: follow }, '
|
|
625
|
+
return h('section', { className: 'dmg-panel', 'data-provider': provider, 'aria-label': provider }, h('div', { className: 'dmg-head' }, h('h3', null, provider), group && !following ? h('button', { type: 'button', className: 'dmg-button', disabled: saving, onClick: follow }, t('followCatalog')) : null), group ? h('p', { className: 'dmg-hint' }, following ? t('visibilityFollow') : t('visibilityManual')) : null, models.length ? h('div', { className: 'dmg-models' }, ...models.map(model => h(ModelLine, {
|
|
598
626
|
key: model.id, model, disabled: saving,
|
|
599
627
|
visible: model.visible ?? (following || (saved[provider] ?? []).includes(model.id)),
|
|
600
628
|
effort: settings?.defaultEfforts?.[model.id],
|
|
601
629
|
onVisible: (visible) => setVisible(model, visible),
|
|
602
630
|
onEffort: (effort) => { void post({ defaultEffort: { model: model.id, effort } }); },
|
|
603
|
-
}))) : null, cards.length ? h('div', { className: 'dmg-cards' }, ...cards.map(card => h(UsageCard, { key: cardKey(card), card, busy, onRefresh: () => onRefresh(card) }))) : null, notice === 'not-reread' ? h('p', { className: 'dmg-hint', role: 'status' },
|
|
631
|
+
}))) : null, cards.length ? h('div', { className: 'dmg-cards' }, ...cards.map(card => h(UsageCard, { key: cardKey(card), card, busy, onRefresh: () => onRefresh(card) }))) : null, notice === 'not-reread' ? h('p', { className: 'dmg-hint', role: 'status' }, t('notReread')) : null, alert ? h('p', { className: 'dmg-error', role: 'alert', 'data-usage-alert': '' }, alert) : null, error ? h('p', { className: 'dmg-error', role: 'alert' }, error) : null);
|
|
604
632
|
}
|
|
605
633
|
function ModelLine({ model, visible, effort, disabled, onVisible, onEffort }) {
|
|
606
634
|
const efforts = model.efforts ?? [];
|
|
607
635
|
// Stale = saved but no longer offered by the gateway (design D7: the client judges this itself).
|
|
608
636
|
const stale = effort !== undefined && !efforts.includes(effort);
|
|
609
637
|
const label = model.name || model.id;
|
|
610
|
-
return h('div', { className: 'dmg-model', 'data-model': model.id }, h('label', null, h('input', { type: 'checkbox', checked: visible, disabled, 'aria-label':
|
|
611
|
-
className: 'dmg-select', 'aria-label':
|
|
638
|
+
return h('div', { className: 'dmg-model', 'data-model': model.id }, h('label', null, h('input', { type: 'checkbox', checked: visible, disabled, 'aria-label': t('showInPicker', { label }), onChange: (e) => onVisible(e.target.checked) }), h('span', null, label), model.name && model.name !== model.id ? h('code', null, model.id) : null), efforts.length ? h('select', {
|
|
639
|
+
className: 'dmg-select', 'aria-label': t('defaultEffortAria', { label }), disabled,
|
|
612
640
|
value: effort ?? '', onChange: (e) => onEffort(e.target.value === '' ? null : e.target.value),
|
|
613
|
-
}, h('option', { value: '' }, '
|
|
641
|
+
}, h('option', { value: '' }, t('defaultEffortNone')), stale ? h('option', { value: effort, disabled: true }, t('effortStale', { effort })) : null, ...efforts.map(id => h('option', { key: id, value: id }, id))) : null);
|
|
614
642
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createElement as h, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { isOk } from './api.js';
|
|
3
3
|
import { settingsCss } from './styles.js';
|
|
4
|
+
import { t } from './locales.js';
|
|
4
5
|
/**
|
|
5
6
|
* Composer speed control (`conversation.input.right`): standard / fast for
|
|
6
7
|
* models whose gateway catalog advertises the `priority` service tier. The
|
|
@@ -51,9 +52,10 @@ export function createSpeedSetter(api, sessionId) {
|
|
|
51
52
|
return tier => api({ method: 'POST', path: 'speed', body: { session: sessionId, tier } }).then(isOk, () => false);
|
|
52
53
|
}
|
|
53
54
|
const POLL_MS = 3000;
|
|
55
|
+
/** Tier presentation: the labels live in the locale dictionaries, not here. */
|
|
54
56
|
const TIERS = [
|
|
55
|
-
{ id: 'standard',
|
|
56
|
-
{ id: 'fast',
|
|
57
|
+
{ id: 'standard', nameKey: 'speedTierStandard', descriptionKey: 'speedTierStandardDescription' },
|
|
58
|
+
{ id: 'fast', nameKey: 'speedTierFast', descriptionKey: 'speedTierFastDescription' },
|
|
57
59
|
];
|
|
58
60
|
export function SpeedSelect({ loadSpeed, setSpeed, watchModel }) {
|
|
59
61
|
const [state, setState] = useState(null);
|
|
@@ -157,17 +159,17 @@ export function SpeedSelect({ loadSpeed, setSpeed, watchModel }) {
|
|
|
157
159
|
else
|
|
158
160
|
setFailed(true);
|
|
159
161
|
};
|
|
160
|
-
const label =
|
|
162
|
+
const label = t('speedLabel', { tier: t(TIERS.find(tier => tier.id === state.tier).nameKey) });
|
|
161
163
|
return h('div', {
|
|
162
164
|
ref: rootRef, className: 'dmg-speed', 'data-magpie-speed': '',
|
|
163
165
|
onKeyDown: (event) => { if (event.key === 'Escape' && open) {
|
|
164
166
|
event.preventDefault();
|
|
165
167
|
setOpen(false);
|
|
166
168
|
} },
|
|
167
|
-
}, h('style', null, settingsCss), failed ? h('span', { className: 'dmg-error', role: 'alert' }, '
|
|
169
|
+
}, h('style', null, settingsCss), failed ? h('span', { className: 'dmg-error', role: 'alert' }, t('speedSaveFailed')) : null, open ? h('div', { className: 'dmg-speed-menu', role: 'menu', 'aria-label': t('speedMenu') }, ...TIERS.map(tier => h('button', {
|
|
168
170
|
key: tier.id, type: 'button', role: 'menuitemradio', className: 'dmg-speed-item', 'data-tier': tier.id,
|
|
169
171
|
'aria-checked': tier.id === state.tier, disabled: busy, onClick: () => choose(tier.id),
|
|
170
|
-
}, h('span', null, tier.id === state.tier ? '✓' : ''), h('span', null, tier.
|
|
172
|
+
}, h('span', null, tier.id === state.tier ? '✓' : ''), h('span', null, t(tier.nameKey), h('small', null, t(tier.descriptionKey)))))) : null, h('button', {
|
|
171
173
|
type: 'button', className: 'dmg-speed-trigger', 'aria-haspopup': 'menu', 'aria-expanded': open, title: label, disabled: busy,
|
|
172
174
|
onClick: () => {
|
|
173
175
|
if (open) {
|
package/lib/client/UsageCard.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createElement as h } from 'react';
|
|
2
2
|
import { formatTime, readTime, windowReading } from './view-model.js';
|
|
3
|
+
import { t } from './locales.js';
|
|
3
4
|
/**
|
|
4
5
|
* One read-only usage card. Every gateway string is a React text child (never
|
|
5
6
|
* HTML), absent fields are omitted, and the only control is "刷新" — there is
|
|
@@ -7,7 +8,7 @@ import { formatTime, readTime, windowReading } from './view-model.js';
|
|
|
7
8
|
*/
|
|
8
9
|
const time = (raw) => h('time', { dateTime: raw }, formatTime(raw));
|
|
9
10
|
const fact = (field, label, value) => h('span', { 'data-field': field, key: field }, h('b', null, `${label} `), h('span', { 'data-value': '' }, value));
|
|
10
|
-
const scalar = (value) => typeof value === 'boolean' ? (value ? '
|
|
11
|
+
const scalar = (value) => typeof value === 'boolean' ? (value ? t('yes') : t('no')) : String(value);
|
|
11
12
|
const present = (value) => value !== undefined && value !== null && value !== '';
|
|
12
13
|
/** Bar colour: ≤70% normal (blue), above 70 and below 90 warn (orange), 90% and above high (red). */
|
|
13
14
|
export const usageLevel = (used) => used >= 90 ? 'high' : used > 70 ? 'warn' : 'normal';
|
|
@@ -15,18 +16,18 @@ function WindowRow({ window }) {
|
|
|
15
16
|
const reading = windowReading(window);
|
|
16
17
|
const showReset = reading !== undefined && window.unlimited !== true && !present(window.display) && present(window.resetsAt);
|
|
17
18
|
const used = typeof window.used === 'number' && reading?.endsWith('%') ? Math.max(0, Math.min(100, window.used)) : undefined;
|
|
18
|
-
return h('div', { className: 'dmg-window', 'data-window': '' }, h('div', { className: 'dmg-window-line' }, h('span', null, present(window.name) ? h('span', { 'data-window-name': '' }, window.name) : null, present(window.name) && reading !== undefined ? ' ' : null, reading !== undefined ? h('span', { 'data-reading': '' }, reading) : null), showReset ? h('span', null, '
|
|
19
|
+
return h('div', { className: 'dmg-window', 'data-window': '' }, h('div', { className: 'dmg-window-line' }, h('span', null, present(window.name) ? h('span', { 'data-window-name': '' }, window.name) : null, present(window.name) && reading !== undefined ? ' ' : null, reading !== undefined ? h('span', { 'data-reading': '' }, reading) : null), showReset ? h('span', null, t('usageResetAt'), time(window.resetsAt)) : null), used !== undefined ? h('div', { className: 'dmg-track', 'aria-hidden': true }, h('div', { className: 'dmg-fill', 'data-level': usageLevel(used), style: { width: `${used}%` } })) : null);
|
|
19
20
|
}
|
|
20
21
|
export function UsageCard({ card, busy, onRefresh }) {
|
|
21
22
|
const read = readTime(card);
|
|
22
23
|
const facts = [
|
|
23
|
-
present(card.user) && fact('user', '
|
|
24
|
-
present(card.plan) && fact('plan', '
|
|
25
|
-
present(card.until) && fact('until', '
|
|
26
|
-
present(card.renew) && fact('renew', '
|
|
27
|
-
present(card.balance) && fact('balance', '
|
|
28
|
-
present(read) && fact('readAt', card.asOf !== undefined ? '
|
|
24
|
+
present(card.user) && fact('user', t('usageUser'), card.user),
|
|
25
|
+
present(card.plan) && fact('plan', t('usagePlan'), card.plan),
|
|
26
|
+
present(card.until) && fact('until', t('usageUntil'), time(card.until)),
|
|
27
|
+
present(card.renew) && fact('renew', t('usageRenew'), scalar(card.renew)),
|
|
28
|
+
present(card.balance) && fact('balance', t('usageBalance'), scalar(card.balance)),
|
|
29
|
+
present(read) && fact('readAt', card.asOf !== undefined ? t('usageReadAtStale') : t('usageReadAt'), time(read)),
|
|
29
30
|
].filter(Boolean);
|
|
30
31
|
const resets = card.resets;
|
|
31
|
-
return h('article', { className: 'dmg-card', 'data-card': '' }, h('div', { className: 'dmg-card-title' }, h('strong', null, present(card.name) ? card.name : card.provider), h('button', { type: 'button', className: 'dmg-button', disabled: busy, onClick: onRefresh }, '
|
|
32
|
+
return h('article', { className: 'dmg-card', 'data-card': '' }, h('div', { className: 'dmg-card-title' }, h('strong', null, present(card.name) ? card.name : card.provider), h('button', { type: 'button', className: 'dmg-button', disabled: busy, onClick: onRefresh }, t('refresh'))), facts.length ? h('div', { className: 'dmg-facts' }, ...facts) : null, ...(card.windows ?? []).map((window, index) => h(WindowRow, { key: index, window })), resets && typeof resets.count === 'number' ? h('div', { className: 'dmg-facts', 'data-field': 'resets' }, h('span', null, h('b', null, t('resetCountLabel')), t('resetRemaining'), h('span', { 'data-reset-count': '' }, String(resets.count)), t('resetRemainingSuffix')), present(resets.until) ? h('span', null, h('b', null, t('resetExpiresLabel')), time(resets.until)) : null) : null, present(card.error) ? h('p', { className: 'dmg-error', 'data-field': 'error', role: 'alert' }, card.error) : null);
|
|
32
33
|
}
|
package/lib/client/index.d.ts
CHANGED
|
@@ -12,11 +12,20 @@ interface SlotRegistration {
|
|
|
12
12
|
label?: () => string;
|
|
13
13
|
inject?: (sessionId: string) => unknown;
|
|
14
14
|
}
|
|
15
|
+
/** Minimal locale face: register the dictionaries once and bind a translator. */
|
|
16
|
+
interface LocaleService {
|
|
17
|
+
register(namespace: string, dictionaries: {
|
|
18
|
+
zh: Record<string, string>;
|
|
19
|
+
en: Record<string, string>;
|
|
20
|
+
}): unknown;
|
|
21
|
+
bind(namespace: string): (key: string) => string;
|
|
22
|
+
}
|
|
15
23
|
interface ClientContext {
|
|
16
24
|
slots: {
|
|
17
25
|
inject(name: string, register: () => unknown): void;
|
|
18
26
|
register(registration: SlotRegistration, component: unknown): unknown;
|
|
19
27
|
};
|
|
28
|
+
locale: LocaleService;
|
|
20
29
|
get(name: string): unknown;
|
|
21
30
|
effect(run: () => () => void, label: string): unknown;
|
|
22
31
|
}
|
package/lib/client/index.js
CHANGED
|
@@ -2,9 +2,12 @@ import { MagpieSettings } from './SettingsPage.js';
|
|
|
2
2
|
import { SpeedSelect, createModelWatcher, createSpeedLoader, createSpeedSetter } from './SpeedSelect.js';
|
|
3
3
|
import { createFetchApi } from './api.js';
|
|
4
4
|
import { registerMagpieSettingsNavIcon } from './settings-nav-icon.js';
|
|
5
|
+
import { NS, bindLocale, en, zh } from './locales.js';
|
|
5
6
|
export const name = 'dsh-magpie-connector/client';
|
|
6
|
-
export const inject = ['slots'];
|
|
7
|
+
export const inject = ['slots', 'locale'];
|
|
7
8
|
export function apply(ctx, api = createFetchApi()) {
|
|
9
|
+
ctx.effect(() => { ctx.locale.register(NS, { zh, en }); return () => { }; }, 'dsh-magpie-connector: dictionaries');
|
|
10
|
+
bindLocale(ctx.locale.bind(NS));
|
|
8
11
|
const label = () => 'Magpie';
|
|
9
12
|
ctx.effect(() => registerMagpieSettingsNavIcon(label), 'dsh-magpie-connector: settings nav icon');
|
|
10
13
|
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
package/lib/client/latency.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/** One measurement pass over every saved line: readings keyed by line id.
|
|
1
|
+
/** One measurement pass over every saved line: readings keyed by line id. `measuring` marks a
|
|
2
|
+
* single row being re-measured on its own (line check), so other rows keep their reading. */
|
|
2
3
|
export type LatencyState = {
|
|
3
4
|
phase: 'idle';
|
|
4
5
|
} | {
|
|
@@ -6,6 +7,7 @@ export type LatencyState = {
|
|
|
6
7
|
} | {
|
|
7
8
|
phase: 'done';
|
|
8
9
|
readings: ReadonlyMap<string, number | null>;
|
|
10
|
+
measuring?: ReadonlySet<string>;
|
|
9
11
|
} | {
|
|
10
12
|
phase: 'failed';
|
|
11
13
|
};
|
package/lib/client/latency.js
CHANGED
|
@@ -38,6 +38,8 @@ export function lineLatency(state, line) {
|
|
|
38
38
|
return { state: 'failed' };
|
|
39
39
|
if (state.phase !== 'done')
|
|
40
40
|
return { state: 'measuring' };
|
|
41
|
+
if (state.measuring?.has(line.id))
|
|
42
|
+
return { state: 'measuring' };
|
|
41
43
|
if (!state.readings.has(line.id))
|
|
42
44
|
return { state: 'idle' };
|
|
43
45
|
const ms = state.readings.get(line.id) ?? null;
|