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 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
- <p align="center">
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
+ ![Model picker grouped by upstream provider: Magpie (Claude), Magpie (Codex) and Magpie (Deepseek)](docs/model-picker.en.png)
9
6
 
10
- *Left: model picker grouped by upstream provider. Right: Settings → Magpie — the connection line, then per-provider model visibility, default effort and usage cards. Captured from an isolated DSH host with a local mock Magpie gateway; models and accounts are synthetic.*
7
+ ![Settings: the connection line on top, then models grouped by provider with visibility, default effort and usage cards](docs/settings.en.png)
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
  ![Magpie connector architecture: Web GUI and Host plugin, credentials and settings, Magpie gateway and upstream providers, optionally through Cloudflare Access](docs/architecture.en.svg)
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
- <p align="center">
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
+ ![模型选择器:按上游 provider 分组为 Magpie (Claude)、Magpie (Codex)、Magpie (Deepseek)](docs/model-picker.png)
9
6
 
10
- *左:按上游 provider 分组的模型选择器;右:设置 → Magpie,顶部连接线路,下方按 provider 分组的模型可见性、默认 effort 与用量卡片。截图来自隔离 DSH 宿主与本地 mock Magpie 网关,模型和账号均为演示数据。*
7
+ ![设置页:顶部连接线路,下方按 provider 分组的模型可见性、默认 effort 与用量卡片](docs/settings.png)
8
+
9
+ *上:按上游 provider 分组的模型选择器;下:设置 → Magpie,顶部连接线路,下方按 provider 分组的模型可见性、默认 effort 与用量卡片。截自隔离 DSH 宿主与本地 mock Magpie 网关,网关地址、模型和账号均为演示数据。*
11
10
 
12
11
  ![Magpie connector 架构:Web GUI 与 Host 插件、凭据与设置、Magpie 网关与上游 provider,可选经 Cloudflare Access](docs/architecture.zh.svg)
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
 
@@ -1,6 +1,6 @@
1
1
  # Screenshots
2
2
 
3
- | file | kind | source | date | reviewer | verdict | note |
4
- |---|---|---|---|---|---|---|
5
- | model-picker.png | screenshot | frame 11 of the 2026-10-28 recording (isolated DSH_HOME instance, temp HOME/XDG/DSH_HOME under /tmp, port 3199; local mock Magpie gateway on loopback with demo models; lossless deviceScaleFactor-2 screenshots of the real Web UI, 1500x880 at 1:1), exported losslessly from the global-palette GIF | 2026-10-29 | lead | pass (reviewed frame: demo workspace and mock models only; no hostname, user, path, token or real session) | 106789 bytes; picker open with Magpie (Claude), Magpie (GPT) and Magpie (deepseek) groups; replaces model-picker.gif because the plugin store does not show GIFs by default |
6
- | settings.png | screenshot | isolated preview home (`git-common-dir/ws/dsh-home/<op>`, port 3202) running the 0.4.0 worktree build against a local loopback mock Magpie gateway (demo models and usage cards); headless Chrome 156 via agent-browser at 1600x1600 CSS pixels | 2026-10-30 | agent (privacy-reviewed; pending lead acceptance) | pass (reviewed: loopback/mock-only addresses 127.0.0.1 and 10.255.255.1, demo@example.com account, demo models and readings; no real hostname, user, key, path, token or session) | 137797 bytes; 0.4.0 connection section with two lines collapsed by default (fast line with 4-bar latency reading, backup line showing timeout), then the claude group with per-model default effort and its usage card with rounded bars; replaces the 0.3.1 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
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 "获取模型与用量" only while no card is shown.
14
+ * connection panel offers "Get models and usage" only while no card is shown.
14
15
  */
15
- export const EMPTY_TEXT = 'Magpie 未返回可见用量';
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: '可达', unauthorized: 'key 无效', forbidden: '无权访问', http: '网关错误', redirect: '被重定向',
22
- unreachable: '连不上', timeout: '超时', 'invalid-catalog': '目录无法解析', incomplete: '配置不完整',
23
- invalid: '请求无效', busy: '已有检查在进行', pending: '宿主正在保存,稍后再试',
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 parts = [CHECK_KIND[snapshot.kind] ?? snapshot.kind];
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(`${snapshot.modelCount ?? snapshot.models?.length ?? 0} 个模型`);
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 ? `与缓存目录一致${lastSyncAt ? `(缓存于 ${formatTime(lastSyncAt)})` : ''}` : `多 ${diff.added.length} · 少 ${diff.removed.length}`);
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 测速中 (the line-check refresh). */
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(`刷新失败:${failureText(response)}`);
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(`刷新失败:${errorText(error)}`);
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' }, '通过自托管的 Magpie 网关使用模型。用量卡片只读,打开本页时读取一次,之后只在你点击卡片上的刷新时重新读取。'), settingsError ? h('p', { className: 'dmg-error', role: 'alert' }, `无法读取设置:${settingsError}`) : null, settings ? h(Connection, { settings, saving: savingSettings, mutateSettings, api, latency, onRefreshLatency: (lineId) => { void readLatency(lineId); },
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' }, '正在读取设置…'), ...providerOrder(groups, cardList).map(provider => h(ProviderGroup, {
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' ? '当前 gateway key 无权读取用量'
314
- : state === 'unconfigured' ? '填写网关地址和 key 后才能读取用量'
315
- : state === 'error' ? '读取用量失败'
316
- : state === 'empty' ? EMPTY_TEXT : undefined;
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' }, RESTRICTED_HINT) : null,
322
- notice === 'not-reread' ? h('p', { key: 'n', className: 'dmg-hint', role: 'status' }, NOT_REREAD) : null,
323
- busy && canFetch ? h('p', { key: 'b', className: 'dmg-hint', role: 'status' }, '正在请求 Magpie 重新读取…') : null,
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': '用量' }, ...lines, canFetch ? h('div', { className: 'dmg-row' }, h('button', { type: 'button', className: 'dmg-button', disabled: busy || saving, onClick: onFetchAll }, '获取模型与用量')) : null);
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 ? 'key 来自启动环境,不能在这里修改' : `未保存:${failureText(response)}` };
335
+ return { error: response.status === 409 ? t('envKeyReadonly') : t('notSaved', { reason: failureText(response) }) };
336
336
  }
337
337
  catch (error) {
338
- return { error: `未保存:${errorText(error)}` };
338
+ return { error: t('notSaved', { reason: errorText(error) }) };
339
339
  }
340
340
  }
341
- const ACCESS_MODES = [{ id: 'direct', label: '直连' }, { id: 'cloudflare', label: 'Cloudflare Access' }];
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 ? `已保存 · 当前线路 ${index + 1}` : '已保存 · 当前无生效线路';
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) ? '检查结果无法解析' : failureText(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]: `检查失败:${errorText(error)}` }));
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': '连接' }, h('div', { className: 'dmg-head' }, h('h3', null, '连接'), h('span', { className: 'dmg-info', role: 'img', tabIndex: 0, 'aria-label': INFO_TEXT, title: INFO_TEXT }, 'ⓘ')), drafts.length === 0 ? h('p', { className: 'dmg-hint' }, '还没有线路。点击「添加线路」填写网关地址与 key。') : null, h('div', { className: 'dmg-lines' }, ...drafts.map((draft, index) => {
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 ? '添加备用线路' : '添加线路')), h('div', { className: 'dmg-row' }, h('button', { type: 'button', className: 'dmg-button', disabled: saving || !dirty, onClick: save }, saving ? '保存中…' : '保存'), h('button', { type: 'button', className: 'dmg-button', disabled: saving, onClick: () => { void post({ resync: true }, '已刷新模型列表'); } }, '刷新模型列表')), h(SyncStatus, { settings }), message ? h('p', { className: message.failed ? 'dmg-error' : 'dmg-ok', ...(message.failed ? { role: 'alert' } : {}) }, message.text) : null, usage);
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 = `${name}线路 ${lineLabel}`;
554
- const label = disabled && reason ? `${action}(${reason})` : action;
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' ? '测速中…' : latency.state === 'failed' ? '—' : latency.state === 'done' ? (reading ? `${latency.ms} ms` : '超时') : '';
568
- const title = latency.state === 'done' ? (reading ? `TCP 建连耗时 ${latency.ms} ms(含 DNS 解析),不是网关健康检查` : '600ms 内未完成 TCP 建连(可能很慢或不可达)') : undefined;
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' }, `多出:${diff.added.join('、')}`) : null,
574
- diff.removed.length ? h('p', { key: 'removed', className: 'dmg-hint' }, `缺少:${diff.removed.join('、')}`) : null,
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' }, `线路 ${lineLabel}`), active ? h('span', { className: 'dmg-active', role: 'img', 'aria-label': '当前线路', title: '当前线路', 'data-active-line': draft.id }) : null, draft.id && !draft.complete ? h('span', { className: 'dmg-unset', 'data-line-incomplete': '' }, '未配置') : null, h('span', { className: 'dmg-line-title' }, `${hostnameOf(draft.baseUrl)}${draft.id ? '' : '(未保存)'}`), h(LatencySlot, { latency })), h('div', { className: 'dmg-line-actions' }, iconButton('上移', h(IconChevronUpOutlineRegular, null), lineLabel, saving || index === 0, () => onMove(-1), '已是第一条'), iconButton('下移', h(IconChevronDownOutlineRegular, null), lineLabel, saving || index === total - 1, () => onMove(1), '已是最后一条'), iconButton('删除', h(IconTrashOutlineRegular, null), lineLabel, saving || total <= 1, onRemove, '仅剩一条'))), expanded ? h('div', { className: 'dmg-line-body', id: bodyId }, h('label', { className: 'dmg-field' }, h('span', null, '网关地址(含 /v1)'), 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, `Gateway API key · ${draft.keyConfigured ? '已配置' : '未配置'}${draft.keyWritable ? '' : '(来自启动环境,只读)'}`), h('input', { className: 'dmg-input', type: 'password', autoComplete: 'off', value: draft.apiKey, disabled: saving || !draft.keyWritable,
579
- placeholder: draft.keyConfigured ? '留空则保持不变' : '必填', onChange: (e) => onChange({ apiKey: e.target.value }) })), h('div', { className: 'dmg-check', 'data-check': '' }, h('div', { className: 'dmg-row' }, h('button', {
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 ? '检查中…' : '检查线路'), check ? h('button', { type: 'button', className: 'dmg-button', disabled: saving || checking, onClick: onClearCheck }, '清除') : null), h('p', { className: 'dmg-hint' }, '只读快照:不改当前线路,也不写入配置。'), 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, `模型 id(${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, '高级连接'), h('div', { className: 'dmg-advanced-body' }, h('label', { className: 'dmg-field' }, h('span', null, `接入方式${draft.accessWritable ? '' : '(Access 凭据来自启动环境,只读)'}`), h('select', { className: 'dmg-select', 'aria-label': '接入方式', value: draft.accessMode, disabled: saving || !draft.accessWritable,
583
- onChange: (e) => onChange({ accessMode: e.target.value }) }, ...ACCESS_MODES.map(mode => h('option', { key: mode.id, value: mode.id }, mode.label)))), draft.accessMode === 'direct'
584
- ? h('p', { className: 'dmg-hint' }, draft.savedAccess ? '保存后将清除已保存的 Cloudflare Access 凭据。' : '网关地址可直接访问时选择(局域网、Tailscale 等组网后的地址)。')
585
- : h('p', { className: 'dmg-hint' }, '网关在 Cloudflare Access 之后时选择:填写服务令牌的两项,仅支持 HTTPS。留空保持不变。'), ...(draft.accessMode === 'cloudflare' ? [
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, `${label} · ${draft.accessConfigured ? '已配置' : '未配置'}${draft.accessWritable ? '' : '(来自启动环境,只读)'}`), h('input', { className: 'dmg-input', type: 'password', autoComplete: 'off', value,
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, `共 ${count} 个模型 · 更新于 `, h('time', { dateTime: settings.lastSyncAt }, formatTime(settings.lastSyncAt))) : null, settings.lastSyncError ? h('p', { className: 'dmg-error' }, `同步失败${settings.lastSyncErrorKind ? `(${SYNC_KIND[settings.lastSyncErrorKind] ?? settings.lastSyncErrorKind})` : ''}:${settings.lastSyncError}`) : null, registration ? h('p', { className: 'dmg-error', role: 'alert' }, `Magpie 路由注册失败(${registration.code}${registration.route ? `,${registration.route}` : ''}):需重启 Host 后才能恢复。`) : null, settings.knownOwnersReset ? h('p', { className: 'dmg-hint' }, '归属列表已重置:已保存的归属无效,下次同步会重新建立。') : null, settings.ownerLimitReached ? h('p', { className: 'dmg-hint' }, `归属数量已达上限(32 个),以下归属未能加入:${rejected.join('、')}`) : 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 }, '跟随网关模型列表') : null), group ? h('p', { className: 'dmg-hint' }, following ? '可见性:跟随网关模型列表,新上架的模型自动出现' : '可见性:只显示勾选的模型,新上架的模型默认隐藏') : null, models.length ? h('div', { className: 'dmg-models' }, ...models.map(model => h(ModelLine, {
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' }, NOT_REREAD) : null, alert ? h('p', { className: 'dmg-error', role: 'alert', 'data-usage-alert': '' }, alert) : null, error ? h('p', { className: 'dmg-error', role: 'alert' }, error) : null);
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': `在选择器中显示 ${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', {
640
- className: 'dmg-select', 'aria-label': `${label} 默认 effort`, disabled,
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: '' }, '默认 effort:不设置'), stale ? h('option', { value: effort, disabled: true }, `${effort}(网关当前不提供,暂不生效)`) : null, ...efforts.map(id => h('option', { key: id, value: id }, id))) : null);
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', name: '标准', description: '默认速度与用量' },
56
- { id: 'fast', name: '快速', description: '请求带 priority 服务档,更快但消耗更多用量' },
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 = `速度 · ${TIERS.find(t => t.id === state.tier).name}`;
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' }, '速度未保存,请重试') : null, open ? h('div', { className: 'dmg-speed-menu', role: 'menu', 'aria-label': '速度' }, ...TIERS.map(tier => h('button', {
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.name, h('small', null, tier.description))))) : null, h('button', {
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) {
@@ -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 ? '是' : '否') : String(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, '重置于 ', 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
+ 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', '账号', card.user),
24
- present(card.plan) && fact('plan', '计划', card.plan),
25
- present(card.until) && fact('until', '到期', time(card.until)),
26
- present(card.renew) && fact('renew', '续订', scalar(card.renew)),
27
- present(card.balance) && fact('balance', '余额', scalar(card.balance)),
28
- present(read) && fact('readAt', card.asOf !== undefined ? '旧读数 · 读取于' : '读取于', time(read)),
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 }, '刷新')), 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, '用量限制重置 '), '剩余 ', h('span', { 'data-reset-count': '' }, String(resets.count)), ' 次'), present(resets.until) ? h('span', null, h('b', null, '过期 '), time(resets.until)) : null) : null, present(card.error) ? h('p', { className: 'dmg-error', 'data-field': 'error', role: 'alert' }, card.error) : null);
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
  }
@@ -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
  }
@@ -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({