dsh-tinyfish 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.

Potentially problematic release.


This version of dsh-tinyfish might be problematic. Click here for more details.

package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 viztor
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 ADDED
@@ -0,0 +1,206 @@
1
+ # dsh-tinyfish
2
+
3
+ [![npm](https://img.shields.io/npm/v/dsh-tinyfish.svg)](https://www.npmjs.com/package/dsh-tinyfish) [![downloads](https://img.shields.io/npm/dm/dsh-tinyfish.svg)](https://www.npmjs.com/package/dsh-tinyfish) [![ci](https://github.com/viztor/dsh-tinyfish/actions/workflows/ci.yml/badge.svg)](https://github.com/viztor/dsh-tinyfish/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/dsh-tinyfish.svg)](https://github.com/viztor/dsh-tinyfish/blob/main/LICENSE) [![node](https://img.shields.io/badge/node-%3E%3D22.14-5FA04E.svg)](https://nodejs.org)
4
+
5
+ **Search and fetch for the DeepSeek Harness, at $0.**
6
+
7
+ A DSH bundle that makes [TinyFish](https://tinyfish.ai) the implementation of the harness's own `web_search` and `web_fetch` tools. Both endpoints are free, so the web path on your host stops costing money per call.
8
+
9
+ | | `dsh-web`'s default | with `dsh-tinyfish` |
10
+ | --- | --- | --- |
11
+ | search | `deepseek-official` | TinyFish `/search` via Monid, **$0** |
12
+ | fetch | `http` | TinyFish `/fetch`, **$0**, returns clean Markdown |
13
+ | turndown conversion | yes, on every fetch | **no** — the content is already Markdown |
14
+ | reversibility | — | two words, no reinstall |
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ cd ~/.dsh/profiles/web
20
+ npm install dsh-tinyfish
21
+ ```
22
+
23
+ Add the bundle to that profile's `package.json`, then restart DSH:
24
+
25
+ ```jsonc
26
+ {
27
+ "dependencies": { "dsh-tinyfish": "^0.2.0" },
28
+ "dsh": {
29
+ "profile": {
30
+ "bundles": [
31
+ "@deepseek-ai/dsh-base",
32
+ "@deepseek-ai/dsh-web-app",
33
+ "dsh-tinyfish",
34
+ ],
35
+ },
36
+ },
37
+ }
38
+ ```
39
+
40
+ That is the whole install. The bundle's own `cordis.patch.yml` selects itself:
41
+
42
+ ```yaml
43
+ - id: web
44
+ name: "@deepseek-ai/dsh-web"
45
+ config:
46
+ searchProvider: tinyfish
47
+ fetchProvider: tinyfish
48
+ ```
49
+
50
+ Restarting matters: bundles are resolved when the harness boots, so `patchReload` will not pick up a newly mounted one.
51
+
52
+ **To go back**, set those two back to `deepseek-official` and `http`. The bundle stays mounted and idle — registration and selection are separate, and only your profile decides which provider wins.
53
+
54
+ **Requirements:** DSH **0.2.0+**, Node **22.14+**, and a credential (below). The harness supplies the `@deepseek-ai/*` peer packages; you do not install them.
55
+
56
+ ## Verify
57
+
58
+ Ask the agent to search for something. Or check the wiring without an agent:
59
+
60
+ ```sh
61
+ cd ~/.dsh/profiles/web
62
+ node -e '
63
+ const m = require("dsh-tinyfish");
64
+ const ctx = { web: { registerSearchProvider(){}, registerFetchProvider(){} }, get: () => undefined };
65
+ m.apply(ctx, m.Config({}));
66
+ console.log("registered:", m.name, "| no throw above = wired");
67
+ '
68
+ ```
69
+
70
+ If search reports `WEB_PROVIDER_CREDENTIAL_MISSING` or `WEB_PROVIDER_UNAVAILABLE`, that is the next section.
71
+
72
+ ## Credentials
73
+
74
+ A fresh install has none, and never will — a key inside an npm tarball would be published forever. Both endpoints are free, but both need an account.
75
+
76
+ **In the profile patch (no restart needed).** The `web-tinyfish` row in your profile's `cordis.patch.yml` sets `apiKeyEnv`, which the harness resolves through its credentials service and the launch environment, and re-reads on every call — so a change takes effect on the next search.
77
+
78
+ **By hand:**
79
+
80
+ | channel | get a key | then |
81
+ | --- | --- | --- |
82
+ | `direct` default | [tinyfish.ai](https://tinyfish.ai) → API keys | `tinyfish auth login`, or `echo $KEY \| tinyfish auth set` |
83
+ | `monid` | [app.monid.ai](https://app.monid.ai) | `monid keys add`, or `export MONID_API_KEY` |
84
+
85
+ The default is `direct` because the package is named for TinyFish: a fresh install asks for the credential its own name implies rather than for an account at a different service. If you would rather go through Monid — it reuses a platform key a Monid MCP mount already holds, and costs the same — pin it in your own profile patch, which is a host decision and does not need a new release of this plugin:
86
+
87
+ ```yaml
88
+ - id: web-tinyfish
89
+ config:
90
+ channel: monid
91
+ ```
92
+
93
+ Resolution order, first match wins: a literal `apiKey` in the settings row → the harness credentials service → the launch environment → the live environment → the CLI stores. A failing credential service falls through to the next source rather than failing the search.
94
+
95
+ ## Configuration
96
+
97
+ Everything lives in one row, `web-tinyfish`, edited in your profile's `cordis.patch.yml`. The row is validated, so an out-of-range value is rejected with a message rather than silently clamped.
98
+
99
+ **Settings → Plugins → TinyFish** edits this row, if you prefer a form to a patch file. The plugin ships both halves: the provider the harness loads, and a client bundle that contributes the page. Changes are staged and written on save, and a key you type is stored by the harness rather than in your profile.
100
+
101
+ The patch file is still the honest place for the first edit — it is where a selection that overrides someone else's layer belongs, and it needs no build. Use whichever suits the change.
102
+
103
+ | key | default | meaning |
104
+ | --- | --- | --- |
105
+ | `channel` | `direct` | `monid` or `direct`; see [Credentials](#credentials) |
106
+ | `apiKey` | _(unset)_ | literal credential for either channel; prefer a ref |
107
+ | `apiKeyEnv` | `TINYFISH_API_KEY` | credential reference, or env var, for `direct` |
108
+ | `monidKeyEnv` | `MONID_API_KEY` | credential reference, or env var, for `monid` |
109
+ | `purpose` | _(unset)_ | goal statement; TinyFish ranks on it |
110
+ | `attempts` | `3` | retries for a transient failure or an empty search (1–5) |
111
+ | `filters.domainType` | _(unset)_ | `web` \| `news` \| `research_paper` |
112
+ | `filters.language` / `.location` | _(unset)_ | geo targeting |
113
+ | `filters.includeDomains` / `.excludeDomains` | _(unset)_ | comma-separated |
114
+ | `monidBase` / `searchBase` / `fetchBase` | upstream | endpoint override, for staging |
115
+ | `search` / `fetch` | `true` | offer this kind at all; `false` declines without unregistering |
116
+
117
+ Search and fetch are switched independently. Both always register, so turning one off makes it report _unavailable_ rather than _missing_ — the harness tells those apart, and only the second means "I turned this off" rather than "the install is broken".
118
+
119
+ ```yaml
120
+ - id: web-tinyfish
121
+ config:
122
+ search: true
123
+ fetch: false # keep TinyFish for search, let dsh-web use another fetch
124
+ ```
125
+
126
+ ### Where a value comes from
127
+
128
+ Every setting resolves in the same three rungs — **row, then environment, then built-in default** — so a deployment can be retargeted without writing a patch file. This is the shape the shipped providers use for `$DEEPSEEK_SEARCH_BASE_URL`.
129
+
130
+ | setting | environment variable |
131
+ | ------------ | -------------------------- |
132
+ | `monidBase` | `TINYFISH_MONID_BASE_URL` |
133
+ | `searchBase` | `TINYFISH_SEARCH_BASE_URL` |
134
+ | `fetchBase` | `TINYFISH_FETCH_BASE_URL` |
135
+
136
+ An endpoint that does not parse makes the provider report itself unavailable rather than being trusted.
137
+
138
+ ### The credential, in order
139
+
140
+ Resolved **per call**, so a key rotated anywhere below takes effect on the next search with no restart. First match wins:
141
+
142
+ | # | source | set it by |
143
+ | --- | --- | --- |
144
+ | 1 | the `apiKey` literal | the row — a secret in config; prefer 2–3 |
145
+ | 2 | the credentials service | `apiKeyEnv` (direct) or `monidKeyEnv` (monid) in the settings UI |
146
+ | 3 | the launch environment | exported before DSH started |
147
+ | 4 | the live environment | `MONID_API_KEY` / `TINYFISH_API_KEY` |
148
+ | 5 | the channel's CLI store | `monid keys add` / `tinyfish auth login` |
149
+
150
+ The harness services sit above the environment on purpose: a value someone typed into Settings is a more deliberate choice than one that merely happens to be exported. A failing service falls through to the next source rather than failing the search, and a host that mounts neither still works.
151
+
152
+ ### Two keys, one settings page
153
+
154
+ The two channels authenticate against different services, so each has **its own** credential reference: `apiKeyEnv` (default `TINYFISH_API_KEY`) for `direct`, and `monidKeyEnv` (default `MONID_API_KEY`) for `monid`. Saving one never overwrites the other, so both can be live at once and switching channels back and forth loses nothing.
155
+
156
+ The page shows the key for the **selected** channel only. Showing both at once would invite pasting the Monid platform key into the field TinyFish authenticates with — and a key sent to the wrong service fails as a 401, which reads as "that key is wrong" rather than as "that was the wrong field". The `apiKey` literal still overrides either channel; it exists for a patch file, and the settings page does not write it.
157
+
158
+ ## Why the fetch path is a real improvement
159
+
160
+ `dsh-tool-web` renders a `kind: "html"` body by running **turndown** to convert HTML to Markdown, behind a depth cap with a `"[HTML content omitted]"` fallback. TinyFish already extracts clean Markdown in a browser-grade extractor, so this provider returns `kind: "text"` and the content reaches the model with no conversion step at all.
161
+
162
+ ## Two channels, one payload
163
+
164
+ Monid is a thin envelope whose `output` is the direct response verbatim, and it forwards parameter names unchanged. One transport serves both, and nothing above it branches on which is active — a test asserts the two agree on the top hit for the same query.
165
+
166
+ | | `direct` (default) | `monid` |
167
+ | ------ | ---------------------------- | -------------------------- |
168
+ | search | `GET api.search.tinyfish.ai` | `POST api.monid.ai/v1/run` |
169
+ | fetch | `POST api.fetch.tinyfish.ai` | `POST api.monid.ai/v1/run` |
170
+ | auth | `X-API-Key` | `Authorization: Bearer` |
171
+ | cost | $0, direct | $0, on the Monid wallet |
172
+
173
+ ## Behaviour worth knowing
174
+
175
+ - **A 404 is a result, not an error.** A per-URL fetch failure comes back carrying its status, because that is resource state the model needs.
176
+ - **`publishedAt` is honest.** TinyFish reports dates as human strings (`"Apr 30, 2026"`, `"1 year ago"`). What parses is coerced to ISO-8601; what does not is dropped rather than invented. Unzoned dates are read as UTC, so the same page reports the same day regardless of where the Worker ran.
177
+ - **Search retries an empty result.** The upstream answers a valid query with nothing about one run in three, so a blank result is retried up to `attempts` before it is believed.
178
+ - **A blocked run is terminal.** If a Monid workspace control stops a run, the error says why and links to top up. It is never retried.
179
+
180
+ ## Not included
181
+
182
+ TinyFish's `agent` and `browser` surfaces are **not** exposed. They cost $0.016/step and $0.002/min, are metered against a wallet, and do not fit `ctx.web` — that seam has exactly two provider kinds, and an agent run is an action, not a search or a fetch. Use the `tinyfish` CLI directly when a page genuinely needs a real browser.
183
+
184
+ ## Development
185
+
186
+ The toolchain is [Vite+](https://viteplus.dev): `vp pack` builds the library with tsdown, `vp test` runs Vitest, and `vp lint` / `vp fmt` are Oxlint and Oxfmt, type-aware. Lint and format settings live in the `lint` and `fmt` blocks of `vite.config.ts` — Vite+ disables nested Oxlint/Oxfmt configs, so a standalone `oxlint.config.ts` would be read by nobody.
187
+
188
+ ```sh
189
+ pnpm install
190
+ pnpm test # 78 hermetic tests — no network, no credential
191
+ pnpm run check # format + lint + types
192
+ pnpm run release:gate # build, then the full gate
193
+ pnpm run test:live # the real APIs, still $0, needs credentials
194
+ ```
195
+
196
+ `pnpm run ci` ends in one script, `scripts/check.mjs`, that runs six package checks in a single pass: `lib/` freshness, peer ranges npm can parse, the bundle contract, the harness surfaces still being present, no credentials in the tree, and — the one that earns its keep — packing the tarball, installing it with plain npm, and loading it. Each is proved by planting the regression it guards, and CI runs all of it.
197
+
198
+ Full process and invariants: [`AGENTS.md`](./AGENTS.md).
199
+
200
+ ## Compatibility
201
+
202
+ Requires **DSH 0.2.0+**; tested against 0.2.0-rc.1. The `@deepseek-ai/dsh-*` peers are `^0.2.0-rc.1`, so a DSH patch release will not orphan the plugin, and a 0.3 contract change still fails loudly rather than silently.
203
+
204
+ ## License
205
+
206
+ MIT
@@ -0,0 +1,41 @@
1
+ # Bundle patch for `dsh-tinyfish`.
2
+ #
3
+ # The plugin only *offers* the `tinyfish` provider on both seam kinds. Whether
4
+ # it is actually used is the profile's decision, in `dsh-web`'s config:
5
+ #
6
+ # - id: web
7
+ # name: '@deepseek-ai/dsh-web'
8
+ # config:
9
+ # searchProvider: tinyfish
10
+ # fetchProvider: tinyfish
11
+ #
12
+ # Both rows are kept separate from the plugin row so the id is set in one place
13
+ # and swapping back to `deepseek-official` / `http` is a two-word edit rather
14
+ # than a plugin removal. Registering a provider nobody selects is free.
15
+ #
16
+ # `channel` picks the upstream route and is deliberately NOT set here, so the
17
+ # schema default stands: `direct`, which calls TinyFish's own API with the key
18
+ # the `tinyfish` CLI stored in ~/.tinyfish/config.json. A package named for
19
+ # TinyFish should not open by asking for an account at a different service.
20
+ #
21
+ # A host that prefers the Monid envelope — which reuses the credential a Monid
22
+ # MCP mount already holds, and costs the same — pins it in its own patch layer,
23
+ # where the preference belongs and where it can be changed without a new
24
+ # release:
25
+ #
26
+ # - id: web-tinyfish
27
+ # config:
28
+ # channel: monid
29
+ #
30
+ # `apiKey` is not set here either — a secret belongs in the environment, the
31
+ # CLI's own store, or the harness credentials service, never in a patch file.
32
+ - insert:
33
+ - id: web-tinyfish
34
+ name: "dsh-tinyfish"
35
+ config:
36
+ attempts: 3
37
+ - id: web
38
+ name: "@deepseek-ai/dsh-web"
39
+ config:
40
+ searchProvider: tinyfish
41
+ fetchProvider: tinyfish
package/lib/client.js ADDED
@@ -0,0 +1,400 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-tinyfish",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
8
+ let react_jsx_runtime = require("react/jsx-runtime");
9
+ //#region src/settings-page.tsx
10
+ /**
11
+ * `dsh-tinyfish` settings page — the client half of the bundle.
12
+ *
13
+ * The harness renders a plugin's settings form only when a client bundle
14
+ * contributes a slot to the Plugins page. It never reads the plugin's `Config`
15
+ * schema, so a package with no client half has no form however well its schema
16
+ * is declared, and the harness degrades quietly rather than saying so.
17
+ *
18
+ * This is that half. It is bundled separately from `src/index.ts` — the host
19
+ * half — into `lib/client.cjs`, wrapped in the `window.__ModuleLoader__.load`
20
+ * call the web client expects, and the manifest declares both `dsh.bundle` and
21
+ * `dsh.client`. Those two keys are read by different subsystems and never
22
+ * consult each other, so one package can carry both and the user installs once.
23
+ *
24
+ * Nothing here writes as the user types. The form stages drafts and writes them
25
+ * on save, so what is on screen is exactly what a save would store.
26
+ *
27
+ * @module dsh-tinyfish/settings-page
28
+ */
29
+ /**
30
+ * The settings namespace, spelled rather than imported.
31
+ *
32
+ * A client package must not depend on a Host package, so this string is
33
+ * duplicated on purpose. It must equal the exported `name` of `src/index.ts`:
34
+ * that is what the Plugins page keys the row by.
35
+ */
36
+ const NS = "web-tinyfish";
37
+ /**
38
+ * Client-side services this page needs.
39
+ *
40
+ * `configForms` supplies the form scope, `slots` is how the page is
41
+ * contributed, `locale` carries the dictionaries, and the two `remote` entries
42
+ * are the credential reference and its invalidation events.
43
+ */
44
+ const inject = [
45
+ "slots",
46
+ "locale",
47
+ "remote",
48
+ "remote.credentials",
49
+ "configForms"
50
+ ];
51
+ /** English copy. */
52
+ const en = {
53
+ title: "TinyFish",
54
+ description: "TinyFish-backed web search and fetch, at $0.",
55
+ channel: "Channel",
56
+ channelHint: "direct calls TinyFish with its own key; monid routes through a Monid key.",
57
+ apiKey: "API key",
58
+ apiKeyHint: "Stored outside the settings file. Leave blank to keep the current key.",
59
+ monidApiKey: "Monid platform key",
60
+ monidApiKeyHint: "Stored separately from the TinyFish key, so switching channels keeps both. `monid keys add` also works and takes precedence over this.",
61
+ apiKeySet: "A key is configured.",
62
+ apiKeyUnset: "No key is configured, so searches fail until one is set.",
63
+ purpose: "Purpose",
64
+ purposeHint: "Optional goal statement; TinyFish ranks results against it.",
65
+ attempts: "Attempts",
66
+ attemptsHint: "Retries for a transient failure or an empty result, 1 to 5.",
67
+ search: "Offer search",
68
+ searchHint: "When off, web_search falls through to another provider.",
69
+ fetch: "Offer fetch",
70
+ fetchHint: "When off, web_fetch falls through to another provider.",
71
+ channelDirect: "Direct",
72
+ channelMonid: "Monid",
73
+ bothOff: "Both providers are off, so this plugin is registered but answers nothing. Turn one back on to use it.",
74
+ overridden: "Overridden",
75
+ reset: "Reset to default",
76
+ invalidNumber: "Enter a whole number, or leave blank to use the default.",
77
+ invalidText: "This value was not accepted; leave blank to use the default.",
78
+ readOnly: "This deployment stores settings read-only.",
79
+ unavailable: "This plugin is not loaded, so it cannot be configured right now.",
80
+ save: "Save",
81
+ saving: "Saving…",
82
+ saveFailed: "The deployment did not accept these values; they were left for you to correct."
83
+ };
84
+ /** Simplified Chinese copy, for the profile locale this bundle was written in. */
85
+ const zh = {
86
+ title: "TinyFish",
87
+ description: "基于 TinyFish 的网页搜索与抓取,零成本。",
88
+ channel: "通道",
89
+ channelHint: "direct 使用 TinyFish 自己的密钥;monid 通过 Monid 密钥转发。",
90
+ apiKey: "API Key",
91
+ apiKeyHint: "不写入设置文件。留空表示保持当前密钥。",
92
+ monidApiKey: "Monid 平台密钥",
93
+ monidApiKeyHint: "与 TinyFish 密钥分开保存,切换通道时两者都会保留。也可运行 `monid keys add`,其优先级高于此项。",
94
+ apiKeySet: "已配置密钥。",
95
+ apiKeyUnset: "未配置密钥,搜索会失败,直到设置为止。",
96
+ purpose: "目标说明",
97
+ purposeHint: "可选的目标描述;TinyFish 会据此排序结果。",
98
+ attempts: "尝试次数",
99
+ attemptsHint: "瞬时失败或结果为空时的重试次数,1 到 5。",
100
+ search: "提供搜索",
101
+ searchHint: "关闭后,web_search 会转由其他提供方处理。",
102
+ fetch: "提供抓取",
103
+ fetchHint: "关闭后,web_fetch 会转由其他提供方处理。",
104
+ channelDirect: "直连",
105
+ channelMonid: "Monid",
106
+ bothOff: "搜索与抓取均已关闭,此插件已注册但不再响应任何请求。重新开启其中一个即可恢复使用。",
107
+ overridden: "已覆盖",
108
+ reset: "恢复默认",
109
+ invalidNumber: "请填整数;留空表示使用默认值。",
110
+ invalidText: "该值未被接受;留空表示使用默认值。",
111
+ readOnly: "本部署的设置为只读。",
112
+ unavailable: "该插件当前未加载,暂时无法配置。",
113
+ save: "保存",
114
+ saving: "保存中…",
115
+ saveFailed: "本部署没有接受这些值,已保留供你修改。"
116
+ };
117
+ /** Field names in one place, so the form and its specs cannot drift apart. */
118
+ const FIELD = {
119
+ channel: "channel",
120
+ apiKey: "apiKey",
121
+ apiKeyEnv: "apiKeyEnv",
122
+ monidApiKey: "monidApiKey",
123
+ monidKeyEnv: "monidKeyEnv",
124
+ purpose: "purpose",
125
+ attempts: "attempts",
126
+ search: "search",
127
+ fetch: "fetch"
128
+ };
129
+ /** Credential references the provider falls back to when the section names none. */
130
+ const DEFAULT_API_KEY_REF = "TINYFISH_API_KEY";
131
+ const DEFAULT_MONID_KEY_REF = "MONID_API_KEY";
132
+ /**
133
+ * A boolean field.
134
+ *
135
+ * The primitives ship a text and a number spec but no boolean one, and the Host
136
+ * validates the stored value regardless — this only decides what a draft means.
137
+ * `parse` returning `undefined` is what blocks the save on a typo instead of
138
+ * silently discarding the edit, which is the difference between a visible
139
+ * mistake and a setting that quietly did not apply.
140
+ *
141
+ * @param field - field name inside the namespace section.
142
+ * @returns the field's conversion spec.
143
+ */
144
+ /** What each accepted boolean draft stages. Anything absent blocks the save. */
145
+ const BOOLEAN_DRAFTS = {
146
+ "": { kind: "clear" },
147
+ true: {
148
+ kind: "set",
149
+ value: true
150
+ },
151
+ false: {
152
+ kind: "set",
153
+ value: false
154
+ }
155
+ };
156
+ function settingsBooleanField(field) {
157
+ return {
158
+ field,
159
+ format: (value) => typeof value === "boolean" || typeof value === "number" ? String(value) : "",
160
+ parse: (text) => BOOLEAN_DRAFTS[text.trim().toLowerCase()]
161
+ };
162
+ }
163
+ /** The section fields this card edits, in render order. */
164
+ const SPECS = [
165
+ (0, _deepseek_ai_dsh_client_ui_primitives.settingsTextField)(FIELD.channel),
166
+ (0, _deepseek_ai_dsh_client_ui_primitives.settingsTextField)(FIELD.purpose),
167
+ (0, _deepseek_ai_dsh_client_ui_primitives.settingsNumberField)(FIELD.attempts),
168
+ settingsBooleanField(FIELD.search),
169
+ settingsBooleanField(FIELD.fetch)
170
+ ];
171
+ /**
172
+ * The credential reference the section currently names.
173
+ *
174
+ * Read from the accepted section rather than from a draft, because the reference
175
+ * is what the *provider* will resolve — a key typed against a reference that is
176
+ * not yet saved would be stored where nothing looks for it.
177
+ *
178
+ * @param snapshot - the form scope's current snapshot.
179
+ * @param which - which channel's reference to read.
180
+ * @returns the reference name, or that channel's default.
181
+ */
182
+ function refOf(snapshot, which) {
183
+ const section = snapshot?.value;
184
+ const field = which === "monid" ? FIELD.monidKeyEnv : FIELD.apiKeyEnv;
185
+ const named = section?.[field];
186
+ const fallback = which === "monid" ? DEFAULT_MONID_KEY_REF : DEFAULT_API_KEY_REF;
187
+ return typeof named === "string" && named.trim() !== "" ? named.trim() : fallback;
188
+ }
189
+ /** The labels the shared form frame renders. */
190
+ const formLabels = (t) => ({
191
+ unavailable: t("unavailable"),
192
+ readOnly: t("readOnly"),
193
+ saveFailed: t("saveFailed"),
194
+ save: t("save"),
195
+ saving: t("saving")
196
+ });
197
+ /**
198
+ * Render the Plugins list's one-line summary, or the settings form.
199
+ *
200
+ * @param props - the view asked for, locale copy, the form snapshot, its
201
+ * actions, and the credential's configured state.
202
+ * @returns the summary, or the form.
203
+ */
204
+ /**
205
+ * Resolve a boolean switch to its effective value.
206
+ *
207
+ * The draft text is authoritative when present; a blank draft means untouched,
208
+ * so it falls back to the schema default (on for both switches). The Switch
209
+ * only ever writes "true" or "false", so any other text cannot occur through
210
+ * the UI — but the fallback keeps the control honest if the section arrives
211
+ * in an unexpected shape.
212
+ */
213
+ function switchValue(text) {
214
+ if (text === "false") return false;
215
+ return true;
216
+ }
217
+ function TinyfishCard(props) {
218
+ const { t } = props;
219
+ if (props.view === "summary") return t("description");
220
+ const state = props.useTinyfishCard((snapshot) => snapshot);
221
+ const disabled = !state.shell.writable;
222
+ const field = (name) => ({
223
+ id: `plugin-config-tinyfish-${name}`,
224
+ disabled,
225
+ overriddenLabel: t("overridden"),
226
+ resetLabel: t("reset"),
227
+ ...state.fields[name],
228
+ onEdit: (text) => {
229
+ props.edit(name, text);
230
+ },
231
+ onReset: () => {
232
+ props.resetField(name);
233
+ }
234
+ });
235
+ const channel = (state.fields[FIELD.channel]?.text ?? "") === "monid" ? "monid" : "direct";
236
+ const key = state.keys[channel];
237
+ const keyChannel = channel === "monid" ? FIELD.monidApiKey : FIELD.apiKey;
238
+ const searchOn = switchValue(state.fields[FIELD.search]?.text ?? "");
239
+ const fetchOn = switchValue(state.fields[FIELD.fetch]?.text ?? "");
240
+ const resetButton = (name, overridden) => overridden && !disabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
241
+ type: "button",
242
+ onClick: () => {
243
+ props.resetField(name);
244
+ },
245
+ children: t("reset")
246
+ }) : null;
247
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.SettingsForm, {
248
+ labels: formLabels(t),
249
+ state: state.shell,
250
+ onSave: props.save,
251
+ onDiscard: props.discard,
252
+ children: [
253
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
254
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.SegmentedControl, {
255
+ id: `plugin-config-tinyfish-${FIELD.channel}`,
256
+ label: t("channel"),
257
+ value: channel,
258
+ options: [{
259
+ value: "direct",
260
+ label: t("channelDirect")
261
+ }, {
262
+ value: "monid",
263
+ label: t("channelMonid")
264
+ }],
265
+ onChange: (next) => {
266
+ props.edit(FIELD.channel, next);
267
+ },
268
+ disabled
269
+ }),
270
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("channelHint") }),
271
+ resetButton(FIELD.channel, state.fields[FIELD.channel]?.overridden ?? false)
272
+ ] }),
273
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.SettingsSecretField, {
274
+ id: `plugin-config-tinyfish-${keyChannel}`,
275
+ label: channel === "monid" ? t("monidApiKey") : t("apiKey"),
276
+ hint: channel === "monid" ? t("monidApiKeyHint") : t("apiKeyHint"),
277
+ text: key.text,
278
+ disabled,
279
+ configured: key.named,
280
+ stateLabel: key.named ? t("apiKeySet") : t("apiKeyUnset"),
281
+ onEdit: (text) => {
282
+ props.edit(keyChannel, text);
283
+ }
284
+ }),
285
+ searchOn && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.SettingsValueField, {
286
+ ...field(FIELD.purpose),
287
+ label: t("purpose"),
288
+ hint: t("purposeHint"),
289
+ invalidLabel: t("invalidText")
290
+ }),
291
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.SettingsValueField, {
292
+ ...field(FIELD.attempts),
293
+ label: t("attempts"),
294
+ hint: t("attemptsHint"),
295
+ invalidLabel: t("invalidNumber"),
296
+ numeric: true
297
+ }),
298
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
299
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Switch, {
300
+ label: t("search"),
301
+ checked: searchOn,
302
+ onChange: (next) => {
303
+ props.edit(FIELD.search, String(next));
304
+ },
305
+ disabled
306
+ }),
307
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("searchHint") }),
308
+ resetButton(FIELD.search, state.fields[FIELD.search]?.overridden ?? false)
309
+ ] }),
310
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
311
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Switch, {
312
+ label: t("fetch"),
313
+ checked: fetchOn,
314
+ onChange: (next) => {
315
+ props.edit(FIELD.fetch, next.toString());
316
+ },
317
+ disabled
318
+ }),
319
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("fetchHint") }),
320
+ resetButton(FIELD.fetch, state.fields[FIELD.fetch]?.overridden ?? false)
321
+ ] }),
322
+ !searchOn && !fetchOn && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("bothOff") })
323
+ ]
324
+ });
325
+ }
326
+ /**
327
+ * Mount the TinyFish settings page while the Host serves its namespace.
328
+ *
329
+ * @param ctx - the browser plugin context.
330
+ */
331
+ function apply(ctx) {
332
+ ctx.effect(() => {
333
+ ctx.locale.register(NS, {
334
+ zh,
335
+ en
336
+ });
337
+ }, "dsh-tinyfish: dictionaries");
338
+ const scope = ctx.configForms.get(NS);
339
+ const model = new _deepseek_ai_dsh_client_ui_primitives.SettingsFormModel(scope, SPECS, [{
340
+ field: FIELD.apiKey,
341
+ write: async (text) => {
342
+ try {
343
+ await ctx.remote.credentials.set(refOf(scope.getSnapshot(), "direct"), text);
344
+ return true;
345
+ } catch {
346
+ return false;
347
+ }
348
+ }
349
+ }, {
350
+ field: FIELD.monidApiKey,
351
+ write: async (text) => {
352
+ try {
353
+ await ctx.remote.credentials.set(refOf(scope.getSnapshot(), "monid"), text);
354
+ return true;
355
+ } catch {
356
+ return false;
357
+ }
358
+ }
359
+ }]);
360
+ const store = model.bind(() => ({
361
+ shell: model.shell(),
362
+ fields: Object.fromEntries(SPECS.map((spec) => [spec.field, model.field(spec.field)])),
363
+ keys: {
364
+ direct: {
365
+ text: model.field(FIELD.apiKey).text,
366
+ named: refOf(scope.getSnapshot(), "direct") !== DEFAULT_API_KEY_REF
367
+ },
368
+ monid: {
369
+ text: model.field(FIELD.monidApiKey).text,
370
+ named: refOf(scope.getSnapshot(), "monid") !== DEFAULT_MONID_KEY_REF
371
+ }
372
+ }
373
+ }));
374
+ ctx.effect(() => () => {
375
+ model.dispose();
376
+ }, "dsh-tinyfish: form subscription");
377
+ ctx.effect(() => {
378
+ ctx.configForms.whileServed([NS], () => {
379
+ ctx.slots.inject("plugins.bundle.config", () => {
380
+ ctx.slots.register({
381
+ name: "plugins.bundle.config",
382
+ key: "dsh-tinyfish",
383
+ locale: NS,
384
+ inject: () => ({
385
+ hooks: { tinyfishCard: store },
386
+ ...model.actions()
387
+ })
388
+ }, TinyfishCard);
389
+ });
390
+ });
391
+ }, "dsh-tinyfish: page");
392
+ }
393
+ //#endregion
394
+ exports.NS = NS;
395
+ exports.apply = apply;
396
+ exports.inject = inject;
397
+
398
+ return module.exports;
399
+ },
400
+ });