dsh-model-flatmap 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -8
- package/README.zh.md +9 -5
- package/client.js +526 -198
- package/docs/DETAIL.md +33 -10
- package/docs/model-picker.jpeg +0 -0
- package/icon.svg +8 -8
- package/index.js +4 -3
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
## What it is
|
|
9
9
|
|
|
10
|
-
`dsh-model-flatmap` is a **
|
|
11
|
-
`conversation.input.model` seat with a single flat
|
|
10
|
+
`dsh-model-flatmap` is a **client-side** DSH plugin that runs in both the **Web GUI and the
|
|
11
|
+
Desktop app**. It replaces the composer's `conversation.input.model` seat with a single flat
|
|
12
|
+
panel rendered in the client UI.
|
|
12
13
|
Instead of nested popovers, the whole catalogue is one searchable grid where the
|
|
13
14
|
model grid, the current model's reasoning levels, and the agent-teamwork switch
|
|
14
15
|
are all visible at once.
|
|
@@ -58,12 +59,18 @@ levels, and the agent-teamwork switch on one surface:
|
|
|
58
59
|
- **Adaptive footer.** The reasoning group and the agent-teamwork switch share
|
|
59
60
|
one line when both fit with a 30 px gap, and fall back to one row each when
|
|
60
61
|
they do not — the break is CSS flex wrapping, not a measured pixel threshold.
|
|
61
|
-
- **Out-of-balance models are struck through.** A model whose last
|
|
62
|
-
on insufficient balance, credit, or quota keeps a `line-through`
|
|
63
|
-
text until a later call for that same model returns anything else.
|
|
64
|
-
follows the model the failing request actually used, and covers the
|
|
65
|
-
`QUOTA` / `ACCOUNT_QUOTA` codes, a provider `402`, and a `429` whose
|
|
66
|
-
reports an empty balance — a plain rate limit never strikes.
|
|
62
|
+
- **Out-of-balance models are struck through — everywhere.** A model whose last
|
|
63
|
+
call failed on insufficient balance, credit, or quota keeps a `line-through`
|
|
64
|
+
on its row text until a later call for that same model returns anything else.
|
|
65
|
+
The strike follows the model the failing request actually used, and covers the
|
|
66
|
+
Host's `QUOTA` / `ACCOUNT_QUOTA` codes, a provider `402`, and a `429` whose
|
|
67
|
+
body reports an empty balance — a plain rate limit never strikes. Because a
|
|
68
|
+
balance is a property of the model route and its account, **not of one
|
|
69
|
+
conversation**, the marks are global: a failure seen in any Session strikes
|
|
70
|
+
the model in every Session and workspace, and the state is persisted to
|
|
71
|
+
`localStorage` so it survives a reload. Observations are ordered by event
|
|
72
|
+
time, so the newest one wins and replaying older history cannot resurrect a
|
|
73
|
+
strike that a later call already cleared.
|
|
67
74
|
- **Theme-aware.** Styled with host theme tokens only; looks correct in both
|
|
68
75
|
light and dark themes.
|
|
69
76
|
|
package/README.zh.md
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
## 这是什么
|
|
9
9
|
|
|
10
|
-
`dsh-model-flatmap`
|
|
11
|
-
`conversation.input.model`
|
|
10
|
+
`dsh-model-flatmap` 是一个**客户端** DSH 插件,在 **Web GUI 与桌面端** 均生效。它把会话输入框中的
|
|
11
|
+
`conversation.input.model` 控件替换成一个平铺面板,渲染在客户端界面中。面板没有
|
|
12
12
|
嵌套的二级菜单:模型网格、当前模型的推理等级,以及智能体协作开关,全部同时可见。
|
|
13
13
|
|
|
14
14
|
插件**只通过公开的 Client API 读写数据**——每个会话的模型目录、协作设置、以及宿主
|
|
@@ -48,10 +48,14 @@
|
|
|
48
48
|
基于 radio 语义)。
|
|
49
49
|
- **自适应底栏。** 推理等级组与智能体协作开关在放得下(中间至少 30 px)时共用一行,
|
|
50
50
|
放不下时各自占一行回到原来的两行布局——换行由 CSS flex 换行决定,不靠 JS 量像素。
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
- **额度不足的模型加删除线,且全局生效。** 某个模型的调用因额度/余额/信用不足而失败后,
|
|
52
|
+
该模型的行文字会带 `line-through` 删除线,直到同一个模型的下一次调用返回非额度不足的
|
|
53
|
+
结果才去掉。标记跟随失败那次请求实际使用的模型;覆盖宿主的 `QUOTA` / `ACCOUNT_QUOTA`
|
|
54
54
|
代码、provider 的 `402`,以及**正文说明余额不足的 `429`**——纯限流的 429 不会划线。
|
|
55
|
+
因为「还有没有额度」是模型路由(及其账号)的属性、**与单个会话无关**,所以标记是全局的:
|
|
56
|
+
在任意会话里观察到的失败,会在所有会话与工作区里同样划线;状态持久化在 `localStorage`
|
|
57
|
+
中,重载后仍然保留。观察按事件时间排序,最新的一条说了算,因此回放更早的历史不会让一条
|
|
58
|
+
已被后续调用清除的划线复活。
|
|
55
59
|
- **跟随主题。** 仅使用宿主主题令牌着色,在浅色与深色主题下都正确。
|
|
56
60
|
|
|
57
61
|
完整行为规格见 [`docs/DETAIL.md`](./docs/DETAIL.md),设计决策见
|
package/client.js
CHANGED
|
@@ -59,12 +59,15 @@ window.__ModuleLoader__.load({
|
|
|
59
59
|
.dshmf-chevronOpen { transform: rotate(180deg); }
|
|
60
60
|
|
|
61
61
|
/* Panel: the host menu surface (menu fill + blur + prominent elevation + lg
|
|
62
|
-
corners), capped at 320px; only the grid scrolls.
|
|
62
|
+
corners), capped at 320px; only the grid scrolls. A 1px light-grey frame
|
|
63
|
+
traces the panel edge in both themes — explicitly NOT a border token: the
|
|
64
|
+
border-* tokens are near-invisible hairlines (see DEC-011/DEC-012), and the
|
|
65
|
+
frame has to be readable on the blurred translucent surface. */
|
|
63
66
|
.dshmf-panel {
|
|
64
67
|
box-sizing: border-box; position: fixed; z-index: 1100;
|
|
65
68
|
display: flex; flex-direction: column; gap: 4px; padding: 6px;
|
|
66
69
|
max-height: 320px; overflow: hidden;
|
|
67
|
-
border:
|
|
70
|
+
border: 1px solid #D1D5DB; border-radius: var(--dsw-radius-lg);
|
|
68
71
|
background: var(--dsw-menu-surface-fill);
|
|
69
72
|
-webkit-backdrop-filter: var(--dsw-menu-backdrop-filter, none);
|
|
70
73
|
backdrop-filter: var(--dsw-menu-backdrop-filter, none);
|
|
@@ -74,6 +77,11 @@ window.__ModuleLoader__.load({
|
|
|
74
77
|
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
|
75
78
|
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
|
76
79
|
}
|
|
80
|
+
/* The same light-grey frame, stepped to the footer divider's dark-theme grey
|
|
81
|
+
(#6B7280, DEC-012) so it stays a light frame on the near-black surface
|
|
82
|
+
without glaring: the width stays 1px in both themes. */
|
|
83
|
+
body[data-ds-dark-theme] .dshmf-panel,
|
|
84
|
+
html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
|
|
77
85
|
|
|
78
86
|
/* Fixed sections. */
|
|
79
87
|
.dshmf-head { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; height: 28px; }
|
|
@@ -118,16 +126,32 @@ window.__ModuleLoader__.load({
|
|
|
118
126
|
color: var(--dsw-alias-state-error-primary); font-size: 11px; line-height: 16px; overflow-wrap: anywhere;
|
|
119
127
|
}
|
|
120
128
|
|
|
121
|
-
/* The only scrolling region.
|
|
122
|
-
|
|
129
|
+
/* The only scrolling region. Its "align-items: start" keeps the text columns
|
|
130
|
+
at their natural height; a divider laid out beside them opts back into
|
|
131
|
+
stretch so it spans the tallest column instead (see .dshmf-colDivider).
|
|
132
|
+
Every child is placed explicitly by grid-column (and the divider by
|
|
133
|
+
grid-row) — see the inline style on each one. Auto-placement must NOT be
|
|
134
|
+
relied on here: a divider that pins its row but not its column is placed
|
|
135
|
+
first, stealing column 1, and the columns then overflow to columns 2 and 3,
|
|
136
|
+
squashing the first text column. */
|
|
137
|
+
.dshmf-grid {
|
|
138
|
+
flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto;
|
|
139
|
+
display: grid; align-items: start;
|
|
140
|
+
}
|
|
123
141
|
.dshmf-col { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
124
142
|
/* When more than one column is laid out, separate adjacent columns with a
|
|
125
143
|
faint vertical hairline so the eye can tell the dense model flows apart.
|
|
126
|
-
The
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
144
|
+
The hairline is its OWN grid item, a sibling of the columns rather than a
|
|
145
|
+
border on one of them: as a border it could only ever be as tall as that
|
|
146
|
+
column's content. Here it sits in a dedicated gutter track and stretches to
|
|
147
|
+
the grid's full height, with a small margin top and bottom.
|
|
148
|
+
Only gutters that actually sit between two columns get one (see the grid
|
|
149
|
+
children), so a single-column layout stays borderless. */
|
|
150
|
+
.dshmf-colDivider {
|
|
151
|
+
grid-row: 1; align-self: stretch; justify-self: center;
|
|
152
|
+
width: 1px; margin: 6px 0;
|
|
153
|
+
background: var(--dsw-alias-border-l3);
|
|
154
|
+
}
|
|
131
155
|
/* Provider / family label: a filled, rounded band that reads as a section
|
|
132
156
|
header, clearly separated from the model rows below it. Bare host
|
|
133
157
|
group-header fill is only a few levels from the translucent panel surface.
|
|
@@ -240,30 +264,28 @@ window.__ModuleLoader__.load({
|
|
|
240
264
|
No JS width guessing, so it stays correct across panel resize, theme and
|
|
241
265
|
font changes. A max-width of 100% lets a child that ends up alone on a line
|
|
242
266
|
cap to the panel width and wrap internally instead of overflowing.
|
|
243
|
-
The
|
|
244
|
-
|
|
245
|
-
border
|
|
267
|
+
The divider sits on this container, not on a child (DEC-016): the two groups
|
|
268
|
+
can share a line or stack, and either way the rule belongs above the whole
|
|
269
|
+
footer block, never between them, so no child carries a border. Host border
|
|
270
|
+
tokens are faint by design, so the line uses explicit greys: medium grey on
|
|
271
|
+
the light panel, a lighter near-white grey on the dark panel. */
|
|
246
272
|
.dshmf-footer {
|
|
247
273
|
flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
|
|
248
274
|
column-gap: 30px; row-gap: 4px;
|
|
275
|
+
border-top: 1px solid #9CA3AF;
|
|
276
|
+
padding-top: 6px;
|
|
249
277
|
}
|
|
278
|
+
body[data-ds-dark-theme] .dshmf-footer,
|
|
279
|
+
html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
250
280
|
|
|
251
281
|
/* Reasoning levels: a caption and a multi-segmentation control on one line;
|
|
252
282
|
the track wraps below the caption only when the row cannot fit. The
|
|
253
283
|
segment visual mirrors the header's grouping control, so the panel has
|
|
254
284
|
one visual language for "one of many, exactly one active". */
|
|
255
|
-
/* One obvious grey divider between the scrolling model grid and the fixed
|
|
256
|
-
reasoning row. Host border tokens are faint by design, so this line uses
|
|
257
|
-
explicit greys: medium grey on the light panel, a lighter near-white grey
|
|
258
|
-
on the dark panel. The teamwork row below has no line (DEC-011). */
|
|
259
285
|
.dshmf-reason {
|
|
260
286
|
flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
|
|
261
287
|
gap: 2px 6px; max-width: 100%;
|
|
262
|
-
border-top: 1px solid #9CA3AF;
|
|
263
|
-
padding-top: 6px;
|
|
264
288
|
}
|
|
265
|
-
body[data-ds-dark-theme] .dshmf-reason,
|
|
266
|
-
html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
267
289
|
.dshmf-reasonLabel {
|
|
268
290
|
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px;
|
|
269
291
|
white-space: nowrap;
|
|
@@ -568,37 +590,88 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
568
590
|
const CLEARING_TURN_ENDS = new Set(['completed', 'max-tokens', 'error'])
|
|
569
591
|
|
|
570
592
|
/**
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
593
|
+
* Wall-clock time one durable event was recorded at. The Client feed keeps
|
|
594
|
+
* the Host's `time` (`Date.now()` at append), which is what lets marks
|
|
595
|
+
* gathered from DIFFERENT Sessions be ordered against each other — a Session
|
|
596
|
+
* feed has no cross-Session sequence number.
|
|
597
|
+
* @param {unknown} event - one durable Session event.
|
|
598
|
+
* @returns {number} the event time, or 0 when the feed carried none.
|
|
599
|
+
*/
|
|
600
|
+
function eventTimeOf(event) {
|
|
601
|
+
const time = event?.time
|
|
602
|
+
return typeof time === 'number' && Number.isFinite(time) ? time : 0
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Decide what ONE durable event says about a model route's balance, with no
|
|
607
|
+
* state involved. This is the whole semantic core of the feature, kept
|
|
608
|
+
* separate from the bookkeeping so it can be pinned directly.
|
|
574
609
|
*
|
|
575
|
-
* A `turn/end`
|
|
576
|
-
* A
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
* @param {unknown} event - one durable Session event
|
|
584
|
-
* @param {{ provider: string, model: string } | null} route - route
|
|
585
|
-
* @returns {boolean}
|
|
610
|
+
* - A `turn/end` that failed on insufficient balance → `true` (mark).
|
|
611
|
+
* - A `turn/end` that proves the model answered otherwise → `false`
|
|
612
|
+
* (clear): a completed turn, a `max-tokens` turn, or a turn that failed
|
|
613
|
+
* for a different reason.
|
|
614
|
+
* - A cancellation (`aborted`), a `blocked` turn, and the synthetic
|
|
615
|
+
* `interrupted` / `forked` closers → `null` (no decision), because the
|
|
616
|
+
* model never answered and the account state is unknown.
|
|
617
|
+
* - Anything that is not a `turn/end`, or has no route → `null`.
|
|
618
|
+
* @param {unknown} event - one durable Session event.
|
|
619
|
+
* @param {{ provider: string, model: string } | null} route - the route that request used.
|
|
620
|
+
* @returns {boolean | null} mark, clear, or no decision.
|
|
586
621
|
*/
|
|
587
|
-
function
|
|
588
|
-
if (event === null || typeof event !== 'object') return
|
|
589
|
-
if (event.type !== 'turn/end' || route === null) return
|
|
622
|
+
function decideMark(event, route) {
|
|
623
|
+
if (event === null || typeof event !== 'object') return null
|
|
624
|
+
if (event.type !== 'turn/end' || route === null || route === undefined) return null
|
|
590
625
|
const reason = event.data?.reason
|
|
591
|
-
if (reason === null || typeof reason !== 'object') return
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
626
|
+
if (reason === null || typeof reason !== 'object') return null
|
|
627
|
+
if (reason.kind === 'error') return isInsufficientBalance(reason.error)
|
|
628
|
+
return CLEARING_TURN_ENDS.has(reason.kind) ? false : null
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Fold ONE decision into the mark map, last-writer-wins by event time.
|
|
633
|
+
*
|
|
634
|
+
* The time watermark is what makes the state GLOBAL and replay-safe: marks
|
|
635
|
+
* arrive from every Session's feed (and from storage written by an earlier
|
|
636
|
+
* page load), so the newest observation wins no matter which Session it came
|
|
637
|
+
* from. Re-walking an old window therefore cannot resurrect a mark that a
|
|
638
|
+
* later call already cleared, and a cleared route keeps a watermark so a
|
|
639
|
+
* stale replay stays a no-op.
|
|
640
|
+
* @param {Map<string, {marked: boolean, at: number}>} entries - state, mutated in place.
|
|
641
|
+
* @param {string} key - the route key.
|
|
642
|
+
* @param {boolean} marked - the decision this event carries.
|
|
643
|
+
* @param {number} at - the event's time.
|
|
644
|
+
* @returns {{ visible: boolean, stored: boolean }} whether the visible mark
|
|
645
|
+
* set changed, and whether the underlying record changed.
|
|
646
|
+
*/
|
|
647
|
+
function foldMarkEntry(entries, key, marked, at) {
|
|
648
|
+
const previous = entries.get(key)
|
|
649
|
+
if (previous !== undefined && at < previous.at) {
|
|
650
|
+
return { visible: false, stored: false }
|
|
597
651
|
}
|
|
598
|
-
if (
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
652
|
+
if (previous !== undefined && previous.marked === marked && previous.at === at) {
|
|
653
|
+
return { visible: false, stored: false }
|
|
654
|
+
}
|
|
655
|
+
entries.set(key, { marked, at })
|
|
656
|
+
return { visible: previous === undefined ? marked : previous.marked !== marked, stored: true }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Fold one durable event into the mark map. Pure helper kept for the tests
|
|
661
|
+
* and for callers that hold their own map; the live store uses
|
|
662
|
+
* {@link decideMark} + {@link foldMarkEntry} so it can batch persistence.
|
|
663
|
+
* @param {Map<string, {marked: boolean, at: number}>} state - state, mutated in place.
|
|
664
|
+
* @param {unknown} event - one durable Session event.
|
|
665
|
+
* @param {{ provider: string, model: string } | null} route - the route that request used.
|
|
666
|
+
* @param {number} [at] - the event's time; defaults to `event.time`.
|
|
667
|
+
* @returns {boolean} whether the visible mark set changed.
|
|
668
|
+
*/
|
|
669
|
+
function foldInsufficient(state, event, route, at) {
|
|
670
|
+
const marked = decideMark(event, route)
|
|
671
|
+
if (marked === null) return false
|
|
672
|
+
const key = routeKeyOf(route.provider, route.model)
|
|
673
|
+
const time = at === undefined ? eventTimeOf(event) : at
|
|
674
|
+
return foldMarkEntry(state, key, marked, time).visible
|
|
602
675
|
}
|
|
603
676
|
|
|
604
677
|
/**
|
|
@@ -1014,6 +1087,320 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
1014
1087
|
}
|
|
1015
1088
|
}
|
|
1016
1089
|
|
|
1090
|
+
// ---------------------------------------------------------------------
|
|
1091
|
+
// Insufficient-balance marks are GLOBAL, not Session-scoped.
|
|
1092
|
+
//
|
|
1093
|
+
// Whether a model has any quota left is a fact about the model route (and
|
|
1094
|
+
// the account behind it), not about one conversation: a failure observed in
|
|
1095
|
+
// one Session must strike the same model everywhere — another Session,
|
|
1096
|
+
// another workspace, and after a reload. So the marks live in ONE
|
|
1097
|
+
// module-level store shared by every seat of this plugin generation, and
|
|
1098
|
+
// are persisted to `localStorage` (origin-scoped, exactly like the grouping
|
|
1099
|
+
// choice) so a fresh page load starts from the last known state.
|
|
1100
|
+
//
|
|
1101
|
+
// Ordering across Sessions is by wall-clock time: each durable event keeps
|
|
1102
|
+
// the Host's `time`, and the newest observation of a route wins. That is
|
|
1103
|
+
// what lets a replayed window (or a stored entry) never override a newer
|
|
1104
|
+
// fact from another Session.
|
|
1105
|
+
// ---------------------------------------------------------------------
|
|
1106
|
+
const MARKS_KEY = 'dshmf:insufficient'
|
|
1107
|
+
/** Marks older than this are dropped on read, so a stale strike cannot live forever. */
|
|
1108
|
+
const MARKS_TTL = 7 * 24 * 60 * 60 * 1000
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Parse a persisted mark record into the in-memory shape, dropping anything
|
|
1112
|
+
* malformed or older than {@link MARKS_TTL}. Pure, so the storage format is
|
|
1113
|
+
* testable without a `localStorage` shim.
|
|
1114
|
+
* @param {unknown} raw - the stored JSON value (already parsed).
|
|
1115
|
+
* @param {number} now - current time, injected for testability.
|
|
1116
|
+
* @returns {Map<string, {marked: boolean, at: number}>} the parsed entries.
|
|
1117
|
+
*/
|
|
1118
|
+
function parseMarks(raw, now) {
|
|
1119
|
+
const entries = new Map()
|
|
1120
|
+
if (raw === null || typeof raw !== 'object') return entries
|
|
1121
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
1122
|
+
if (typeof key !== 'string' || key === '') continue
|
|
1123
|
+
if (value === null || typeof value !== 'object') continue
|
|
1124
|
+
const at = typeof value.at === 'number' && Number.isFinite(value.at) ? value.at : 0
|
|
1125
|
+
if (typeof value.marked !== 'boolean') continue
|
|
1126
|
+
if (now - at > MARKS_TTL) continue
|
|
1127
|
+
entries.set(key, { marked: value.marked, at })
|
|
1128
|
+
}
|
|
1129
|
+
return entries
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Serialise the in-memory entries for storage, dropping anything older than
|
|
1134
|
+
* {@link MARKS_TTL} so a long-lived page cannot grow the record forever. A
|
|
1135
|
+
* cleared route keeps its watermark only while it can still protect against
|
|
1136
|
+
* a replayed older failure, which is the same window.
|
|
1137
|
+
* @param {Map<string, {marked: boolean, at: number}>} entries - the state.
|
|
1138
|
+
* @param {number} [now] - current time, injected for testability.
|
|
1139
|
+
* @returns {Record<string, {marked: boolean, at: number}>} a JSON-ready record.
|
|
1140
|
+
*/
|
|
1141
|
+
function serializeMarks(entries, now) {
|
|
1142
|
+
const time = now === undefined ? Date.now() : now
|
|
1143
|
+
const out = {}
|
|
1144
|
+
for (const [key, value] of entries) {
|
|
1145
|
+
if (time - value.at > MARKS_TTL) continue
|
|
1146
|
+
out[key] = { marked: value.marked, at: value.at }
|
|
1147
|
+
}
|
|
1148
|
+
return out
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Read the persisted marks, or an empty map when storage is unavailable,
|
|
1153
|
+
* holds garbage, or throws (private mode, sandboxed frame, quota).
|
|
1154
|
+
* @returns {Map<string, {marked: boolean, at: number}>} the stored entries.
|
|
1155
|
+
*/
|
|
1156
|
+
function readMarks() {
|
|
1157
|
+
try {
|
|
1158
|
+
if (typeof localStorage === 'undefined') return new Map()
|
|
1159
|
+
const text = localStorage.getItem(MARKS_KEY)
|
|
1160
|
+
if (text === null || text === '') return new Map()
|
|
1161
|
+
return parseMarks(JSON.parse(text), Date.now())
|
|
1162
|
+
} catch {
|
|
1163
|
+
return new Map()
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Persist the marks. A write failure is swallowed: the in-memory store still
|
|
1169
|
+
* drives every open panel in this page.
|
|
1170
|
+
* @param {Map<string, {marked: boolean, at: number}>} entries - the state.
|
|
1171
|
+
*/
|
|
1172
|
+
function writeMarks(entries) {
|
|
1173
|
+
try {
|
|
1174
|
+
if (typeof localStorage === 'undefined') return
|
|
1175
|
+
localStorage.setItem(MARKS_KEY, JSON.stringify(serializeMarks(entries)))
|
|
1176
|
+
} catch {
|
|
1177
|
+
/* storage unavailable — keep the in-memory state only */
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** Whether two visible mark maps hold the same entries. */
|
|
1182
|
+
function sameMarks(left, right) {
|
|
1183
|
+
if (left.size !== right.size) return false
|
|
1184
|
+
for (const key of left) {
|
|
1185
|
+
if (!right.has(key)) return false
|
|
1186
|
+
}
|
|
1187
|
+
return true
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Create the one global mark store for this module generation.
|
|
1192
|
+
*
|
|
1193
|
+
* It owns the state, the listeners, and the Session-feed subscriptions; the
|
|
1194
|
+
* seats only read it. Observing EVERY Session (not just the one a seat
|
|
1195
|
+
* renders for) is what makes a failure recorded anywhere visible
|
|
1196
|
+
* everywhere, including in a Session whose own feed was never opened in this
|
|
1197
|
+
* page load (its marks come from storage).
|
|
1198
|
+
*
|
|
1199
|
+
* @param {object} deps - resolution hooks.
|
|
1200
|
+
* @param {() => object | undefined} deps.sessionsOf - read the `sessions` service, per call.
|
|
1201
|
+
* @param {() => Map<string, {marked: boolean, at: number}>} deps.readStored - initial/persisted state.
|
|
1202
|
+
* @param {(entries: Map<string, {marked: boolean, at: number}>) => void} deps.writeStored - persistence sink.
|
|
1203
|
+
* @returns {object} the store: `subscribe`, `getSnapshot`, `dispose`, `sync`.
|
|
1204
|
+
*/
|
|
1205
|
+
function createMarkStore(deps) {
|
|
1206
|
+
/** Internal truth: route key → last decision and when it was made. */
|
|
1207
|
+
const entries = new Map(deps.readStored())
|
|
1208
|
+
/** Visible subset the rows read: route key → true. */
|
|
1209
|
+
let visible = new Map()
|
|
1210
|
+
let snapshot = { marks: visible }
|
|
1211
|
+
const listeners = new Set()
|
|
1212
|
+
/** Per-Session route of the latest request header (a route is Session-local). */
|
|
1213
|
+
const routes = new Map()
|
|
1214
|
+
/** Per-Session stop handle for its event feed. */
|
|
1215
|
+
const stops = new Map()
|
|
1216
|
+
/** Sessions attached on behalf of a mounted seat; never pruned by a list sync. */
|
|
1217
|
+
const pinned = new Set()
|
|
1218
|
+
/** Last `sessions` service seen, so a reloaded service is re-attached. */
|
|
1219
|
+
let attachedSessions
|
|
1220
|
+
let stopList
|
|
1221
|
+
let disposed = false
|
|
1222
|
+
|
|
1223
|
+
/** Rebuild the visible map; report whether the rendered set changed. */
|
|
1224
|
+
const recompute = () => {
|
|
1225
|
+
const next = new Map()
|
|
1226
|
+
for (const [key, entry] of entries) {
|
|
1227
|
+
if (entry.marked) next.set(key, true)
|
|
1228
|
+
}
|
|
1229
|
+
if (sameMarks(visible, next)) return false
|
|
1230
|
+
visible = next
|
|
1231
|
+
snapshot = { marks: visible }
|
|
1232
|
+
return true
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
recompute()
|
|
1236
|
+
|
|
1237
|
+
const publish = () => {
|
|
1238
|
+
for (const listener of listeners) listener()
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/** Fold one batch of durable entries for one Session. */
|
|
1242
|
+
const absorb = (sessionId, windowEntries) => {
|
|
1243
|
+
let stored = false
|
|
1244
|
+
let changed = false
|
|
1245
|
+
for (const entry of windowEntries) {
|
|
1246
|
+
const event = entry?.event
|
|
1247
|
+
if (event === null || event === undefined) continue
|
|
1248
|
+
const header = routeOfHeader(event)
|
|
1249
|
+
if (header !== null) {
|
|
1250
|
+
routes.set(sessionId, header)
|
|
1251
|
+
continue
|
|
1252
|
+
}
|
|
1253
|
+
if (event.type !== 'turn/end') continue
|
|
1254
|
+
const route = routes.get(sessionId) ?? null
|
|
1255
|
+
const marked = decideMark(event, route)
|
|
1256
|
+
if (marked === null) continue
|
|
1257
|
+
const result = foldMarkEntry(
|
|
1258
|
+
entries, routeKeyOf(route.provider, route.model), marked, eventTimeOf(event),
|
|
1259
|
+
)
|
|
1260
|
+
if (result.stored) stored = true
|
|
1261
|
+
if (result.visible) changed = true
|
|
1262
|
+
}
|
|
1263
|
+
return { stored, changed }
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** Read a window's entries defensively. */
|
|
1267
|
+
const entriesOf = window => (Array.isArray(window?.entries) ? window.entries : [])
|
|
1268
|
+
|
|
1269
|
+
/** Apply one feed window for one Session. */
|
|
1270
|
+
const applyWindow = (sessionId, window, full) => {
|
|
1271
|
+
const change = window?.change
|
|
1272
|
+
let result
|
|
1273
|
+
if (full || change?.kind === 'replace') {
|
|
1274
|
+
// The window is that Session's whole truth, so its route restarts and
|
|
1275
|
+
// its entries are re-walked. Global watermarks keep a newer fact from
|
|
1276
|
+
// another Session from being overwritten by this replay.
|
|
1277
|
+
routes.delete(sessionId)
|
|
1278
|
+
result = absorb(sessionId, entriesOf(window))
|
|
1279
|
+
} else if (change?.kind === 'append') {
|
|
1280
|
+
result = absorb(sessionId, change.entries)
|
|
1281
|
+
} else {
|
|
1282
|
+
// `prepend` pages in OLDER history and `settle-assistant` only swaps a
|
|
1283
|
+
// live attempt for its durable form; neither can change a mark.
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
if (result.stored) deps.writeStored(entries)
|
|
1287
|
+
if (result.changed && recompute()) publish()
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Attach to one Session's durable feed. Idempotent, and a no-op when the
|
|
1292
|
+
* Session has no retained binding (an unopened Session has no feed; its
|
|
1293
|
+
* marks can only come from storage).
|
|
1294
|
+
*/
|
|
1295
|
+
const attachSession = sessionId => {
|
|
1296
|
+
if (disposed || stops.has(sessionId)) return
|
|
1297
|
+
const sessions = deps.sessionsOf()
|
|
1298
|
+
let binding
|
|
1299
|
+
try {
|
|
1300
|
+
binding = typeof sessions?.binding === 'function' ? sessions.binding(sessionId) : undefined
|
|
1301
|
+
} catch {
|
|
1302
|
+
binding = undefined
|
|
1303
|
+
}
|
|
1304
|
+
const source = binding?.eventSource
|
|
1305
|
+
if (source === undefined || typeof source.subscribe !== 'function'
|
|
1306
|
+
|| typeof source.getSnapshot !== 'function') return
|
|
1307
|
+
try {
|
|
1308
|
+
applyWindow(sessionId, source.getSnapshot(), true)
|
|
1309
|
+
} catch {
|
|
1310
|
+
/* an unreadable feed leaves the store as it was */
|
|
1311
|
+
}
|
|
1312
|
+
const listener = () => {
|
|
1313
|
+
try {
|
|
1314
|
+
applyWindow(sessionId, source.getSnapshot(), false)
|
|
1315
|
+
} catch {
|
|
1316
|
+
/* a fold failure must not escape into the slot */
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
try {
|
|
1320
|
+
const stop = source.subscribe(listener)
|
|
1321
|
+
stops.set(sessionId, typeof stop === 'function' ? stop : () => {})
|
|
1322
|
+
} catch {
|
|
1323
|
+
/* subscription refused: leave this Session unattached */
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Attach to every Session the list currently names, and drop the rest. */
|
|
1328
|
+
const sync = () => {
|
|
1329
|
+
if (disposed) return
|
|
1330
|
+
const sessions = deps.sessionsOf()
|
|
1331
|
+
if (sessions === undefined) return
|
|
1332
|
+
// A client reload replaces the service; re-attach from the new one.
|
|
1333
|
+
if (sessions !== attachedSessions) {
|
|
1334
|
+
for (const stop of stops.values()) stop()
|
|
1335
|
+
stops.clear()
|
|
1336
|
+
routes.clear()
|
|
1337
|
+
if (typeof stopList === 'function') stopList()
|
|
1338
|
+
stopList = undefined
|
|
1339
|
+
attachedSessions = sessions
|
|
1340
|
+
try {
|
|
1341
|
+
if (sessions.list !== undefined && typeof sessions.list.subscribe === 'function') {
|
|
1342
|
+
stopList = sessions.list.subscribe(() => { sync() })
|
|
1343
|
+
}
|
|
1344
|
+
} catch {
|
|
1345
|
+
stopList = undefined
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
let ids = []
|
|
1349
|
+
try {
|
|
1350
|
+
const list = sessions.list?.getSnapshot?.()
|
|
1351
|
+
ids = Array.isArray(list?.ids) ? list.ids : []
|
|
1352
|
+
} catch {
|
|
1353
|
+
ids = []
|
|
1354
|
+
}
|
|
1355
|
+
for (const id of ids) attachSession(id)
|
|
1356
|
+
// Drop feeds for Sessions the list no longer names, unless a mounted
|
|
1357
|
+
// seat is currently reading that Session (it may not be listed yet).
|
|
1358
|
+
for (const id of [...stops.keys()]) {
|
|
1359
|
+
if (pinned.has(id)) continue
|
|
1360
|
+
if (!ids.includes(id)) {
|
|
1361
|
+
stops.get(id)?.()
|
|
1362
|
+
stops.delete(id)
|
|
1363
|
+
routes.delete(id)
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
return {
|
|
1369
|
+
/** The seat's `useSyncExternalStore` pair. */
|
|
1370
|
+
subscribe: listener => {
|
|
1371
|
+
listeners.add(listener)
|
|
1372
|
+
sync()
|
|
1373
|
+
return () => { listeners.delete(listener) }
|
|
1374
|
+
},
|
|
1375
|
+
getSnapshot: () => snapshot,
|
|
1376
|
+
/** Re-attach (services may bind after the plugin activated). */
|
|
1377
|
+
sync,
|
|
1378
|
+
/**
|
|
1379
|
+
* Pin and immediately attach one Session's feed, for a mounted seat.
|
|
1380
|
+
* The list may not name this Session yet (or at all), so it is attached
|
|
1381
|
+
* directly instead of waiting for a list sync.
|
|
1382
|
+
*/
|
|
1383
|
+
watch: sessionId => {
|
|
1384
|
+
if (disposed) return
|
|
1385
|
+
pinned.add(sessionId)
|
|
1386
|
+
sync()
|
|
1387
|
+
attachSession(sessionId)
|
|
1388
|
+
},
|
|
1389
|
+
/** Release every feed subscription. The marks themselves stay persisted. */
|
|
1390
|
+
dispose: () => {
|
|
1391
|
+
if (disposed) return
|
|
1392
|
+
disposed = true
|
|
1393
|
+
for (const stop of stops.values()) stop()
|
|
1394
|
+
stops.clear()
|
|
1395
|
+
routes.clear()
|
|
1396
|
+
pinned.clear()
|
|
1397
|
+
if (typeof stopList === 'function') stopList()
|
|
1398
|
+
stopList = undefined
|
|
1399
|
+
listeners.clear()
|
|
1400
|
+
},
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1017
1404
|
const dict = {
|
|
1018
1405
|
zh: {
|
|
1019
1406
|
'provider.account': 'DeepSeek 账号',
|
|
@@ -1101,7 +1488,7 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
1101
1488
|
|
|
1102
1489
|
/**
|
|
1103
1490
|
* Shared empty mark map. One frozen instance keeps `getSnapshot` referentially
|
|
1104
|
-
* stable for every seat that has no
|
|
1491
|
+
* stable for every seat that has no store, so an unmarked panel never
|
|
1105
1492
|
* re-renders because of this feature.
|
|
1106
1493
|
*/
|
|
1107
1494
|
const EMPTY_INSUFFICIENT_MARKS = new Map()
|
|
@@ -1115,13 +1502,13 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
1115
1502
|
const EMPTY_INSUFFICIENT_SNAPSHOT = { marks: EMPTY_INSUFFICIENT_MARKS }
|
|
1116
1503
|
|
|
1117
1504
|
/**
|
|
1118
|
-
* Stand-in mark face used
|
|
1119
|
-
*
|
|
1505
|
+
* Stand-in mark face used when the store degraded. It never notifies and
|
|
1506
|
+
* always answers the same empty snapshot.
|
|
1120
1507
|
*/
|
|
1121
1508
|
const EMPTY_INSUFFICIENT = {
|
|
1122
1509
|
subscribe: () => () => {},
|
|
1123
1510
|
getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
|
|
1124
|
-
|
|
1511
|
+
watch: () => {},
|
|
1125
1512
|
}
|
|
1126
1513
|
|
|
1127
1514
|
// ---------------------------------------------------------------------
|
|
@@ -1150,18 +1537,20 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
1150
1537
|
const getSnapshot = useCallback(() => store.getSnapshot(), [store])
|
|
1151
1538
|
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
1152
1539
|
|
|
1153
|
-
// Per-model insufficient-balance marks,
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1540
|
+
// Per-model insufficient-balance marks, read from the ONE global store so
|
|
1541
|
+
// a failure recorded in any Session or workspace strikes the model here
|
|
1542
|
+
// too. The fallback keeps the hook shape stable when the face is omitted.
|
|
1156
1543
|
const markFace = insufficient ?? EMPTY_INSUFFICIENT
|
|
1157
1544
|
const subscribeMarks = useCallback(listener => markFace.subscribe(listener), [markFace])
|
|
1158
1545
|
const getMarks = useCallback(() => markFace.getSnapshot(), [markFace])
|
|
1159
1546
|
const marks = useSyncExternalStore(subscribeMarks, getMarks, getMarks)
|
|
1160
1547
|
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
useEffect(() =>
|
|
1548
|
+
// Keep the store attached to this Session's feed while the seat is mounted.
|
|
1549
|
+
// It is shared, so unmounting must NOT dispose it — the marks belong to the
|
|
1550
|
+
// page, not to this seat.
|
|
1551
|
+
useEffect(() => {
|
|
1552
|
+
if (typeof markFace.watch === 'function') markFace.watch(sessionId)
|
|
1553
|
+
}, [markFace, sessionId])
|
|
1165
1554
|
|
|
1166
1555
|
const [open, setOpen] = useState(false)
|
|
1167
1556
|
const [query, setQuery] = useState('')
|
|
@@ -1661,11 +2050,44 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
1661
2050
|
}, block.label)
|
|
1662
2051
|
: renderRow(block.entry))
|
|
1663
2052
|
|
|
2053
|
+
// Each child is placed EXPLICITLY (`grid-column` below), never by
|
|
2054
|
+
// auto-placement: a divider that pinned its row but not its column was
|
|
2055
|
+
// laid out first, took column 1, and pushed the columns into columns 2
|
|
2056
|
+
// and 3 — which squashed the first text column to 1px and stacked the
|
|
2057
|
+
// columns on top of each other. Track 2k+1 holds column k (0-based) and
|
|
2058
|
+
// track 2k+2 the gutter that follows it, so a 3-column layout is
|
|
2059
|
+
// col gutter col gutter col.
|
|
1664
2060
|
const renderColumn = (column, columnIndex) => h('div', {
|
|
1665
|
-
className: 'dshmf-col'
|
|
2061
|
+
className: 'dshmf-col',
|
|
1666
2062
|
key: `col-${columnIndex}`,
|
|
2063
|
+
style: { gridColumn: String(columnIndex * 2 + 1), gridRow: '1' },
|
|
1667
2064
|
}, column.map(unit => unit.blocks.map(renderBlock)))
|
|
1668
2065
|
|
|
2066
|
+
// Vertical separators are siblings of the columns, not a border on one
|
|
2067
|
+
// of them: a border can only ever be as tall as its own column, while a
|
|
2068
|
+
// grid item can stretch to the whole grid height (see .dshmf-colDivider).
|
|
2069
|
+
// Called with the index of the column that PRECEDES the gutter.
|
|
2070
|
+
const renderDivider = dividerIndex => h('div', {
|
|
2071
|
+
className: 'dshmf-colDivider',
|
|
2072
|
+
role: 'presentation',
|
|
2073
|
+
key: `gutter-${dividerIndex}`,
|
|
2074
|
+
style: { gridColumn: String(dividerIndex * 2), gridRow: '1' },
|
|
2075
|
+
})
|
|
2076
|
+
|
|
2077
|
+
const renderColumns = () => {
|
|
2078
|
+
const children = []
|
|
2079
|
+
plan.columns.forEach((column, columnIndex) => {
|
|
2080
|
+
if (columnIndex > 0) children.push(renderDivider(columnIndex))
|
|
2081
|
+
children.push(renderColumn(column, columnIndex))
|
|
2082
|
+
})
|
|
2083
|
+
return children
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// `minmax(0, 1fr)` per column, a hairline track per gutter between them.
|
|
2087
|
+
const gridTemplateColumns = plan.columns
|
|
2088
|
+
.map(() => 'minmax(0, 1fr)')
|
|
2089
|
+
.join(' 1px ')
|
|
2090
|
+
|
|
1669
2091
|
const renderFailure = failure => h('div', {
|
|
1670
2092
|
className: 'dshmf-failure',
|
|
1671
2093
|
key: `failure-${failure.id}`,
|
|
@@ -1819,13 +2241,15 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
1819
2241
|
h('div', {
|
|
1820
2242
|
className: 'dshmf-grid',
|
|
1821
2243
|
style: {
|
|
1822
|
-
gridTemplateColumns:
|
|
2244
|
+
gridTemplateColumns: showEmptyState
|
|
2245
|
+
? 'minmax(0, 1fr)'
|
|
2246
|
+
: gridTemplateColumns,
|
|
1823
2247
|
columnGap: `${COL_GAP}px`,
|
|
1824
2248
|
},
|
|
1825
2249
|
},
|
|
1826
2250
|
showEmptyState
|
|
1827
2251
|
? h('div', { className: 'dshmf-empty', role: 'status' }, t(emptyKey))
|
|
1828
|
-
:
|
|
2252
|
+
: renderColumns(),
|
|
1829
2253
|
state.failures.length === 0 ? null : state.failures.map(renderFailure),
|
|
1830
2254
|
),
|
|
1831
2255
|
|
|
@@ -2042,7 +2466,7 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
2042
2466
|
// Pure layout and grouping derivations plus the component itself,
|
|
2043
2467
|
// exposed for `node --test` so grouping, filtering and the column
|
|
2044
2468
|
// algorithm are pinned without a browser. The shell never reads this key.
|
|
2045
|
-
__test: { familyOf, familyRank, familyVariant: rowVariantOf, idVariantOf, matchScore, providerScore, searchTokens, buildUnits, columnHeight, packColumns, planColumns, readGrouping, writeGrouping, GROUPING_KEY, GROUPING_DEFAULT, isInsufficientBalance, foldInsufficient, routeOfHeader, routeKeyOf },
|
|
2469
|
+
__test: { familyOf, familyRank, familyVariant: rowVariantOf, idVariantOf, matchScore, providerScore, searchTokens, buildUnits, columnHeight, packColumns, planColumns, readGrouping, writeGrouping, GROUPING_KEY, GROUPING_DEFAULT, isInsufficientBalance, decideMark, eventTimeOf, foldInsufficient, foldMarkEntry, routeOfHeader, routeKeyOf, MARKS_KEY, MARKS_TTL, parseMarks, serializeMarks, readMarks, writeMarks, createMarkStore },
|
|
2046
2470
|
__component: ModelFlatMap,
|
|
2047
2471
|
__seat: Seat,
|
|
2048
2472
|
apply(ctx) {
|
|
@@ -2099,6 +2523,33 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
2099
2523
|
}
|
|
2100
2524
|
}
|
|
2101
2525
|
|
|
2526
|
+
// ---- the GLOBAL insufficient-balance store
|
|
2527
|
+
//
|
|
2528
|
+
// Created once per activation and shared by every seat, because the
|
|
2529
|
+
// balance state belongs to the model route (and its account), not to one
|
|
2530
|
+
// conversation or workspace. It observes every Session the list names,
|
|
2531
|
+
// persists to `localStorage`, and starts from what the last page load
|
|
2532
|
+
// recorded. A failure to build it degrades to an empty face rather than
|
|
2533
|
+
// costing the control.
|
|
2534
|
+
let markStore
|
|
2535
|
+
try {
|
|
2536
|
+
markStore = createMarkStore({
|
|
2537
|
+
sessionsOf: () => {
|
|
2538
|
+
try {
|
|
2539
|
+
return typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
|
|
2540
|
+
} catch {
|
|
2541
|
+
return undefined
|
|
2542
|
+
}
|
|
2543
|
+
},
|
|
2544
|
+
readStored: readMarks,
|
|
2545
|
+
writeStored: writeMarks,
|
|
2546
|
+
})
|
|
2547
|
+
ctx.effect(() => () => { markStore.dispose() }, 'dsh-model-flatmap: balance marks')
|
|
2548
|
+
} catch (error) {
|
|
2549
|
+
console.error('dsh-model-flatmap: the balance mark store failed to start', error)
|
|
2550
|
+
markStore = undefined
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2102
2553
|
/**
|
|
2103
2554
|
* Build the injected face for one Session.
|
|
2104
2555
|
* @param sessionId - the Session this seat renders for.
|
|
@@ -2137,146 +2588,27 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
2137
2588
|
}
|
|
2138
2589
|
const first = resolve()
|
|
2139
2590
|
|
|
2140
|
-
//
|
|
2141
|
-
//
|
|
2142
|
-
//
|
|
2143
|
-
//
|
|
2144
|
-
|
|
2145
|
-
// when a fold actually changes the marks, so `useSyncExternalStore`
|
|
2146
|
-
// re-renders the panel on a real transition and stays quiet on every
|
|
2147
|
-
// other event.
|
|
2148
|
-
const insufficientListeners = new Set()
|
|
2149
|
-
let insufficientMarks = new Map()
|
|
2150
|
-
let insufficientSnapshot = { marks: insufficientMarks }
|
|
2151
|
-
// The route of the latest request header: a `turn/end` belongs to the
|
|
2152
|
-
// model that request was dispatched to.
|
|
2153
|
-
let pendingRoute = null
|
|
2154
|
-
let stopInsufficient = null
|
|
2155
|
-
|
|
2156
|
-
/** Publish a new snapshot only when the mark set really changed. */
|
|
2157
|
-
const publishInsufficient = () => {
|
|
2158
|
-
insufficientSnapshot = { marks: insufficientMarks }
|
|
2159
|
-
for (const listener of insufficientListeners) listener()
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
/** Read the durable entries out of the current window snapshot. */
|
|
2163
|
-
const entriesOf = window => (Array.isArray(window?.entries) ? window.entries : [])
|
|
2164
|
-
|
|
2165
|
-
/**
|
|
2166
|
-
* Fold one batch of durable entries into the marks. Live chunks carry
|
|
2167
|
-
* no `turn/end`, so they are skipped by type inside the fold.
|
|
2168
|
-
* @param entries - durable event entries from the feed.
|
|
2169
|
-
* @returns whether any mark changed.
|
|
2170
|
-
*/
|
|
2171
|
-
const absorb = entries => {
|
|
2172
|
-
let changed = false
|
|
2173
|
-
for (const entry of entries) {
|
|
2174
|
-
const event = entry?.event
|
|
2175
|
-
if (event === null || event === undefined) continue
|
|
2176
|
-
const header = routeOfHeader(event)
|
|
2177
|
-
if (header !== null) {
|
|
2178
|
-
pendingRoute = header
|
|
2179
|
-
continue
|
|
2180
|
-
}
|
|
2181
|
-
if (event.type !== 'turn/end') continue
|
|
2182
|
-
if (foldInsufficient(insufficientMarks, event, pendingRoute)) changed = true
|
|
2183
|
-
}
|
|
2184
|
-
return changed
|
|
2185
|
-
}
|
|
2186
|
-
|
|
2187
|
-
/**
|
|
2188
|
-
* Attach the feed listener. A `replace` (initial load, history
|
|
2189
|
-
* replacement) refolds from scratch, because the window it describes
|
|
2190
|
-
* is the whole truth; an `append` folds only the new entries. A
|
|
2191
|
-
* `prepend` loads OLDER history at the front and cannot change a mark
|
|
2192
|
-
* (marks come from the tail), so it is ignored rather than refolded —
|
|
2193
|
-
* refolding would also re-walk history on every page-in.
|
|
2194
|
-
*/
|
|
2195
|
-
const startInsufficientFeed = () => {
|
|
2196
|
-
if (stopInsufficient !== null) return
|
|
2197
|
-
let binding
|
|
2591
|
+
// Attach this Session's feed as soon as its seat exists, not only
|
|
2592
|
+
// when a subscriber appears: the seat always subscribes, but the
|
|
2593
|
+
// face is built before the first render, and a feed that is already
|
|
2594
|
+
// live should contribute its history immediately.
|
|
2595
|
+
if (markStore !== undefined) {
|
|
2198
2596
|
try {
|
|
2199
|
-
|
|
2597
|
+
markStore.watch(sessionId)
|
|
2200
2598
|
} catch {
|
|
2201
|
-
|
|
2202
|
-
}
|
|
2203
|
-
const source = binding?.eventSource
|
|
2204
|
-
if (source === undefined || typeof source.subscribe !== 'function'
|
|
2205
|
-
|| typeof source.getSnapshot !== 'function') return
|
|
2206
|
-
/** Whether two mark maps hold the same entries. */
|
|
2207
|
-
const sameMarks = (left, right) => {
|
|
2208
|
-
if (left.size !== right.size) return false
|
|
2209
|
-
for (const [key, value] of left) {
|
|
2210
|
-
if (right.get(key) !== value) return false
|
|
2211
|
-
}
|
|
2212
|
-
return true
|
|
2213
|
-
}
|
|
2214
|
-
const applyWindow = (window, full) => {
|
|
2215
|
-
const change = window?.change
|
|
2216
|
-
if (full || change?.kind === 'replace') {
|
|
2217
|
-
const previous = insufficientMarks
|
|
2218
|
-
const next = new Map()
|
|
2219
|
-
insufficientMarks = next
|
|
2220
|
-
pendingRoute = null
|
|
2221
|
-
absorb(entriesOf(window))
|
|
2222
|
-
// Adopt the refolded map as the snapshot either way, so the
|
|
2223
|
-
// snapshot and the live state never diverge; only NOTIFY when
|
|
2224
|
-
// the marks really changed, so a history replacement that
|
|
2225
|
-
// re-derives the same marks does not repaint the panel.
|
|
2226
|
-
insufficientSnapshot = { marks: next }
|
|
2227
|
-
if (!sameMarks(previous, next)) publishInsufficient()
|
|
2228
|
-
return
|
|
2229
|
-
}
|
|
2230
|
-
if (change?.kind === 'append' && absorb(change.entries)) publishInsufficient()
|
|
2231
|
-
}
|
|
2232
|
-
try {
|
|
2233
|
-
applyWindow(source.getSnapshot(), true)
|
|
2234
|
-
} catch {
|
|
2235
|
-
/* an unreadable feed leaves the panel unmarked, never broken */
|
|
2236
|
-
}
|
|
2237
|
-
const listener = () => {
|
|
2238
|
-
try {
|
|
2239
|
-
applyWindow(source.getSnapshot(), false)
|
|
2240
|
-
} catch {
|
|
2241
|
-
/* a fold failure must not escape into the slot */
|
|
2242
|
-
}
|
|
2243
|
-
}
|
|
2244
|
-
try {
|
|
2245
|
-
const stop = source.subscribe(listener)
|
|
2246
|
-
stopInsufficient = typeof stop === 'function' ? stop : () => {}
|
|
2247
|
-
} catch {
|
|
2248
|
-
stopInsufficient = null
|
|
2599
|
+
/* the store degrades to storage-only state */
|
|
2249
2600
|
}
|
|
2250
2601
|
}
|
|
2251
2602
|
|
|
2252
|
-
startInsufficientFeed()
|
|
2253
|
-
|
|
2254
|
-
/**
|
|
2255
|
-
* The seat's read face for the marks. `subscribe` and `getSnapshot`
|
|
2256
|
-
* are the `useSyncExternalStore` pair; the snapshot object is stable
|
|
2257
|
-
* between real transitions.
|
|
2258
|
-
*/
|
|
2259
|
-
const insufficient = {
|
|
2260
|
-
subscribe: listener => {
|
|
2261
|
-
insufficientListeners.add(listener)
|
|
2262
|
-
// The binding may have materialized after the face was built.
|
|
2263
|
-
startInsufficientFeed()
|
|
2264
|
-
return () => { insufficientListeners.delete(listener) }
|
|
2265
|
-
},
|
|
2266
|
-
getSnapshot: () => insufficientSnapshot,
|
|
2267
|
-
/** Stop observing the feed; called when the seat's owner disposes. */
|
|
2268
|
-
dispose: () => {
|
|
2269
|
-
if (stopInsufficient !== null) stopInsufficient()
|
|
2270
|
-
stopInsufficient = null
|
|
2271
|
-
insufficientListeners.clear()
|
|
2272
|
-
},
|
|
2273
|
-
}
|
|
2274
|
-
|
|
2275
2603
|
return {
|
|
2276
2604
|
sessionId,
|
|
2277
2605
|
resolveDirectory: resolve,
|
|
2278
2606
|
safeRead,
|
|
2279
|
-
|
|
2607
|
+
// The GLOBAL mark store, shared by every seat of this plugin
|
|
2608
|
+
// generation. `subscribe` additionally attaches the store to this
|
|
2609
|
+
// Session's feed, so a Session that binds late (or after the page
|
|
2610
|
+
// loaded) still contributes its events.
|
|
2611
|
+
insufficient: markStore ?? EMPTY_INSUFFICIENT,
|
|
2280
2612
|
// `available: false` only for an addressed subagent Session, which
|
|
2281
2613
|
// the shipped seat also hides.
|
|
2282
2614
|
available: first.addressed !== true,
|
|
@@ -2336,11 +2668,7 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
2336
2668
|
// component's hook shape never changes and the rows simply stay
|
|
2337
2669
|
// unstruck. It shares the module-level empty snapshot so the
|
|
2338
2670
|
// identity stays stable across renders.
|
|
2339
|
-
insufficient:
|
|
2340
|
-
subscribe: () => () => {},
|
|
2341
|
-
getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
|
|
2342
|
-
dispose: () => {},
|
|
2343
|
-
},
|
|
2671
|
+
insufficient: EMPTY_INSUFFICIENT,
|
|
2344
2672
|
}
|
|
2345
2673
|
}
|
|
2346
2674
|
}
|
package/docs/DETAIL.md
CHANGED
|
@@ -77,8 +77,9 @@ and the agent teamwork switch are all visible at once.
|
|
|
77
77
|
side, otherwise the footer wraps back to the original two-line stack. The
|
|
78
78
|
break is decided by CSS flex wrapping and the 30 px `column-gap`, never by a
|
|
79
79
|
JS width threshold, so it stays correct across panel resizes, font loads and
|
|
80
|
-
both themes. The single grey rule
|
|
81
|
-
|
|
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).
|
|
82
83
|
The fixed height budget measures the footer container once, not its two
|
|
83
84
|
children, so a shared line is not double-counted against the grid.
|
|
84
85
|
- **Selected model.** The current model reads as a solid deep-blue band with
|
|
@@ -136,11 +137,31 @@ and the agent teamwork switch are all visible at once.
|
|
|
136
137
|
that failed for a different reason. A cancellation (`aborted`), a `blocked`
|
|
137
138
|
turn, and the synthetic `interrupted` / `forked` closers clear nothing —
|
|
138
139
|
the model never answered, so they say nothing about the account.
|
|
139
|
-
- **
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
144
165
|
- **Agent teamwork.** The switch keeps the host's semantics: the same label,
|
|
145
166
|
the same confirmation before disabling it mid-conversation, and no optimistic
|
|
146
167
|
update — the state comes from the `agentTeam` projection. The row lives in
|
|
@@ -157,9 +178,11 @@ Everything goes through public Client APIs:
|
|
|
157
178
|
- The directory store is subscribed through `useSyncExternalStore`, so a
|
|
158
179
|
catalog update — including one caused by a selection — repaints the panel.
|
|
159
180
|
- `ctx.sessions.binding(sessionId).eventSource` is the Session's contiguous
|
|
160
|
-
durable event window.
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
163
186
|
- `ctx.get('remote').agentTeams.setEnabled()` writes the teamwork selection.
|
|
164
187
|
- `ctx.locale` supplies the copy, resolves catalog badge text, and prefers the
|
|
165
188
|
host's own `agent-team` wording when that namespace is registered.
|
package/docs/model-picker.jpeg
CHANGED
|
Binary file
|
package/icon.svg
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
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
2
|
<title>Flat model picker</title>
|
|
3
|
-
<rect x="4" y="8" width="56" height="48" rx="10" fill="none" stroke="
|
|
4
|
-
<rect x="11" y="15" width="20" height="5" rx="2.5" fill="
|
|
5
|
-
<rect x="35" y="15" width="18" height="5" rx="2.5" fill="
|
|
6
|
-
<rect x="11" y="25" width="20" height="5" rx="2.5" fill="
|
|
7
|
-
<rect x="35" y="25" width="18" height="5" rx="2.5" fill="
|
|
8
|
-
<rect x="11" y="35" width="20" height="5" rx="2.5" fill="
|
|
9
|
-
<rect x="35" y="35" width="18" height="5" rx="2.5" fill="
|
|
10
|
-
<rect x="11" y="45" width="42" height="4" rx="2" fill="
|
|
3
|
+
<rect x="4" y="8" width="56" height="48" rx="10" fill="none" stroke="#3B82F6" stroke-width="3"/>
|
|
4
|
+
<rect x="11" y="15" width="20" height="5" rx="2.5" fill="#3B82F6"/>
|
|
5
|
+
<rect x="35" y="15" width="18" height="5" rx="2.5" fill="#60A5FA"/>
|
|
6
|
+
<rect x="11" y="25" width="20" height="5" rx="2.5" fill="#3B82F6"/>
|
|
7
|
+
<rect x="35" y="25" width="18" height="5" rx="2.5" fill="#60A5FA"/>
|
|
8
|
+
<rect x="11" y="35" width="20" height="5" rx="2.5" fill="#3B82F6"/>
|
|
9
|
+
<rect x="35" y="35" width="18" height="5" rx="2.5" fill="#60A5FA"/>
|
|
10
|
+
<rect x="11" y="45" width="42" height="4" rx="2" fill="#93C5FD"/>
|
|
11
11
|
</svg>
|
package/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Host half of the flat model picker bundle.
|
|
3
3
|
*
|
|
4
|
-
* The plugin is
|
|
5
|
-
* seat with a flat panel rendered
|
|
6
|
-
*
|
|
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.
|
|
7
8
|
* The Host therefore contributes no service, tool or event.
|
|
8
9
|
*/
|
|
9
10
|
export const name = 'dsh-model-flatmap'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model-flatmap",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Flat, two-level-free model picker for the DSH composer: every model and reasoning level on one surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
"icon.svg"
|
|
20
20
|
],
|
|
21
21
|
"dsh": {
|
|
22
|
-
"bundle": {
|
|
22
|
+
"bundle": {
|
|
23
|
+
"patch": "./cordis.patch.yml"
|
|
24
|
+
},
|
|
23
25
|
"client": {
|
|
24
26
|
"platform": "web",
|
|
25
27
|
"immediately": true
|