dsh-model-flatmap 1.0.0 → 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.
- package/README.md +15 -8
- package/README.zh.md +9 -5
- package/client.js +516 -196
- package/docs/DETAIL.md +33 -10
- package/index.js +4 -3
- package/package.json +1 -1
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
|
@@ -118,16 +118,32 @@ window.__ModuleLoader__.load({
|
|
|
118
118
|
color: var(--dsw-alias-state-error-primary); font-size: 11px; line-height: 16px; overflow-wrap: anywhere;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
/* The only scrolling region.
|
|
122
|
-
|
|
121
|
+
/* The only scrolling region. Its "align-items: start" keeps the text columns
|
|
122
|
+
at their natural height; a divider laid out beside them opts back into
|
|
123
|
+
stretch so it spans the tallest column instead (see .dshmf-colDivider).
|
|
124
|
+
Every child is placed explicitly by grid-column (and the divider by
|
|
125
|
+
grid-row) — see the inline style on each one. Auto-placement must NOT be
|
|
126
|
+
relied on here: a divider that pins its row but not its column is placed
|
|
127
|
+
first, stealing column 1, and the columns then overflow to columns 2 and 3,
|
|
128
|
+
squashing the first text column. */
|
|
129
|
+
.dshmf-grid {
|
|
130
|
+
flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto;
|
|
131
|
+
display: grid; align-items: start;
|
|
132
|
+
}
|
|
123
133
|
.dshmf-col { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
124
134
|
/* When more than one column is laid out, separate adjacent columns with a
|
|
125
135
|
faint vertical hairline so the eye can tell the dense model flows apart.
|
|
126
|
-
The
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
The hairline is its OWN grid item, a sibling of the columns rather than a
|
|
137
|
+
border on one of them: as a border it could only ever be as tall as that
|
|
138
|
+
column's content. Here it sits in a dedicated gutter track and stretches to
|
|
139
|
+
the grid's full height, with a small margin top and bottom.
|
|
140
|
+
Only gutters that actually sit between two columns get one (see the grid
|
|
141
|
+
children), so a single-column layout stays borderless. */
|
|
142
|
+
.dshmf-colDivider {
|
|
143
|
+
grid-row: 1; align-self: stretch; justify-self: center;
|
|
144
|
+
width: 1px; margin: 6px 0;
|
|
145
|
+
background: var(--dsw-alias-border-l3);
|
|
146
|
+
}
|
|
131
147
|
/* Provider / family label: a filled, rounded band that reads as a section
|
|
132
148
|
header, clearly separated from the model rows below it. Bare host
|
|
133
149
|
group-header fill is only a few levels from the translucent panel surface.
|
|
@@ -240,30 +256,28 @@ window.__ModuleLoader__.load({
|
|
|
240
256
|
No JS width guessing, so it stays correct across panel resize, theme and
|
|
241
257
|
font changes. A max-width of 100% lets a child that ends up alone on a line
|
|
242
258
|
cap to the panel width and wrap internally instead of overflowing.
|
|
243
|
-
The
|
|
244
|
-
|
|
245
|
-
border
|
|
259
|
+
The divider sits on this container, not on a child (DEC-016): the two groups
|
|
260
|
+
can share a line or stack, and either way the rule belongs above the whole
|
|
261
|
+
footer block, never between them, so no child carries a border. Host border
|
|
262
|
+
tokens are faint by design, so the line uses explicit greys: medium grey on
|
|
263
|
+
the light panel, a lighter near-white grey on the dark panel. */
|
|
246
264
|
.dshmf-footer {
|
|
247
265
|
flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
|
|
248
266
|
column-gap: 30px; row-gap: 4px;
|
|
267
|
+
border-top: 1px solid #9CA3AF;
|
|
268
|
+
padding-top: 6px;
|
|
249
269
|
}
|
|
270
|
+
body[data-ds-dark-theme] .dshmf-footer,
|
|
271
|
+
html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
250
272
|
|
|
251
273
|
/* Reasoning levels: a caption and a multi-segmentation control on one line;
|
|
252
274
|
the track wraps below the caption only when the row cannot fit. The
|
|
253
275
|
segment visual mirrors the header's grouping control, so the panel has
|
|
254
276
|
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
277
|
.dshmf-reason {
|
|
260
278
|
flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
|
|
261
279
|
gap: 2px 6px; max-width: 100%;
|
|
262
|
-
border-top: 1px solid #9CA3AF;
|
|
263
|
-
padding-top: 6px;
|
|
264
280
|
}
|
|
265
|
-
body[data-ds-dark-theme] .dshmf-reason,
|
|
266
|
-
html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
267
281
|
.dshmf-reasonLabel {
|
|
268
282
|
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px;
|
|
269
283
|
white-space: nowrap;
|
|
@@ -568,37 +582,88 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
568
582
|
const CLEARING_TURN_ENDS = new Set(['completed', 'max-tokens', 'error'])
|
|
569
583
|
|
|
570
584
|
/**
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
585
|
+
* Wall-clock time one durable event was recorded at. The Client feed keeps
|
|
586
|
+
* the Host's `time` (`Date.now()` at append), which is what lets marks
|
|
587
|
+
* gathered from DIFFERENT Sessions be ordered against each other — a Session
|
|
588
|
+
* feed has no cross-Session sequence number.
|
|
589
|
+
* @param {unknown} event - one durable Session event.
|
|
590
|
+
* @returns {number} the event time, or 0 when the feed carried none.
|
|
591
|
+
*/
|
|
592
|
+
function eventTimeOf(event) {
|
|
593
|
+
const time = event?.time
|
|
594
|
+
return typeof time === 'number' && Number.isFinite(time) ? time : 0
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Decide what ONE durable event says about a model route's balance, with no
|
|
599
|
+
* state involved. This is the whole semantic core of the feature, kept
|
|
600
|
+
* separate from the bookkeeping so it can be pinned directly.
|
|
574
601
|
*
|
|
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}
|
|
602
|
+
* - A `turn/end` that failed on insufficient balance → `true` (mark).
|
|
603
|
+
* - A `turn/end` that proves the model answered otherwise → `false`
|
|
604
|
+
* (clear): a completed turn, a `max-tokens` turn, or a turn that failed
|
|
605
|
+
* for a different reason.
|
|
606
|
+
* - A cancellation (`aborted`), a `blocked` turn, and the synthetic
|
|
607
|
+
* `interrupted` / `forked` closers → `null` (no decision), because the
|
|
608
|
+
* model never answered and the account state is unknown.
|
|
609
|
+
* - Anything that is not a `turn/end`, or has no route → `null`.
|
|
610
|
+
* @param {unknown} event - one durable Session event.
|
|
611
|
+
* @param {{ provider: string, model: string } | null} route - the route that request used.
|
|
612
|
+
* @returns {boolean | null} mark, clear, or no decision.
|
|
586
613
|
*/
|
|
587
|
-
function
|
|
588
|
-
if (event === null || typeof event !== 'object') return
|
|
589
|
-
if (event.type !== 'turn/end' || route === null) return
|
|
614
|
+
function decideMark(event, route) {
|
|
615
|
+
if (event === null || typeof event !== 'object') return null
|
|
616
|
+
if (event.type !== 'turn/end' || route === null || route === undefined) return null
|
|
590
617
|
const reason = event.data?.reason
|
|
591
|
-
if (reason === null || typeof reason !== 'object') return
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
618
|
+
if (reason === null || typeof reason !== 'object') return null
|
|
619
|
+
if (reason.kind === 'error') return isInsufficientBalance(reason.error)
|
|
620
|
+
return CLEARING_TURN_ENDS.has(reason.kind) ? false : null
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Fold ONE decision into the mark map, last-writer-wins by event time.
|
|
625
|
+
*
|
|
626
|
+
* The time watermark is what makes the state GLOBAL and replay-safe: marks
|
|
627
|
+
* arrive from every Session's feed (and from storage written by an earlier
|
|
628
|
+
* page load), so the newest observation wins no matter which Session it came
|
|
629
|
+
* from. Re-walking an old window therefore cannot resurrect a mark that a
|
|
630
|
+
* later call already cleared, and a cleared route keeps a watermark so a
|
|
631
|
+
* stale replay stays a no-op.
|
|
632
|
+
* @param {Map<string, {marked: boolean, at: number}>} entries - state, mutated in place.
|
|
633
|
+
* @param {string} key - the route key.
|
|
634
|
+
* @param {boolean} marked - the decision this event carries.
|
|
635
|
+
* @param {number} at - the event's time.
|
|
636
|
+
* @returns {{ visible: boolean, stored: boolean }} whether the visible mark
|
|
637
|
+
* set changed, and whether the underlying record changed.
|
|
638
|
+
*/
|
|
639
|
+
function foldMarkEntry(entries, key, marked, at) {
|
|
640
|
+
const previous = entries.get(key)
|
|
641
|
+
if (previous !== undefined && at < previous.at) {
|
|
642
|
+
return { visible: false, stored: false }
|
|
597
643
|
}
|
|
598
|
-
if (
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
644
|
+
if (previous !== undefined && previous.marked === marked && previous.at === at) {
|
|
645
|
+
return { visible: false, stored: false }
|
|
646
|
+
}
|
|
647
|
+
entries.set(key, { marked, at })
|
|
648
|
+
return { visible: previous === undefined ? marked : previous.marked !== marked, stored: true }
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Fold one durable event into the mark map. Pure helper kept for the tests
|
|
653
|
+
* and for callers that hold their own map; the live store uses
|
|
654
|
+
* {@link decideMark} + {@link foldMarkEntry} so it can batch persistence.
|
|
655
|
+
* @param {Map<string, {marked: boolean, at: number}>} state - state, mutated in place.
|
|
656
|
+
* @param {unknown} event - one durable Session event.
|
|
657
|
+
* @param {{ provider: string, model: string } | null} route - the route that request used.
|
|
658
|
+
* @param {number} [at] - the event's time; defaults to `event.time`.
|
|
659
|
+
* @returns {boolean} whether the visible mark set changed.
|
|
660
|
+
*/
|
|
661
|
+
function foldInsufficient(state, event, route, at) {
|
|
662
|
+
const marked = decideMark(event, route)
|
|
663
|
+
if (marked === null) return false
|
|
664
|
+
const key = routeKeyOf(route.provider, route.model)
|
|
665
|
+
const time = at === undefined ? eventTimeOf(event) : at
|
|
666
|
+
return foldMarkEntry(state, key, marked, time).visible
|
|
602
667
|
}
|
|
603
668
|
|
|
604
669
|
/**
|
|
@@ -1014,6 +1079,320 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
1014
1079
|
}
|
|
1015
1080
|
}
|
|
1016
1081
|
|
|
1082
|
+
// ---------------------------------------------------------------------
|
|
1083
|
+
// Insufficient-balance marks are GLOBAL, not Session-scoped.
|
|
1084
|
+
//
|
|
1085
|
+
// Whether a model has any quota left is a fact about the model route (and
|
|
1086
|
+
// the account behind it), not about one conversation: a failure observed in
|
|
1087
|
+
// one Session must strike the same model everywhere — another Session,
|
|
1088
|
+
// another workspace, and after a reload. So the marks live in ONE
|
|
1089
|
+
// module-level store shared by every seat of this plugin generation, and
|
|
1090
|
+
// are persisted to `localStorage` (origin-scoped, exactly like the grouping
|
|
1091
|
+
// choice) so a fresh page load starts from the last known state.
|
|
1092
|
+
//
|
|
1093
|
+
// Ordering across Sessions is by wall-clock time: each durable event keeps
|
|
1094
|
+
// the Host's `time`, and the newest observation of a route wins. That is
|
|
1095
|
+
// what lets a replayed window (or a stored entry) never override a newer
|
|
1096
|
+
// fact from another Session.
|
|
1097
|
+
// ---------------------------------------------------------------------
|
|
1098
|
+
const MARKS_KEY = 'dshmf:insufficient'
|
|
1099
|
+
/** Marks older than this are dropped on read, so a stale strike cannot live forever. */
|
|
1100
|
+
const MARKS_TTL = 7 * 24 * 60 * 60 * 1000
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Parse a persisted mark record into the in-memory shape, dropping anything
|
|
1104
|
+
* malformed or older than {@link MARKS_TTL}. Pure, so the storage format is
|
|
1105
|
+
* testable without a `localStorage` shim.
|
|
1106
|
+
* @param {unknown} raw - the stored JSON value (already parsed).
|
|
1107
|
+
* @param {number} now - current time, injected for testability.
|
|
1108
|
+
* @returns {Map<string, {marked: boolean, at: number}>} the parsed entries.
|
|
1109
|
+
*/
|
|
1110
|
+
function parseMarks(raw, now) {
|
|
1111
|
+
const entries = new Map()
|
|
1112
|
+
if (raw === null || typeof raw !== 'object') return entries
|
|
1113
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
1114
|
+
if (typeof key !== 'string' || key === '') continue
|
|
1115
|
+
if (value === null || typeof value !== 'object') continue
|
|
1116
|
+
const at = typeof value.at === 'number' && Number.isFinite(value.at) ? value.at : 0
|
|
1117
|
+
if (typeof value.marked !== 'boolean') continue
|
|
1118
|
+
if (now - at > MARKS_TTL) continue
|
|
1119
|
+
entries.set(key, { marked: value.marked, at })
|
|
1120
|
+
}
|
|
1121
|
+
return entries
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Serialise the in-memory entries for storage, dropping anything older than
|
|
1126
|
+
* {@link MARKS_TTL} so a long-lived page cannot grow the record forever. A
|
|
1127
|
+
* cleared route keeps its watermark only while it can still protect against
|
|
1128
|
+
* a replayed older failure, which is the same window.
|
|
1129
|
+
* @param {Map<string, {marked: boolean, at: number}>} entries - the state.
|
|
1130
|
+
* @param {number} [now] - current time, injected for testability.
|
|
1131
|
+
* @returns {Record<string, {marked: boolean, at: number}>} a JSON-ready record.
|
|
1132
|
+
*/
|
|
1133
|
+
function serializeMarks(entries, now) {
|
|
1134
|
+
const time = now === undefined ? Date.now() : now
|
|
1135
|
+
const out = {}
|
|
1136
|
+
for (const [key, value] of entries) {
|
|
1137
|
+
if (time - value.at > MARKS_TTL) continue
|
|
1138
|
+
out[key] = { marked: value.marked, at: value.at }
|
|
1139
|
+
}
|
|
1140
|
+
return out
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Read the persisted marks, or an empty map when storage is unavailable,
|
|
1145
|
+
* holds garbage, or throws (private mode, sandboxed frame, quota).
|
|
1146
|
+
* @returns {Map<string, {marked: boolean, at: number}>} the stored entries.
|
|
1147
|
+
*/
|
|
1148
|
+
function readMarks() {
|
|
1149
|
+
try {
|
|
1150
|
+
if (typeof localStorage === 'undefined') return new Map()
|
|
1151
|
+
const text = localStorage.getItem(MARKS_KEY)
|
|
1152
|
+
if (text === null || text === '') return new Map()
|
|
1153
|
+
return parseMarks(JSON.parse(text), Date.now())
|
|
1154
|
+
} catch {
|
|
1155
|
+
return new Map()
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Persist the marks. A write failure is swallowed: the in-memory store still
|
|
1161
|
+
* drives every open panel in this page.
|
|
1162
|
+
* @param {Map<string, {marked: boolean, at: number}>} entries - the state.
|
|
1163
|
+
*/
|
|
1164
|
+
function writeMarks(entries) {
|
|
1165
|
+
try {
|
|
1166
|
+
if (typeof localStorage === 'undefined') return
|
|
1167
|
+
localStorage.setItem(MARKS_KEY, JSON.stringify(serializeMarks(entries)))
|
|
1168
|
+
} catch {
|
|
1169
|
+
/* storage unavailable — keep the in-memory state only */
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** Whether two visible mark maps hold the same entries. */
|
|
1174
|
+
function sameMarks(left, right) {
|
|
1175
|
+
if (left.size !== right.size) return false
|
|
1176
|
+
for (const key of left) {
|
|
1177
|
+
if (!right.has(key)) return false
|
|
1178
|
+
}
|
|
1179
|
+
return true
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Create the one global mark store for this module generation.
|
|
1184
|
+
*
|
|
1185
|
+
* It owns the state, the listeners, and the Session-feed subscriptions; the
|
|
1186
|
+
* seats only read it. Observing EVERY Session (not just the one a seat
|
|
1187
|
+
* renders for) is what makes a failure recorded anywhere visible
|
|
1188
|
+
* everywhere, including in a Session whose own feed was never opened in this
|
|
1189
|
+
* page load (its marks come from storage).
|
|
1190
|
+
*
|
|
1191
|
+
* @param {object} deps - resolution hooks.
|
|
1192
|
+
* @param {() => object | undefined} deps.sessionsOf - read the `sessions` service, per call.
|
|
1193
|
+
* @param {() => Map<string, {marked: boolean, at: number}>} deps.readStored - initial/persisted state.
|
|
1194
|
+
* @param {(entries: Map<string, {marked: boolean, at: number}>) => void} deps.writeStored - persistence sink.
|
|
1195
|
+
* @returns {object} the store: `subscribe`, `getSnapshot`, `dispose`, `sync`.
|
|
1196
|
+
*/
|
|
1197
|
+
function createMarkStore(deps) {
|
|
1198
|
+
/** Internal truth: route key → last decision and when it was made. */
|
|
1199
|
+
const entries = new Map(deps.readStored())
|
|
1200
|
+
/** Visible subset the rows read: route key → true. */
|
|
1201
|
+
let visible = new Map()
|
|
1202
|
+
let snapshot = { marks: visible }
|
|
1203
|
+
const listeners = new Set()
|
|
1204
|
+
/** Per-Session route of the latest request header (a route is Session-local). */
|
|
1205
|
+
const routes = new Map()
|
|
1206
|
+
/** Per-Session stop handle for its event feed. */
|
|
1207
|
+
const stops = new Map()
|
|
1208
|
+
/** Sessions attached on behalf of a mounted seat; never pruned by a list sync. */
|
|
1209
|
+
const pinned = new Set()
|
|
1210
|
+
/** Last `sessions` service seen, so a reloaded service is re-attached. */
|
|
1211
|
+
let attachedSessions
|
|
1212
|
+
let stopList
|
|
1213
|
+
let disposed = false
|
|
1214
|
+
|
|
1215
|
+
/** Rebuild the visible map; report whether the rendered set changed. */
|
|
1216
|
+
const recompute = () => {
|
|
1217
|
+
const next = new Map()
|
|
1218
|
+
for (const [key, entry] of entries) {
|
|
1219
|
+
if (entry.marked) next.set(key, true)
|
|
1220
|
+
}
|
|
1221
|
+
if (sameMarks(visible, next)) return false
|
|
1222
|
+
visible = next
|
|
1223
|
+
snapshot = { marks: visible }
|
|
1224
|
+
return true
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
recompute()
|
|
1228
|
+
|
|
1229
|
+
const publish = () => {
|
|
1230
|
+
for (const listener of listeners) listener()
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** Fold one batch of durable entries for one Session. */
|
|
1234
|
+
const absorb = (sessionId, windowEntries) => {
|
|
1235
|
+
let stored = false
|
|
1236
|
+
let changed = false
|
|
1237
|
+
for (const entry of windowEntries) {
|
|
1238
|
+
const event = entry?.event
|
|
1239
|
+
if (event === null || event === undefined) continue
|
|
1240
|
+
const header = routeOfHeader(event)
|
|
1241
|
+
if (header !== null) {
|
|
1242
|
+
routes.set(sessionId, header)
|
|
1243
|
+
continue
|
|
1244
|
+
}
|
|
1245
|
+
if (event.type !== 'turn/end') continue
|
|
1246
|
+
const route = routes.get(sessionId) ?? null
|
|
1247
|
+
const marked = decideMark(event, route)
|
|
1248
|
+
if (marked === null) continue
|
|
1249
|
+
const result = foldMarkEntry(
|
|
1250
|
+
entries, routeKeyOf(route.provider, route.model), marked, eventTimeOf(event),
|
|
1251
|
+
)
|
|
1252
|
+
if (result.stored) stored = true
|
|
1253
|
+
if (result.visible) changed = true
|
|
1254
|
+
}
|
|
1255
|
+
return { stored, changed }
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** Read a window's entries defensively. */
|
|
1259
|
+
const entriesOf = window => (Array.isArray(window?.entries) ? window.entries : [])
|
|
1260
|
+
|
|
1261
|
+
/** Apply one feed window for one Session. */
|
|
1262
|
+
const applyWindow = (sessionId, window, full) => {
|
|
1263
|
+
const change = window?.change
|
|
1264
|
+
let result
|
|
1265
|
+
if (full || change?.kind === 'replace') {
|
|
1266
|
+
// The window is that Session's whole truth, so its route restarts and
|
|
1267
|
+
// its entries are re-walked. Global watermarks keep a newer fact from
|
|
1268
|
+
// another Session from being overwritten by this replay.
|
|
1269
|
+
routes.delete(sessionId)
|
|
1270
|
+
result = absorb(sessionId, entriesOf(window))
|
|
1271
|
+
} else if (change?.kind === 'append') {
|
|
1272
|
+
result = absorb(sessionId, change.entries)
|
|
1273
|
+
} else {
|
|
1274
|
+
// `prepend` pages in OLDER history and `settle-assistant` only swaps a
|
|
1275
|
+
// live attempt for its durable form; neither can change a mark.
|
|
1276
|
+
return
|
|
1277
|
+
}
|
|
1278
|
+
if (result.stored) deps.writeStored(entries)
|
|
1279
|
+
if (result.changed && recompute()) publish()
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Attach to one Session's durable feed. Idempotent, and a no-op when the
|
|
1284
|
+
* Session has no retained binding (an unopened Session has no feed; its
|
|
1285
|
+
* marks can only come from storage).
|
|
1286
|
+
*/
|
|
1287
|
+
const attachSession = sessionId => {
|
|
1288
|
+
if (disposed || stops.has(sessionId)) return
|
|
1289
|
+
const sessions = deps.sessionsOf()
|
|
1290
|
+
let binding
|
|
1291
|
+
try {
|
|
1292
|
+
binding = typeof sessions?.binding === 'function' ? sessions.binding(sessionId) : undefined
|
|
1293
|
+
} catch {
|
|
1294
|
+
binding = undefined
|
|
1295
|
+
}
|
|
1296
|
+
const source = binding?.eventSource
|
|
1297
|
+
if (source === undefined || typeof source.subscribe !== 'function'
|
|
1298
|
+
|| typeof source.getSnapshot !== 'function') return
|
|
1299
|
+
try {
|
|
1300
|
+
applyWindow(sessionId, source.getSnapshot(), true)
|
|
1301
|
+
} catch {
|
|
1302
|
+
/* an unreadable feed leaves the store as it was */
|
|
1303
|
+
}
|
|
1304
|
+
const listener = () => {
|
|
1305
|
+
try {
|
|
1306
|
+
applyWindow(sessionId, source.getSnapshot(), false)
|
|
1307
|
+
} catch {
|
|
1308
|
+
/* a fold failure must not escape into the slot */
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
try {
|
|
1312
|
+
const stop = source.subscribe(listener)
|
|
1313
|
+
stops.set(sessionId, typeof stop === 'function' ? stop : () => {})
|
|
1314
|
+
} catch {
|
|
1315
|
+
/* subscription refused: leave this Session unattached */
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** Attach to every Session the list currently names, and drop the rest. */
|
|
1320
|
+
const sync = () => {
|
|
1321
|
+
if (disposed) return
|
|
1322
|
+
const sessions = deps.sessionsOf()
|
|
1323
|
+
if (sessions === undefined) return
|
|
1324
|
+
// A client reload replaces the service; re-attach from the new one.
|
|
1325
|
+
if (sessions !== attachedSessions) {
|
|
1326
|
+
for (const stop of stops.values()) stop()
|
|
1327
|
+
stops.clear()
|
|
1328
|
+
routes.clear()
|
|
1329
|
+
if (typeof stopList === 'function') stopList()
|
|
1330
|
+
stopList = undefined
|
|
1331
|
+
attachedSessions = sessions
|
|
1332
|
+
try {
|
|
1333
|
+
if (sessions.list !== undefined && typeof sessions.list.subscribe === 'function') {
|
|
1334
|
+
stopList = sessions.list.subscribe(() => { sync() })
|
|
1335
|
+
}
|
|
1336
|
+
} catch {
|
|
1337
|
+
stopList = undefined
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
let ids = []
|
|
1341
|
+
try {
|
|
1342
|
+
const list = sessions.list?.getSnapshot?.()
|
|
1343
|
+
ids = Array.isArray(list?.ids) ? list.ids : []
|
|
1344
|
+
} catch {
|
|
1345
|
+
ids = []
|
|
1346
|
+
}
|
|
1347
|
+
for (const id of ids) attachSession(id)
|
|
1348
|
+
// Drop feeds for Sessions the list no longer names, unless a mounted
|
|
1349
|
+
// seat is currently reading that Session (it may not be listed yet).
|
|
1350
|
+
for (const id of [...stops.keys()]) {
|
|
1351
|
+
if (pinned.has(id)) continue
|
|
1352
|
+
if (!ids.includes(id)) {
|
|
1353
|
+
stops.get(id)?.()
|
|
1354
|
+
stops.delete(id)
|
|
1355
|
+
routes.delete(id)
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
return {
|
|
1361
|
+
/** The seat's `useSyncExternalStore` pair. */
|
|
1362
|
+
subscribe: listener => {
|
|
1363
|
+
listeners.add(listener)
|
|
1364
|
+
sync()
|
|
1365
|
+
return () => { listeners.delete(listener) }
|
|
1366
|
+
},
|
|
1367
|
+
getSnapshot: () => snapshot,
|
|
1368
|
+
/** Re-attach (services may bind after the plugin activated). */
|
|
1369
|
+
sync,
|
|
1370
|
+
/**
|
|
1371
|
+
* Pin and immediately attach one Session's feed, for a mounted seat.
|
|
1372
|
+
* The list may not name this Session yet (or at all), so it is attached
|
|
1373
|
+
* directly instead of waiting for a list sync.
|
|
1374
|
+
*/
|
|
1375
|
+
watch: sessionId => {
|
|
1376
|
+
if (disposed) return
|
|
1377
|
+
pinned.add(sessionId)
|
|
1378
|
+
sync()
|
|
1379
|
+
attachSession(sessionId)
|
|
1380
|
+
},
|
|
1381
|
+
/** Release every feed subscription. The marks themselves stay persisted. */
|
|
1382
|
+
dispose: () => {
|
|
1383
|
+
if (disposed) return
|
|
1384
|
+
disposed = true
|
|
1385
|
+
for (const stop of stops.values()) stop()
|
|
1386
|
+
stops.clear()
|
|
1387
|
+
routes.clear()
|
|
1388
|
+
pinned.clear()
|
|
1389
|
+
if (typeof stopList === 'function') stopList()
|
|
1390
|
+
stopList = undefined
|
|
1391
|
+
listeners.clear()
|
|
1392
|
+
},
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1017
1396
|
const dict = {
|
|
1018
1397
|
zh: {
|
|
1019
1398
|
'provider.account': 'DeepSeek 账号',
|
|
@@ -1101,7 +1480,7 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
1101
1480
|
|
|
1102
1481
|
/**
|
|
1103
1482
|
* Shared empty mark map. One frozen instance keeps `getSnapshot` referentially
|
|
1104
|
-
* stable for every seat that has no
|
|
1483
|
+
* stable for every seat that has no store, so an unmarked panel never
|
|
1105
1484
|
* re-renders because of this feature.
|
|
1106
1485
|
*/
|
|
1107
1486
|
const EMPTY_INSUFFICIENT_MARKS = new Map()
|
|
@@ -1115,13 +1494,13 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
1115
1494
|
const EMPTY_INSUFFICIENT_SNAPSHOT = { marks: EMPTY_INSUFFICIENT_MARKS }
|
|
1116
1495
|
|
|
1117
1496
|
/**
|
|
1118
|
-
* Stand-in mark face used
|
|
1119
|
-
*
|
|
1497
|
+
* Stand-in mark face used when the store degraded. It never notifies and
|
|
1498
|
+
* always answers the same empty snapshot.
|
|
1120
1499
|
*/
|
|
1121
1500
|
const EMPTY_INSUFFICIENT = {
|
|
1122
1501
|
subscribe: () => () => {},
|
|
1123
1502
|
getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
|
|
1124
|
-
|
|
1503
|
+
watch: () => {},
|
|
1125
1504
|
}
|
|
1126
1505
|
|
|
1127
1506
|
// ---------------------------------------------------------------------
|
|
@@ -1150,18 +1529,20 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
1150
1529
|
const getSnapshot = useCallback(() => store.getSnapshot(), [store])
|
|
1151
1530
|
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
1152
1531
|
|
|
1153
|
-
// Per-model insufficient-balance marks,
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1532
|
+
// Per-model insufficient-balance marks, read from the ONE global store so
|
|
1533
|
+
// a failure recorded in any Session or workspace strikes the model here
|
|
1534
|
+
// too. The fallback keeps the hook shape stable when the face is omitted.
|
|
1156
1535
|
const markFace = insufficient ?? EMPTY_INSUFFICIENT
|
|
1157
1536
|
const subscribeMarks = useCallback(listener => markFace.subscribe(listener), [markFace])
|
|
1158
1537
|
const getMarks = useCallback(() => markFace.getSnapshot(), [markFace])
|
|
1159
1538
|
const marks = useSyncExternalStore(subscribeMarks, getMarks, getMarks)
|
|
1160
1539
|
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
useEffect(() =>
|
|
1540
|
+
// Keep the store attached to this Session's feed while the seat is mounted.
|
|
1541
|
+
// It is shared, so unmounting must NOT dispose it — the marks belong to the
|
|
1542
|
+
// page, not to this seat.
|
|
1543
|
+
useEffect(() => {
|
|
1544
|
+
if (typeof markFace.watch === 'function') markFace.watch(sessionId)
|
|
1545
|
+
}, [markFace, sessionId])
|
|
1165
1546
|
|
|
1166
1547
|
const [open, setOpen] = useState(false)
|
|
1167
1548
|
const [query, setQuery] = useState('')
|
|
@@ -1661,11 +2042,44 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
1661
2042
|
}, block.label)
|
|
1662
2043
|
: renderRow(block.entry))
|
|
1663
2044
|
|
|
2045
|
+
// Each child is placed EXPLICITLY (`grid-column` below), never by
|
|
2046
|
+
// auto-placement: a divider that pinned its row but not its column was
|
|
2047
|
+
// laid out first, took column 1, and pushed the columns into columns 2
|
|
2048
|
+
// and 3 — which squashed the first text column to 1px and stacked the
|
|
2049
|
+
// columns on top of each other. Track 2k+1 holds column k (0-based) and
|
|
2050
|
+
// track 2k+2 the gutter that follows it, so a 3-column layout is
|
|
2051
|
+
// col gutter col gutter col.
|
|
1664
2052
|
const renderColumn = (column, columnIndex) => h('div', {
|
|
1665
|
-
className: 'dshmf-col'
|
|
2053
|
+
className: 'dshmf-col',
|
|
1666
2054
|
key: `col-${columnIndex}`,
|
|
2055
|
+
style: { gridColumn: String(columnIndex * 2 + 1), gridRow: '1' },
|
|
1667
2056
|
}, column.map(unit => unit.blocks.map(renderBlock)))
|
|
1668
2057
|
|
|
2058
|
+
// Vertical separators are siblings of the columns, not a border on one
|
|
2059
|
+
// of them: a border can only ever be as tall as its own column, while a
|
|
2060
|
+
// grid item can stretch to the whole grid height (see .dshmf-colDivider).
|
|
2061
|
+
// Called with the index of the column that PRECEDES the gutter.
|
|
2062
|
+
const renderDivider = dividerIndex => h('div', {
|
|
2063
|
+
className: 'dshmf-colDivider',
|
|
2064
|
+
role: 'presentation',
|
|
2065
|
+
key: `gutter-${dividerIndex}`,
|
|
2066
|
+
style: { gridColumn: String(dividerIndex * 2), gridRow: '1' },
|
|
2067
|
+
})
|
|
2068
|
+
|
|
2069
|
+
const renderColumns = () => {
|
|
2070
|
+
const children = []
|
|
2071
|
+
plan.columns.forEach((column, columnIndex) => {
|
|
2072
|
+
if (columnIndex > 0) children.push(renderDivider(columnIndex))
|
|
2073
|
+
children.push(renderColumn(column, columnIndex))
|
|
2074
|
+
})
|
|
2075
|
+
return children
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// `minmax(0, 1fr)` per column, a hairline track per gutter between them.
|
|
2079
|
+
const gridTemplateColumns = plan.columns
|
|
2080
|
+
.map(() => 'minmax(0, 1fr)')
|
|
2081
|
+
.join(' 1px ')
|
|
2082
|
+
|
|
1669
2083
|
const renderFailure = failure => h('div', {
|
|
1670
2084
|
className: 'dshmf-failure',
|
|
1671
2085
|
key: `failure-${failure.id}`,
|
|
@@ -1819,13 +2233,15 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
1819
2233
|
h('div', {
|
|
1820
2234
|
className: 'dshmf-grid',
|
|
1821
2235
|
style: {
|
|
1822
|
-
gridTemplateColumns:
|
|
2236
|
+
gridTemplateColumns: showEmptyState
|
|
2237
|
+
? 'minmax(0, 1fr)'
|
|
2238
|
+
: gridTemplateColumns,
|
|
1823
2239
|
columnGap: `${COL_GAP}px`,
|
|
1824
2240
|
},
|
|
1825
2241
|
},
|
|
1826
2242
|
showEmptyState
|
|
1827
2243
|
? h('div', { className: 'dshmf-empty', role: 'status' }, t(emptyKey))
|
|
1828
|
-
:
|
|
2244
|
+
: renderColumns(),
|
|
1829
2245
|
state.failures.length === 0 ? null : state.failures.map(renderFailure),
|
|
1830
2246
|
),
|
|
1831
2247
|
|
|
@@ -2042,7 +2458,7 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
2042
2458
|
// Pure layout and grouping derivations plus the component itself,
|
|
2043
2459
|
// exposed for `node --test` so grouping, filtering and the column
|
|
2044
2460
|
// 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 },
|
|
2461
|
+
__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
2462
|
__component: ModelFlatMap,
|
|
2047
2463
|
__seat: Seat,
|
|
2048
2464
|
apply(ctx) {
|
|
@@ -2099,6 +2515,33 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
2099
2515
|
}
|
|
2100
2516
|
}
|
|
2101
2517
|
|
|
2518
|
+
// ---- the GLOBAL insufficient-balance store
|
|
2519
|
+
//
|
|
2520
|
+
// Created once per activation and shared by every seat, because the
|
|
2521
|
+
// balance state belongs to the model route (and its account), not to one
|
|
2522
|
+
// conversation or workspace. It observes every Session the list names,
|
|
2523
|
+
// persists to `localStorage`, and starts from what the last page load
|
|
2524
|
+
// recorded. A failure to build it degrades to an empty face rather than
|
|
2525
|
+
// costing the control.
|
|
2526
|
+
let markStore
|
|
2527
|
+
try {
|
|
2528
|
+
markStore = createMarkStore({
|
|
2529
|
+
sessionsOf: () => {
|
|
2530
|
+
try {
|
|
2531
|
+
return typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
|
|
2532
|
+
} catch {
|
|
2533
|
+
return undefined
|
|
2534
|
+
}
|
|
2535
|
+
},
|
|
2536
|
+
readStored: readMarks,
|
|
2537
|
+
writeStored: writeMarks,
|
|
2538
|
+
})
|
|
2539
|
+
ctx.effect(() => () => { markStore.dispose() }, 'dsh-model-flatmap: balance marks')
|
|
2540
|
+
} catch (error) {
|
|
2541
|
+
console.error('dsh-model-flatmap: the balance mark store failed to start', error)
|
|
2542
|
+
markStore = undefined
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2102
2545
|
/**
|
|
2103
2546
|
* Build the injected face for one Session.
|
|
2104
2547
|
* @param sessionId - the Session this seat renders for.
|
|
@@ -2137,146 +2580,27 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
2137
2580
|
}
|
|
2138
2581
|
const first = resolve()
|
|
2139
2582
|
|
|
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
|
|
2583
|
+
// Attach this Session's feed as soon as its seat exists, not only
|
|
2584
|
+
// when a subscriber appears: the seat always subscribes, but the
|
|
2585
|
+
// face is built before the first render, and a feed that is already
|
|
2586
|
+
// live should contribute its history immediately.
|
|
2587
|
+
if (markStore !== undefined) {
|
|
2198
2588
|
try {
|
|
2199
|
-
|
|
2589
|
+
markStore.watch(sessionId)
|
|
2200
2590
|
} 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
|
|
2591
|
+
/* the store degrades to storage-only state */
|
|
2249
2592
|
}
|
|
2250
2593
|
}
|
|
2251
2594
|
|
|
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
2595
|
return {
|
|
2276
2596
|
sessionId,
|
|
2277
2597
|
resolveDirectory: resolve,
|
|
2278
2598
|
safeRead,
|
|
2279
|
-
|
|
2599
|
+
// The GLOBAL mark store, shared by every seat of this plugin
|
|
2600
|
+
// generation. `subscribe` additionally attaches the store to this
|
|
2601
|
+
// Session's feed, so a Session that binds late (or after the page
|
|
2602
|
+
// loaded) still contributes its events.
|
|
2603
|
+
insufficient: markStore ?? EMPTY_INSUFFICIENT,
|
|
2280
2604
|
// `available: false` only for an addressed subagent Session, which
|
|
2281
2605
|
// the shipped seat also hides.
|
|
2282
2606
|
available: first.addressed !== true,
|
|
@@ -2336,11 +2660,7 @@ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
|
2336
2660
|
// component's hook shape never changes and the rows simply stay
|
|
2337
2661
|
// unstruck. It shares the module-level empty snapshot so the
|
|
2338
2662
|
// identity stays stable across renders.
|
|
2339
|
-
insufficient:
|
|
2340
|
-
subscribe: () => () => {},
|
|
2341
|
-
getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
|
|
2342
|
-
dispose: () => {},
|
|
2343
|
-
},
|
|
2663
|
+
insufficient: EMPTY_INSUFFICIENT,
|
|
2344
2664
|
}
|
|
2345
2665
|
}
|
|
2346
2666
|
}
|
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/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'
|