@maci0/dsh-omniroute 0.0.0-stage → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-omniroute contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,227 @@
1
- # Temporary Holding Version
1
+ # dsh-omniroute
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ OmniRoute's own API, inside DeepSeek Harness. The router that fronts every
4
+ upstream account knows things the OpenAI-compatible `/v1` surface does not: which
5
+ models it can serve *right now*, which accounts it holds, and what each of those
6
+ accounts has left. This plugin serves all three as JSON routes on the harness's
7
+ own origin, behind the same trust fence as the harness's browser routes, with the
8
+ OmniRoute API key never leaving the host process.
9
+
10
+ ## What you get
11
+
12
+ - **The live catalog, not `/v1/models`.** `available` and `vision` per
13
+ model, with `?available=1`, `?q=`, and `?provider=` to narrow it.
14
+ - **The account inventory.** Every upstream connection with its id, provider, switch
15
+ state, and whether it publishes quota.
16
+ - **Per-connection quota.** Plan and windows for each connection that publishes one.
17
+ - **An explicit picker sync.** `?sync=1` merges the available models into a provider
18
+ route's settings, so the model picker learns them. It never runs on a timer.
19
+ - **The key stays on the host.** Routes answer formatted JSON only.
20
+
21
+ ## Install
22
+
23
+ > **Install it as a bundle.** `dsh plugin add …` mounts the row from the
24
+ > package's own patch layer, which is what the settings editor can write to. A
25
+ > row added with `--patch` is an overlay: it disappears at the next start, and
26
+ > the Plugins card cannot save into it (the editor refuses a write an overlay
27
+ > would win).
28
+
29
+ ```sh
30
+ dsh plugin --profile web add @maci0/dsh-omniroute@0.8.2
31
+ ```
32
+
33
+ This installs the public npm package; no GitHub token or `~/.secrets` setup is needed.
34
+ The version is pinned. To upgrade, run the same command with a newer version,
35
+ then restart `dsh web` (bundle layers compose at boot).
36
+
37
+ Do **not** also paste the `id: omniroute` row from `cordis.patch.yml` into the
38
+ profile's own patch: `insert` does not dedupe ids.
39
+
40
+ ## Configure
41
+
42
+ Every field is editable from the Web client: open **Plugins**, then the
43
+ **omniroute** row, then **Configure**. The card validates the URL, the non-empty
44
+ references, and the numeric bounds before the write, saves every changed field
45
+ in one update, and offers **Reset to defaults** for the fields you overrode.
46
+ Every field is `volatile()`, so a save reaches the running routes: each is read
47
+ per request and a settings write drops the served readings, so a new origin or
48
+ cache window applies to the next poll instead of a restart.
49
+
50
+ The same row can be set by hand in the profile's own `cordis.patch.yml`; a patch
51
+ replaces the targeted row's whole `config`, so restate every key you keep:
52
+
53
+ ```yaml
54
+ - id: omniroute
55
+ config:
56
+ baseURL: http://192.168.0.100:20128/v1 # origin is used; the /v1 path is dropped
57
+ apiKeyEnv: OMNIROUTE_API_KEY # credential reference, then that env var
58
+ timeoutMs: 10000 # per OmniRoute request
59
+ cacheSeconds: 30 # how long one reading is served from cache
60
+ syncNamespace: llm-pi-ai # where ?sync=1 writes
61
+ syncProvider: omniroute # which provider route it writes for
62
+ ```
63
+
64
+ | Key | Default | Bounds | Meaning |
65
+ |---|---|---|---|
66
+ | `baseURL` | `http://localhost:20128` | absolute http(s) | The deployment. Its origin addresses OmniRoute's management API, so a `/v1` suffix is fine and ignored. A value without an http(s) scheme fails at load, and a live edit to one makes every route answer a configuration error without asking anything; no default origin is guessed. |
67
+ | `apiKeyEnv` | `OMNIROUTE_API_KEY` | non-empty | Credential reference resolved through `ctx.credentials`, falling back to the launcher's environment. The key needs the scopes the endpoints use: this box's key carries `self:usage` and `manage`. |
68
+ | `timeoutMs` | `10000` | 1–60000 | Deadline for each OmniRoute request, including the connection listing. |
69
+ | `cacheSeconds` | `30` | 0–3600 | How long one reading is served before OmniRoute is asked again. `0` re-asks every time. |
70
+ | `syncNamespace` | `llm-pi-ai` | non-empty | Settings namespace `?sync=1` merges into. |
71
+ | `syncProvider` | `omniroute` | non-empty | Provider route inside that namespace. |
72
+
73
+ ## Routes
74
+
75
+ - `GET /omniroute/models`: the live catalog, with `available` and `vision` per
76
+ model, and `?available=1`, `?q=<text>`, `?provider=<key>` to narrow it.
77
+ `?sync=1` also copies the available models into a provider route's settings,
78
+ which is how the model picker learns them.
79
+ - `GET /omniroute/connections`: every upstream connection, with the id, the
80
+ provider, whether it is switched on, and whether it publishes quota.
81
+ - `GET /omniroute/quota`: the plan and windows of every connection that
82
+ publishes one.
83
+
84
+ Every route also answers `?refresh=1`, which ignores the cache for that read.
85
+ `fetchedAt` is when OmniRoute answered, so a reading served from cache keeps the
86
+ time it was taken.
87
+
88
+ ```
89
+ http://127.0.0.1:3080/omniroute/models?available=1&q=claude
90
+ ```
91
+
92
+ `/omniroute/models` answers with the catalog and its counts:
93
+
94
+ ```json
95
+ {
96
+ "status": "ok",
97
+ "provider": "omniroute",
98
+ "origin": "http://192.168.0.100:20128",
99
+ "fetchedAt": 1789793193906,
100
+ "counts": { "total": 525, "available": 95, "shown": 95 },
101
+ "models": [
102
+ { "id": "cc/claude-opus-5", "provider": "cc", "name": "Claude Opus 5", "available": true, "vision": true }
103
+ ]
104
+ }
105
+ ```
106
+
107
+ `/omniroute/quota` answers with the connection list and one entry per window:
108
+
109
+ ```json
110
+ {
111
+ "status": "ok",
112
+ "windows": [
113
+ {
114
+ "connection": "acbe586b-7488-44c4-b8d1-de4982c18ed7",
115
+ "provider": "deepseek",
116
+ "plan": "DeepSeek",
117
+ "window": "credits_usd",
118
+ "used": 0,
119
+ "total": 0,
120
+ "remaining": 91.27,
121
+ "remainingPercent": 100,
122
+ "unlimited": true,
123
+ "currency": "USD",
124
+ "limitReached": false
125
+ }
126
+ ],
127
+ "limitReached": []
128
+ }
129
+ ```
130
+
131
+ ### Filling the model picker
132
+
133
+ The picker reads a route's models from `settings.yaml`, so the live catalog only
134
+ reaches it when something writes them there. That is what `?sync=1` does, and it
135
+ is deliberately explicit rather than automatic: a background writer that
136
+ rewrites a provider row on a timer is not something a plugin should do to a
137
+ settings document it does not own.
138
+
139
+ ```sh
140
+ curl 'http://127.0.0.1:3080/omniroute/models?available=1&sync=1'
141
+ ```
142
+
143
+ That fetches the catalog, keeps the `available` models (after `?q=` and
144
+ `?provider=`, so a narrowed sync is possible), and merges
145
+ `providers.<syncProvider>.models` into `syncNamespace`. The reply names what it
146
+ wrote:
147
+
148
+ ```json
149
+ "synced": { "namespace": "llm-pi-ai", "path": "providers.omniroute.models", "count": 95 }
150
+ ```
151
+
152
+ The write is a merge, so fields the row already carries (`apiKeyEnv`, `baseURL`,
153
+ `compat`) survive; only `models` is replaced.
154
+
155
+ ## How it works
156
+
157
+ - **The endpoints are OmniRoute's own.** They were read off a running `v3.8.50`
158
+ server rather than guessed: `GET /api/openapi/spec` lists 361 endpoints, and
159
+ `GET /api/agent-skills` indexes them per area. The paths people commonly try
160
+ (`/api/balance`, `/api/usage`, `/api/quota`, `/key/info`) are not among them
161
+ (`/key/info` is the web app's HTML fallback), which is why earlier attempts at
162
+ this router found nothing.
163
+ - **The catalog comes from `/api/models`, not `/v1/models`.** Both answer, but
164
+ only the management listing carries `available` and `supportsVision`, and only
165
+ it says which models are servable now instead of which have ever existed.
166
+ - **Quota is per connection, because OmniRoute holds the accounts.** The router
167
+ publishes no figure for itself, so the plugin lists connections, skips the ones
168
+ that are switched off or hide their quota, and asks `/api/usage/<id>` for the
169
+ rest. A connection that fails to answer contributes no window instead of
170
+ failing the read. `/api/quota/plans` resolves plans without consumption and
171
+ `/api/quota/pools` is empty unless the deployment defines pools, so neither is
172
+ read here.
173
+ - **Readings are cached** for `cacheSeconds`: the catalog, and the connection
174
+ listing together with its windows (both connection routes serve that one
175
+ reading, so they never disagree). Concurrent callers share one in-flight read,
176
+ so a refresh loop cannot multiply requests to the router.
177
+ - **Every route checks the trust fence first.** The plugin injects the
178
+ composition's `connection` service and asks `requestRejection` before anything
179
+ else; a composition without that service never mounts the routes.
180
+ - **The key stays in this process.** Routes answer formatted JSON only: no key,
181
+ no Authorization header. A refusal is this plugin's own one-line message
182
+ (HTTP status, timeout, unreachable, non-JSON body), never the router's body;
183
+ a failure inside another service (credentials, settings) is logged on the
184
+ host and answered generically.
185
+
186
+ ## Limits
187
+
188
+ - **One quota read is one listing plus one request per visible connection.** On
189
+ the reference box that is 21 requests, cached for `cacheSeconds`. A deployment
190
+ with many connections should raise `cacheSeconds`.
191
+ - **Quota is read per connection, not per route.** A route served by OmniRoute
192
+ spends whichever account the router picks, so no single figure belongs to it.
193
+ `dsh-quota-check`'s OmniRoute probe renders the fullest window across
194
+ connections for exactly that reason.
195
+ - **No statusbar chip.** The browser half ships the row's configuration card
196
+ only; the routes are the read surface, and `dsh-quota-check` draws the chip.
197
+ - **The catalog carries no capacities.** `/api/models` reports availability and
198
+ vision but no context window, so a synced model entry sets only `id` and
199
+ `name`. Set capacities in the provider row when they matter.
200
+ - **`?sync=1` writes a namespace this plugin does not own.** It merges into
201
+ `syncNamespace` (default `llm-pi-ai`) because that is where the picker reads
202
+ from. Point it elsewhere, or leave it alone, when that route is not yours.
203
+ - **Read-only.** Creating keys, connections, and combos is out of scope; nothing
204
+ here mutates the router.
205
+
206
+ ## Development
207
+
208
+ dsh loads plugins on Node `^22.19.0 || >=24.0.0`; development and tests run on bun.
209
+
210
+ ```sh
211
+ bun install # first run only (typescript, @types/node, cordis, carrier, schemastery)
212
+ bun run typecheck
213
+ bun test # parsers, the client, both route halves, and a real-composition boot
214
+ bun run build # tsc -> lib/*.js
215
+ ```
216
+
217
+ For local development, `dsh plugin --profile <name> add <path-to-checkout>`
218
+ (after `bun run build`), then restart `dsh web`.
219
+
220
+ `bun test` includes the real-composition case: the plugin mounts into a real
221
+ Cordis `Context` beside the real HTTP carrier on an OS-assigned port, a route is
222
+ driven over real HTTP with the settings write observed, disposing the fiber
223
+ must withdraw every route, and no route exists until the trust fence is mounted.
224
+
225
+ ## Licence
226
+
227
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ # The dsh-omniroute bundle patch: applied automatically when a profile lists
2
+ # this bundle (`dsh plugin add`/`update` appends the package to
3
+ # dsh.profile.bundles). Override the row from the profile's own
4
+ # cordis.patch.yml with a `- id: omniroute` row, which replaces the row's whole
5
+ # `config`.
6
+ # Do not also insert this same row into the profile patch: insert does not
7
+ # dedupe ids, and a second row would register the plugin twice.
8
+ - insert:
9
+ - id: omniroute
10
+ name: '@maci0/dsh-omniroute'
package/icon.svg ADDED
@@ -0,0 +1,8 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M15.2 16.2L10.2 11.6M20.8 16.2L25.8 11.6M15.2 19.8L10.2 24.4M20.8 19.8L25.8 24.4" stroke="#145AF3" stroke-width="1.6" stroke-linecap="round"/>
3
+ <circle cx="18" cy="18" r="4" fill="#145AF3"/>
4
+ <circle cx="8" cy="9.5" r="2.4" fill="#7CB7FF"/>
5
+ <circle cx="28" cy="9.5" r="2.4" fill="#45D9E7"/>
6
+ <circle cx="8" cy="26.5" r="2.4" fill="#F2AF63"/>
7
+ <circle cx="28" cy="26.5" r="2.4" fill="#7CB7FF"/>
8
+ </svg>
package/lib/client.js ADDED
@@ -0,0 +1,383 @@
1
+ /**
2
+ * dsh-omniroute browser half: the OmniRoute card on the Plugins page.
3
+ *
4
+ * One surface: the OmniRoute card on the Plugins page, keyed on the
5
+ * `omniroute` settings namespace the host half declares as its row id. Every
6
+ * field of the row is volatile, and the host reads it per request, so a save
7
+ * lands on the next poll without remounting the plugin or restarting the
8
+ * server.
9
+ *
10
+ * The form stages edits locally and writes them in one `scope.mutate` call:
11
+ * a half-typed base URL must not reach the settings document field by field.
12
+ * Chrome is a stylesheet, not inline style objects: the module system claims
13
+ * every `<style>` tag a factory appends while it materializes and removes it
14
+ * when the package unloads. Classes are `or-`-prefixed because that sheet
15
+ * lands in the page's own document.
16
+ *
17
+ * This file is plain JavaScript on purpose. The client module system serves a
18
+ * package's `exports["./client"]` artifact as a lazy-CJS factory registered on
19
+ * `window.__ModuleLoader__`, and that is the whole format: an out-of-tree
20
+ * plugin can author it directly instead of reproducing the repository's tsdown
21
+ * client preset. `react` is provided by the module system; nothing else is
22
+ * required here.
23
+ */
24
+
25
+ window.__ModuleLoader__.load({
26
+ id: '@maci0/dsh-omniroute',
27
+
28
+ factory: (require) => {
29
+ var module = { exports: {} }
30
+ var exports = module.exports
31
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
32
+
33
+ const React = require('react')
34
+
35
+ /** Settings namespace shared with the host half; also this card's slot key. */
36
+ const NAMESPACE = 'omniroute'
37
+
38
+ /** Locale namespace for this plugin's copy. */
39
+ const LOCALE_NS = 'omniroute'
40
+
41
+ /**
42
+ * Every editable row field, in display order. `integer` fields are sent as
43
+ * numbers and must be positive integers; the host schema bounds each one,
44
+ * and the card refuses what it can see before the write.
45
+ */
46
+ const FIELDS = [
47
+ { key: 'baseURL', kind: 'url', label: 'labelBaseURL', hint: 'hintBaseURL' },
48
+ { key: 'apiKeyEnv', kind: 'text', label: 'labelApiKeyEnv', hint: 'hintApiKeyEnv' },
49
+ { key: 'timeoutMs', kind: 'integer', min: 1, max: 60_000, label: 'labelTimeoutMs', hint: 'hintTimeoutMs' },
50
+ { key: 'cacheSeconds', kind: 'integer', min: 0, max: 3_600, label: 'labelCacheSeconds', hint: 'hintCacheSeconds' },
51
+ { key: 'syncNamespace', kind: 'text', label: 'labelSyncNamespace', hint: 'hintSyncNamespace' },
52
+ { key: 'syncProvider', kind: 'text', label: 'labelSyncProvider', hint: 'hintSyncProvider' },
53
+ ]
54
+
55
+ /** Every class is `or-`-prefixed: the sheet lands in the page's own document. */
56
+ const CSS = [
57
+ '.or-page{display:flex;flex-direction:column;gap:12px}',
58
+ '.or-field{display:flex;flex-direction:column;gap:4px}',
59
+ '.or-label{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)}',
60
+ '.or-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
61
+ '.or-input{font:inherit;font-size:13px;line-height:1.5;padding:5px 12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;width:100%;box-sizing:border-box}',
62
+ '.or-input:disabled{cursor:default;opacity:.5}',
63
+ '.or-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}',
64
+ '.or-button{appearance:none;font:inherit;font-size:13px;line-height:1.5;padding:5px 14px;cursor:pointer;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
65
+ '.or-button:disabled{cursor:default;opacity:.5}',
66
+ '.or-button-quiet{padding:3px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
67
+ '.or-status{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
68
+ '.or-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}',
69
+ ].join('')
70
+
71
+ // Appended while the factory materializes: the module system claims the tag
72
+ // for this package and disposes it on unload. Guarded because the unit
73
+ // tests evaluate this file without a DOM.
74
+ if (typeof document !== 'undefined') {
75
+ const style = document.createElement('style')
76
+ style.textContent = CSS
77
+ document.head.append(style)
78
+ }
79
+
80
+ /** Plugin version, shown in the card footer. Kept in lockstep with package.json. */
81
+ const VERSION = '0.8.2'
82
+
83
+ const en = {
84
+ title: 'OmniRoute',
85
+ summary: 'OmniRoute catalog, connections, and quota at {baseURL}.',
86
+ summaryEmpty: 'OmniRoute catalog, connections, and quota.',
87
+ labelBaseURL: 'Base URL',
88
+ labelApiKeyEnv: 'API key reference',
89
+ labelTimeoutMs: 'Request deadline (ms)',
90
+ labelCacheSeconds: 'Cache seconds',
91
+ labelSyncNamespace: 'Sync namespace',
92
+ labelSyncProvider: 'Sync provider',
93
+ hintBaseURL: 'OmniRoute origin, with or without its /v1 path.',
94
+ hintApiKeyEnv: 'Credential reference holding the API key; the key itself never reaches the browser.',
95
+ hintTimeoutMs: 'Per-request deadline for one OmniRoute call.',
96
+ hintCacheSeconds: 'How long a reading is served before OmniRoute is asked again. 0 asks every time.',
97
+ hintSyncNamespace: 'Profile entry id ?sync=1 writes the model catalog into.',
98
+ hintSyncProvider: 'Provider route ?sync=1 writes the model catalog into.',
99
+ overridden: 'overridden',
100
+ save: 'Save',
101
+ saving: 'Saving…',
102
+ saved: 'Saved. The next poll uses these values.',
103
+ reset: 'Reset to defaults',
104
+ persists: 'Stored in your profile; every route reads the row on each request.',
105
+ readOnly: 'Read-only: this deployment does not persist settings.',
106
+ baseUrlInvalid: 'Base URL must be an absolute http(s) URL.',
107
+ textInvalid: 'Must not be empty.',
108
+ numberInvalid: 'Must be a whole number within the allowed range.',
109
+ rejected: 'The Host refused the write; the previous values are still in effect.',
110
+ failed: 'Could not save: {message}',
111
+ version: 'v{version}',
112
+ }
113
+
114
+ const zh = {
115
+ title: 'OmniRoute',
116
+ summary: 'OmniRoute 目录、连接与额度:{baseURL}。',
117
+ summaryEmpty: 'OmniRoute 目录、连接与额度。',
118
+ labelBaseURL: '基础地址',
119
+ labelApiKeyEnv: 'API 密钥引用',
120
+ labelTimeoutMs: '请求超时(毫秒)',
121
+ labelCacheSeconds: '缓存秒数',
122
+ labelSyncNamespace: '同步命名空间',
123
+ labelSyncProvider: '同步提供方',
124
+ hintBaseURL: 'OmniRoute 地址,可带或不带 /v1 路径。',
125
+ hintApiKeyEnv: '保存 API 密钥的凭据引用;密钥本身不会进入浏览器。',
126
+ hintTimeoutMs: '单次 OmniRoute 调用的超时时间。',
127
+ hintCacheSeconds: '一次读取在再次询问 OmniRoute 前可复用的时长。0 表示每次都询问。',
128
+ hintSyncNamespace: '?sync=1 写入模型目录的配置条目 id。',
129
+ hintSyncProvider: '?sync=1 写入模型目录的提供方路由。',
130
+ overridden: '已覆盖',
131
+ save: '保存',
132
+ saving: '保存中…',
133
+ saved: '已保存。下一次轮询将使用这些值。',
134
+ reset: '恢复默认',
135
+ persists: '保存在你的配置中;每个路由都会在每次请求时读取该行。',
136
+ readOnly: '只读:此部署不持久化设置。',
137
+ baseUrlInvalid: '基础地址必须是绝对的 http(s) URL。',
138
+ textInvalid: '不能为空。',
139
+ numberInvalid: '必须是允许范围内的整数。',
140
+ rejected: 'Host 拒绝了写入;原先的值仍然有效。',
141
+ failed: '保存失败:{message}',
142
+ version: 'v{version}',
143
+ }
144
+
145
+ /**
146
+ * Bind one settings scope to a React subscription.
147
+ * @param scope - the scope bound to the omniroute settings namespace.
148
+ * @returns a hook reading that scope's current snapshot.
149
+ */
150
+ function useScope(scope) {
151
+ const subscribe = (listener) => scope.subscribe(listener)
152
+ const getSnapshot = () => scope.getSnapshot()
153
+ return () => React.useSyncExternalStore(subscribe, getSnapshot)
154
+ }
155
+
156
+ /**
157
+ * Read a snapshot's resolved row. A namespace this deployment does not
158
+ * serve reports no row, which the card renders as nothing at all.
159
+ * @param snapshot - the settings scope snapshot.
160
+ * @returns the resolved row, or `undefined` when unreadable.
161
+ */
162
+ function rowOf(snapshot) {
163
+ if (snapshot.status !== 'ready') return undefined
164
+ return snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value : {}
165
+ }
166
+
167
+ /** One editable field: label, hint, input, and the override marker. */
168
+ function Field(props) {
169
+ const { t, field, value, overridden, disabled, onChange } = props
170
+ return React.createElement(
171
+ 'div',
172
+ { className: 'or-field' },
173
+ React.createElement(
174
+ 'div',
175
+ { className: 'or-row' },
176
+ React.createElement('span', { className: 'or-label' }, t(field.label)),
177
+ overridden === true
178
+ ? React.createElement('span', { className: 'or-hint' }, `(${t('overridden')})`)
179
+ : null,
180
+ ),
181
+ React.createElement('input', {
182
+ className: 'or-input',
183
+ type: field.kind === 'integer' ? 'number' : 'text',
184
+ value: value,
185
+ disabled,
186
+ 'aria-label': t(field.label),
187
+ onChange: (event) => { onChange(field.key, event.target.value) },
188
+ }),
189
+ React.createElement('span', { className: 'or-hint' }, t(field.hint)),
190
+ )
191
+ }
192
+
193
+ /**
194
+ * Build the card component over one bound settings scope.
195
+ * @param scope - the scope bound to the omniroute settings namespace.
196
+ * @param t - translate function bound to this plugin's locale namespace.
197
+ * @returns the component the slot renders.
198
+ */
199
+ function createCard(scope, t) {
200
+ const useOmniRoute = useScope(scope)
201
+
202
+ return function OmniRouteCard(props) {
203
+ const snapshot = useOmniRoute()
204
+ const [draft, setDraft] = React.useState(null)
205
+ const [error, setError] = React.useState(null)
206
+ const [status, setStatus] = React.useState(null)
207
+ // True while a write is in flight: Save and Reset stay disabled, so a
208
+ // double click sends one write.
209
+ const [pending, setPending] = React.useState(false)
210
+
211
+ const row = rowOf(snapshot)
212
+ // A namespace this deployment does not serve renders no trace of itself.
213
+ if (row === undefined) return null
214
+
215
+ if (props != null && props.view === 'summary') {
216
+ return row.baseURL === undefined
217
+ ? t('summaryEmpty')
218
+ : t('summary', { baseURL: String(row.baseURL) })
219
+ }
220
+
221
+ const disabled = !snapshot.writable
222
+ const busy = disabled || pending
223
+ const shown = draft ?? row
224
+ const user = snapshot.user !== null && typeof snapshot.user === 'object' ? snapshot.user : {}
225
+
226
+ /**
227
+ * Validate and collect the staged edits.
228
+ * @returns an error key, or the ordered path operations to write.
229
+ */
230
+ const collect = () => {
231
+ const ops = []
232
+ for (const field of FIELDS) {
233
+ const value = shown[field.key]
234
+ if (String(value) === String(row[field.key])) continue
235
+ if (field.kind === 'integer') {
236
+ // An emptied box is not zero: `Number('')` is 0, which would pass
237
+ // a min of 0 and write a value nobody typed.
238
+ const text = String(value).trim()
239
+ const parsed = Number(text)
240
+ if (text === '' || !Number.isInteger(parsed) || parsed < field.min || parsed > field.max) {
241
+ return { error: 'numberInvalid' }
242
+ }
243
+ ops.push({ op: 'set', path: [field.key], value: parsed })
244
+ continue
245
+ }
246
+ const text = String(value).trim()
247
+ if (text === '') return { error: 'textInvalid' }
248
+ if (field.kind === 'url' && !/^https?:\/\//u.test(text)) return { error: 'baseUrlInvalid' }
249
+ ops.push({ op: 'set', path: [field.key], value: text })
250
+ }
251
+ return { ops }
252
+ }
253
+
254
+ /** Run one settings write; a synchronous throw becomes a rejection. */
255
+ const write = (ops) => new Promise((resolve) => { resolve(scope.mutate(ops)) })
256
+
257
+ const save = () => {
258
+ if (pending) return
259
+ setError(null)
260
+ const result = collect()
261
+ if (result.error !== undefined) {
262
+ setError(t(result.error))
263
+ return
264
+ }
265
+ if (result.ops.length === 0) {
266
+ // Nothing moved: an edit that landed back on the stored value is
267
+ // not a change, and the row already holds it.
268
+ setStatus(null)
269
+ return
270
+ }
271
+ setStatus(t('saving'))
272
+ setPending(true)
273
+ write(result.ops)
274
+ .then((accepted) => {
275
+ if (accepted === false) {
276
+ setError(t('rejected'))
277
+ setStatus(null)
278
+ return
279
+ }
280
+ setDraft(null)
281
+ setStatus(t('saved'))
282
+ })
283
+ .catch((cause) => {
284
+ setStatus(null)
285
+ setError(t('failed', { message: cause instanceof Error ? cause.message : String(cause) }))
286
+ })
287
+ .finally(() => { setPending(false) })
288
+ }
289
+
290
+ const reset = () => {
291
+ if (pending) return
292
+ setDraft(null)
293
+ setStatus(null)
294
+ setError(null)
295
+ const ops = Object.keys(user)
296
+ .filter((key) => FIELDS.some((field) => field.key === key))
297
+ .map((key) => ({ op: 'unset', path: [key] }))
298
+ if (ops.length === 0) return
299
+ setPending(true)
300
+ write(ops).then((accepted) => {
301
+ // The Host resolves false when it refuses the write, so a swallowed
302
+ // refusal would leave the override in place with nothing said.
303
+ if (accepted === false) setError(t('rejected'))
304
+ }).catch((cause) => {
305
+ setError(t('failed', { message: cause instanceof Error ? cause.message : String(cause) }))
306
+ }).finally(() => { setPending(false) })
307
+ }
308
+
309
+ const overridden = Object.keys(user).some((key) => FIELDS.some((field) => field.key === key))
310
+
311
+ return React.createElement(
312
+ 'div',
313
+ { className: 'or-page' },
314
+ ...FIELDS.map((field) => React.createElement(Field, {
315
+ key: field.key,
316
+ t,
317
+ field,
318
+ value: String(shown[field.key] ?? ''),
319
+ overridden: Object.hasOwn(user, field.key),
320
+ disabled: busy,
321
+ onChange: (key, value) => {
322
+ setStatus(null)
323
+ setDraft({ ...shown, [key]: value })
324
+ },
325
+ })),
326
+ React.createElement(
327
+ 'div',
328
+ { className: 'or-row' },
329
+ React.createElement(
330
+ 'button',
331
+ { type: 'button', className: 'or-button', disabled: busy, onClick: save },
332
+ t('save'),
333
+ ),
334
+ overridden
335
+ ? React.createElement(
336
+ 'button',
337
+ { type: 'button', className: 'or-button or-button-quiet', disabled: busy, onClick: reset },
338
+ t('reset'),
339
+ )
340
+ : null,
341
+ ),
342
+ React.createElement(
343
+ 'div',
344
+ { className: 'or-status' },
345
+ status ?? (snapshot.writable ? t('persists') : t('readOnly')),
346
+ ' ',
347
+ t('version', { version: VERSION }),
348
+ ),
349
+ error === null ? null : React.createElement('div', { className: 'or-error' }, error),
350
+ )
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Mount the browser surface: the OmniRoute card on the Plugins page.
356
+ * @param ctx - the browser plugin context.
357
+ */
358
+ function apply(ctx) {
359
+ const t = ctx.locale.bind(LOCALE_NS)
360
+ ctx.effect(
361
+ () => ctx.locale.register(LOCALE_NS, { en, zh }),
362
+ 'dsh-omniroute: locale dictionary',
363
+ )
364
+
365
+ const scope = ctx.configForms.get(NAMESPACE)
366
+ const Card = createCard(scope, t)
367
+
368
+ // The owner declares its own slot; injecting waits for it to exist, so
369
+ // this registration does not depend on plugin load order. The card takes
370
+ // no injected props (it closes over its own bound scope), so the entry
371
+ // declares the documented `locale` namespace and no `inject`.
372
+ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
373
+ name: 'plugins.row.config',
374
+ key: '@maci0/dsh-omniroute#omniroute',
375
+ locale: LOCALE_NS,
376
+ }, Card))
377
+ }
378
+
379
+ exports.apply = apply
380
+ exports.inject = ['slots', 'configForms', 'locale']
381
+ return module.exports
382
+ },
383
+ })
package/lib/host.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The slice of the DeepSeek Harness host surface this plugin uses, declared
3
+ * structurally.
4
+ *
5
+ * The plugin installs from outside the harness checkout and has no runtime
6
+ * dependency on harness packages: the services it reaches are typed here, and
7
+ * a composition that mounts none of them simply omits that capability. The
8
+ * same shape also lets the unit tests drive `apply` with a plain fake context.
9
+ *
10
+ * @module dsh-omniroute/host
11
+ */
12
+ export {};