dsh-any-deepseek 0.0.6 → 0.0.7

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/README.md CHANGED
@@ -98,6 +98,35 @@ v1 is deliberately manual. A channel that fails reports its error; it does not s
98
98
  over to another one. Switching invalidates the prompt cache for the running conversation, so
99
99
  switch at a natural boundary (or start a new session).
100
100
 
101
+ The current channel is marked on its row (left colour bar, stronger border, tinted background)
102
+ rather than by a small badge — a single-choice list has to be readable at a glance.
103
+
104
+ ### Thinking level
105
+
106
+ Each channel declares its selectable thinking levels, and the level picked in the harness is
107
+ passed through to the relay:
108
+
109
+ ```
110
+ harness reasoningEffort → pi-ai ThinkingLevel → model.thinkingLevelMap → wire value
111
+ ```
112
+
113
+ There is no mapping layer in this plugin: the `thinkingLevelMap` the transport applies **is**
114
+ the mapping, and a second one here would drift from it. A level declared `null` is unsupported
115
+ and is not offered in the picker.
116
+
117
+ ### Display names
118
+
119
+ `label` is a free-form display name, and it is what tells two accounts on the same relay apart.
120
+ Rename a channel from the panel; the channel **id** never changes, because the id is the
121
+ channel's identity (its key and its `current` target hang off it).
122
+
123
+ ### Image input
124
+
125
+ Not supported yet, and declared as such. A harness `ImageBlock` carries only an
126
+ `attachmentId`; turning that into request bytes requires the durable attachment service, which
127
+ this adapter does not consult. Declaring image support anyway would make the harness stop
128
+ substituting its placeholder text and drop the image silently — worse than declaring text-only.
129
+
101
130
  ## Usage accounting
102
131
 
103
132
  The ledger is append-only JSONL, split by month, under the harness home:
package/lib/client.js CHANGED
@@ -117,7 +117,7 @@ window.__ModuleLoader__.load({
117
117
  }
118
118
  //#endregion
119
119
  //#region \0ad-css:src/client/SettingsPage
120
- const css = "/* Panel styles.\n *\n * 🔴 There are NO `--dsw-alias-*` design tokens on this host (measured 2026-10-21: the document\n * declares 29 custom properties and not one is a colour). This file used to reference a dozen of\n * them, every one falling back to a hardcoded LIGHT value (`rgba(0,0,0,0.12)` and friends) — so\n * on the dark theme the borders and dividers were effectively invisible.\n *\n * ⇒ Derive everything from `currentColor`, which the host sets per theme. `color-mix` gives the\n * low-contrast borders and fills that a token would have provided, and it follows the theme\n * automatically instead of assuming one palette.\n */\n\n.ad-page {\n display: flex;\n flex-direction: column;\n gap: 20px;\n padding: 4px 2px 12px;\n}\n\n/* One place to change the two theme-derived values the whole panel uses. */\n.ad-page {\n --ad-line: color-mix(in srgb, currentColor 18%, transparent);\n --ad-line-strong: color-mix(in srgb, currentColor 32%, transparent);\n --ad-fill-subtle: color-mix(in srgb, currentColor 7%, transparent);\n --ad-danger: #e5534b;\n}\n\n.ad-head {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.ad-title {\n margin: 0;\n font-size: 15px;\n font-weight: 600;\n}\n\n.ad-intro {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n opacity: 0.72;\n}\n\n.ad-error {\n border: 1px solid var(--ad-danger);\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n}\n\n.ad-section {\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.ad-sectionTitle {\n margin: 0;\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n opacity: 0.6;\n}\n\n.ad-rows {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.ad-row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n border: 1px solid var(--ad-line);\n border-radius: 8px;\n padding: 10px 12px;\n}\n\n.ad-row-current {\n border-color: var(--ad-line-strong);\n}\n\n.ad-rowMain {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 0;\n}\n\n.ad-rowTitleLine {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 13px;\n font-weight: 500;\n}\n\n.ad-rowTitle {\n white-space: nowrap;\n}\n\n.ad-badge {\n font-size: 10px;\n padding: 1px 6px;\n border-radius: 999px;\n border: 1px solid currentColor;\n opacity: 0.8;\n}\n\n.ad-warn {\n color: #b7791f;\n cursor: help;\n}\n\n.ad-rowMeta {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n opacity: 0.66;\n min-width: 0;\n}\n\n.ad-mono {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n word-break: break-all;\n}\n\n.ad-sep {\n opacity: 0.5;\n}\n\n.ad-rowActions {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n}\n\n.ad-btn {\n font-size: 11px;\n padding: 4px 10px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n\n.ad-btn:disabled {\n opacity: 0.45;\n cursor: default;\n}\n\n.ad-btn-quiet {\n opacity: 0.8;\n}\n\n.ad-probe {\n font-size: 11px;\n padding-top: 2px;\n}\n\n.ad-probe-ok {\n color: #2f855a;\n}\n\n.ad-probe-bad {\n color: #c53030;\n word-break: break-word;\n}\n\n.ad-hint,\n.ad-empty,\n.ad-note {\n margin: 0;\n font-size: 11px;\n line-height: 1.6;\n opacity: 0.66;\n}\n\n.ad-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n flex-shrink: 0;\n display: inline-block;\n}\n\n.ad-dot-on {\n background: #2f855a;\n}\n\n.ad-dot-off {\n border: 1px solid currentColor;\n opacity: 0.5;\n}\n\n.ad-dot-unknown {\n background: linear-gradient(90deg, currentColor 50%, transparent 50%);\n opacity: 0.5;\n}\n\n/* The channel row is a radio \"pick\" zone: the whole left side is clickable, and the radio sits\n at the start of the line so \"which one is live\" is readable down a single column. */\n.ad-rowPick {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n flex: 1;\n min-width: 0;\n cursor: pointer;\n}\n\n.ad-rowPick input[type='radio'] {\n flex: none;\n /* Optically centred against the first text line rather than the row box. */\n margin-top: 3px;\n accent-color: var(--ad-line-strong);\n cursor: pointer;\n}\n\n/* The switch is gone: selection is the only axis (see `channels.ts`). Keeping the rules would\n invite someone to bring the second axis back. */\n\n.ad-tabs {\n display: flex;\n gap: 6px;\n}\n\n.ad-tab {\n font-size: 11px;\n padding: 3px 10px;\n border-radius: 999px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n\n.ad-tab-on {\n border-color: var(--ad-line-strong);\n font-weight: 600;\n}\n\n.ad-tab-refresh {\n margin-left: auto;\n opacity: 0.75;\n}\n\n.ad-table {\n width: 100%;\n border-collapse: collapse;\n font-size: 11px;\n}\n\n.ad-table th,\n.ad-table td {\n padding: 5px 8px;\n border-bottom: 1px solid var(--ad-line);\n text-align: left;\n}\n\n.ad-table th {\n font-weight: 500;\n opacity: 0.6;\n}\n\n.ad-num {\n text-align: right;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n\n.ad-total td {\n font-weight: 600;\n border-bottom: none;\n}\n\n/* ── add-channel form ─────────────────────────────────────────────────────── */\n\n.ad-addForm {\n display: flex;\n flex-direction: column;\n gap: 8px;\n border: 1px dashed var(--ad-line);\n border-radius: 8px;\n padding: 10px 12px;\n}\n\n.ad-field {\n display: flex;\n flex-direction: column;\n gap: 3px;\n font-size: 11px;\n}\n\n.ad-field > span {\n opacity: 0.7;\n}\n\n.ad-input {\n font-size: 11px;\n padding: 4px 8px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n font-family: inherit;\n}\n\n/*\n * 🔴 A native `<select>` cannot be themed by leaving the background transparent.\n *\n * The popup list is drawn by the browser, not by the closed control's box: the options inherit\n * nothing from it, so `background: transparent` made them render in the platform default (light)\n * palette against this dark theme — reported by the owner and measured on the real host.\n *\n * 🔴 SECOND correction (measured 2026-10-21): this file used `var(--dsw-alias-color-*, #fallback)`\n * throughout, and **none of those variables exist**. The host declares only 29 custom properties\n * in total and not one is a colour token, so every `var()` here was falling back — and a fallback\n * that happens to be set inside a shorthand can be silently dropped (the select's background\n * computed to `rgba(0,0,0,0)`, i.e. still transparent, which is exactly the bug).\n *\n * ⇒ Use only values this file controls, derived from `currentColor` (the host sets a light text\n * colour on both themes) plus explicit translucent layers. No host token is assumed.\n */\nselect.ad-input {\n /* Tells the engine which palette to use for the popup. This part DID work. */\n color-scheme: dark light;\n /*\n * Paint the closed box with a layer that follows the surrounding text colour, so it matches\n * whichever theme is active instead of hardcoding a dark value into a light theme.\n */\n background-color: color-mix(in srgb, currentColor 8%, transparent);\n color: inherit;\n cursor: pointer;\n padding-right: 22px;\n /* Room for the chevron drawn below, so text never runs under it. */\n appearance: none;\n background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),\n linear-gradient(135deg, currentColor 50%, transparent 50%);\n background-position: calc(100% - 12px) calc(50% + 1px), calc(100% - 8px) calc(50% + 1px);\n background-size: 4px 4px, 4px 4px;\n background-repeat: no-repeat;\n}\n\nselect.ad-input:hover:not(:disabled) {\n background-color: color-mix(in srgb, currentColor 14%, transparent);\n}\n\nselect.ad-input:focus-visible {\n outline: 2px solid currentColor;\n outline-offset: 1px;\n}\n\n/*\n * The popup itself follows `color-scheme`; these keep the option rows explicit for engines that\n * honour per-option colours. `color-scheme: dark` above is what actually stops the light-on-dark\n * list, because the popup is native chrome rather than a styled element.\n */\nselect.ad-input option {\n background-color: Canvas;\n color: CanvasText;\n}\n\n.ad-fieldError {\n margin: 0;\n font-size: 11px;\n color: #c53030;\n}\n\n/* ── key row ──────────────────────────────────────────────────────────────── */\n\n/*\n * The key line states a FACT and offers the fix beside it: \"configured / not configured\",\n * the reference name the secret is filed under, and one text button that opens the input.\n *\n * The value is never shown — the harness credential seam answers \"configured?\" and refuses to\n * answer anything else, so there is nothing to render even if we wanted to.\n */\n.ad-keyLine {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n padding-top: 2px;\n}\n\n.ad-keyOn {\n color: #2f855a;\n}\n\n.ad-keyOff {\n color: #b7791f;\n}\n\n/* The reference name, not a secret: monospace so it lines up and misreads as a name, not a value. */\n.ad-keyName {\n font-size: 10px;\n padding: 1px 5px;\n border-radius: 4px;\n background: var(--ad-fill-subtle);\n opacity: 0.8;\n}\n\n/* A text-shaped action. Styled as a link because it opens an inline editor, not a dialog. */\n.ad-link {\n font-size: 11px;\n padding: 0;\n border: none;\n background: none;\n color: inherit;\n text-decoration: underline;\n text-underline-offset: 2px;\n opacity: 0.75;\n cursor: pointer;\n}\n\n.ad-link:hover {\n opacity: 1;\n}\n\n/* The inline key editor: one masked field and one button, on the same line as the fact above. */\n.ad-keyForm {\n display: flex;\n align-items: center;\n gap: 6px;\n padding-top: 4px;\n}\n\n.ad-keyForm .ad-input {\n flex: 1;\n min-width: 0;\n}\n\n/* A destructive action is quiet until hovered: it should be findable, not inviting. */\n.ad-btn-danger {\n opacity: 0.6;\n}\n\n.ad-btn-danger:hover:not(:disabled) {\n opacity: 1;\n border-color: var(--ad-danger);\n color: var(--ad-danger);\n}\n";
120
+ const css = "/* Panel styles.\n *\n * 🔴 There are NO `--dsw-alias-*` design tokens on this host (measured 2026-10-21: the document\n * declares 29 custom properties and not one is a colour). This file used to reference a dozen of\n * them, every one falling back to a hardcoded LIGHT value (`rgba(0,0,0,0.12)` and friends) — so\n * on the dark theme the borders and dividers were effectively invisible.\n *\n * ⇒ Derive everything from `currentColor`, which the host sets per theme. `color-mix` gives the\n * low-contrast borders and fills that a token would have provided, and it follows the theme\n * automatically instead of assuming one palette.\n */\n\n.ad-page {\n display: flex;\n flex-direction: column;\n gap: 20px;\n padding: 4px 2px 12px;\n}\n\n/* One place to change the two theme-derived values the whole panel uses. */\n.ad-page {\n --ad-line: color-mix(in srgb, currentColor 18%, transparent);\n --ad-line-strong: color-mix(in srgb, currentColor 32%, transparent);\n --ad-fill-subtle: color-mix(in srgb, currentColor 7%, transparent);\n /* The current row's fill: visible against both themes because it is derived from the text\n colour the host already picked, not from an assumed palette. */\n --ad-fill-current: color-mix(in srgb, currentColor 11%, transparent);\n --ad-danger: #e5534b;\n}\n\n.ad-head {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.ad-title {\n margin: 0;\n font-size: 15px;\n font-weight: 600;\n}\n\n.ad-intro {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n opacity: 0.72;\n}\n\n.ad-error {\n border: 1px solid var(--ad-danger);\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n}\n\n.ad-section {\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.ad-sectionTitle {\n margin: 0;\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n opacity: 0.6;\n}\n\n.ad-rows {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.ad-row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n border: 1px solid var(--ad-line);\n border-radius: 8px;\n padding: 10px 12px;\n /*\n * The left edge is reserved on EVERY row so the current one does not shift its content when\n * selected — a row that reindents on click makes the list feel like it moved.\n */\n border-left: 3px solid transparent;\n}\n\n/*\n * 🔴 The current channel is marked three ways, not one.\n *\n * It used to be a small badge plus a barely-different border, and the owner could not tell at a\n * glance which relay was live — glanceability is the whole point of a single-choice list. So:\n * a colour bar down the left edge, a stronger border, and a tinted fill. All derived from\n * `currentColor`, because this host ships no colour tokens (measured: 29 custom properties, none\n * of them a colour), so any hardcoded value would assume the wrong theme.\n */\n.ad-row-current {\n border-color: var(--ad-line-strong);\n border-left-color: currentColor;\n background: var(--ad-fill-current);\n}\n\n/* The 当前 badge keeps its place as the textual confirmation, for when colour is not enough. */\n.ad-row-current .ad-badge {\n border-color: currentColor;\n opacity: 1;\n font-weight: 600;\n}\n\n/* A rename button must not look like the row's own selection affordance. */\n.ad-renameBtn {\n margin-left: auto;\n flex: none;\n}\n\n.ad-rowMain {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 0;\n}\n\n.ad-rowTitleLine {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 13px;\n font-weight: 500;\n}\n\n.ad-rowTitle {\n white-space: nowrap;\n}\n\n.ad-badge {\n font-size: 10px;\n padding: 1px 6px;\n border-radius: 999px;\n border: 1px solid currentColor;\n opacity: 0.8;\n}\n\n.ad-warn {\n color: #b7791f;\n cursor: help;\n}\n\n.ad-rowMeta {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n opacity: 0.66;\n min-width: 0;\n}\n\n.ad-mono {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n word-break: break-all;\n}\n\n.ad-sep {\n opacity: 0.5;\n}\n\n.ad-rowActions {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n}\n\n.ad-btn {\n font-size: 11px;\n padding: 4px 10px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n\n.ad-btn:disabled {\n opacity: 0.45;\n cursor: default;\n}\n\n.ad-btn-quiet {\n opacity: 0.8;\n}\n\n.ad-probe {\n font-size: 11px;\n padding-top: 2px;\n}\n\n.ad-probe-ok {\n color: #2f855a;\n}\n\n.ad-probe-bad {\n color: #c53030;\n word-break: break-word;\n}\n\n.ad-hint,\n.ad-empty,\n.ad-note {\n margin: 0;\n font-size: 11px;\n line-height: 1.6;\n opacity: 0.66;\n}\n\n.ad-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n flex-shrink: 0;\n display: inline-block;\n}\n\n.ad-dot-on {\n background: #2f855a;\n}\n\n.ad-dot-off {\n border: 1px solid currentColor;\n opacity: 0.5;\n}\n\n.ad-dot-unknown {\n background: linear-gradient(90deg, currentColor 50%, transparent 50%);\n opacity: 0.5;\n}\n\n/* The channel row is a radio \"pick\" zone: the whole left side is clickable, and the radio sits\n at the start of the line so \"which one is live\" is readable down a single column. */\n.ad-rowPick {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n flex: 1;\n min-width: 0;\n cursor: pointer;\n}\n\n.ad-rowPick input[type='radio'] {\n flex: none;\n /* Optically centred against the first text line rather than the row box. */\n margin-top: 3px;\n accent-color: var(--ad-line-strong);\n cursor: pointer;\n}\n\n/* The switch is gone: selection is the only axis (see `channels.ts`). Keeping the rules would\n invite someone to bring the second axis back. */\n\n.ad-tabs {\n display: flex;\n gap: 6px;\n}\n\n.ad-tab {\n font-size: 11px;\n padding: 3px 10px;\n border-radius: 999px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n\n.ad-tab-on {\n border-color: var(--ad-line-strong);\n font-weight: 600;\n}\n\n.ad-tab-refresh {\n margin-left: auto;\n opacity: 0.75;\n}\n\n.ad-table {\n width: 100%;\n border-collapse: collapse;\n font-size: 11px;\n}\n\n.ad-table th,\n.ad-table td {\n padding: 5px 8px;\n border-bottom: 1px solid var(--ad-line);\n text-align: left;\n}\n\n.ad-table th {\n font-weight: 500;\n opacity: 0.6;\n}\n\n.ad-num {\n text-align: right;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n\n.ad-total td {\n font-weight: 600;\n border-bottom: none;\n}\n\n/* ── add-channel form ─────────────────────────────────────────────────────── */\n\n.ad-addForm {\n display: flex;\n flex-direction: column;\n gap: 8px;\n border: 1px dashed var(--ad-line);\n border-radius: 8px;\n padding: 10px 12px;\n}\n\n.ad-field {\n display: flex;\n flex-direction: column;\n gap: 3px;\n font-size: 11px;\n}\n\n.ad-field > span {\n opacity: 0.7;\n}\n\n.ad-input {\n font-size: 11px;\n padding: 4px 8px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n font-family: inherit;\n}\n\n/*\n * 🔴 A native `<select>` cannot be themed by leaving the background transparent.\n *\n * The popup list is drawn by the browser, not by the closed control's box: the options inherit\n * nothing from it, so `background: transparent` made them render in the platform default (light)\n * palette against this dark theme — reported by the owner and measured on the real host.\n *\n * 🔴 SECOND correction (measured 2026-10-21): this file used `var(--dsw-alias-color-*, #fallback)`\n * throughout, and **none of those variables exist**. The host declares only 29 custom properties\n * in total and not one is a colour token, so every `var()` here was falling back — and a fallback\n * that happens to be set inside a shorthand can be silently dropped (the select's background\n * computed to `rgba(0,0,0,0)`, i.e. still transparent, which is exactly the bug).\n *\n * ⇒ Use only values this file controls, derived from `currentColor` (the host sets a light text\n * colour on both themes) plus explicit translucent layers. No host token is assumed.\n */\nselect.ad-input {\n /* Tells the engine which palette to use for the popup. This part DID work. */\n color-scheme: dark light;\n /*\n * Paint the closed box with a layer that follows the surrounding text colour, so it matches\n * whichever theme is active instead of hardcoding a dark value into a light theme.\n */\n background-color: color-mix(in srgb, currentColor 8%, transparent);\n color: inherit;\n cursor: pointer;\n padding-right: 22px;\n /* Room for the chevron drawn below, so text never runs under it. */\n appearance: none;\n background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),\n linear-gradient(135deg, currentColor 50%, transparent 50%);\n background-position: calc(100% - 12px) calc(50% + 1px), calc(100% - 8px) calc(50% + 1px);\n background-size: 4px 4px, 4px 4px;\n background-repeat: no-repeat;\n}\n\nselect.ad-input:hover:not(:disabled) {\n background-color: color-mix(in srgb, currentColor 14%, transparent);\n}\n\nselect.ad-input:focus-visible {\n outline: 2px solid currentColor;\n outline-offset: 1px;\n}\n\n/*\n * The popup itself follows `color-scheme`; these keep the option rows explicit for engines that\n * honour per-option colours. `color-scheme: dark` above is what actually stops the light-on-dark\n * list, because the popup is native chrome rather than a styled element.\n */\nselect.ad-input option {\n background-color: Canvas;\n color: CanvasText;\n}\n\n.ad-fieldError {\n margin: 0;\n font-size: 11px;\n color: #c53030;\n}\n\n/* ── key row ──────────────────────────────────────────────────────────────── */\n\n/*\n * The key line states a FACT and offers the fix beside it: \"configured / not configured\",\n * the reference name the secret is filed under, and one text button that opens the input.\n *\n * The value is never shown — the harness credential seam answers \"configured?\" and refuses to\n * answer anything else, so there is nothing to render even if we wanted to.\n */\n.ad-keyLine {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n padding-top: 2px;\n}\n\n.ad-keyOn {\n color: #2f855a;\n}\n\n.ad-keyOff {\n color: #b7791f;\n}\n\n/* The reference name, not a secret: monospace so it lines up and misreads as a name, not a value. */\n.ad-keyName {\n font-size: 10px;\n padding: 1px 5px;\n border-radius: 4px;\n background: var(--ad-fill-subtle);\n opacity: 0.8;\n}\n\n/* A text-shaped action. Styled as a link because it opens an inline editor, not a dialog. */\n.ad-link {\n font-size: 11px;\n padding: 0;\n border: none;\n background: none;\n color: inherit;\n text-decoration: underline;\n text-underline-offset: 2px;\n opacity: 0.75;\n cursor: pointer;\n}\n\n.ad-link:hover {\n opacity: 1;\n}\n\n/* The inline key editor: one masked field and one button, on the same line as the fact above. */\n.ad-keyForm {\n display: flex;\n align-items: center;\n gap: 6px;\n padding-top: 4px;\n}\n\n.ad-keyForm .ad-input {\n flex: 1;\n min-width: 0;\n}\n\n/* A destructive action is quiet until hovered: it should be findable, not inviting. */\n.ad-btn-danger {\n opacity: 0.6;\n}\n\n.ad-btn-danger:hover:not(:disabled) {\n opacity: 1;\n border-color: var(--ad-danger);\n color: var(--ad-danger);\n}\n";
121
121
  if (typeof document !== "undefined" && document.querySelector("style[data-ad-css=\"SettingsPage\"]") === null) {
122
122
  const tag = document.createElement("style");
123
123
  tag.setAttribute("data-ad-css", "SettingsPage");
@@ -182,12 +182,24 @@ window.__ModuleLoader__.load({
182
182
  * second store to hold the value) was the design the owner rejected.
183
183
  */
184
184
  const [newSecret, setNewSecret] = (0, react.useState)("");
185
+ /**
186
+ * The channel's display name, as typed.
187
+ *
188
+ * Empty means "use the preset's name". It is a real field (`label`) that the runtime has always
189
+ * honoured — the panel simply never exposed it, which made "two accounts on the same relay"
190
+ * impossible to tell apart.
191
+ */
192
+ const [newLabel, setNewLabel] = (0, react.useState)("");
185
193
  /** Which channel currently has its inline key field open (null = none). */
186
194
  const [keying, setKeying] = (0, react.useState)(null);
187
195
  const [keyDraft, setKeyDraft] = (0, react.useState)("");
188
196
  const [keySaving, setKeySaving] = (0, react.useState)(false);
189
- /** The channel id the form last derived from a preset (see the select's onChange). */
197
+ /** Which channel currently has its inline RENAME field open (null = none). */
198
+ const [renaming, setRenaming] = (0, react.useState)(null);
199
+ const [renameDraft, setRenameDraft] = (0, react.useState)("");
200
+ /** The channel id and the label the form last derived from a preset (see the select). */
190
201
  const lastSuggestedId = (0, react.useRef)("");
202
+ const lastSuggestedLabel = (0, react.useRef)("");
191
203
  const refresh = (0, react.useCallback)(async () => {
192
204
  const [loaded, statsNow] = await Promise.all([loadChannels(), loadStats(group)]);
193
205
  setRows(loaded.channels);
@@ -216,6 +228,15 @@ window.__ModuleLoader__.load({
216
228
  * reasons they cannot (transport). Both are facts, and silently discarding them is what makes
217
229
  * a panel look alive while nothing is saved.
218
230
  *
231
+ * 🔴 **A successful write RE-READS, right here.** The rows the panel renders come from
232
+ * `/any-deepseek/channels`, which is computed from the configuration — not a live push. So a
233
+ * write that does not re-read leaves the panel showing the document as it was BEFORE the write,
234
+ * and the only way to see the result was to close and reopen the panel. Reported by the owner
235
+ * as "adding or removing a channel does not show up until I reopen".
236
+ *
237
+ * Doing it centrally matters: the previous code had `refresh()` at one call site and not at the
238
+ * others, so correctness depended on remembering. A new write path now inherits it.
239
+ *
219
240
  * 🔴 **The refusal message must NOT name causes we have not checked.** This used to read
220
241
  * "只读部署,或设置服务未启用" — and the one time it actually fired, the real cause was
221
242
  * neither: `current` was missing `.volatile()` in the schema, three layers away. Naming two
@@ -226,8 +247,14 @@ window.__ModuleLoader__.load({
226
247
  */
227
248
  const write = (run, context = "保存失败") => {
228
249
  run().then((accepted) => {
229
- if (!accepted) setError(`${context}:宿主拒绝了这个修改`);
230
- else setError(null);
250
+ if (!accepted) {
251
+ setError(`${context}:宿主拒绝了这个修改`);
252
+ return;
253
+ }
254
+ setError(null);
255
+ refresh().catch((cause) => {
256
+ setError(`已写入,但重新读取失败:${cause instanceof Error ? cause.message : String(cause)}`);
257
+ });
231
258
  }, (cause) => {
232
259
  setError(`${context}:${cause instanceof Error ? cause.message : String(cause)}`);
233
260
  });
@@ -259,21 +286,35 @@ window.__ModuleLoader__.load({
259
286
  * there is no second store to keep in step, so there is no ordering to get wrong and no way for
260
287
  * "channel exists but its key does not" to be a reachable state. Pre-filling endpoint fields is
261
288
  * still avoided: a preset-backed channel records the preset, so later preset fixes reach it.
289
+ *
290
+ * `label` is only written when the user changed it. Leaving it out lets the preset's display
291
+ * name apply and keep tracking preset changes; writing the same string back would freeze
292
+ * today's name into the document.
262
293
  */
263
294
  const addChannel = () => {
264
295
  const id = newId.trim();
265
296
  if (newPreset === "" || id === "" || newIdProblem !== null || newSecret.trim() === "") return;
266
- write(() => setField(["channels", id], {
297
+ const label = newLabel.trim();
298
+ const entry = {
267
299
  preset: newPreset,
268
300
  apiKey: newSecret.trim()
269
- }), "添加渠道失败");
301
+ };
302
+ if (label !== "") entry.label = label;
303
+ write(() => setField(["channels", id], entry), "添加渠道失败");
270
304
  if (current === "") write(() => setField(["current"], id), "设为当前渠道失败");
271
305
  setNewSecret("");
272
306
  setNewId("");
307
+ setNewLabel("");
273
308
  setNewPreset("");
274
309
  lastSuggestedId.current = "";
310
+ lastSuggestedLabel.current = "";
275
311
  setAdding(false);
276
312
  };
313
+ /**
314
+ * Remove one channel.
315
+ *
316
+ * The row disappears because `write()` re-reads on success; there is nothing to remember here.
317
+ */
277
318
  const removeChannel = (id) => {
278
319
  write(() => removeField(["channels", id]), "移除渠道失败");
279
320
  if (current === id) write(() => removeField(["current"]), "清除当前选择失败");
@@ -312,6 +353,32 @@ window.__ModuleLoader__.load({
312
353
  setKeySaving(false);
313
354
  }
314
355
  };
356
+ /**
357
+ * Rename one channel.
358
+ *
359
+ * `label` is the display name and nothing else: the channel's identity is its id (the
360
+ * configuration key), which renaming must never touch — a renamed key would be a different
361
+ * channel, with a different key and a different `current` target.
362
+ *
363
+ * Clearing the name removes the field, which restores the preset's name rather than leaving an
364
+ * empty label in the document.
365
+ * @param row - the channel being renamed.
366
+ */
367
+ const renameChannel = (row) => {
368
+ const next = renameDraft.trim();
369
+ if (next === "") write(() => removeField([
370
+ "channels",
371
+ row.id,
372
+ "label"
373
+ ]), "恢复默认名失败");
374
+ else write(() => setField([
375
+ "channels",
376
+ row.id,
377
+ "label"
378
+ ], next), "重命名失败");
379
+ setRenaming(null);
380
+ setRenameDraft("");
381
+ };
315
382
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
316
383
  className: "ad-page",
317
384
  children: [
@@ -359,6 +426,8 @@ window.__ModuleLoader__.load({
359
426
  const probeResult = verdict[row.id];
360
427
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
361
428
  className: `ad-row${isCurrent ? " ad-row-current" : ""}`,
429
+ "aria-current": isCurrent ? "true" : void 0,
430
+ "data-current": isCurrent ? "true" : "false",
362
431
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
363
432
  className: "ad-rowPick",
364
433
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -388,9 +457,45 @@ window.__ModuleLoader__.load({
388
457
  className: "ad-warn",
389
458
  title: row.warnings.join("\n"),
390
459
  children: "⚠"
460
+ }),
461
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
462
+ type: "button",
463
+ className: "ad-link ad-renameBtn",
464
+ title: "改显示名(渠道 id 不变)",
465
+ onClick: (event) => {
466
+ event.preventDefault();
467
+ event.stopPropagation();
468
+ setRenaming(renaming === row.id ? null : row.id);
469
+ setRenameDraft(renaming === row.id ? "" : row.label);
470
+ },
471
+ children: renaming === row.id ? "取消" : "改名"
391
472
  })
392
473
  ]
393
474
  }),
475
+ renaming === row.id && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
476
+ className: "ad-keyForm",
477
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
478
+ className: "ad-input",
479
+ value: renameDraft,
480
+ placeholder: "显示名(留空则用预设默认名)",
481
+ onClick: (event) => event.preventDefault(),
482
+ onChange: (event) => setRenameDraft(event.target.value),
483
+ onKeyDown: (event) => {
484
+ if (event.key === "Enter") {
485
+ event.preventDefault();
486
+ renameChannel(row);
487
+ }
488
+ }
489
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
490
+ type: "button",
491
+ className: "ad-btn",
492
+ onClick: (event) => {
493
+ event.preventDefault();
494
+ renameChannel(row);
495
+ },
496
+ children: "保存名字"
497
+ })]
498
+ }),
394
499
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
395
500
  className: "ad-rowMeta",
396
501
  children: [
@@ -501,9 +606,13 @@ window.__ModuleLoader__.load({
501
606
  const next = event.target.value;
502
607
  setNewPreset(next);
503
608
  const previousId = lastSuggestedId.current;
609
+ const previousLabel = lastSuggestedLabel.current;
504
610
  const nextId = suggestId(next, rows);
611
+ const nextLabel = presets.find((preset) => preset.id === next)?.displayName ?? "";
505
612
  setNewId((current) => current === "" || current === previousId ? nextId : current);
613
+ setNewLabel((current) => current === "" || current === previousLabel ? nextLabel : current);
506
614
  lastSuggestedId.current = nextId;
615
+ lastSuggestedLabel.current = nextLabel;
507
616
  },
508
617
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
509
618
  value: "",
@@ -527,6 +636,15 @@ window.__ModuleLoader__.load({
527
636
  className: "ad-fieldError",
528
637
  children: newIdProblem
529
638
  }),
639
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
640
+ className: "ad-field",
641
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "显示名" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
642
+ className: "ad-input",
643
+ value: newLabel,
644
+ placeholder: newPreset === "" ? "先选上面的规格" : "例如「Open Design(主号)」",
645
+ onChange: (event) => setNewLabel(event.target.value)
646
+ })]
647
+ }),
530
648
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
531
649
  className: "ad-field",
532
650
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "密钥" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -573,6 +691,7 @@ window.__ModuleLoader__.load({
573
691
  onClick: () => {
574
692
  setAdding(true);
575
693
  lastSuggestedId.current = "";
694
+ lastSuggestedLabel.current = "";
576
695
  },
577
696
  children: "添加渠道"
578
697
  }),
package/lib/index.js CHANGED
@@ -161,13 +161,30 @@ const DEFAULT_MAX_TOKENS = 32768;
161
161
  *
162
162
  * Kept as one constant because these are properties of the *model*, not of a relay; a relay
163
163
  * that disagrees corrects them in its own preset.
164
+ *
165
+ * ⚠️ `input` is `['text']` **on purpose, for now**. The model is multimodal, but declaring image
166
+ * support is a promise this adapter cannot keep yet: a harness `ImageBlock` carries only an
167
+ * `attachmentId`, and turning that into bytes requires the durable attachment service
168
+ * (`ctx.attachments.readImageRequest`), which the adapter does not consult. Declaring images
169
+ * without that path would be worse than declaring text — the harness would stop substituting its
170
+ * placeholder text and the image would be silently dropped. See SESSION decision 38.
164
171
  */
165
172
  const V41_FLASH_CAPS = {
166
173
  contextWindow: 1e6,
167
174
  maxTokens: 384e3,
168
175
  input: ["text"],
176
+ /**
177
+ * Selectable thinking levels, in pi-ai's vocabulary.
178
+ *
179
+ * The VALUES are the wire spellings this model family accepts (`reasoning_effort`); `null`
180
+ * would mark a level as unsupported. pi-ai consumes this map directly as `thinkingLevelMap`,
181
+ * which is why this plugin does not implement a translation layer of its own — a second
182
+ * mapping would inevitably drift from the one the transport actually applies.
183
+ */
169
184
  reasoningEfforts: {
185
+ off: null,
170
186
  low: "low",
187
+ medium: "medium",
171
188
  high: "high",
172
189
  max: "max"
173
190
  }
@@ -184,19 +201,35 @@ const DEEPSEEK_THINKING_COMPAT = {
184
201
  thinkingFormat: "deepseek"
185
202
  };
186
203
  /**
187
- * Header family the OpenCode Go endpoint requires.
204
+ * Headers the OpenCode Go endpoint requires, MINUS the session id.
205
+ *
206
+ * `/zen/go` answers 400 `Request is missing x-opencode-session` without it, and the official
207
+ * documentation adds two requirements this preset has to respect:
208
+ *
209
+ * 1. the session id must be **stable per conversation** — a constant shared by every
210
+ * conversation violates the thing the header exists for (routing and cache affinity);
211
+ * 2. the client must **identify itself with its own user agent** rather than a generic SDK name.
188
212
  *
189
- * `/zen/go` answers 400 `Request is missing x-opencode-session` without it. The values are
190
- * static on purpose: the header is resolved once per route, so a per-request value would
191
- * either change nothing or silently change the cache key.
213
+ * So the session id is deliberately NOT here: `adapter.ts` injects it per request from the
214
+ * harness's own session identity. What stays here is the static identity of this client.
215
+ *
216
+ * ⚠️ The upstream documentation lists **DeepSeek Harness as a known-problematic client** for this
217
+ * endpoint ("session information arrives on some model paths, but is missing on others"). Even
218
+ * with everything here correct, expect this relay to be stricter than the others.
192
219
  */
193
220
  const OPENCODE_HEADERS = {
194
- "x-opencode-session": "dsh-any-deepseek",
195
- "X-Session-ID": "dsh-any-deepseek",
196
221
  "x-opencode-client": "dsh",
197
222
  "x-opencode-project": "global"
198
223
  };
199
224
  /**
225
+ * The user agent a request to OpenCode Go identifies itself with.
226
+ *
227
+ * Requirement 2 above: a generic SDK name is exactly what they ask clients not to send.
228
+ */
229
+ const CLIENT_USER_AGENT = "dsh-any-deepseek/1.0";
230
+ /** Header carrying the per-conversation routing id on OpenCode Go. */
231
+ const OPENCODE_SESSION_HEADER = "x-opencode-session";
232
+ /**
200
233
  * Presets keyed by a stable id.
201
234
  *
202
235
  * The id is what a channel entry stores (`preset: opencode-go`), so it is part of the user's
@@ -590,6 +623,8 @@ const PROVIDER_NAME = "Any DeepSeek";
590
623
  * @returns a pi-ai chat model describing that relay's view of the model.
591
624
  */
592
625
  function buildPiModel(channel) {
626
+ const thinkingLevelMap = channel.reasoningEfforts === void 0 ? void 0 : Object.fromEntries(Object.entries(channel.reasoningEfforts).filter(([, value]) => value !== null));
627
+ const hasThinking = thinkingLevelMap !== void 0 && Object.keys(thinkingLevelMap).length > 0;
593
628
  return {
594
629
  id: channel.model,
595
630
  name: channel.label,
@@ -605,7 +640,8 @@ function buildPiModel(channel) {
605
640
  },
606
641
  contextWindow: channel.contextWindow,
607
642
  maxTokens: channel.maxTokens,
608
- reasoning: channel.reasoningEfforts !== void 0 && Object.keys(channel.reasoningEfforts).length > 0,
643
+ reasoning: hasThinking,
644
+ ...hasThinking ? { thinkingLevelMap } : {},
609
645
  ...channel.compat === void 0 ? {} : { compat: channel.compat },
610
646
  ...channel.headers === void 0 ? {} : { headers: { ...channel.headers } }
611
647
  };
@@ -987,7 +1023,7 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
987
1023
  }
988
1024
  async resolveModel(provider, model, _signal) {
989
1025
  const channel = this.selected();
990
- const efforts = Object.keys(channel?.reasoningEfforts ?? {});
1026
+ const efforts = Object.entries(channel?.reasoningEfforts ?? {}).filter(([, wire]) => wire !== null).map(([id]) => id);
991
1027
  const reasoning = efforts.length === 0 ? {} : { reasoning: { efforts: efforts.map((id) => ({
992
1028
  id: ReasoningEffortId(id),
993
1029
  name: id
@@ -1084,11 +1120,34 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
1084
1120
  models: [model],
1085
1121
  api: openAICompletionsApi()
1086
1122
  }));
1123
+ /**
1124
+ * Per-request headers.
1125
+ *
1126
+ * Two things OpenCode Go requires cannot live in a preset, because neither is a constant:
1127
+ *
1128
+ * · `x-opencode-session` must be STABLE PER CONVERSATION. A single shared literal satisfied
1129
+ * the letter of "send the header" while defeating its purpose (routing and prompt-cache
1130
+ * affinity), which is why the previous preset's fixed value produced 400s. The harness
1131
+ * already stamps every request with `sessionId`; that is the correct source.
1132
+ * · `User-Agent` must identify THIS client rather than the generic SDK. pi-ai sets its own,
1133
+ * so the preset's static headers alone can never satisfy that rule.
1134
+ */
1135
+ const requestHeaders = {};
1136
+ if (channel.headers !== void 0 && Object.hasOwn(channel.headers, "x-opencode-client")) {
1137
+ const session = options.sessionId === void 0 ? void 0 : String(options.sessionId);
1138
+ if (session !== void 0 && session !== "") {
1139
+ requestHeaders[OPENCODE_SESSION_HEADER] = session;
1140
+ requestHeaders["X-Session-ID"] = session;
1141
+ }
1142
+ requestHeaders["User-Agent"] = CLIENT_USER_AGENT;
1143
+ }
1087
1144
  const streamOptions = {
1088
1145
  apiKey: key,
1089
1146
  signal: options.signal,
1090
1147
  ...options.temperature === void 0 ? {} : { temperature: options.temperature },
1091
- ...options.maxTokens === void 0 ? {} : { maxTokens: options.maxTokens }
1148
+ ...options.maxTokens === void 0 ? {} : { maxTokens: options.maxTokens },
1149
+ ...options.reasoningEffort === void 0 ? {} : { reasoning: options.reasoningEffort },
1150
+ ...Object.keys(requestHeaders).length === 0 ? {} : { headers: requestHeaders }
1092
1151
  };
1093
1152
  try {
1094
1153
  const events = models.streamSimple(model, toPiContext(options), streamOptions);
package/lib/presets.d.ts CHANGED
@@ -81,6 +81,14 @@ export declare const ADVERTISED_MODEL_NAME = "DeepSeek V4.1 Flash";
81
81
  */
82
82
  export declare const DEFAULT_CONTEXT_WINDOW = 262144;
83
83
  export declare const DEFAULT_MAX_TOKENS = 32768;
84
+ /**
85
+ * The user agent a request to OpenCode Go identifies itself with.
86
+ *
87
+ * Requirement 2 above: a generic SDK name is exactly what they ask clients not to send.
88
+ */
89
+ export declare const CLIENT_USER_AGENT = "dsh-any-deepseek/1.0";
90
+ /** Header carrying the per-conversation routing id on OpenCode Go. */
91
+ export declare const OPENCODE_SESSION_HEADER = "x-opencode-session";
84
92
  /**
85
93
  * Presets keyed by a stable id.
86
94
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-deepseek",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "One DeepSeek V4.1 Flash provider for DeepSeek Harness: paste a key per channel, switch channels manually, token usage booked per real upstream.",
5
5
  "license": "MIT",
6
6
  "repository": {