dsh-magpie-connector 0.4.2 → 0.5.1
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 +11 -0
- package/README.md +5 -5
- package/README.zh.md +5 -5
- package/docs/SCREENSHOTS.md +4 -4
- package/docs/model-picker.en.png +0 -0
- package/docs/model-picker.png +0 -0
- package/docs/settings.en.png +0 -0
- package/docs/settings.png +0 -0
- package/lib/client/SettingsPage.d.ts +0 -10
- package/lib/client/SettingsPage.js +56 -57
- 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/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 +434 -104
- package/package.json +4 -2
- package/screenshots.json +1 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1 — 2026-11-02
|
|
4
|
+
|
|
5
|
+
- README 中英首屏改用 deviceScaleFactor 3 的静态 PNG(3300x1920 / 2400x2400),替换旧的低分辨率图与 GIF;同时改为 markdown 图片写法,商店详情页可直接渲染。
|
|
6
|
+
- 仅文档与图片调整,无代码/行为变更。
|
|
7
|
+
|
|
8
|
+
## 0.5.0 — 2026-10-31
|
|
9
|
+
|
|
10
|
+
- 设置页与速度选择器支持界面语言:新增 `settings.magpie` 的 zh/en 字典(`src/client/locales.ts`),通过 `ctx.locale` 注册并在 `apply()` 绑定;文案不再硬编码中文,跟随 DSH 界面语言(英文界面下为英文)。未绑定翻译器时(单测、无 locale 服务的宿主)仍回退到中文,行为不变。
|
|
11
|
+
- `dsh.client.inject` 与 peer 依赖补 `@deepseek-ai/dsh-client-locale`。
|
|
12
|
+
- 新增字典键集合一致性与中英切换测试(`test/client-locales.test.ts`)。
|
|
13
|
+
|
|
3
14
|
## 0.4.2
|
|
4
15
|
|
|
5
16
|
- **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.
|
package/README.md
CHANGED
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
English · [简体中文](README.zh.md)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
<img src="docs/model-picker.png" alt="Model picker grouped by upstream provider: Magpie (Claude), Magpie (GPT), Magpie (deepseek)" width="49%">
|
|
7
|
-
<img src="docs/settings.png" alt="Settings: the connection line on top, then models grouped by provider with visibility, default effort and usage cards" width="49%">
|
|
8
|
-
</p>
|
|
5
|
+

|
|
9
6
|
|
|
10
|
-
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
*Top: model picker grouped by upstream provider. Bottom: Settings → Magpie — the connection line, then per-provider model visibility, default effort and usage cards. Captured from an isolated DSH host (English UI) with a local mock Magpie gateway; gateway address, models, account and readings are synthetic.*
|
|
11
10
|
|
|
12
11
|

|
|
13
12
|
|
|
@@ -70,6 +69,7 @@ Need another method — custom headers, mTLS, another zero-trust gateway? **Issu
|
|
|
70
69
|
- **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
70
|
- **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
71
|
- **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`.
|
|
72
|
+
- **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
73
|
|
|
74
74
|
### Code map
|
|
75
75
|
|
package/README.zh.md
CHANGED
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) · 简体中文
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
<img src="docs/model-picker.png" alt="模型选择器:按上游 provider 分组为 Magpie (Claude)、Magpie (GPT)、Magpie (deepseek)" width="49%">
|
|
7
|
-
<img src="docs/settings.png" alt="设置页:顶部连接线路,下方按 provider 分组的模型可见性、默认 effort 与用量卡片" width="49%">
|
|
8
|
-
</p>
|
|
5
|
+

|
|
9
6
|
|
|
10
|
-
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
*上:按上游 provider 分组的模型选择器;下:设置 → Magpie,顶部连接线路,下方按 provider 分组的模型可见性、默认 effort 与用量卡片。截自隔离 DSH 宿主与本地 mock Magpie 网关,网关地址、模型和账号均为演示数据。*
|
|
11
10
|
|
|
12
11
|

|
|
13
12
|
|
|
@@ -72,6 +71,7 @@ dsh plugin --profile web add dsh-magpie-connector
|
|
|
72
71
|
- **传输边界刻意收窄。** host 请求经私有 `undici` dispatcher 直连配置的网关,不经过全局代理 dispatcher;拒绝重定向;只允许 `GET /models`、`POST /responses`、`POST /chat/completions`、`GET /magpie/quotas/cards`、`POST /magpie/quotas/refresh`;从不调用 Magpie GUI 的 `/api/*` 管理接口。厂商出站代理在 Magpie 侧配置。
|
|
73
72
|
- **不支持:** 非空 `stop` 序列会在发请求前失败(`MAGPIE_STOP_UNSUPPORTED`),因为 Magpie 无法在所有上游协议转换中保留 stop。用量卡片与 Codex 重置次数只读展示,插件不会调用账号管理或重置端点。
|
|
74
73
|
- **密钥不会进入响应或日志。** 网关反射的文本在进入设置接口或日志之前会用当前 key 脱敏。写盘文件权限 `0600`;设置接口只报告 `configured` / `writable`。
|
|
74
|
+
- **界面跟随 DSH 语言。** 0.5.0 起设置页与速度菜单带中英文字典,按当前 DSH 界面语言渲染(`ctx.locale`);0.5.0 之前文案只有中文。宿主没有 locale 服务(或单测环境)时回退到中文参考集,已保存设置与行为不受影响。
|
|
75
75
|
|
|
76
76
|
### 代码导览
|
|
77
77
|
|
package/docs/SCREENSHOTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Screenshots
|
|
2
2
|
|
|
3
|
-
|
|
|
4
|
-
|
|
5
|
-
| model-picker.png | screenshot |
|
|
6
|
-
| settings.png | screenshot |
|
|
3
|
+
| model-picker.png | screenshot | 0.5.0 build in an isolated DSH_HOME (temp HOME/DSH_HOME under /tmp, port 3399) against a local loopback mock Magpie gateway; Chinese UI; headless Chrome 156 via CDP at deviceScaleFactor 3, region clip 1100x640 | 2026-11-02 | agent (privacy-reviewed; pending lead acceptance) | pass (reviewed: Chinese UI, demo workspace, loopback gateway address, mock models only; no hostname, user, key, path or real session) | 3300x1920, 195233 bytes; model picker open with Magpie (Claude), Magpie (Codex) and Magpie (Deepseek) groups; Chinese README first image |
|
|
4
|
+
| settings.png | screenshot | same 0.5.0 isolated host and mock gateway; Chinese UI; settings dialog region 800x800 at deviceScaleFactor 3 | 2026-11-02 | agent (privacy-reviewed; pending lead acceptance) | pass (reviewed: Chinese settings copy, demo@example.com account, mock models and readings; no hostname, user, key, path or real session) | 2400x2400, 321018 bytes; connection line with latency, 5 models, per-model default effort and the Claude usage card; Chinese README second image |
|
|
5
|
+
| model-picker.en.png | screenshot | same 0.5.0 isolated host and mock gateway; English UI; region clip 1100x640 at deviceScaleFactor 3 | 2026-11-02 | agent (privacy-reviewed; pending lead acceptance) | pass (reviewed: English UI, demo workspace, loopback gateway address, mock models only; no hostname, user, key, path or real session) | 3300x1920, 188924 bytes; English README first image |
|
|
6
|
+
| settings.en.png | screenshot | same 0.5.0 isolated host and mock gateway; English UI; settings dialog region 800x800 at deviceScaleFactor 3 | 2026-11-02 | agent (privacy-reviewed; pending lead acceptance) | pass (reviewed: English settings copy, demo@example.com account, mock models and readings; no hostname, user, key, path or real session) | 2400x2400, 326190 bytes; English README second image |
|
|
Binary file
|
package/docs/model-picker.png
CHANGED
|
Binary file
|
|
Binary file
|
package/docs/settings.png
CHANGED
|
Binary file
|
|
@@ -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}`);
|
|
@@ -93,7 +93,7 @@ export function MagpieSettings({ api }) {
|
|
|
93
93
|
};
|
|
94
94
|
/** One measurement per panel open (and after a successful line write); failures stay silent.
|
|
95
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
|
|
96
|
+
* reading instead of all flipping to the measuring state (the line-check refresh). */
|
|
97
97
|
const readLatency = async (only) => {
|
|
98
98
|
const epoch = ++latencyEpoch.current;
|
|
99
99
|
if (only === undefined)
|
|
@@ -101,7 +101,7 @@ export function MagpieSettings({ api }) {
|
|
|
101
101
|
else
|
|
102
102
|
setLatency(current => current.phase === 'done' ? { phase: 'done', readings: current.readings, measuring: new Set([only]) } : current);
|
|
103
103
|
const settle = () => {
|
|
104
|
-
// Superseded by a newer pass: drop the stale result but never leave the row stuck on
|
|
104
|
+
// Superseded by a newer pass: drop the stale result but never leave the row stuck on measuring.
|
|
105
105
|
if (only === undefined)
|
|
106
106
|
return;
|
|
107
107
|
setLatency(current => current.phase === 'done' ? { phase: 'done', readings: current.readings } : current);
|
|
@@ -247,7 +247,7 @@ export function MagpieSettings({ api }) {
|
|
|
247
247
|
return;
|
|
248
248
|
// A failed refresh is not a card result: keep what was shown and say why.
|
|
249
249
|
if (!isOk(response)) {
|
|
250
|
-
setUsageAlert(
|
|
250
|
+
setUsageAlert(t('refreshFailed', { reason: failureText(response) }));
|
|
251
251
|
return;
|
|
252
252
|
}
|
|
253
253
|
const next = parseCards(response.body, response.status);
|
|
@@ -256,7 +256,7 @@ export function MagpieSettings({ api }) {
|
|
|
256
256
|
}
|
|
257
257
|
catch (error) {
|
|
258
258
|
if (epoch === cardsEpoch.current)
|
|
259
|
-
setUsageAlert(
|
|
259
|
+
setUsageAlert(t('refreshFailed', { reason: errorText(error) }));
|
|
260
260
|
}
|
|
261
261
|
finally {
|
|
262
262
|
if (epoch === cardsEpoch.current) {
|
|
@@ -279,9 +279,9 @@ export function MagpieSettings({ api }) {
|
|
|
279
279
|
}
|
|
280
280
|
};
|
|
281
281
|
const groupNote = (provider) => noteScope === provider ? { notice, alert: usageAlert } : {};
|
|
282
|
-
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); },
|
|
283
283
|
usage: h(UsageStatus, { cards, busy, ...(noteScope === undefined ? { notice, alert: usageAlert } : {}), onFetchAll: () => { void fetchAll(); }, saving: savingSettings }) })
|
|
284
|
-
: 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, {
|
|
285
285
|
key: provider, provider,
|
|
286
286
|
group: groups.find(g => g.provider === provider),
|
|
287
287
|
settings,
|
|
@@ -309,42 +309,41 @@ function noticeAfter(target, before, next) {
|
|
|
309
309
|
*/
|
|
310
310
|
function UsageStatus({ cards, busy, saving, notice, alert, onFetchAll }) {
|
|
311
311
|
const state = !cards ? 'loading' : cards.status !== 'ok' ? cards.status : cards.cards.length ? 'ok' : 'empty';
|
|
312
|
-
const message = state === 'loading' ? '
|
|
313
|
-
: state === 'forbidden' ? '
|
|
314
|
-
: state === 'unconfigured' ? '
|
|
315
|
-
: state === 'error' ? '
|
|
316
|
-
: 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;
|
|
317
317
|
const canFetch = state === 'empty' || state === 'error';
|
|
318
318
|
const lines = [
|
|
319
319
|
message ? h('p', { key: 'm', className: state === 'error' || state === 'forbidden' ? 'dmg-error' : 'dmg-hint', role: 'status' }, message) : null,
|
|
320
320
|
cards?.error && state !== 'ok' ? h('p', { key: 'e', className: 'dmg-error' }, cards.error) : null,
|
|
321
|
-
state === 'empty' && notice === 'restricted' ? h('p', { key: 'r', className: 'dmg-hint' },
|
|
322
|
-
notice === 'not-reread' ? h('p', { key: 'n', className: 'dmg-hint', role: 'status' },
|
|
323
|
-
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,
|
|
324
324
|
alert ? h('p', { key: 'a', className: 'dmg-error', role: 'alert', 'data-usage-alert': '' }, alert) : null,
|
|
325
325
|
].filter(Boolean);
|
|
326
326
|
if (!lines.length && !canFetch)
|
|
327
327
|
return null;
|
|
328
|
-
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);
|
|
329
329
|
}
|
|
330
330
|
async function postSettings(api, body) {
|
|
331
331
|
try {
|
|
332
332
|
const response = await api({ method: 'POST', path: 'settings', body });
|
|
333
333
|
if (isOk(response))
|
|
334
334
|
return { view: response.body };
|
|
335
|
-
return { error: response.status === 409 ? '
|
|
335
|
+
return { error: response.status === 409 ? t('envKeyReadonly') : t('notSaved', { reason: failureText(response) }) };
|
|
336
336
|
}
|
|
337
337
|
catch (error) {
|
|
338
|
-
return { error:
|
|
338
|
+
return { error: t('notSaved', { reason: errorText(error) }) };
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
|
-
const ACCESS_MODES = [{ id: 'direct',
|
|
342
|
-
const INFO_TEXT = '线路按序号优先尝试,连不上自动试下一条;绿色方块表示当前线路;多条线路应指向同一个 Magpie 网关;延迟是 TCP 建连耗时(含 DNS 解析),不是网关健康检查。';
|
|
341
|
+
const ACCESS_MODES = [{ id: 'direct', labelKey: 'direct' }, { id: 'cloudflare', labelKey: 'cloudflareAccess' }];
|
|
343
342
|
const hostnameOf = (baseUrl) => { try {
|
|
344
343
|
return new URL(baseUrl).hostname || baseUrl;
|
|
345
344
|
}
|
|
346
345
|
catch {
|
|
347
|
-
return baseUrl || '
|
|
346
|
+
return baseUrl || t('hostnameUnset');
|
|
348
347
|
} };
|
|
349
348
|
function lineDrafts(settings) {
|
|
350
349
|
const configured = (access) => access?.clientId?.configured === true || access?.clientSecret?.configured === true;
|
|
@@ -454,7 +453,7 @@ function Connection({ settings, saving, mutateSettings, api, usage, latency, onR
|
|
|
454
453
|
};
|
|
455
454
|
const savedLabel = (view) => {
|
|
456
455
|
const index = (view.lines ?? []).findIndex(line => line.id === view.activeLineId);
|
|
457
|
-
return index >= 0 ?
|
|
456
|
+
return index >= 0 ? t('savedLine', { n: index + 1 }) : t('savedNoActive');
|
|
458
457
|
};
|
|
459
458
|
const save = () => {
|
|
460
459
|
// A save attempt retires every snapshot: the checked draft is about to become the saved one,
|
|
@@ -502,7 +501,7 @@ function Connection({ settings, saving, mutateSettings, api, usage, latency, onR
|
|
|
502
501
|
}
|
|
503
502
|
const snapshot = parseCheck(response.body);
|
|
504
503
|
if (!snapshot) {
|
|
505
|
-
setCheckErrors(current => ({ ...current, [draft.draftId]: isOk(response) ? '
|
|
504
|
+
setCheckErrors(current => ({ ...current, [draft.draftId]: isOk(response) ? t('checkUnparsable') : failureText(response) }));
|
|
506
505
|
return;
|
|
507
506
|
}
|
|
508
507
|
setChecks(current => ({ ...current, [draft.draftId]: { signature, activeLineId: activeId, result: snapshot } }));
|
|
@@ -513,7 +512,7 @@ function Connection({ settings, saving, mutateSettings, api, usage, latency, onR
|
|
|
513
512
|
}
|
|
514
513
|
catch (error) {
|
|
515
514
|
if (epoch === checkEpoch.current)
|
|
516
|
-
setCheckErrors(current => ({ ...current, [draft.draftId]:
|
|
515
|
+
setCheckErrors(current => ({ ...current, [draft.draftId]: t('checkFailed', { reason: errorText(error) }) }));
|
|
517
516
|
}
|
|
518
517
|
finally {
|
|
519
518
|
if (epoch === checkEpoch.current) {
|
|
@@ -528,7 +527,7 @@ function Connection({ settings, saving, mutateSettings, api, usage, latency, onR
|
|
|
528
527
|
setCheckErrors(current => { const next = { ...current }; delete next[draftId]; return next; });
|
|
529
528
|
};
|
|
530
529
|
const catalogIds = cacheIds(settings.groups);
|
|
531
|
-
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) => {
|
|
532
531
|
const entry = checks[draft.draftId];
|
|
533
532
|
const fresh = entry && entry.signature === checkSignature(draft) && entry.activeLineId === activeId ? entry : undefined;
|
|
534
533
|
return h(LineCard, {
|
|
@@ -547,11 +546,11 @@ function Connection({ settings, saving, mutateSettings, api, usage, latency, onR
|
|
|
547
546
|
onCheck: () => { void runCheck(draft); },
|
|
548
547
|
onClearCheck: () => clearCheck(draft.draftId),
|
|
549
548
|
});
|
|
550
|
-
})), 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);
|
|
551
550
|
}
|
|
552
551
|
function iconButton(name, icon, lineLabel, disabled, onClick, reason) {
|
|
553
|
-
const action =
|
|
554
|
-
const label = disabled && reason ?
|
|
552
|
+
const action = t('lineAction', { action: name, line: lineLabel });
|
|
553
|
+
const label = disabled && reason ? t('lineActionReason', { action, reason }) : action;
|
|
555
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));
|
|
556
555
|
return disabled ? button : h(Tooltip, { label: action, children: button });
|
|
557
556
|
}
|
|
@@ -564,36 +563,36 @@ function LatencySlot({ latency }) {
|
|
|
564
563
|
attrs['data-latency-ms'] = latency.ms === null || latency.ms === undefined ? '' : String(latency.ms);
|
|
565
564
|
}
|
|
566
565
|
const reading = latency.state === 'done' && latency.ms !== null && latency.ms !== undefined;
|
|
567
|
-
const text = latency.state === 'measuring' ? '
|
|
568
|
-
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;
|
|
569
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));
|
|
570
569
|
}
|
|
571
570
|
function LineCard({ draft, index, total, saving, active, expanded, latency, checking, checkDisabled, check, checkError, diff, lastSyncAt, onToggle, onChange, onMove, onRemove, onCheck, onClearCheck }) {
|
|
572
571
|
const diffParts = diff ? [
|
|
573
|
-
diff.added.length ? h('p', { key: 'added', className: 'dmg-hint' },
|
|
574
|
-
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,
|
|
575
574
|
].filter(Boolean) : [];
|
|
576
575
|
const lineLabel = String(index + 1);
|
|
577
576
|
const bodyId = `dmg-line-body-${draft.draftId}`;
|
|
578
|
-
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' },
|
|
579
|
-
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', {
|
|
580
579
|
type: 'button', className: 'dmg-button', 'data-check-run': '',
|
|
581
580
|
disabled: saving || checkDisabled || draft.baseUrl.trim() === '', onClick: onCheck,
|
|
582
|
-
}, checking ? '
|
|
583
|
-
onChange: (e) => onChange({ accessMode: e.target.value }) }, ...ACCESS_MODES.map(mode => h('option', { key: mode.id, value: mode.id }, mode.
|
|
584
|
-
? h('p', { className: 'dmg-hint' }, draft.savedAccess ? '
|
|
585
|
-
: 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' ? [
|
|
586
585
|
['Access Client ID', draft.accessClientId, 'accessClientId'],
|
|
587
586
|
['Access Client Secret', draft.accessClientSecret, 'accessClientSecret'],
|
|
588
|
-
].map(([label, value, field]) => h('label', { key: label, className: 'dmg-field' }, h('span', null,
|
|
589
|
-
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'),
|
|
590
589
|
onChange: (e) => onChange({ [field]: e.target.value }) }))) : [])))) : null);
|
|
591
590
|
}
|
|
592
591
|
function SyncStatus({ settings }) {
|
|
593
592
|
const count = (settings.groups ?? []).reduce((n, g) => n + g.models.length, 0);
|
|
594
593
|
const rejected = settings.rejectedOwners ?? [];
|
|
595
594
|
const registration = settings.routeRegistrationError;
|
|
596
|
-
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);
|
|
597
596
|
}
|
|
598
597
|
function ProviderGroup({ provider, group, settings, cards, busy, saving, mutateSettings, onRefresh, notice, alert }) {
|
|
599
598
|
const [error, setError] = useState('');
|
|
@@ -623,21 +622,21 @@ function ProviderGroup({ provider, group, settings, cards, busy, saving, mutateS
|
|
|
623
622
|
return { visibleModels: rest };
|
|
624
623
|
});
|
|
625
624
|
};
|
|
626
|
-
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, {
|
|
627
626
|
key: model.id, model, disabled: saving,
|
|
628
627
|
visible: model.visible ?? (following || (saved[provider] ?? []).includes(model.id)),
|
|
629
628
|
effort: settings?.defaultEfforts?.[model.id],
|
|
630
629
|
onVisible: (visible) => setVisible(model, visible),
|
|
631
630
|
onEffort: (effort) => { void post({ defaultEffort: { model: model.id, effort } }); },
|
|
632
|
-
}))) : 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);
|
|
633
632
|
}
|
|
634
633
|
function ModelLine({ model, visible, effort, disabled, onVisible, onEffort }) {
|
|
635
634
|
const efforts = model.efforts ?? [];
|
|
636
635
|
// Stale = saved but no longer offered by the gateway (design D7: the client judges this itself).
|
|
637
636
|
const stale = effort !== undefined && !efforts.includes(effort);
|
|
638
637
|
const label = model.name || model.id;
|
|
639
|
-
return h('div', { className: 'dmg-model', 'data-model': model.id }, h('label', null, h('input', { type: 'checkbox', checked: visible, disabled, 'aria-label':
|
|
640
|
-
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,
|
|
641
640
|
value: effort ?? '', onChange: (e) => onEffort(e.target.value === '' ? null : e.target.value),
|
|
642
|
-
}, 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);
|
|
643
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({
|