dsh-magpie-connector 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ **BREAKING:** the settings format changes from a single top-level `baseUrl` to an ordered `lines[]` list, and credentials move to per-line derived refs. Existing `settings.json` files with only `baseUrl` and the legacy fixed refs (`MAGPIE_API_KEY`, `MAGPIE_ACCESS_CLIENT_ID`, `MAGPIE_ACCESS_CLIENT_SECRET`) are **not read, migrated or deleted**; reconfigure the connection in Settings after upgrading. `visibleModels` / `defaultEfforts` are preserved. Back up `settings.json` and the old credential refs before upgrading if you need a rollback path.
6
+
7
+ - **Multiple connection lines.** Settings hold an ordered list of 0–5 lines (empty on first install; normally one), each with its own address, gateway key and access mode (`direct` or Cloudflare Access). Array order is the priority order used for automatic selection; moving lines does not change their identity or credentials.
8
+ - **Stable random line ids.** The host generates an 8-hex-character id when a line is created (unique among existing and pending lines); it is stored in settings, never shown in the UI (the UI shows the 1-based position) and never changes when the address, key, order, or another line changes.
9
+ - **Per-line credentials.** Every line uses `MAGPIE_LINE_<ID>_API_KEY` / `MAGPIE_LINE_<ID>_ACCESS_CLIENT_ID` / `MAGPIE_LINE_<ID>_ACCESS_CLIENT_SECRET` (uppercase id). Deleting a line clears its writable refs; environment-owned values are reported through `retainedCredentials`. New lines omit `id` in POST bodies and are assigned one by the host.
10
+ - **Automatic line selection.** Before each gateway operation the first reachable complete line (priority order) is chosen with a ≤600ms TCP probe (10s positive / 15s negative in-memory cache, no background polling). A single-line setup never probes. If all lines are unreachable the first line carries the real request and the error surfaces as-is.
11
+ - **Line switching keeps the catalog.** Switching lines carries the current model catalog over (no empty flash), clears usage cards and re-syncs the new line. Editing a non-active line does not clear the catalog or trigger a sync; editing the active line keeps the identity-change semantics. Generated requests are never replayed or switched: a connection-phase failure only invalidates that line's probe cache.
12
+ - **Per-line Cloudflare Access.** Access is enabled per line and sent only to HTTPS lines that enable it; plain-HTTP lines never carry the service token. Switching a line back to Direct clears its saved Access pair on save.
13
+ - **Load validation and write safety.** Malformed/duplicate persisted lines or pending records make the whole connection read as unconfigured (no requests, no credential resolution, file untouched). Credential writes are persisted as `(lineId, field)` pending entries before secrets are written; partial failures keep the pending quarantine across restarts and require an explicit complete retry. Removed/new line ids are cleaned through that quarantine.
14
+ - **Settings UI.** Line cards with the active line expanded by default, a 4×4 green active marker after its number, an info tooltip explaining priority, add/move/delete controls, an empty state with an add button for first install, and silent switching (no toast/alert).
15
+ - **Docs/tests.** README, architecture diagrams, internal API notes and the CHANGELOG document the breaking upgrade and reconfiguration steps; the old settings screenshot was removed pending re-recording.
16
+
3
17
  ## 0.1.2
4
18
 
5
19
  - Default request output budget now reserves context space for DSH compaction: `min(max_output_tokens, max(1, context_window/4, context_window/2 − 65536))`. The gateway's capacity stays the pi-ai model output limit, so the model's real ceiling is unchanged.
package/README.md CHANGED
@@ -4,10 +4,9 @@ English · [简体中文](README.zh.md)
4
4
 
5
5
  <p align="center">
6
6
  <img src="docs/model-picker.gif" alt="Model picker grouped by upstream provider, with the GPT-only standard/fast control" width="49%">
7
- <img src="docs/settings.gif" alt="Settings: connection, Cloudflare Access, provider groups and usage cards" width="49%">
8
7
  </p>
9
8
 
10
- *Left: model picker. Right: Settings → Magpie. Recorded from an isolated DSH host with a local mock Magpie gateway; models and accounts are synthetic.*
9
+ *Model picker recorded from an isolated DSH host with a local mock Magpie gateway; models and accounts are synthetic. A fresh settings screenshot is pending re-recording.*
11
10
 
12
11
  [Repository](https://github.com/prgrmrwy/dsh-magpie-connector) · [Releases](https://github.com/prgrmrwy/dsh-magpie-connector/releases) · [npm](https://www.npmjs.com/package/dsh-magpie-connector)
13
12
 
@@ -18,9 +17,10 @@ English · [简体中文](README.zh.md)
18
17
 
19
18
  | Capability | What it does |
20
19
  |---|---|
21
- | **Gateway direct connection** (basic) | One gateway URL and one key. The model catalog comes from the gateway's `GET /models`; Responses-capable models use Responses, everything else uses Chat Completions. Text, tool round trips, images, usage, cancellation and DSH-managed retries go through. |
20
+ | **Gateway lines** (basic) | Each line has its own URL and gateway key. The model catalog comes from the gateway's `GET /models`; Responses-capable models use Responses, everything else uses Chat Completions. Text, tool round trips, images, usage, cancellation and DSH-managed retries go through. |
21
+ | **Automatic multi-line failover** (signature) | Empty on first install; normally one line; add backup lines and let the order decide priority. Before every gateway operation the plugin picks the first reachable complete line with a ≤600ms TCP probe (10s positive / 15s negative in-memory cache, no background polling), so home uses the LAN while outside falls back to the public endpoint. Switching lines carries the catalog over (no flash), clears usage cards and re-syncs the new line; single-line users keep 0.1.x behavior. |
22
22
  | **Grouped by upstream provider** (signature) | The model picker shows `Magpie (Claude)`, `Magpie (GPT)` and `Magpie (<other owner>)` headings while the real route stays one `dsh-magpie-connector`. Settings and usage cards use the same grouping, and a provider that only appears in usage is never dropped. |
23
- | **Cloudflare Access remote connection** | For a gateway behind Cloudflare Tunnel + Access, save the service token's Client ID and Secret. Every gateway request then also carries `CF-Access-Client-Id` / `CF-Access-Client-Secret`; HTTPS only. |
23
+ | **Per-line Cloudflare Access** | Each line independently chooses Direct or Cloudflare Access. Access-enabled line requests carry the service token pair plus the bearer key; the token is never sent to a plain-HTTP line. |
24
24
  | **Read-only usage cards** | Each upstream account shows plan, expiry, window readings and Codex reset credits when the gateway provides them. Cards are read once when settings open and only re-read when you press **Refresh** on that card; bars turn orange above 70% and red at 90%. |
25
25
  | **Model visibility and default effort** | Per provider, leave visibility untouched to follow the gateway catalog, or save an explicit allowlist so new models stay hidden. Per model, save a default reasoning effort; DSH applies it on the next request. |
26
26
  | **Per-session fast mode** | Models advertising the `priority` service tier get a standard/fast switch next to the composer. The choice is per session and in host memory only; it adds `service_tier: "priority"` to requests. |
@@ -39,14 +39,12 @@ The model picker headings need the managed five-package Host extension that ohmy
39
39
 
40
40
  Open **Settings → Magpie**.
41
41
 
42
- 1. Enter the gateway API URL **including `/v1`**, for example `http://192.168.1.20:3425/v1`.
43
- 2. Enter the gateway key. It is stored through DSH credentials as `MAGPIE_API_KEY`; a key supplied by the launch environment is read-only here. Address, visibility and default efforts live at `$DSH_HOME/plugins/dsh-magpie-connector/settings.json` — the key is never written there or returned by the settings API.
44
- 3. Save. Saving the connection synchronizes the catalog; **Refresh model list** synchronizes again, and there is no catalog polling.
42
+ 1. In **line 1**, enter the gateway API URL **including `/v1`**, for example `http://192.168.1.20:3425/v1`, and that line's gateway key. The line list, addresses, visibility and default efforts live at `$DSH_HOME/plugins/dsh-magpie-connector/settings.json` — keys and Access values are never written there or returned by the settings API.
43
+ 2. For multiple ingresses press **Add backup line**; each line has its own address and key. Use the move-up/move-down buttons to change priority (number 1 wins). Saving keeps only the active line expanded; the 4×4 green marker after its number shows the current line, and the info icon explains priority and automatic fallback.
44
+ 3. Inside each line's **Advanced connection** choose Direct or Cloudflare Access. LAN and Tailscale are Direct; Cloudflare Access requires an HTTPS line and switching back to Direct clears that line's two Access credentials on save. Cloudflare Free/Pro returns 524 when the origin sends nothing for 100 seconds.
45
+ 4. Saving synchronizes the catalog; **Refresh model list** synchronizes again, and there is no catalog polling.
45
46
 
46
- **Advanced connection** (expanded by default) chooses how the gateway is reached:
47
-
48
- - **Direct** (default) — the gateway URL is reachable as-is. LAN and Tailscale both count: after joining the tailnet use `http://<host>.<tailnet>.ts.net:3425/v1` or the subnet-routed address; nothing else is configured in the plugin.
49
- - **Cloudflare Access** — for Cloudflare Tunnel + Access when the client cannot run a VPN. Enter the Access service token pair; every request carries both headers plus the bearer key. Saving after switching back to Direct clears the saved Access credentials. Cloudflare Free/Pro returns 524 when the origin sends nothing for 100 seconds.
47
+ > **0.2.0 BREAKING upgrade**: settings are now a single `lines[]` format. A legacy single-address `settings.json` (top-level `baseUrl`) and the legacy fixed credential refs (`MAGPIE_API_KEY` / `MAGPIE_ACCESS_*`) are **not read or migrated**; re-add your lines and enter each line's key/Access in Settings. Model visibility and default efforts are preserved; old credential records can be deleted manually in DSH credentials. Back up `settings.json` and the old refs first if you want to keep a rollback path.
50
48
 
51
49
  Need another method — custom headers, mTLS, another zero-trust gateway? **Issues and pull requests are welcome**; describe your deployment and the gateway version.
52
50
 
@@ -94,7 +92,7 @@ Tests use local mock gateways and the real wire library; no real key is required
94
92
  dsh plugin --profile web remove dsh-magpie-connector
95
93
  ```
96
94
 
97
- Restart DSH and refresh the page. Disposal releases registrations, timers and connections; it does not remove credentials or settings. To erase data, delete the `MAGPIE_API_KEY` credential through DSH credentials management and remove the plugin settings file after locating it from the installed plugin's data directory. Do not remove the credential if another integration still uses it.
95
+ Restart DSH and refresh the page. Disposal releases registrations, timers and connections; it does not remove credentials or settings. To erase data, delete each line's `MAGPIE_LINE_<ID>_*` refs (plus any pre-0.2.0 `MAGPIE_API_KEY` / `MAGPIE_ACCESS_*` leftovers) through DSH credentials management and remove the plugin settings file after locating it from the installed plugin's data directory. Do not remove a credential if another integration still uses it.
98
96
 
99
97
  Inside [ohmydsh](https://github.com/prgrmrwy/ohmydsh), set the manifest entry to `enabled: false` and rebuild instead.
100
98
 
package/README.zh.md CHANGED
@@ -4,10 +4,9 @@
4
4
 
5
5
  <p align="center">
6
6
  <img src="docs/model-picker.gif" alt="模型选择器:按上游 provider 分组,仅 GPT 显示标准/快速" width="49%">
7
- <img src="docs/settings.gif" alt="设置页:连接、Cloudflare Access、分组配置与用量卡片" width="49%">
8
7
  </p>
9
8
 
10
- *左:模型选择器;右:设置 → Magpie。录屏来自隔离 DSH 宿主与本地 mock Magpie 网关,模型和账号均为演示数据。*
9
+ *模型选择器录屏来自隔离 DSH 宿主与本地 mock Magpie 网关,模型和账号均为演示数据;设置页的线路列表截图待重录。*
11
10
 
12
11
  [插件仓库](https://github.com/prgrmrwy/dsh-magpie-connector) · [版本发布](https://github.com/prgrmrwy/dsh-magpie-connector/releases) · [npm](https://www.npmjs.com/package/dsh-magpie-connector)
13
12
 
@@ -18,9 +17,10 @@
18
17
 
19
18
  | 能力 | 具体行为 |
20
19
  |---|---|
21
- | **Magpie 网关直连**(基础) | 只需网关地址和 key。模型目录来自网关 `GET /models`;支持 Responses 的模型走 Responses,其余走 Chat Completions。文本、工具往返、图片、usage、取消以及 DSH 统一重试都能正常工作。 |
20
+ | **Magpie 网关接入**(基础) | 每条线路只需地址和该线路的 key。模型目录来自网关 `GET /models`;支持 Responses 的模型走 Responses,其余走 Chat Completions。文本、工具往返、图片、usage、取消以及 DSH 统一重试都能正常工作。 |
21
+ | **多线路自动切换**(特色) | 通常一条线路(首次安装为空,最多 5 条),可添加备用线路并按序号自动选择:每次操作前对候选地址做 ≤600ms 的 TCP 可达探测(正缓存 10s / 负缓存 15s,不后台轮询),在家走局域网、在外自动落到公网。切换线路时模型目录继承不闪断、用量卡片清空并按新线路重读;单线路(通常一条)行为与旧版一致。 |
22
22
  | **按上游 provider 分组**(特色) | 模型选择器显示 `Magpie (Claude)`、`Magpie (GPT)`、`Magpie (<其它归属>)` 等标题,真实 route 始终只有一个 `dsh-magpie-connector`。设置页和用量卡片使用同一套分组,只出现在用量里的 provider 也不会被丢弃。 |
23
- | **Cloudflare Access 远程连接** | 网关在 Cloudflare Tunnel + Access 之后、客户端不方便装 VPN 时,保存服务令牌的 Client ID 和 Secret。此后每个网关请求都会带上 `CF-Access-Client-Id` / `CF-Access-Client-Secret`,同时保留 bearer key;仅支持 HTTPS。 |
23
+ | **按线路的 Cloudflare Access** | 每条线路可单独选择「直连」或「Cloudflare Access」。启用后该线路的请求带上服务令牌两项并保留 bearer key;服务令牌永远不会发往明文 HTTP 线路。 |
24
24
  | **只读用量卡片** | 每个上游账号展示计划、到期时间、各窗口读数和 Codex 重置次数(网关提供时)。打开设置页只读一次,之后只有点单张卡片的「刷新」才会重新读取;用量条超过 70% 变橙、达到 90% 变红。 |
25
25
  | **模型可见性与默认 effort** | 每个 provider 不设置可见性时跟随网关目录;保存过选择后只显示勾选项,新上架模型默认隐藏。每个模型可保存一个默认推理 effort,DSH 在下一次请求应用。 |
26
26
  | **会话级快速档** | 网关声明 `priority` 服务档的模型,输入框旁会出现标准/快速切换。选择按会话保存在 host 内存,快速档会给请求带上 `service_tier: "priority"`。 |
@@ -39,14 +39,12 @@ dsh plugin --profile web add dsh-magpie-connector
39
39
 
40
40
  打开 **设置 → Magpie**。
41
41
 
42
- 1. 填写**包含 `/v1`** 的网关 API 地址,例如 `http://192.168.1.20:3425/v1`。
43
- 2. 填写 gateway key。它通过 DSH credentials 以 `MAGPIE_API_KEY` 保存;来自启动环境的 key 在这里只读。地址、可见性与默认 effort 保存在 `$DSH_HOME/plugins/dsh-magpie-connector/settings.json` —— key 不会写入该文件,也不会被设置接口返回。
44
- 3. 保存。保存连接会同步模型目录;「刷新模型列表」可再次同步,插件不会定时轮询目录。
42
+ 1. 在**线路 1** 里填写**包含 `/v1`** 的网关 API 地址,例如 `http://192.168.1.20:3425/v1`,以及这条线路的 gateway key。地址、线路列表、可见性与默认 effort 保存在 `$DSH_HOME/plugins/dsh-magpie-connector/settings.json` —— key/Access 不会写入该文件,也不会被设置接口返回。
43
+ 2. 需要多条线路时点「添加备用线路」,每条线路填写自己的地址与 key;用「上移/下移」调整优先级(序号 1 优先)。保存后打开设置页默认只展开当前生效线路,序号后的 4×4 绿色方块表示当前线路;点击 info 图标可查看优先级与自动回退说明。
44
+ 3. 每条线路的**高级连接**里选择「直连」或「Cloudflare Access」。局域网与 Tailscale 都属于直连;Cloudflare Access 需要 HTTPS 线路,从 Access 切回直连并保存会清除该线路的两项 Access 凭据。Cloudflare 免费/Pro 套餐在源站 100 秒内没有响应时会返回 524。
45
+ 4. 保存会同步模型目录;「刷新模型列表」可再次同步,插件不会定时轮询目录。
45
46
 
46
- **高级连接**(默认展开)决定如何到达网关:
47
-
48
- - **直连**(默认)—— 网关地址可直接访问。局域网和 Tailscale 都属于这种:客户端加入 tailnet 后填 `http://<主机>.<tailnet>.ts.net:3425/v1` 或子网路由地址即可,插件里没有额外配置。
49
- - **Cloudflare Access** —— 网关在 Cloudflare Tunnel + Access 之后、客户端不方便装 VPN 时使用。填写服务令牌两项;每个请求同时携带两项头和 bearer key。从 Cloudflare Access 切回直连并保存会清除已保存的 Access 凭据。Cloudflare 免费/Pro 套餐在源站 100 秒内没有响应时会返回 524。
47
+ > **0.2.0 BREAKING 升级**:设置格式改为单一 `lines[]`,旧版单地址 `settings.json`(只有 `baseUrl`)与旧固定凭据引用(`MAGPIE_API_KEY` / `MAGPIE_ACCESS_*`)**不会被读取或迁移**,升级后需在设置页重新添加线路并填写每线路 key/Access。模型可见性与默认 effort 会保留;旧凭据记录可由你在 DSH credentials 中手动删除。升级前若要保留回滚能力,请备份 `settings.json` 与旧凭据引用。
50
48
 
51
49
  还需要别的接入方式(自定义请求头、mTLS、其它零信任网关)?**欢迎提 issue 或 PR**,说明你的部署方式和网关版本。
52
50
 
@@ -65,6 +63,8 @@ dsh plugin --profile web add dsh-magpie-connector
65
63
  - **失败交给 DSH 重试,插件不重放请求。** 适配器声明与 `dsh-plugin-subscriptions` 相同的 `llm-retry` 策略(最多 10 次,1 秒起指数退避至 60 秒,±20% 抖动,429 时遵守 `Retry-After`)。`TIMEOUT`、`TRANSPORT`、`SERVER`、`RATE_LIMIT`、`EMPTY_RESPONSE` 会重试;401/403、额度用尽、上下文超限与其它 4xx 不重试。
66
64
  - **默认输出预算为上下文压缩预留空间。** 网关的 `max_output_tokens` 是模型能力上限,不直接作为每次请求的默认预算。交给 DSH 的默认预算是 `min(能力上限, max(1, 窗口/4, 窗口/2 − 65536))`,给 DSH 的上下文压缩留出空间;显式设置的 `maxTokens` 不受影响。上下文窗口小于 104,026 token 的模型仍按同一公式计算,但不保证通过 DSH 的压缩检查。
67
65
  - **只有持续静默才会结束流。** 响应头、SSE 事件和心跳都算有数据;连续 300 秒没有任何数据才以 `TIMEOUT` 结束(消息含 `MAGPIE_STREAM_TIMEOUT`)。只要网关还在发心跳,长时间推理不会被截断。
66
+ - **自动选线不会重放请求。** 每次操作开始前按序号做 TCP 可达探测并选线路;连接阶段失败只使该线路缓存失效、本次操作失败,下一次操作重新选择。生成请求从不被插件换线或重放,其它失败交给 DSH 重试策略。
67
+ - **切换线路与编辑线路语义不同。** 自动切换保留模型目录(不闪断)、清空用量卡片并以新线路重同步;编辑生效线路的地址/key/接入方式才会按换代清空目录;编辑非生效线路不影响当前目录。
68
68
  - **更改连接不会中止正在进行的生成。** 新地址或新 key 会立即隐藏旧目录与旧卡片,但已 prepare 或正在运行的生成会用捕获到的旧地址与 key 跑完。用户取消与插件卸载仍会中止它们。
69
69
  - **传输边界刻意收窄。** host 请求经私有 `undici` dispatcher 直连配置的网关,不经过全局代理 dispatcher;拒绝重定向;只允许 `GET /models`、`POST /responses`、`POST /chat/completions`、`GET /magpie/quotas/cards`、`POST /magpie/quotas/refresh`;从不调用 Magpie GUI 的 `/api/*` 管理接口。厂商出站代理在 Magpie 侧配置。
70
70
  - **不支持:** 非空 `stop` 序列会在发请求前失败(`MAGPIE_STOP_UNSUPPORTED`),因为 Magpie 无法在所有上游协议转换中保留 stop。用量卡片与 Codex 重置次数只读展示,插件不会调用账号管理或重置端点。
@@ -94,7 +94,7 @@ npm run build --workspace=dsh-magpie-connector
94
94
  dsh plugin --profile web remove dsh-magpie-connector
95
95
  ```
96
96
 
97
- 重启 DSH 并刷新页面。dispose 只释放注册、计时器与连接,不删除凭据和设置。需要彻底清理时,通过 DSH credentials 管理删除 `MAGPIE_API_KEY`,并在确认安装版本的数据目录后删除插件设置文件。如果其它集成仍在使用该凭据,不要删除。
97
+ 重启 DSH 并刷新页面。dispose 只释放注册、计时器与连接,不删除凭据和设置。需要彻底清理时,通过 DSH credentials 管理删除各线路的 `MAGPIE_LINE_<ID>_*` 凭据(以及升级前遗留的 `MAGPIE_API_KEY` / `MAGPIE_ACCESS_*`),并在确认安装版本的数据目录后删除插件设置文件。如果其它集成仍在使用某个凭据,不要删除。
98
98
 
99
99
  在 [ohmydsh](https://github.com/prgrmrwy/ohmydsh) 中则把 manifest 条目设为 `enabled: false` 后重新构建。
100
100
 
@@ -3,4 +3,5 @@
3
3
  | file | kind | source | date | reviewer | verdict | note |
4
4
  |---|---|---|---|---|---|---|
5
5
  | model-picker.gif | screenshot | isolated DSH_HOME instance (temp HOME/XDG/DSH_HOME under /tmp, port 3199), synthetic data: local mock Magpie gateway on loopback with demo models (only Codex models advertise the priority tier, as on Magpie); lossless deviceScaleFactor-2 screenshots of the real Web UI, 1500x880 at 1:1 | 2026-10-28 | lead | pass (viewed frames: demo workspace, mock models and mock account only; no hostname, user, path, token or real session) | Model picker grouped by upstream provider, search, switch to GPT shows the standard/fast control, switch back to Claude hides it immediately |
6
- | settings.gif | screenshot | same isolated instance and recording approach as model-picker.gif (2x screenshots box-filtered to 1264x1130); mock Magpie gateway serves the catalog and synthetic usage cards | 2026-10-28 | lead | pass (viewed frames: settings section only; key field shows the launch-environment marker, access fields contain demo values, account row is synthetic) | Settings → Magpie: connection, Cloudflare Access mode, provider groups (visibility, default effort), usage card refresh |
6
+
7
+ > Settings screenshot removed pending re-recording after the 0.2.0 line-list settings rewrite; the old recording showed the pre-multi-line connection form.
@@ -61,7 +61,7 @@
61
61
  "id": "credentials",
62
62
  "type": "security",
63
63
  "label": "DSH credentials",
64
- "sublabel": "MAGPIE_API_KEY · Access tokens",
64
+ "sublabel": "per-line key · Access (bound to stable id)",
65
65
  "pos": [
66
66
  180,
67
67
  150
@@ -259,14 +259,14 @@
259
259
  "title": "Connection methods",
260
260
  "items": [
261
261
  "Direct: LAN and Tailscale subnet routing use the same gateway address",
262
- "Cloudflare Access: each request carries CF-Access-Client-Id/Secret; HTTPS only"
262
+ "Automatic line selection: ≤600ms TCP probe (10s positive / 15s negative cache); Access headers only on the HTTPS line that enables them"
263
263
  ]
264
264
  },
265
265
  {
266
266
  "dot": "rose",
267
267
  "title": "Boundaries and data",
268
268
  "items": [
269
- "The key lives in DSH credentials and is never returned by the settings API",
269
+ "Each line's key/Access values live in DSH credentials and are never returned by the settings API",
270
270
  "A private undici dispatcher talks only to the configured gateway and rejects redirects",
271
271
  "No Magpie GUI management API; changing the connection does not abort running generations"
272
272
  ]
@@ -493,8 +493,8 @@ rect.c-bg-rect { fill: var(--bg); }
493
493
  <text x="535" y="371" class="t-muted" font-size="9" text-anchor="middle">LLM adapter + settings/usage API</text>
494
494
  </g>
495
495
 
496
- <g id="node-credentials" data-node-id="credentials" data-node-label="DSH credentials" tabindex="0" role="button" aria-label="Focus DSH credentials, MAGPIE_API_KEY · Access tokens, Local DSH Host" aria-pressed="false" data-node-kind="security" data-node-sublabel="MAGPIE_API_KEY · Access tokens" data-node-context="Local DSH Host">
497
- <title>DSH credentials · MAGPIE_API_KEY · Access tokens · Local DSH Host</title>
496
+ <g id="node-credentials" data-node-id="credentials" data-node-label="DSH credentials" tabindex="0" role="button" aria-label="Focus DSH credentials, per-line key · Access, Local DSH Host" aria-pressed="false" data-node-kind="security" data-node-sublabel="per-line key · Access" data-node-context="Local DSH Host">
497
+ <title>DSH credentials · per-line key · Access · Local DSH Host</title>
498
498
  <rect x="180" y="150" width="170" height="66" rx="6" class="c-mask"/>
499
499
  <rect x="180" y="150" width="170" height="66" rx="6" class="c-security" stroke-width="1.5"/>
500
500
  <g aria-hidden="true" data-semantic-sigil="security" class="semantic-sigil s-security" transform="translate(186 156) scale(0.6875)">
@@ -502,7 +502,7 @@ rect.c-bg-rect { fill: var(--bg); }
502
502
  <path d="m5.8 8 1.5 1.5 3-3"/>
503
503
  </g>
504
504
  <text data-node-label="" x="265" y="181" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">DSH credentials</text>
505
- <text x="265" y="197" class="t-muted" font-size="9" text-anchor="middle">MAGPIE_API_KEY · Access tokens</text>
505
+ <text x="265" y="197" class="t-muted" font-size="9" text-anchor="middle">per-line key · Access</text>
506
506
  </g>
507
507
 
508
508
  <g id="node-settings" data-node-id="settings" data-node-label="settings.json" tabindex="0" role="button" aria-label="Focus settings.json, address · visibility · default effort, Local DSH Host" aria-pressed="false" data-node-kind="database" data-node-sublabel="address · visibility · default effort" data-node-context="Local DSH Host">
@@ -61,7 +61,7 @@
61
61
  "id": "credentials",
62
62
  "type": "security",
63
63
  "label": "DSH credentials",
64
- "sublabel": "MAGPIE_API_KEY · Access 令牌",
64
+ "sublabel": "按线路 key · Access(稳定 id 绑定)",
65
65
  "pos": [
66
66
  180,
67
67
  150
@@ -83,7 +83,7 @@
83
83
  "id": "settings",
84
84
  "type": "database",
85
85
  "label": "settings.json",
86
- "sublabel": "地址 · 可见性 · 默认 effort",
86
+ "sublabel": "线路列表 · 可见性 · 默认 effort",
87
87
  "pos": [
88
88
  470,
89
89
  500
@@ -258,15 +258,15 @@
258
258
  "dot": "emerald",
259
259
  "title": "接入方式",
260
260
  "items": [
261
- "直连:局域网、Tailscale 子网路由使用同一网关地址",
262
- "Cloudflare Access:每个请求附加 CF-Access-Client-Id/Secret,仅 HTTPS"
261
+ "多线路:首次安装为空、通常 1 条,可加备用线路,序号即优先级",
262
+ "自动选线:≤600ms TCP 探测(正 10s/负 15s 缓存),Access 仅随启用它的 HTTPS 线路发送"
263
263
  ]
264
264
  },
265
265
  {
266
266
  "dot": "rose",
267
267
  "title": "边界与数据",
268
268
  "items": [
269
- "key 存 DSH credentials,设置接口不回显",
269
+ "每线路 key/Access 存 DSH credentials,设置接口不回显",
270
270
  "私有 undici dispatcher 只直连网关,拒绝重定向",
271
271
  "不调用 Magpie GUI 管理接口,切换连接不中止在途生成"
272
272
  ]
@@ -493,8 +493,8 @@ rect.c-bg-rect { fill: var(--bg); }
493
493
  <text x="535" y="371" class="t-muted" font-size="9" text-anchor="middle">LLM adapter + 设置/用量 API</text>
494
494
  </g>
495
495
 
496
- <g id="node-credentials" data-node-id="credentials" data-node-label="DSH credentials" tabindex="0" role="button" aria-label="聚焦DSH credentials,MAGPIE_API_KEY · Access 令牌, 本机 DSH Host" aria-pressed="false" data-node-kind="security" data-node-sublabel="MAGPIE_API_KEY · Access 令牌" data-node-context="本机 DSH Host">
497
- <title>DSH credentials · MAGPIE_API_KEY · Access 令牌 · 本机 DSH Host</title>
496
+ <g id="node-credentials" data-node-id="credentials" data-node-label="DSH credentials" tabindex="0" role="button" aria-label="聚焦DSH credentials,按线路 key · Access, 本机 DSH Host" aria-pressed="false" data-node-kind="security" data-node-sublabel="按线路 key · Access" data-node-context="本机 DSH Host">
497
+ <title>DSH credentials · 按线路 key · Access · 本机 DSH Host</title>
498
498
  <rect x="180" y="150" width="170" height="66" rx="6" class="c-mask"/>
499
499
  <rect x="180" y="150" width="170" height="66" rx="6" class="c-security" stroke-width="1.5"/>
500
500
  <g aria-hidden="true" data-semantic-sigil="security" class="semantic-sigil s-security" transform="translate(186 156) scale(0.6875)">
@@ -502,11 +502,11 @@ rect.c-bg-rect { fill: var(--bg); }
502
502
  <path d="m5.8 8 1.5 1.5 3-3"/>
503
503
  </g>
504
504
  <text data-node-label="" x="265" y="181" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">DSH credentials</text>
505
- <text x="265" y="197" class="t-muted" font-size="9" text-anchor="middle">MAGPIE_API_KEY · Access 令牌</text>
505
+ <text x="265" y="197" class="t-muted" font-size="9" text-anchor="middle">按线路 key · Access</text>
506
506
  </g>
507
507
 
508
- <g id="node-settings" data-node-id="settings" data-node-label="settings.json" tabindex="0" role="button" aria-label="聚焦settings.json,地址 · 可见性 · 默认 effort, 本机 DSH Host" aria-pressed="false" data-node-kind="database" data-node-sublabel="地址 · 可见性 · 默认 effort" data-node-context="本机 DSH Host">
509
- <title>settings.json · 地址 · 可见性 · 默认 effort · 本机 DSH Host</title>
508
+ <g id="node-settings" data-node-id="settings" data-node-label="settings.json" tabindex="0" role="button" aria-label="聚焦settings.json,线路列表 · 可见性 · 默认 effort, 本机 DSH Host" aria-pressed="false" data-node-kind="database" data-node-sublabel="线路列表 · 可见性 · 默认 effort" data-node-context="本机 DSH Host">
509
+ <title>settings.json · 线路列表 · 可见性 · 默认 effort · 本机 DSH Host</title>
510
510
  <rect x="470" y="500" width="170" height="66" rx="6" class="c-mask"/>
511
511
  <rect x="470" y="500" width="170" height="66" rx="6" class="c-database" stroke-width="1.5"/>
512
512
  <g aria-hidden="true" data-semantic-sigil="database" class="semantic-sigil s-database" transform="translate(476 506) scale(0.6875)">
@@ -514,7 +514,7 @@ rect.c-bg-rect { fill: var(--bg); }
514
514
  <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2"/>
515
515
  </g>
516
516
  <text data-node-label="" x="555" y="531" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">settings.json</text>
517
- <text x="555" y="547" class="t-muted" font-size="9" text-anchor="middle">地址 · 可见性 · 默认 effort</text>
517
+ <text x="555" y="547" class="t-muted" font-size="9" text-anchor="middle">线路列表 · 可见性 · 默认 effort</text>
518
518
  </g>
519
519
 
520
520
  <g id="node-cloudflare" data-node-id="cloudflare" data-node-label="Cloudflare Access" tabindex="0" role="button" aria-label="聚焦Cloudflare Access,可选 · 服务令牌, 架构组件" aria-pressed="false" data-node-kind="security" data-node-sublabel="可选 · 服务令牌" data-node-context="架构组件">
@@ -1,8 +1,32 @@
1
- # dsh-magpie-connector host integration API
1
+ ## 0.2.0 single-schema line settings API (current)
2
2
 
3
- **Latest assembly checkpoint (12:04 America/New_York):** host-features has composed `registerRoutes`/`SettingsController`, cards and speed; adapter visibility/default-effort projection is integrated. The full shared package passes **20 test files / 124 tests**, typecheck and build. Older “outstanding assembly” notes below describe the pre-handoff API boundary and are superseded by this checkpoint. `runtime.settings` remains the core-only handler; use the composed settings route for preference updates. No shared task completion is implied.
3
+ BREAKING: the host settings surface keeps only the `lines[]` schema. Legacy top-level `baseUrl`, `nextLineId`, notices and fixed `MAGPIE_API_KEY` / `MAGPIE_ACCESS_*` refs no longer exist; the 0.1.x notes below are historical.
4
4
 
5
- This is the task-1 → host-features handoff. UI sources are owned separately. Core files are `src/host/{runtime,adapter,catalog,settings,transport}.ts` and `src/index.ts`.
5
+ ```ts
6
+ // GET projection (SettingsController)
7
+ { lines: [{ id, baseUrl, accessMode, complete,
8
+ key: { configured, writable },
9
+ access: { clientId: { configured, writable }, clientSecret: { configured, writable } },
10
+ refs: { key, accessClientId, accessClientSecret } }],
11
+ activeLineId, pending, retainedCredentials,
12
+ lastSyncAt?, lastSyncError?, lastSyncErrorKind?, groups, visibleModels, defaultEfforts }
13
+
14
+ // POST (SettingsController passes the connection subset to runtime.settings)
15
+ { lines: [{ id?, baseUrl: string, accessMode: 'direct' | 'cloudflare',
16
+ key?: string | null, accessClientId?: string | null, accessClientSecret?: string | null }],
17
+ resync?: true }
18
+ // `resync: true` alone is valid (force one catalog sync). A line with zero lines in the
19
+ // committed list is only reachable through load/first install; POST requires 1–5 lines.
20
+ ```
21
+
22
+ - A line is complete when it has an address, a resolvable key and (for `cloudflare`) an HTTPS address plus both Access values. Only complete lines take part in automatic selection.
23
+ - Line ids are host-generated 8-hex-character random ids (unique among existing and pending lines), stored in settings, stable across reorder/delete/restart and never derived from the address or key. Creation must omit `id`; existing ids must reference a current line and may not repeat (400 zero-write otherwise).
24
+ - Every line uses `MAGPIE_LINE_<ID>_API_KEY` / `..._ACCESS_CLIENT_ID` / `..._ACCESS_CLIENT_SECRET` (uppercase id). Sorting/deleting never rebinds references; deleting a line clears writable refs and reports environment-owned values through `retainedCredentials`.
25
+ - Credential fields are tri-state: omitted = keep, `null` = clear (writable refs only), string = write. Whole-table saves/reorders must omit credentials they do not want to change.
26
+ - Loading validates the persisted lines and pending entries strictly: invalid format, duplicate ids/addresses, >5 lines or malformed pending make the whole connection read as empty (unconfigured) without touching the file or resolving credentials.
27
+ - Subscribe to `credentials/reference-updated` for **any** ref and map it with `lineIdOfCredentialRef(ref)`: a change on the active line invalidates the generation, a change on a non-active line does not.
28
+ - Credential writes are not transactional. Attempted `(lineId, field)` pairs are persisted as `pending` before any secret write; on successful whole-table commit, pending entries for lines absent from the commit are cleaned automatically (unset writable refs, keep env-owned ones in `retainedCredentials`). Deletes/access-mode changes commit only after every writable credential was cleared.
29
+ - `Operation` captures `lineId`, `accessMode`, `baseUrl` and the credentials; use `op` (not `gen.baseUrl`) for requests. `Generation` still owns catalog/cards; a line switch replaces the generation but carries the catalog, so `isCurrent(gen)` remains the commit guard.
6
30
 
7
31
  ## Runtime and connection identity
8
32
 
@@ -13,22 +37,22 @@ await runtime.syncCatalog()
13
37
  const op = await runtime.beginOperation()
14
38
  try {
15
39
  const response = await runtime.transport.request(
16
- { baseUrl: op.gen.baseUrl, key: op.key }, 'GET', '/magpie/quotas/cards',
40
+ { baseUrl: op.baseUrl, key: op.key }, 'GET', '/magpie/quotas/cards',
17
41
  { signal: op.gen.bgAbort.signal },
18
42
  )
19
43
  // Read/project/redact first; commit only if runtime.isCurrent(op.gen).
20
44
  } finally { op.release() }
21
45
  ```
22
46
 
23
- - `credentials` is `Pick<CredentialProvider, 'describe' | 'resolve' | 'set' | 'unset'>`. Fixed exported `KEY_REF` is `credentialRef('MAGPIE_API_KEY')`. No credential files or process-environment fallback are read.
24
- - `SettingsStore(path?)` loads/saves `{baseUrl, visibleModels, defaultEfforts}`. With no path it is an in-memory test store. `save(value)` atomically replaces a 0600 settings file. Never put key material in this object.
47
+ - `credentials` is `Pick<CredentialProvider, 'describe' | 'resolve' | 'set' | 'unset'>`. Line references are derived with `lineCredentialRefs(lineId)`; `lineIdOfCredentialRef(ref)` maps a ref back to its line id. Legacy fixed refs are not read. No credential files or process-environment fallback are read.
48
+ - `SettingsStore(path?)` loads/saves the single-schema `{lines, visibleModels, defaultEfforts, credentialUpdatePending?}` (see above). With no path it is an in-memory test store. `save(value)` atomically replaces a 0600 settings file. Never put key material in this object.
25
49
  - `beginOperation(): Promise<Operation>` resolves credentials once, changes generation before dispatch if necessary, starts new-identity catalog sync, and retains that key for redaction until `release()`.
26
50
  - `Operation = { gen, key, release }`. Release is idempotent. Keep the operation alive through response/error projection, not merely through request dispatch.
27
51
  - `Generation` exposes `id`, `baseUrl`, `keyFingerprint`, `catalog`, `bgAbort`, `syncPromise?`, `lastSyncAt?`, `lastSyncError?`, `lastSyncErrorKind?`. The string-indexed extension slot lets features attach generation-local state, e.g. `gen.magpieCards`. Do not put feature state on a global singleton.
28
52
  - `runtime.isCurrent(gen)` must guard every result commit. Dispose also makes it false. A stale request must not put old cards into the new generation.
29
- - `runtime.invalidateCredential()` synchronously drops visible old state and aborts background operations before asynchronously resolving/syncing. Subscribe to `credentials/reference-updated` only for `KEY_REF`. Own key writes suppress that listener until the combined settings mutation commits.
53
+ - `runtime.invalidateCredential(ref?)` maps a `credentials/reference-updated` ref to its line id (`lineIdOfCredentialRef`) and rotates only when that line is active; without a ref it rotates the current generation. It synchronously drops visible old state and aborts background operations before asynchronously resolving/syncing. Own credential writes suppress that listener until the combined settings mutation commits.
30
54
  - `runtime.redact(text)` replaces current and active-operation keys literally with `[redacted]`. All gateway strings going into HTTP responses or logs must use this method. Catalog metadata is redacted before it is cached.
31
- - `runtime.settings(body?)` currently handles the **core** partial update union `{baseUrl?, key?:string|null, resync?:true}` and GET projection, returning a `Response`. It rejects unknown keys. The feature integration must validate the full combined settings request **before any core mutation**, then coordinate visibility/default-effort storage; do not POST preferences directly to this core-only handler.
55
+ - `runtime.settings(body?)` handles the single-schema settings update `{lines?, resync?}` (plus GET projection) and returns a `Response`; the generic SettingsController adds preference fields (`visibleModels`, `defaultEffort`) around it. Credential fields use the tri-state (omit/null/write) contract above; unknown keys are rejected with 400 and nothing is written.
32
56
  - `publish()` must call the adapter registration handle's `replace(['dsh-magpie-connector'])`; no payload-free imitation event is needed. Same-generation sync is single-flight and times out after 15 seconds. Last successful catalog/time survive same-generation failure; identity changes never retain old data.
33
57
 
34
58
  ## Catalog and adapter
@@ -80,7 +80,7 @@ export function MagpieSettings({ api }) {
80
80
  setSavingSettings(true);
81
81
  const version = ++settingsVersion.current;
82
82
  const body = typeof change === 'function' ? change(settingsRef.current) : change;
83
- const identityWrite = ['baseUrl', 'key', 'accessClientId', 'accessClientSecret'].some(field => Object.hasOwn(body, field));
83
+ const identityWrite = ['lines', 'baseUrl', 'key', 'accessClientId', 'accessClientSecret'].some(field => Object.hasOwn(body, field));
84
84
  const epoch = identityWrite ? clearUsage() : cardsEpoch.current;
85
85
  try {
86
86
  const result = await postSettings(api, body);
@@ -212,65 +212,98 @@ async function postSettings(api, body) {
212
212
  }
213
213
  }
214
214
  const ACCESS_MODES = [{ id: 'direct', label: '直连' }, { id: 'cloudflare', label: 'Cloudflare Access' }];
215
+ const INFO_TEXT = '线路按序号优先尝试,连不上自动试下一条;绿色方块表示当前线路;多条线路应指向同一个 Magpie 网关。';
216
+ const hostnameOf = (baseUrl) => { try {
217
+ return new URL(baseUrl).hostname || baseUrl;
218
+ }
219
+ catch {
220
+ return baseUrl || '未配置';
221
+ } };
222
+ function lineDrafts(settings) {
223
+ const configured = (access) => access?.clientId?.configured === true || access?.clientSecret?.configured === true;
224
+ const source = (settings.lines ?? []).map(line => ({ id: line.id, baseUrl: line.baseUrl ?? '', accessMode: (line.accessMode ?? 'direct'), key: line.key, access: line.access }));
225
+ return source.map((line, index) => ({
226
+ draftId: line.id ?? `new-${index}`, ...(line.id ? { id: line.id } : {}), baseUrl: line.baseUrl, accessMode: line.accessMode,
227
+ savedAccess: line.accessMode === 'cloudflare', keyConfigured: line.key?.configured === true, keyWritable: line.key?.writable !== false, apiKey: '',
228
+ accessConfigured: configured(line.access), accessWritable: line.access?.clientId?.writable !== false && line.access?.clientSecret?.writable !== false,
229
+ accessClientId: '', accessClientSecret: '',
230
+ }));
231
+ }
232
+ const lineSignature = (draft) => JSON.stringify([draft.id ?? null, draft.baseUrl.trim(), draft.accessMode]);
215
233
  function Connection({ settings, saving, mutateSettings, usage }) {
216
- const [baseUrl, setBaseUrl] = useState(settings.baseUrl ?? '');
217
- const [key, setKey] = useState('');
218
- const [accessClientId, setAccessClientId] = useState('');
219
- const [accessClientSecret, setAccessClientSecret] = useState('');
234
+ const baseline = lineDrafts(settings);
235
+ const [drafts, setDrafts] = useState(baseline);
236
+ const [expanded, setExpanded] = useState(settings.activeLineId ?? baseline[0]?.draftId ?? null);
220
237
  const [message, setMessage] = useState();
221
- const writable = settings.key?.writable !== false;
222
- const configured = settings.key?.configured === true;
223
- const savedUrl = settings.baseUrl ?? '';
224
- const access = settings.access;
225
- const accessSaved = access?.clientId?.configured === true || access?.clientSecret?.configured === true;
226
- const accessLocked = access?.clientId?.writable === false || access?.clientSecret?.writable === false;
227
- // The mode is not stored: it follows whether Access credentials exist, so switching to 直连 and saving clears them.
228
- const [mode, setMode] = useState(accessSaved ? 'cloudflare' : 'direct');
229
- useEffect(() => { setMode(accessSaved ? 'cloudflare' : 'direct'); }, [accessSaved]);
230
- const clearsAccess = mode === 'direct' && accessSaved;
238
+ useEffect(() => { const next = lineDrafts(settings); setDrafts(next); setExpanded(settings.activeLineId ?? next[0]?.draftId ?? null); }, [settings]);
239
+ // Legacy hosts have no line list: keep line 1 marked active. A v2 host that
240
+ // reports a null/absent active line (nothing complete) must show no marker.
241
+ const activeId = settings.activeLineId ?? null;
242
+ const update = (draftId, patch) => setDrafts(current => current.map(draft => draft.draftId === draftId ? { ...draft, ...patch } : draft));
243
+ const move = (index, delta) => setDrafts(current => {
244
+ const target = index + delta;
245
+ if (target < 0 || target >= current.length)
246
+ return current;
247
+ const next = [...current];
248
+ const swap = next[index];
249
+ next[index] = next[target];
250
+ next[target] = swap;
251
+ return next;
252
+ });
253
+ const remove = (draftId) => setDrafts(current => current.length <= 1 ? current : current.filter(draft => draft.draftId !== draftId));
254
+ const add = () => setDrafts(current => {
255
+ const draft = { draftId: `new-${current.length}-${Date.now()}`, baseUrl: '', accessMode: 'direct', savedAccess: false, keyConfigured: false, keyWritable: true, apiKey: '', accessConfigured: false, accessWritable: true, accessClientId: '', accessClientSecret: '' };
256
+ setExpanded(draft.draftId);
257
+ return [...current, draft];
258
+ });
259
+ const dirty = drafts.length !== baseline.length || drafts.some((draft, index) => {
260
+ const before = baseline[index];
261
+ return !before || lineSignature(draft) !== lineSignature(before) || !!draft.apiKey
262
+ || (draft.accessMode === 'cloudflare' && (!!draft.accessClientId || !!draft.accessClientSecret))
263
+ || (draft.accessMode === 'direct' && draft.savedAccess);
264
+ });
231
265
  const post = async (body, done) => {
232
266
  setMessage(undefined);
233
267
  const result = await mutateSettings(body);
234
268
  if (!result)
235
269
  return;
236
- if (result.view) {
237
- setKey('');
238
- setAccessClientId('');
239
- setAccessClientSecret('');
270
+ if (result.view)
240
271
  setMessage({ text: done, failed: false });
241
- }
242
272
  else
243
273
  setMessage({ text: result.error, failed: true });
244
274
  };
245
275
  const save = () => {
246
- const body = {};
247
- if (baseUrl.trim() !== savedUrl)
248
- body.baseUrl = baseUrl.trim();
249
- if (key)
250
- body.key = key;
251
- if (clearsAccess) {
252
- body.accessClientId = null;
253
- body.accessClientSecret = null;
254
- }
255
- if (mode === 'cloudflare' && accessClientId)
256
- body.accessClientId = accessClientId;
257
- if (mode === 'cloudflare' && accessClientSecret)
258
- body.accessClientSecret = accessClientSecret;
259
- void post(body, clearsAccess ? '已保存,已清除 Access 凭据' : '已保存');
276
+ const lines = drafts.map(draft => ({
277
+ ...(draft.id ? { id: draft.id } : {}),
278
+ baseUrl: draft.baseUrl.trim(), accessMode: draft.accessMode,
279
+ ...(draft.apiKey ? { key: draft.apiKey } : {}),
280
+ ...(draft.accessMode === 'cloudflare'
281
+ ? { ...(draft.accessClientId ? { accessClientId: draft.accessClientId } : {}), ...(draft.accessClientSecret ? { accessClientSecret: draft.accessClientSecret } : {}) }
282
+ : (draft.savedAccess ? { accessClientId: null, accessClientSecret: null } : {})),
283
+ }));
284
+ void post({ lines }, '已保存');
260
285
  };
261
- const dirty = !!key || baseUrl.trim() !== savedUrl || clearsAccess || (mode === 'cloudflare' && (!!accessClientId || !!accessClientSecret));
262
- return h('section', { className: 'dmg-panel', 'aria-label': '连接' }, h('h3', null, '连接'), h('label', { className: 'dmg-field' }, h('span', null, '网关地址(含 /v1)'), h('input', { className: 'dmg-input', value: baseUrl, disabled: saving, placeholder: 'http://127.0.0.1:3425/v1', onChange: (e) => setBaseUrl(e.target.value) })), h('label', { className: 'dmg-field' }, h('span', null, `Gateway API key · ${configured ? '已配置' : '未配置'}${writable ? '' : '(来自启动环境,只读)'}`), h('input', {
263
- className: 'dmg-input', type: 'password', autoComplete: 'off', value: key, disabled: saving || !writable,
264
- placeholder: configured ? '留空则保持不变' : '必填', onChange: (e) => setKey(e.target.value),
265
- })), 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, `接入方式${accessLocked ? '(Access 凭据来自启动环境,只读)' : ''}`), h('select', { className: 'dmg-select', 'aria-label': '接入方式', value: mode, disabled: saving || accessLocked,
266
- onChange: (e) => setMode(e.target.value) }, ...ACCESS_MODES.map(m => h('option', { key: m.id, value: m.id }, m.label)))), mode === 'direct'
267
- ? h('p', { className: 'dmg-hint' }, clearsAccess ? '保存后将清除已保存的 Cloudflare Access 凭据。' : '网关地址可直接访问时选择(局域网、Tailscale 等组网后的地址)。')
268
- : h('p', { className: 'dmg-hint' }, '网关在 Cloudflare Access 之后时选择:填写服务令牌的两项,仅支持 HTTPS。留空保持不变。'), ...(mode === 'cloudflare' ? [
269
- ['Access Client ID', access?.clientId, accessClientId, setAccessClientId],
270
- ['Access Client Secret', access?.clientSecret, accessClientSecret, setAccessClientSecret],
271
- ].map(([label, status, value, setValue]) => h('label', { key: label, className: 'dmg-field' }, h('span', null, `${label} · ${status?.configured ? '已配置' : '未配置'}${status?.writable === false ? '(来自启动环境,只读)' : ''}`), h('input', { className: 'dmg-input', type: 'password', autoComplete: 'off', value,
272
- disabled: saving || status?.writable === false, placeholder: status?.configured ? '留空则保持不变' : '必填',
273
- onChange: (e) => setValue(e.target.value) }))) : []))), h('div', { className: 'dmg-row' }, h('button', { type: 'button', className: 'dmg-button', disabled: saving || !dirty, onClick: save }, saving ? '保存中…' : '保存'), configured && writable ? h('button', { type: 'button', className: 'dmg-button', disabled: saving, onClick: () => { void post({ key: null }, '已清除 key'); } }, '清除 key') : null, 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', role: message.failed ? 'alert' : 'status' }, message.text) : null, usage);
286
+ return h('section', { className: 'dmg-panel', 'aria-label': '连接' }, h('div', { className: 'dmg-head' }, h('h3', null, '连接'), h('span', { className: 'dmg-info', role: 'img', 'aria-label': '线路优先级说明', title: INFO_TEXT }, 'ⓘ')), drafts.length === 0 ? h('p', { className: 'dmg-hint' }, '还没有线路。点击「添加线路」填写网关地址与 key。') : null, h('div', { className: 'dmg-lines' }, ...drafts.map((draft, index) => h(LineCard, {
287
+ key: draft.draftId, draft, index, total: drafts.length, saving,
288
+ active: !!draft.id && draft.id === activeId,
289
+ expanded: expanded === draft.draftId,
290
+ onToggle: () => setExpanded(current => current === draft.draftId ? null : draft.draftId),
291
+ onChange: (patch) => update(draft.draftId, patch),
292
+ onMove: (delta) => move(index, delta),
293
+ onRemove: () => remove(draft.draftId),
294
+ }))), 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', role: message.failed ? 'alert' : 'status' }, message.text) : null, usage);
295
+ }
296
+ function LineCard({ draft, index, total, saving, active, expanded, onToggle, onChange, onMove, onRemove }) {
297
+ return h('div', { className: 'dmg-line', 'data-line-id': draft.id, 'data-expanded': expanded ? 'true' : 'false' }, h('div', { className: 'dmg-line-head' }, h('span', { className: 'dmg-line-index' }, `线路 ${index + 1}`), active ? h('span', { className: 'dmg-active', role: 'img', 'aria-label': '当前线路', title: '当前线路', 'data-active-line': draft.id }) : null, h('span', { className: 'dmg-line-title' }, `${hostnameOf(draft.baseUrl)}${draft.id ? '' : '(未保存)'}`), h('div', { className: 'dmg-line-actions' }, h('button', { type: 'button', className: 'dmg-button', disabled: saving || index === 0, onClick: () => onMove(-1) }, '上移'), h('button', { type: 'button', className: 'dmg-button', disabled: saving || index === total - 1, onClick: () => onMove(1) }, '下移'), h('button', { type: 'button', className: 'dmg-button', disabled: saving || total <= 1, onClick: onRemove }, '删除'), h('button', { type: 'button', className: 'dmg-button', disabled: saving, onClick: onToggle }, expanded ? '收起' : '展开'))), expanded ? h('div', { className: 'dmg-line-body' }, 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,
298
+ placeholder: draft.keyConfigured ? '留空则保持不变' : '必填', onChange: (e) => onChange({ apiKey: e.target.value }) })), 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,
299
+ onChange: (e) => onChange({ accessMode: e.target.value }) }, ...ACCESS_MODES.map(mode => h('option', { key: mode.id, value: mode.id }, mode.label)))), draft.accessMode === 'direct'
300
+ ? h('p', { className: 'dmg-hint' }, draft.savedAccess ? '保存后将清除已保存的 Cloudflare Access 凭据。' : '网关地址可直接访问时选择(局域网、Tailscale 等组网后的地址)。')
301
+ : h('p', { className: 'dmg-hint' }, '网关在 Cloudflare Access 之后时选择:填写服务令牌的两项,仅支持 HTTPS。留空保持不变。'), ...(draft.accessMode === 'cloudflare' ? [
302
+ ['Access Client ID', draft.accessClientId, 'accessClientId'],
303
+ ['Access Client Secret', draft.accessClientSecret, 'accessClientSecret'],
304
+ ].map(([label, value, field]) => h('label', { key: label, className: 'dmg-field' }, h('span', null, `${label} · ${(field === 'accessClientId' ? draft.accessConfigured : draft.accessConfigured) ? '已配置' : '未配置'}${draft.accessWritable ? '' : '(来自启动环境,只读)'}`), h('input', { className: 'dmg-input', type: 'password', autoComplete: 'off', value,
305
+ disabled: saving || !draft.accessWritable, placeholder: '留空则保持不变',
306
+ onChange: (e) => onChange({ [field]: e.target.value }) }))) : [])))) : null);
274
307
  }
275
308
  function SyncStatus({ settings }) {
276
309
  const count = (settings.groups ?? []).reduce((n, g) => n + g.models.length, 0);
@@ -3,4 +3,4 @@
3
3
  * Colours come only from `--dsw-alias-*` theme tokens (they flip under the
4
4
  * shell's dark theme); geometry follows the official settings section.
5
5
  */
6
- export declare const settingsCss = "\n.dmg-settings{display:flex;flex-direction:column;gap:16px;max-width:620px;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary);min-width:0}\n.dmg-settings h3{margin:0;font-size:14px;line-height:22px;font-weight:500}\n.dmg-settings h4{margin:0;font-size:13px;line-height:20px;font-weight:500}\n.dmg-settings p{margin:0}\n.dmg-intro,.dmg-hint,.dmg-meta{color:var(--dsw-alias-label-tertiary);font-size:12px;overflow-wrap:anywhere}\n.dmg-panel{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border:.5px solid var(--dsw-alias-settings-card-stroke,var(--dsw-alias-border-l4));border-radius:16px;background:var(--dsw-alias-settings-card-fill,var(--dsw-alias-bg-layer-2))}\n.dmg-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}\n.dmg-head{display:flex;align-items:center;gap:8px;justify-content:space-between}\n.dmg-field{display:flex;flex-direction:column;gap:4px}\n.dmg-field>span{color:var(--dsw-alias-label-secondary);font-size:12px}\n.dmg-input,.dmg-select{box-sizing:border-box;min-width:0;height:32px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit}\n.dmg-select{height:26px;padding:0 6px;font-size:12px}\n.dmg-input:focus-visible,.dmg-select:focus-visible,.dmg-button:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}\n.dmg-button{height:26px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;cursor:pointer;white-space:nowrap}\n.dmg-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}\n.dmg-button:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}\n.dmg-error{color:var(--dsw-alias-state-error-primary);font-size:12px;overflow-wrap:anywhere;white-space:pre-wrap}\n.dmg-ok{color:var(--dsw-alias-label-secondary);font-size:12px}\n.dmg-usage{display:flex;flex-direction:column;gap:6px}\n.dmg-advanced>summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px}\n.dmg-advanced-body{display:flex;flex-direction:column;gap:10px;margin-top:10px;padding-left:10px;border-left:.5px solid var(--dsw-alias-border-l2)}\n.dmg-cards{display:flex;flex-direction:column;gap:8px}\n.dmg-card{display:flex;flex-direction:column;gap:6px;padding:8px 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:12px}\n.dmg-card-title{display:flex;align-items:center;gap:8px;min-width:0}\n.dmg-card-title>strong{font-weight:500;overflow-wrap:anywhere}\n.dmg-card-title>.dmg-button{margin-left:auto}\n.dmg-facts{display:flex;flex-wrap:wrap;gap:2px 12px;color:var(--dsw-alias-label-tertiary);font-size:12px}\n.dmg-facts>span>b{font-weight:400;color:var(--dsw-alias-label-secondary)}\n.dmg-window{display:flex;flex-direction:column;gap:3px}\n.dmg-window-line{display:flex;justify-content:space-between;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}\n.dmg-window-line time{color:var(--dsw-alias-label-tertiary)}\n.dmg-track{height:6px;border-radius:3px;overflow:hidden;background:var(--dsw-alias-border-l2)}\n.dmg-fill{height:100%;background:var(--dsw-alias-state-business-primary)}\n.dmg-fill[data-level=warn]{background:var(--dsw-alias-state-warn-primary)}\n.dmg-fill[data-level=high]{background:var(--dsw-alias-state-error-primary)}\n.dmg-models{display:flex;flex-direction:column}\n.dmg-model{display:flex;align-items:center;gap:8px;padding:6px 0;border-top:.5px solid var(--dsw-alias-border-l2)}\n.dmg-model label{display:flex;align-items:center;gap:6px;min-width:0;flex:1}\n.dmg-model label>span{overflow-wrap:anywhere}\n.dmg-model code{font-size:11px;color:var(--dsw-alias-label-tertiary)}\n.dmg-speed{position:relative;display:inline-flex}\n.dmg-speed-trigger{border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;padding:2px 8px;cursor:pointer;white-space:nowrap}\n.dmg-speed-trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}\n.dmg-speed-menu{position:absolute;bottom:100%;right:0;margin-bottom:4px;min-width:180px;padding:4px;z-index:20;display:flex;flex-direction:column;gap:2px;border-radius:16px;background:var(--dsw-alias-bg-layer-2);box-shadow:0 8px 28px var(--dsw-alias-bg-mask-1,#0003)}\n.dmg-speed-item{display:flex;align-items:flex-start;gap:6px;width:100%;border:none;border-radius:8px;background:transparent;padding:6px 8px;cursor:pointer;font:inherit;text-align:left;color:var(--dsw-alias-label-primary)}\n.dmg-speed-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}\n.dmg-speed-item>span:first-child{width:14px;flex:none;font-size:12px}\n.dmg-speed-item small{display:block;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}\n";
6
+ export declare const settingsCss = "\n.dmg-settings{display:flex;flex-direction:column;gap:16px;max-width:620px;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary);min-width:0}\n.dmg-settings h3{margin:0;font-size:14px;line-height:22px;font-weight:500}\n.dmg-settings h4{margin:0;font-size:13px;line-height:20px;font-weight:500}\n.dmg-settings p{margin:0}\n.dmg-intro,.dmg-hint,.dmg-meta{color:var(--dsw-alias-label-tertiary);font-size:12px;overflow-wrap:anywhere}\n.dmg-panel{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border:.5px solid var(--dsw-alias-settings-card-stroke,var(--dsw-alias-border-l4));border-radius:16px;background:var(--dsw-alias-settings-card-fill,var(--dsw-alias-bg-layer-2))}\n.dmg-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}\n.dmg-head{display:flex;align-items:center;gap:8px;justify-content:space-between}\n.dmg-field{display:flex;flex-direction:column;gap:4px}\n.dmg-field>span{color:var(--dsw-alias-label-secondary);font-size:12px}\n.dmg-input,.dmg-select{box-sizing:border-box;min-width:0;height:32px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit}\n.dmg-select{height:26px;padding:0 6px;font-size:12px}\n.dmg-input:focus-visible,.dmg-select:focus-visible,.dmg-button:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}\n.dmg-button{height:26px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;cursor:pointer;white-space:nowrap}\n.dmg-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}\n.dmg-button:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}\n.dmg-error{color:var(--dsw-alias-state-error-primary);font-size:12px;overflow-wrap:anywhere;white-space:pre-wrap}\n.dmg-ok{color:var(--dsw-alias-label-secondary);font-size:12px}\n.dmg-usage{display:flex;flex-direction:column;gap:6px}\n.dmg-advanced>summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px}\n.dmg-advanced-body{display:flex;flex-direction:column;gap:10px;margin-top:10px;padding-left:10px;border-left:.5px solid var(--dsw-alias-border-l2)}\n.dmg-cards{display:flex;flex-direction:column;gap:8px}\n.dmg-card{display:flex;flex-direction:column;gap:6px;padding:8px 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:12px}\n.dmg-card-title{display:flex;align-items:center;gap:8px;min-width:0}\n.dmg-card-title>strong{font-weight:500;overflow-wrap:anywhere}\n.dmg-card-title>.dmg-button{margin-left:auto}\n.dmg-facts{display:flex;flex-wrap:wrap;gap:2px 12px;color:var(--dsw-alias-label-tertiary);font-size:12px}\n.dmg-facts>span>b{font-weight:400;color:var(--dsw-alias-label-secondary)}\n.dmg-window{display:flex;flex-direction:column;gap:3px}\n.dmg-window-line{display:flex;justify-content:space-between;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}\n.dmg-window-line time{color:var(--dsw-alias-label-tertiary)}\n.dmg-track{height:6px;border-radius:3px;overflow:hidden;background:var(--dsw-alias-border-l2)}\n.dmg-fill{height:100%;background:var(--dsw-alias-state-business-primary)}\n.dmg-fill[data-level=warn]{background:var(--dsw-alias-state-warn-primary)}\n.dmg-fill[data-level=high]{background:var(--dsw-alias-state-error-primary)}\n.dmg-models{display:flex;flex-direction:column}\n.dmg-model{display:flex;align-items:center;gap:8px;padding:6px 0;border-top:.5px solid var(--dsw-alias-border-l2)}\n.dmg-model label{display:flex;align-items:center;gap:6px;min-width:0;flex:1}\n.dmg-model label>span{overflow-wrap:anywhere}\n.dmg-model code{font-size:11px;color:var(--dsw-alias-label-tertiary)}\n.dmg-info{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;border:.5px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-tertiary);font-size:11px;cursor:default}\n.dmg-lines{display:flex;flex-direction:column;gap:8px}\n.dmg-line{border:.5px solid var(--dsw-alias-border-l2);border-radius:12px;padding:8px 10px;display:flex;flex-direction:column;gap:8px;background:var(--dsw-alias-bg-layer-1)}\n.dmg-line-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}\n.dmg-line-index{font-weight:500}\n.dmg-line-title{color:var(--dsw-alias-label-tertiary);font-size:12px;overflow-wrap:anywhere;min-width:0;flex:1}\n.dmg-active{width:4px;height:4px;flex:none;background:var(--dmg-active,#22c55e)}\n.dmg-line-actions{display:flex;gap:6px;margin-left:auto}\n.dmg-line-body{display:flex;flex-direction:column;gap:10px;padding-left:10px;border-left:.5px solid var(--dsw-alias-border-l2)}\n.dmg-speed{position:relative;display:inline-flex}\n.dmg-speed-trigger{border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;padding:2px 8px;cursor:pointer;white-space:nowrap}\n.dmg-speed-trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}\n.dmg-speed-menu{position:absolute;bottom:100%;right:0;margin-bottom:4px;min-width:180px;padding:4px;z-index:20;display:flex;flex-direction:column;gap:2px;border-radius:16px;background:var(--dsw-alias-bg-layer-2);box-shadow:0 8px 28px var(--dsw-alias-bg-mask-1,#0003)}\n.dmg-speed-item{display:flex;align-items:flex-start;gap:6px;width:100%;border:none;border-radius:8px;background:transparent;padding:6px 8px;cursor:pointer;font:inherit;text-align:left;color:var(--dsw-alias-label-primary)}\n.dmg-speed-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}\n.dmg-speed-item>span:first-child{width:14px;flex:none;font-size:12px}\n.dmg-speed-item small{display:block;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}\n";