dsh-per-message-model 0.1.1 → 0.1.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.
Files changed (2) hide show
  1. package/lib/client.js +107 -60
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -110,6 +110,58 @@ window.__ModuleLoader__.load({
110
110
  return modelBadge(entry, t);
111
111
  }
112
112
  //#endregion
113
+ //#region client footer
114
+ /**
115
+ * Composer-dock model button: shows the LAST model actually used by the
116
+ * session (the folded `current` entry, i.e. the latest assistant message's
117
+ * provider/model), not the composer's selected default. Hover keeps the
118
+ * existing chip behavior (color change + title); a single click rolls the
119
+ * model name out from left to right like a scroll (icon rotates by a random
120
+ * angle); clicking again rolls it back (icon rotates back).
121
+ */
122
+ function FooterModelBadge({ useProjection, t }) {
123
+ const [open, setOpen] = React.useState(false);
124
+ const [rot, setRot] = React.useState(0);
125
+ const value = useProjection("perMessageModel");
126
+ const current = value === void 0 || value === null || typeof value !== "object" || value.current === void 0
127
+ ? void 0
128
+ : value.current;
129
+ const known = current !== void 0 && typeof current === "object";
130
+ const provider = known && typeof current.provider === "string" ? current.provider : null;
131
+ const model = known && typeof current.model === "string" ? current.model : null;
132
+ const label = provider !== null && model !== null
133
+ ? provider + " / " + model
134
+ : t("badge.unknown");
135
+ const toggle = () => {
136
+ if (open) {
137
+ setOpen(false);
138
+ } else {
139
+ setRot(Math.round(90 + Math.random() * 180));
140
+ setOpen(true);
141
+ }
142
+ };
143
+ return createElement("button", {
144
+ type: "button",
145
+ className: BADGE_PREFIX + "footer",
146
+ "data-dsh-per-message-model-footer": "true",
147
+ "aria-label": label,
148
+ title: label,
149
+ "aria-expanded": open ? "true" : "false",
150
+ onClick: toggle
151
+ }, [
152
+ createElement("span", {
153
+ key: "icon",
154
+ className: BADGE_PREFIX + "footer-icon",
155
+ style: { transform: open ? "rotate(" + rot + "deg)" : "rotate(0deg)" }
156
+ }, chipIcon()),
157
+ createElement("span", {
158
+ key: "label",
159
+ className: BADGE_PREFIX + "footer-label",
160
+ "data-open": open ? "true" : "false"
161
+ }, label)
162
+ ]);
163
+ }
164
+ //#endregion
113
165
  //#region client divider
114
166
  /** Fixed Chinese copy for the model-switch divider (user-required). */
115
167
  const SWITCH_COPY = "模型切换:";
@@ -160,77 +212,61 @@ window.__ModuleLoader__.load({
160
212
  return div;
161
213
  }
162
214
  /**
163
- * In-stream model-switch divider. Mounted on the turnTail chain seat (the
164
- * only per-message-adjacent additive seat), but instead of rendering at the
165
- * turn tail it inserts the divider DOM node directly AFTER the assistant
166
- * message that PRECEDES the switch (fromTurn/fromStep the official
167
- * assistant-step chat 节点 key). The divider therefore lands at the exact
168
- * mid-stream switch point, scrolls with the conversation, and is never
169
- * fixed to the viewport. The component reads the live projection, so the
170
- * inserted node appears once the new route's message arrives.
171
- * @param turn - the current turn id (owner prop).
215
+ * In-stream model-switch divider. Session-scoped maintenance component
216
+ * (mounted on the always-alive session header seat, not a per-turn seat):
217
+ * it watches the live projection and keeps a divider node at EVERY switch
218
+ * boundary strictly BETWEEN the last call of the previous model
219
+ * (fromTurn/fromStep anchor) and the first call of the new model
220
+ * (toTurn/toStep anchor). It prefers inserting after the from-step node;
221
+ * when that node is absent (windowed history) it falls back to inserting
222
+ * before the to-step node. A MutationObserver continuously repairs
223
+ * dividers lost to windowing/re-render, so they survive scrolling and
224
+ * turn lifecycle. Nodes are deduplicated by the switch's fromSeq.
172
225
  */
173
- function ModelSwitchDivider({ turn, useProjection }) {
226
+ function ModelSwitchDivider({ useProjection }) {
174
227
  const value = useProjection("perMessageModel");
175
228
  const switches = value === void 0 || value === null || typeof value !== "object" || !Array.isArray(value.switches)
176
229
  ? []
177
230
  : value.switches;
178
- // The owner prop is the full turn location object; its numeric id is
179
- // exposed as `.turn` (matches the projection's fromTurn/toTurn).
180
- const turnId = turn !== null && typeof turn === "object" && Number.isSafeInteger(turn.turn) ? turn.turn : null;
181
231
  React.useEffect(() => {
182
232
  if (typeof document === "undefined" || typeof MutationObserver === "undefined") return;
183
- const inserted = [];
184
233
  let observer = null;
185
- const pending = switches.filter((sw) =>
186
- sw.fromTurn === turnId &&
187
- sw.fromStep !== void 0 && sw.fromStep !== null &&
188
- sw.to !== null && typeof sw.to === "object" &&
189
- typeof sw.to.provider === "string" && typeof sw.to.model === "string"
190
- );
191
234
  const tryInsert = () => {
192
- let remaining = 0;
193
- for (const sw of pending) {
194
- const key = ASSISTANT_STEP_KEY_PREFIX + sw.fromTurn + ":" + sw.fromStep;
195
- const anchor = document.querySelector('[data-chat-anchor-key="' + key + '"]');
196
- if (anchor === null) { remaining += 1; continue; }
197
- if (anchor.nextElementSibling !== null && anchor.nextElementSibling.hasAttribute("data-dsh-model-switch")) continue;
235
+ for (const sw of switches) {
236
+ if (sw.to === null || typeof sw.to !== "object" ||
237
+ typeof sw.to.provider !== "string" || typeof sw.to.model !== "string") continue;
238
+ if (sw.fromTurn === void 0 || sw.fromTurn === null ||
239
+ sw.fromStep === void 0 || sw.fromStep === null) continue;
240
+ const seqKey = Number.isSafeInteger(sw.fromSeq) ? sw.fromSeq : (sw.fromTurn + ":" + sw.fromStep);
241
+ const existing = document.querySelector('[data-dsh-model-switch][data-from-seq="' + seqKey + '"]');
242
+ if (existing !== null) continue;
243
+ const fromKey = ASSISTANT_STEP_KEY_PREFIX + sw.fromTurn + ":" + sw.fromStep;
244
+ const toKey = Number.isSafeInteger(sw.toTurn) && Number.isSafeInteger(sw.toStep)
245
+ ? ASSISTANT_STEP_KEY_PREFIX + sw.toTurn + ":" + sw.toStep
246
+ : null;
247
+ const fromAnchor = document.querySelector('[data-chat-anchor-key="' + fromKey + '"]');
248
+ const toAnchor = toKey !== null ? document.querySelector('[data-chat-anchor-key="' + toKey + '"]') : null;
249
+ const anchor = fromAnchor !== null ? fromAnchor : toAnchor;
250
+ if (anchor === null) continue;
198
251
  const node = buildDividerNode(sw);
199
- anchor.insertAdjacentElement("afterend", node);
200
- inserted.push(node);
252
+ node.setAttribute("data-from-seq", String(seqKey));
253
+ if (fromAnchor !== null) fromAnchor.insertAdjacentElement("afterend", node);
254
+ else toAnchor.insertAdjacentElement("beforebegin", node);
201
255
  }
202
- return remaining;
203
256
  };
204
- // First pass: the anchor nodes are usually already rendered. If any
205
- // are still missing (windowed/historical rendering), watch the chat
206
- // flow until they appear, then insert and stop watching.
207
- let remaining = tryInsert();
208
- if (remaining > 0 && inserted.length === 0) {
209
- observer = new MutationObserver(() => {
210
- const left = tryInsert();
211
- if (left === 0) {
212
- observer.disconnect();
213
- observer = null;
214
- }
215
- });
216
- observer.observe(document.body, { childList: true, subtree: true });
217
- }
257
+ tryInsert();
258
+ // Keep repairing: windowed conversation rendering mounts/unmounts
259
+ // chat nodes while scrolling, so dividers must be re-inserted on
260
+ // any childList change. The callback is cheap (per-switch
261
+ // querySelector + dedupe) and exits immediately once all exist.
262
+ observer = new MutationObserver(() => tryInsert());
263
+ observer.observe(document.body, { childList: true, subtree: true });
218
264
  return () => {
219
265
  if (observer !== null) observer.disconnect();
220
- for (const node of inserted) node.remove();
221
266
  };
222
- }, [switches, turnId]);
267
+ }, [switches]);
223
268
  return null;
224
269
  }
225
- /**
226
- * Chain selector: this occupant is the only turnTail registrant, so it is
227
- * always elected; the component itself decides whether to insert anything
228
- * (select cannot reach the projection store synchronously, so the reactive
229
- * read lives in the component via useProjection).
230
- */
231
- function selectModelSwitch() {
232
- return true;
233
- }
234
270
  //#endregion
235
271
  //#region client plugin
236
272
  /** Required services for the additive seats. */
@@ -250,10 +286,16 @@ window.__ModuleLoader__.load({
250
286
  `div[class*="actions"]:has([data-slot="conversation.chat.assistant-actions"]) [class*="timeEnd"]{order:4}`,
251
287
  // In-stream divider: full-width block, embeds in the message flow.
252
288
  // Label (模型切换:) and arrow are gray; model names are darker/bolder.
253
- `[data-dsh-model-switch]{display:flex;align-items:center;gap:8px;margin:10px 0 16px;padding:10px 14px;border:1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.30));border-left:4px solid var(--dsw-alias-state-business-primary, #4f8cff);border-radius:10px;background:var(--dsw-alias-bg-module-platform, rgba(128,128,128,.16));box-shadow:0 1px 3px rgba(0,0,0,.08);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;user-select:none}`,
289
+ `[data-dsh-model-switch]{display:flex;align-items:center;gap:8px;margin:10px 0 16px;padding:10px 14px;border:1.5px solid var(--dsw-alias-state-business-primary, #4f8cff);border-radius:10px;background:var(--dsw-alias-state-business-tertiary, rgba(79,140,255,.12));box-shadow:0 1px 3px rgba(0,0,0,.10);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;user-select:none}`,
254
290
  `[data-dsh-model-switch] [class*="-divider-label"]{flex:none;color:var(--dsw-alias-label-tertiary);font-weight:600}`,
255
291
  `[data-dsh-model-switch] [class*="-divider-model"]{flex:none;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary);font-weight:700}`,
256
- `[data-dsh-model-switch] [class*="-divider-arrow"]{flex:none;display:inline-flex;align-items:center;color:var(--dsw-alias-label-tertiary)}`
292
+ `[data-dsh-model-switch] [class*="-divider-arrow"]{flex:none;display:inline-flex;align-items:center;color:var(--dsw-alias-label-tertiary)}`,
293
+ // Composer-dock model button: chip icon + unfoldable label (scroll).
294
+ `[data-dsh-per-message-model-footer]{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));border-radius:8px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;max-width:100%;overflow:hidden}`
295
+ `[data-dsh-per-message-model-footer]:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12))}`
296
+ `[data-dsh-per-message-model-footer] [class*="-footer-icon"]{display:inline-flex;align-items:center;flex:none;transition:transform .45s cubic-bezier(.34,1.56,.64,1)}`
297
+ `[data-dsh-per-message-model-footer] [class*="-footer-label"]{display:inline-block;overflow:hidden;white-space:nowrap;max-width:0;opacity:0;transition:max-width .45s cubic-bezier(.4,0,.2,1),opacity .35s ease;font-size:12px;line-height:16px;color:var(--dsw-alias-label-primary);font-weight:600}`
298
+ `[data-dsh-per-message-model-footer] [class*="-footer-label"][data-open="true"]{max-width:360px;opacity:1}`
257
299
  ].join("\n");
258
300
  document.head.appendChild(badgeStyle);
259
301
  ctx.effect(() => () => badgeStyle.remove(), "per-message-model: css");
@@ -270,13 +312,18 @@ window.__ModuleLoader__.load({
270
312
  locale: NS,
271
313
  order: 40
272
314
  }, PerMessageModelBadge));
273
- ctx.slots.inject("conversation.chat.turnTail", () => ctx.slots.register({
274
- name: "conversation.chat.turnTail",
315
+ ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
316
+ name: "conversation.session.header.actions",
275
317
  id: "dsh-per-message-model-switch",
276
318
  locale: NS,
277
- order: 60,
278
- select: selectModelSwitch
319
+ order: 90
279
320
  }, ModelSwitchDivider));
321
+ ctx.slots.inject("conversation.input.dock", () => ctx.slots.register({
322
+ name: "conversation.input.dock",
323
+ id: "dsh-per-message-model-footer",
324
+ locale: NS,
325
+ order: 90
326
+ }, FooterModelBadge));
280
327
  }
281
328
  //#endregion
282
329
  exports.apply = apply;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-per-message-model",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "在 DSH 主对话每条 assistant 回复旁展示该条回复实际使用的 provider / model 徽章(可选附带 reasoning effort)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",