dsh-model-flatmap 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-model-flatmap
3
+ name: dsh-model-flatmap
package/docs/DETAIL.md ADDED
@@ -0,0 +1,243 @@
1
+ # Flat model picker
2
+
3
+ Replaces the composer's model selector with a single flat panel. There are no
4
+ second-level menus: the model grid, the reasoning levels of the current model,
5
+ and the agent teamwork switch are all visible at once.
6
+
7
+ ## What it does
8
+
9
+ - **One surface.** The trigger opens one panel capped at 320 px. The header,
10
+ and one adaptive footer holding the reasoning group and the teamwork row,
11
+ stay fixed; only the model grid scrolls. The two footer groups share a single
12
+ line when both fit with a 30 px gap, and otherwise fall back to one row each.
13
+ - **Global search.** The search box filters across every provider and every
14
+ family. Groups that end up empty are not rendered, so no stray headings
15
+ remain. Prefix matches rank first, then substring matches, then ordered
16
+ character matches.
17
+ - **Word-wise search.** Whitespace splits the query into tokens, and every
18
+ token has to land somewhere in the name, so `mod inte` finds
19
+ `ModelScope International` even though the two words are never contiguous.
20
+ A hit ranks by its weakest token, so a multi-word query never outranks a
21
+ plain prefix. A single-token query keeps the original ranking exactly.
22
+ - **Provider-wide search.** A query that matches a *provider* — its id, its
23
+ catalog name, or the label its section draws — selects that provider as a
24
+ whole: every model it offers stays in the grid, not only the ones whose names
25
+ contain the query. Typing `dou` therefore surfaces `豆包(doubao)` with its
26
+ entire catalog. Models matched by their own name still rank first; the rest
27
+ follow in catalog order. Provider matching is word-wise too, and tokens may
28
+ land in different faces of the same provider — the id answers `mod`, the
29
+ localized name answers `inte`. It accepts prefixes and substrings only: an
30
+ ordered-subsequence pass would let a two-letter query drag every provider in,
31
+ which is noise rather than a match. This applies to both grouping modes.
32
+ - **Two grouping modes.** A segment control switches the grid between
33
+ *By provider* and *By family*. Grouping and search are independent: the
34
+ segment only changes how the filtered result is organised.
35
+ - **Model families.** A built-in dictionary recognises DeepSeek, Grok, Opus,
36
+ Haiku, Sonnet, Claude, Qwen, GLM, Kimi, GPT, Gemini, Llama, Mistral and
37
+ others. A model the dictionary does not cover is inferred from its id;
38
+ anything unjudgeable falls into *Others*, which always sorts last. Families
39
+ stay flat — no nesting.
40
+ - **Registered brands keep their generations apart.** Merging `hy3` and `hy4`
41
+ into one `HY` family (DEC-004) was wrong: the generation has to be visible.
42
+ A registered brand (`FAMILY_BRANDS`, currently `HY`) resolves to
43
+ `BRAND + generation` when its id carries a generation digit, so `hy3` /
44
+ `hy3-x` → `HY3`, `hy4-preview` / `hy4free` / `hy4std` → `HY4`, and a bare
45
+ `hy` → `HY`. Generations rank next to their bare brand in numeric order.
46
+ Unregistered brands still collapse their version tail (`acme4-turbo` and
47
+ `acme-7b` are one `Acme`), and dictionary families are never split by
48
+ generation. A provider prefix does not hide the brand (`codebuddy-hy3` →
49
+ `HY3`).
50
+ - **Family-grouped rows are provider-primary.** A family heading says what the
51
+ models *are*, so the family name is not repeated in a row. The row's primary
52
+ text is the provider name — the one thing a heading cannot tell you when a
53
+ family spans providers. The full model name and description stay in the row
54
+ tooltip. Only when one provider offers several models in the same family do
55
+ those rows carry a short secondary variant derived from the id tail (e.g.
56
+ `hy4free` → `free`, `hy3-x` → `x`); the plain base model carries no tag.
57
+ Colliding variants fall back to the unique model id. Under *By provider* the
58
+ heading already is the provider, so the primary text stays the model name and
59
+ no secondary tag is drawn.
60
+ - **Adaptive columns.** The panel measures one group title and one model row,
61
+ subtracts the fixed sections from the 320 px cap, and picks the smallest
62
+ column count from 1 to 3 that fits. If even three columns overflow, it keeps
63
+ three and the grid scrolls. A group title never separates from its first
64
+ model, and the last column's deficit is balanced against its neighbour.
65
+ - **Reasoning levels.** The current model's advertised levels render as a
66
+ caption ("推理等级" / "Reasoning effort") with a sparkle icon and a hint
67
+ tooltip, on the same line as a multi-segmentation control — the track wraps
68
+ to its own line only when the row cannot fit. The segments are radio
69
+ choices (`role="radiogroup"` / `role="radio"`, exactly one `aria-checked`
70
+ at a time, arrow keys move the selection) drawn with the header segment's
71
+ visual: a toned track and a floating active segment, so the active level is
72
+ unmistakable. A click submits through `session.selectModel` and the group
73
+ refreshes from the directory once it updates. A model without reasoning
74
+ metadata hides the whole group, and it costs no height.
75
+ The group shares a wrapping footer line with the teamwork row: when
76
+ reasoning + 30 px + teamwork fits the panel's inner width they sit side by
77
+ side, otherwise the footer wraps back to the original two-line stack. The
78
+ break is decided by CSS flex wrapping and the 30 px `column-gap`, never by a
79
+ JS width threshold, so it stays correct across panel resizes, font loads and
80
+ both themes. The single grey rule belongs to the footer container, so it sits
81
+ above the whole footer block and stays put in both states; neither group
82
+ draws a divider of its own (DEC-016).
83
+ The fixed height budget measures the footer container once, not its two
84
+ children, so a shared line is not double-counted against the grid.
85
+ - **Selected model.** The current model reads as a solid deep-blue band with
86
+ white, bold text — no checkmark on the right. Selection lives on the row
87
+ itself (`aria-pressed="true"`), so nothing is reserved at the trailing edge:
88
+ unselected rows keep their full width for the name, the provider tag, and the
89
+ badge, and the measurement probe no longer mirrors a tail column. The blue
90
+ is `--dsw-static-deepseek-600` and the text is
91
+ `--dsw-static-neutral-bluish-00`, both from the theme-invariant static
92
+ palette, so the band looks the same in light and dark themes. Hover skips the
93
+ selected row so the neutral hover wash never covers the highlight.
94
+ - **Badges.** A model's catalog badge (for example a third-party rate
95
+ multiplier such as `×0.1`) renders inside its row. A model with no badge
96
+ renders no empty slot. On the selected row the badge switches to a
97
+ translucent white chip so it stays legible on the blue fill.
98
+ - **Out-of-balance models are struck through.** When a model call fails because
99
+ the account has no usable balance, credit, or quota left, that model's row
100
+ text is drawn with a `line-through` decoration, and the strike stays until a
101
+ later call for the same model returns something other than an
102
+ insufficient-balance failure. The row is not recoloured, so the strike reads
103
+ the same on the selected row's blue band and in both themes, and it does not
104
+ compete with the error strip's red. Only the row's text (the name and a
105
+ family variant tag) is struck; the badge chip and the pending spinner are
106
+ not.
107
+ - **What counts.** The Host already classifies an exhausted account into the
108
+ provider-neutral `QUOTA` / `ACCOUNT_QUOTA` codes and maps a provider HTTP
109
+ `402` to `QUOTA`. The panel accepts those codes, a structured `status: 402`
110
+ (or an `HTTP_402` code), and — for a failure the Host flattened to another
111
+ code with the provider's wording kept — the provider text itself
112
+ (`insufficient balance` / `insufficient credits` / `quota exceeded` /
113
+ `out of credits` / `not enough quota` / `no credits remaining` /
114
+ `额度已用尽` / `余额不足` / `预算已用完`, among others).
115
+ - **A `429` is decided by its body, not by the status.** Several providers
116
+ answer an exhausted balance with HTTP **429** rather than 402 — for example
117
+ `429: {"message":"insufficient balance","request_id":"…"}`, which the Host
118
+ classifies as `RATE_LIMIT`. A `429` therefore neither strikes nor clears on
119
+ its status alone: the message decides, so a 429 carrying balance wording
120
+ strikes while a genuine rate limit (`Rate limit exceeded`,
121
+ `访问量过大`, `使用量已超出频率限制`) does not. The rule is pinned against
122
+ real recorded failures (see the `REAL_INSUFFICIENT` / `REAL_TRANSIENT`
123
+ tables in the render tests, harvested from local session history).
124
+ - **What does not count.** A transient limit leaves the account usable, so
125
+ `RATE_LIMIT` with rate-limit wording, `TIMEOUT`, `TRANSPORT`,
126
+ `CONTEXT_WINDOW_EXCEEDED`, a `SERVER` failure, and a credential that simply
127
+ cannot use the model never strike a row. Nor does a message that only
128
+ *mentions* 额度 — a context-overflow failure says “与网络、账号、额度都无关”
129
+ and a transport failure merely suggests waiting for quota to recover; both
130
+ are excluded, and the tests pin them.
131
+ - **Which model.** The failing call's model is read from the `request/header`
132
+ event that immediately precedes the failing `turn/end`, so the mark lands
133
+ on the route (`provider/model`) the request was actually dispatched to —
134
+ not on the currently selected model, which may have changed since.
135
+ - **When the strike ends.** A `turn/end` for the same route that proves the
136
+ model answered clears it: a completed turn, a `max-tokens` turn, or a turn
137
+ that failed for a different reason. A cancellation (`aborted`), a `blocked`
138
+ turn, and the synthetic `interrupted` / `forked` closers clear nothing —
139
+ the model never answered, so they say nothing about the account.
140
+ - **The marks are global, not per conversation.** Whether a model has quota
141
+ left is a fact about the model route and its account, so the state is NOT
142
+ scoped to the Session a panel happens to render for. One module-level store
143
+ per plugin generation observes the durable feed of **every** Session the
144
+ Session list names, and every seat reads that one store. A failure recorded
145
+ in any Session therefore strikes the same model in every Session and
146
+ workspace, including in a Session whose feed this page never opened.
147
+ - **It survives a reload.** The marks are persisted to `localStorage`
148
+ (origin-scoped, like the grouping choice), so a fresh page load starts from
149
+ the last known state instead of an empty one. Reads and writes degrade
150
+ silently when storage is unavailable, and records older than 7 days are
151
+ dropped so a stale strike cannot live forever.
152
+ - **The newest observation wins.** A Session feed carries no cross-Session
153
+ sequence number, but every durable event keeps the Host's wall-clock `time`.
154
+ Each route keeps the time of its last decision, and an observation older
155
+ than the stored one is ignored. This is what makes the global state safe:
156
+ replaying an older window (a history replacement, or a second Session whose
157
+ history is older) cannot resurrect a strike that a newer call already
158
+ cleared, and a cleared route keeps its watermark for the same reason.
159
+ - **Where it comes from.** The store folds each Session's durable event feed
160
+ (`sessions.binding(id).eventSource`), which is the same source the Chat
161
+ reads its quota notice from, and re-attaches when the `sessions` service is
162
+ replaced. No Host change is involved. A history replacement refolds that
163
+ Session from scratch; `prepend` pages in older history and cannot change a
164
+ mark, so it is ignored.
165
+ - **Agent teamwork.** The switch keeps the host's semantics: the same label,
166
+ the same confirmation before disabling it mid-conversation, and no optimistic
167
+ update — the state comes from the `agentTeam` projection. The row lives in
168
+ the shared footer line described under *Reasoning levels*, immediately right
169
+ of the reasoning group when both fit on one line.
170
+
171
+ ## Data and interaction
172
+
173
+ Everything goes through public Client APIs:
174
+
175
+ - `ctx.modelDirectories.directoryFor(sessionId)` supplies the catalog, the
176
+ current selection, and the pending selection; `load()` refreshes it and
177
+ `select()` submits.
178
+ - The directory store is subscribed through `useSyncExternalStore`, so a
179
+ catalog update — including one caused by a selection — repaints the panel.
180
+ - `ctx.sessions.binding(sessionId).eventSource` is the Session's contiguous
181
+ durable event window. A single module-level store folds the feed of **every**
182
+ Session the Session list names (`ctx.sessions.list`) to derive the global
183
+ per-model insufficient-balance marks, persists them to `localStorage`, and
184
+ every seat subscribes through `useSyncExternalStore` so a mark transition
185
+ repaints the panel while every other event stays quiet.
186
+ - `ctx.get('remote').agentTeams.setEnabled()` writes the teamwork selection.
187
+ - `ctx.locale` supplies the copy, resolves catalog badge text, and prefers the
188
+ host's own `agent-team` wording when that namespace is registered.
189
+
190
+ No host component, stylesheet, or DOM is read or restyled.
191
+
192
+ ## Layout behaviour
193
+
194
+ - Window resize, a grouping switch, and search input all re-plan the columns,
195
+ debounced, and the plan runs once more after fonts finish loading so the
196
+ first frame's measurements do not stick.
197
+ - The panel opens above the trigger and flips below when there is no room,
198
+ staying inside the viewport.
199
+ - An unbound Session, an empty catalog, a failed provider, and a search with no
200
+ hits each have their own state. A directory that has not resolved yet still
201
+ renders a trigger rather than blanking the composer.
202
+
203
+ ## Styling
204
+
205
+ Colours, radii, spacing, and elevation come from the `--dsw-alias-*`,
206
+ `--dsw-static-*`, `--dsw-elevation-*`, `--dsw-menu-*`, and `--dsw-radius-*`
207
+ tokens, so light and dark themes both follow the host palette. The selected
208
+ row's blue is deliberately drawn from the static palette (`--dsw-static-*`),
209
+ which is theme-invariant: the alias `--dsw-alias-state-business-primary`
210
+ flips to a light blue with dark text in the dark theme, which would break the
211
+ requested deep-blue-with-white-text treatment there. The panel uses the same menu surface
212
+ material as the host's own menus (menu fill, backdrop blur, prominent
213
+ elevation, large radius), and hover, focus, selected, and disabled states follow
214
+ the shipped picker's visual language.
215
+
216
+ Each provider / family label is deliberately set apart from the model rows
217
+ beneath it: it draws as a filled, rounded band that floats on
218
+ `--dsw-alias-bg-layer-1` with `--dsw-elevation-soft` — the same recipe as the
219
+ active segment. That fill is opaque white in light themes and near-black in
220
+ dark, so it steps clearly away from the translucent panel surface (and the
221
+ transparent model rows) in both themes, with no hard-coded colour. The bare
222
+ `--dsw-alias-menu-group-header-fill` token was only a few levels from the
223
+ surface and read as almost the same background as the rows (see DEC-005). The
224
+ label keeps smaller but bold type with a stronger label colour. The model rows
225
+ stay 13 px regular text on a transparent background, so the band reads as a
226
+ section header rather than another selectable row.
227
+
228
+ The band also carries a low-key frame: a hairline border
229
+ (`--dsw-alias-border-l2`) and a short brand-blue left edge
230
+ (`--dsw-alias-state-business-primary`, the panel's own accent). That marks each
231
+ group start clearly without boxing the group's rows — a group's rows may flow
232
+ across columns, so only its label is framed (see DEC-003).
233
+
234
+ ## Limitations
235
+
236
+ - The panel replaces the composer seat only; the `/model` popup is untouched.
237
+ - The panel does not host the `conversation.input.model.label` and
238
+ `conversation.input.model.options` child slots. The shipped registration keeps
239
+ those declarations alive, so other plugins' contributions stay valid; their
240
+ content simply is not drawn inside this panel.
241
+ - Addressed subagent sessions hide the seat, matching the shipped selector.
242
+ - The badge comes from the catalog entry, so a provider that publishes no badge
243
+ shows none.
Binary file
package/icon.svg ADDED
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Flat model picker">
2
+ <title>Flat model picker</title>
3
+ <rect x="4" y="8" width="56" height="48" rx="10" fill="none" stroke="currentColor" stroke-width="3" opacity="0.85"/>
4
+ <rect x="11" y="15" width="20" height="5" rx="2.5" fill="currentColor" opacity="0.55"/>
5
+ <rect x="35" y="15" width="18" height="5" rx="2.5" fill="currentColor" opacity="0.25"/>
6
+ <rect x="11" y="25" width="20" height="5" rx="2.5" fill="currentColor" opacity="0.55"/>
7
+ <rect x="35" y="25" width="18" height="5" rx="2.5" fill="currentColor" opacity="0.25"/>
8
+ <rect x="11" y="35" width="20" height="5" rx="2.5" fill="currentColor" opacity="0.55"/>
9
+ <rect x="35" y="35" width="18" height="5" rx="2.5" fill="currentColor" opacity="0.25"/>
10
+ <rect x="11" y="45" width="42" height="4" rx="2" fill="currentColor" opacity="0.18"/>
11
+ </svg>
package/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Host half of the flat model picker bundle.
3
+ *
4
+ * The plugin is client-side and runs in both the Web GUI and the Desktop app: it
5
+ * replaces the composer's `conversation.input.model` seat with a flat panel rendered
6
+ * in the client UI, and reads the Session's model directory, projections and
7
+ * `session.selectModel` through public Client APIs.
8
+ * The Host therefore contributes no service, tool or event.
9
+ */
10
+ export const name = 'dsh-model-flatmap'
11
+ export const inject = []
12
+
13
+ export function apply() {}
package/locale/en.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Flat model picker",
4
+ "description": "Replaces the composer model selector with one flat panel: search, provider or model-family grouping, every model and every reasoning level visible at once, and the agent teamwork switch on the same surface. No second-level menus.",
5
+ "detail": "docs/DETAIL.md"
6
+ }
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "模型平铺选择面板",
4
+ "description": "用一块平铺面板替换输入框的模型选择器:搜索、按 Provider 或模型家族分组,所有模型与全部推理等级一次可见,智能体团队协作开关也在同一面板内,没有任何二级菜单。",
5
+ "detail": "docs/DETAIL.md"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "dsh-model-flatmap",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Flat, two-level-free model picker for the DSH composer: every model and reasoning level on one surface.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.js",
8
+ "./client": "./client.js",
9
+ "./package.json": "./package.json",
10
+ "./locale/*.json": "./locale/*.json",
11
+ "./icon": "./icon.svg"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "client.js",
16
+ "cordis.patch.yml",
17
+ "locale/*.json",
18
+ "docs",
19
+ "icon.svg"
20
+ ],
21
+ "dsh": {
22
+ "bundle": { "patch": "./cordis.patch.yml" },
23
+ "client": {
24
+ "platform": "web",
25
+ "immediately": true
26
+ }
27
+ }
28
+ }