dsh-per-message-model 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-per-message-model contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # dsh-per-message-model
2
+
3
+ 在 DSH Web GUI 主对话中,每条 assistant 回复旁显示该条回复实际使用的模型徽章,并在会话中发生模型切换时渲染嵌入消息流的切换分割线。
4
+
5
+ 纯展示插件:不改写、不拦截任何模型请求;不做 token 计价、余额或厂商 logo。
6
+
7
+ ## 效果
8
+
9
+ - **消息级芯片图标**:每条已完成的 assistant 消息动作行(copy / branch 按钮之后、时间之前)显示一个小芯片图标;鼠标悬停(`title`)显示该条消息实际使用的完整 `provider / model · 思考 effort`。
10
+ - **会话 Header**:会话动作区显示最近一条 assistant 消息的模型徽章。
11
+ - **模型切换分割线**:当相邻 assistant 消息的 provider/model 发生变化时,在**真正切换的位置**(切换源消息之后、下一条消息之前,即消息中途切换点,而非整条消息末尾)渲染一条横贯消息流的粗分割线:`模型切换:[旧模型] → [新模型]`。文案固定中文,"模型切换:"为灰色、箭头为自渲染 SVG 图片(灰色粗箭头)、两个模型名为更深更粗的黑色。分割线像模型输出一样嵌入消息流,随滚动出现/消失,不固定于视口。
12
+ - 投影缺失时图标退化为「模型未知」(fail-soft,便于确认插件已接管)。
13
+
14
+ ## 数据来源(服务端投影)
15
+
16
+ `lib/route-metadata.js` 注册只读 projection key `perMessageModel`,折叠会话事件:
17
+
18
+ | 事件 | 字段 | 用途 |
19
+ |---|---|---|
20
+ | `assistant/message` | `data.message.id` / `data.message.source.{provider,model}` | 消息级 provider/model(内核强制携带,消息级最准确) |
21
+ | `request/header` | `data.header.config.reasoningEffort` / `data.header.adapterDefaults.reasoningEffort` | 请求级 effort,滚动关联到该请求产出的全部消息 |
22
+
23
+ 输出 `view = { messages: { [messageId]: {...} }, current: {...}, switches: [{ fromSeq, from, toSeq, to }] }`。
24
+ `switches` 记录相邻消息的模型切换边界(`fromSeq` = 旧模型消息 seq,`toSeq` = 新模型消息 seq)。
25
+ 客户端通过官方 `useProjection` 读取,不接触会话快照。
26
+
27
+ ## 挂载点(客户端,纯 additive)
28
+
29
+ | 槽位 | 用途 |
30
+ |---|---|
31
+ | `conversation.session.header.actions` | 会话 Header 当前模型徽章 |
32
+ | `conversation.chat.assistant-actions` | 每条 assistant 消息底部动作行芯片图标(CSS flex order 排到 branch 之后、时间之前) |
33
+ | `conversation.chat.turnTail` | 模型切换分割线(chain 槽位,`fromSeq` 匹配旧模型消息尾部) |
34
+
35
+ 三者均为官方扩展点:不 shadow、不替换任何官方组件。
36
+
37
+ ## 开发
38
+
39
+ ```sh
40
+ npm run check # node --check 三个 lib 文件
41
+ npm test # node --test(projection 单测 + manifest + client 静态断言)
42
+ ```
43
+
44
+ ## 安装
45
+
46
+ ```sh
47
+ npm pack
48
+ dsh plugin --profile web add ./dsh-per-message-model-<version>.tgz
49
+ # 重启 DSH 后生效
50
+ ```
51
+
52
+ 注意:升级版本后若 node_modules 中包文件未刷新(pnpm file: 依赖版本不变不重拷),需先 `dsh plugin remove dsh-per-message-model` 再 `add`,或手动同步 `node_modules/dsh-per-message-model/lib/*.js`。
53
+
54
+ ## 非目标
55
+
56
+ - 不拦截/改写模型请求(纯展示)。
57
+ - 不做 token 计价、余额、厂商 logo(dsh-token-usage / dsh-model-vendor-badge 的职责)。
58
+ - 不动子代理会话(子代理已有 dsh-subagent-route-badges)。
59
+
60
+ ## License
61
+
62
+ MIT
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-per-message-model
3
+ name: 'dsh-per-message-model'
package/lib/client.js ADDED
@@ -0,0 +1,286 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-per-message-model",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ const React = require("react");
8
+ const createElement = React.createElement;
9
+ //#region client constants
10
+ const NS = "dsh-per-message-model";
11
+ const BADGE_PREFIX = "dsh-per-message-model-";
12
+ const zh = {
13
+ "badge.unknown": "模型未知",
14
+ "badge.unknown.title": "模型元数据不可用(会话事件中未见对应 assistant/message)",
15
+ "badge.effort": "思考",
16
+ "badge.effort.default": "默认",
17
+ "badge.effort.adapterDefault": "适配器默认",
18
+ "badge.separator": " · "
19
+ };
20
+ const en = {
21
+ "badge.unknown": "model unknown",
22
+ "badge.unknown.title": "model metadata unavailable (no matching assistant/message seen in session events)",
23
+ "badge.effort": "thinking",
24
+ "badge.effort.default": "default",
25
+ "badge.effort.adapterDefault": "adapter default",
26
+ "badge.separator": " · "
27
+ };
28
+ //#endregion
29
+ //#region client icon
30
+ /** Compact chip icon: renders as a small model-chip glyph; the full
31
+ * `provider / model · 思考 effort` value lives in title/aria so hovering
32
+ * reveals the complete model name without a long text pill in the row. */
33
+ function chipIcon() {
34
+ return createElement("svg", {
35
+ width: "12",
36
+ height: "12",
37
+ viewBox: "0 0 12 12",
38
+ fill: "none",
39
+ "aria-hidden": "true",
40
+ style: { display: "block" }
41
+ }, [
42
+ createElement("rect", {
43
+ key: "body",
44
+ x: "1.5",
45
+ y: "1.5",
46
+ width: "9",
47
+ height: "9",
48
+ rx: "1.5",
49
+ stroke: "currentColor",
50
+ strokeWidth: "1.1"
51
+ }),
52
+ createElement("rect", {
53
+ key: "core",
54
+ x: "4",
55
+ y: "4",
56
+ width: "4",
57
+ height: "4",
58
+ rx: "0.8",
59
+ fill: "currentColor"
60
+ }),
61
+ createElement("path", {
62
+ key: "pin1",
63
+ d: "M6 1.5V.5M6 11.5v-1M1.5 6H.5M11.5 6h-1M3.2 1.8 2.5 1.1M9.5 10.9l-.7-.7M8.8 1.8l.7-.7M2.5 10.9l.7-.7",
64
+ stroke: "currentColor",
65
+ strokeWidth: "1",
66
+ strokeLinecap: "round"
67
+ })
68
+ ]);
69
+ }
70
+ /**
71
+ * Per-message model badge as a small icon button. Missing projections
72
+ * degrade to the same icon with an unknown title (fail-soft, verifiable).
73
+ * @param value - a wire projection entry ({ provider, model, reasoningEffort, reasoningSource }).
74
+ * @param t - locale seat bound to this plugin's namespace.
75
+ */
76
+ function modelBadge(value, t) {
77
+ const known = value !== null && typeof value === "object";
78
+ const provider = known && typeof value.provider === "string" ? value.provider : null;
79
+ const model = known && typeof value.model === "string" ? value.model : null;
80
+ const hasModel = provider !== null && model !== null;
81
+ const label = hasModel
82
+ ? `${provider} / ${model}${t("badge.separator")}${t("badge.effort")} ${known && typeof value.reasoningEffort === "string" && value.reasoningEffort.length > 0 ? value.reasoningEffort : t("badge.effort.default")}`
83
+ : t("badge.unknown");
84
+ const title = hasModel && known && value.reasoningSource === "adapter-default"
85
+ ? `${label}(${t("badge.effort.adapterDefault")})`
86
+ : hasModel ? label : t("badge.unknown.title");
87
+ return createElement("span", {
88
+ className: BADGE_PREFIX + "icon",
89
+ "data-dsh-per-message-model": "true",
90
+ "aria-label": label,
91
+ title
92
+ }, chipIcon());
93
+ }
94
+ /** Session-header occupant: current model badge (latest folded assistant message). */
95
+ function SessionModelBadge({ useProjection, t }) {
96
+ const value = useProjection("perMessageModel");
97
+ const current = value === void 0 || value === null || typeof value !== "object" || value.current === void 0
98
+ ? void 0
99
+ : value.current;
100
+ return modelBadge(current, t);
101
+ }
102
+ /** Assistant-actions occupant: per-message model icon, keyed by messageId
103
+ * (renders inside the official per-message action row, reordered after the
104
+ * branch button and before the time clock via CSS flex order). */
105
+ function PerMessageModelBadge({ messageId, useProjection, t }) {
106
+ const value = useProjection("perMessageModel");
107
+ const entry = value === void 0 || value === null || typeof value !== "object" || value.messages === void 0
108
+ ? void 0
109
+ : value.messages[messageId];
110
+ return modelBadge(entry, t);
111
+ }
112
+ //#endregion
113
+ //#region client divider
114
+ /** Fixed Chinese copy for the model-switch divider (user-required). */
115
+ const SWITCH_COPY = "模型切换:";
116
+ const ASSISTANT_STEP_KEY_PREFIX = "14:assistant-step";
117
+ /**
118
+ * Build the divider DOM node (plain DOM, not React): inserted directly
119
+ * after the assistant message that PRECEDES a model switch, so it lands at
120
+ * the exact switch point mid-stream instead of the turn tail.
121
+ */
122
+ function buildDividerNode(sw) {
123
+ const div = document.createElement("div");
124
+ div.setAttribute("data-dsh-model-switch", "true");
125
+ const label = document.createElement("span");
126
+ label.className = BADGE_PREFIX + "divider-label";
127
+ label.textContent = SWITCH_COPY;
128
+ div.appendChild(label);
129
+ const from = sw.from !== null && typeof sw.from === "object"
130
+ ? `${sw.from.provider} / ${sw.from.model}`
131
+ : null;
132
+ if (from !== null) {
133
+ const fromEl = document.createElement("span");
134
+ fromEl.className = BADGE_PREFIX + "divider-model";
135
+ fromEl.textContent = from;
136
+ div.appendChild(fromEl);
137
+ }
138
+ const arrow = document.createElement("span");
139
+ arrow.className = BADGE_PREFIX + "divider-arrow";
140
+ // arrowSvg returns a React element; build the SVG DOM node instead.
141
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
142
+ svg.setAttribute("width", "14");
143
+ svg.setAttribute("height", "12");
144
+ svg.setAttribute("viewBox", "0 0 14 12");
145
+ svg.setAttribute("fill", "none");
146
+ svg.setAttribute("aria-hidden", "true");
147
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
148
+ path.setAttribute("d", "M1 6h10.5M7.5 1.5 12 6l-4.5 4.5");
149
+ path.setAttribute("stroke", "currentColor");
150
+ path.setAttribute("stroke-width", "1.8");
151
+ path.setAttribute("stroke-linecap", "round");
152
+ path.setAttribute("stroke-linejoin", "round");
153
+ svg.appendChild(path);
154
+ arrow.appendChild(svg);
155
+ div.appendChild(arrow);
156
+ const toEl = document.createElement("span");
157
+ toEl.className = BADGE_PREFIX + "divider-model";
158
+ toEl.textContent = `${sw.to.provider} / ${sw.to.model}`;
159
+ div.appendChild(toEl);
160
+ return div;
161
+ }
162
+ /**
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).
172
+ */
173
+ function ModelSwitchDivider({ turn, useProjection }) {
174
+ const value = useProjection("perMessageModel");
175
+ const switches = value === void 0 || value === null || typeof value !== "object" || !Array.isArray(value.switches)
176
+ ? []
177
+ : 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
+ React.useEffect(() => {
182
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return;
183
+ const inserted = [];
184
+ 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
+ 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;
198
+ const node = buildDividerNode(sw);
199
+ anchor.insertAdjacentElement("afterend", node);
200
+ inserted.push(node);
201
+ }
202
+ return remaining;
203
+ };
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
+ }
218
+ return () => {
219
+ if (observer !== null) observer.disconnect();
220
+ for (const node of inserted) node.remove();
221
+ };
222
+ }, [switches, turnId]);
223
+ return null;
224
+ }
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
+ //#endregion
235
+ //#region client plugin
236
+ /** Required services for the additive seats. */
237
+ const inject = [
238
+ "slots",
239
+ "locale"
240
+ ];
241
+ function apply(ctx) {
242
+ const badgeStyle = document.createElement("style");
243
+ badgeStyle.dataset.dshPerMessageModel = BADGE_PREFIX;
244
+ badgeStyle.textContent = [
245
+ // Per-message icon: small chip, full value in title/aria.
246
+ `[data-dsh-per-message-model]{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;margin:0 2px;color:var(--dsw-alias-label-tertiary);border-radius:5px;cursor:default}`,
247
+ `[data-dsh-per-message-model]:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base-2, var(--dsw-alias-bg-hover, rgba(128,128,128,.12)))}`,
248
+ // Reorder inside the official action row: copy(0) branch(0) icon(3) clock(4)
249
+ `[data-dsh-per-message-model]{order:3}`,
250
+ `div[class*="actions"]:has([data-slot="conversation.chat.assistant-actions"]) [class*="timeEnd"]{order:4}`,
251
+ // In-stream divider: full-width block, embeds in the message flow.
252
+ // Label (模型切换:) and arrow are gray; model names are darker/bolder.
253
+ `[data-dsh-model-switch]{display:flex;align-items:center;gap:8px;margin:2px 0 10px;padding:8px 12px;border-top:2px solid var(--dsw-alias-border-strong, var(--dsw-alias-border-l2, rgba(128,128,128,.35)));color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-base-2, transparent);border-radius:8px;font-size:12px;line-height:18px;user-select:none}`,
254
+ `[data-dsh-model-switch] [class*="-divider-label"]{flex:none;color:var(--dsw-alias-label-tertiary);font-weight:600}`,
255
+ `[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)}`
257
+ ].join("\n");
258
+ document.head.appendChild(badgeStyle);
259
+ ctx.effect(() => () => badgeStyle.remove(), "per-message-model: css");
260
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "per-message-model: dictionaries");
261
+ ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
262
+ name: "conversation.session.header.actions",
263
+ id: "dsh-per-message-model-header",
264
+ locale: NS,
265
+ order: 80
266
+ }, SessionModelBadge));
267
+ ctx.slots.inject("conversation.chat.assistant-actions", () => ctx.slots.register({
268
+ name: "conversation.chat.assistant-actions",
269
+ id: "dsh-per-message-model-badge",
270
+ locale: NS,
271
+ order: 40
272
+ }, PerMessageModelBadge));
273
+ ctx.slots.inject("conversation.chat.turnTail", () => ctx.slots.register({
274
+ name: "conversation.chat.turnTail",
275
+ id: "dsh-per-message-model-switch",
276
+ locale: NS,
277
+ order: 60,
278
+ select: selectModelSwitch
279
+ }, ModelSwitchDivider));
280
+ }
281
+ //#endregion
282
+ exports.apply = apply;
283
+ exports.inject = inject;
284
+ return module.exports;
285
+ }
286
+ });
package/lib/index.js ADDED
@@ -0,0 +1,9 @@
1
+ import { definition } from './route-metadata.js';
2
+
3
+ /** Host half: a read-only projection; it never intercepts or changes requests. */
4
+ export const inject = ['sessionProjections'];
5
+
6
+ export function apply(ctx) {
7
+ ctx.sessionProjections.register(definition);
8
+ }
9
+ export { definition } from './route-metadata.js';
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Per-message model projection: fold session events into a
3
+ * messageId -> { provider, model, reasoningEffort, reasoningSource } map,
4
+ * a `current` entry (the latest folded assistant message), and a `switches`
5
+ * list of model-switch boundaries (adjacent assistant messages whose
6
+ * provider/model pair changed). The same projection drives the per-message
7
+ * badge, the session-header badge, and the in-stream model-switch divider.
8
+ *
9
+ * Data sources (both load-bearing, enforced by dsh-session at the seed/load
10
+ * boundary):
11
+ * - `assistant/message`: `data.message.id` (message identity) and
12
+ * `data.message.source.{provider,model}` (the message-level pair; the
13
+ * session kernel requires a model source with a provider/model pair on
14
+ * every assistant message, so a hit is authoritative for that message).
15
+ * - `request/header`: `data.header.config.{provider,model,reasoningEffort}`
16
+ * plus `data.header.adapterDefaults.reasoningEffort` (adapter-default
17
+ * marker). One request can produce several assistant messages, so the
18
+ * latest header's effort is carried forward to every message folded after
19
+ * it, until the next header supersedes it.
20
+ *
21
+ * This projection is read-only: it never intercepts, changes, or replays any
22
+ * model request. Clients read the wire view through `useProjection`.
23
+ */
24
+ const KEY = 'perMessageModel';
25
+
26
+ /** Fresh fold state: per-message entries, rolling request effort, latest entry, switches. */
27
+ const initialState = () => ({
28
+ messages: {},
29
+ effort: null,
30
+ effortSource: 'unspecified',
31
+ last: null,
32
+ switches: []
33
+ });
34
+
35
+ const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
36
+ const text = (v) => typeof v === 'string' && v.length > 0 ? v : null;
37
+
38
+ /** Resolve the current effort marker from a request header. */
39
+ function headerEffort(header) {
40
+ const config = header && record(header.config) ? header.config : null;
41
+ if (config === null) return { effort: null, effortSource: 'unspecified' };
42
+ const effort = text(config.reasoningEffort);
43
+ const defaults = record(header.adapterDefaults) ? header.adapterDefaults : null;
44
+ const effortSource = effort !== null && defaults?.reasoningEffort === true
45
+ ? 'adapter-default'
46
+ : effort !== null ? 'explicit' : 'unspecified';
47
+ return { effort, effortSource };
48
+ }
49
+
50
+ /** Same model pair (provider+model), or either missing — treated as a switch boundary. */
51
+ function sameRoute(a, b) {
52
+ if (a === null || b === null) return false;
53
+ return a.provider === b.provider && a.model === b.model;
54
+ }
55
+
56
+ function apply(state, event) {
57
+ if (!record(state) || !record(event) || typeof event.type !== 'string') return state;
58
+ if (event.type === 'request/header') {
59
+ const header = record(event.data) && record(event.data.header) ? event.data.header : null;
60
+ if (header === null) return state;
61
+ const { effort, effortSource } = headerEffort(header);
62
+ // Preserve message entries; only the request-level effort rolls forward.
63
+ return { ...state, effort, effortSource };
64
+ }
65
+ if (event.type === 'assistant/message') {
66
+ const data = record(event.data) ? event.data : null;
67
+ const message = data && record(data.message) ? data.message : null;
68
+ const source = message && record(message.source) ? message.source : null;
69
+ const id = text(message?.id);
70
+ const provider = text(source?.provider);
71
+ const model = text(source?.model);
72
+ if (id === null || provider === null || model === null) return state;
73
+ const turn = Number.isSafeInteger(data?.turn) ? data.turn : null;
74
+ const step = Number.isSafeInteger(data?.step) ? data.step : null;
75
+ const entry = {
76
+ provider,
77
+ model,
78
+ reasoningEffort: state.effort,
79
+ reasoningSource: state.effortSource,
80
+ turn,
81
+ step
82
+ };
83
+ const seq = Number.isSafeInteger(event.seq) ? event.seq : null;
84
+ const prev = record(state.last) ? { provider: text(state.last.provider), model: text(state.last.model) } : null;
85
+ let switches = state.switches;
86
+ if (seq !== null && !sameRoute(prev, { provider, model })) {
87
+ // A switch boundary: the previous assistant message (if any) ends the
88
+ // old route; this message begins the new route.
89
+ const from = prev !== null && prev.provider !== null && prev.model !== null
90
+ ? { provider: prev.provider, model: prev.model }
91
+ : null;
92
+ switches = [...switches, {
93
+ fromSeq: prev !== null && Number.isSafeInteger(state.lastSeq) ? state.lastSeq : null,
94
+ fromTurn: prev !== null && Number.isSafeInteger(state.lastTurn) ? state.lastTurn : null,
95
+ fromStep: prev !== null && Number.isSafeInteger(state.lastStep) ? state.lastStep : null,
96
+ from,
97
+ toSeq: seq,
98
+ toTurn: turn,
99
+ toStep: step,
100
+ to: { provider, model }
101
+ }];
102
+ }
103
+ return {
104
+ ...state,
105
+ messages: { ...state.messages, [id]: entry },
106
+ last: entry,
107
+ lastSeq: seq,
108
+ lastTurn: turn,
109
+ lastStep: step,
110
+ switches
111
+ };
112
+ }
113
+ return state;
114
+ }
115
+
116
+ /** Client-visible wire view: stable messageId map, latest entry, switch list. */
117
+ function view(state) {
118
+ if (!record(state) || !record(state.messages)) return null;
119
+ const messages = {};
120
+ for (const [id, entry] of Object.entries(state.messages)) {
121
+ if (!record(entry)) continue;
122
+ const provider = text(entry.provider);
123
+ const model = text(entry.model);
124
+ if (provider === null || model === null) continue;
125
+ messages[id] = {
126
+ provider,
127
+ model,
128
+ reasoningEffort: text(entry.reasoningEffort),
129
+ reasoningSource: ['explicit', 'adapter-default', 'unspecified'].includes(entry.reasoningSource)
130
+ ? entry.reasoningSource
131
+ : 'unspecified'
132
+ };
133
+ }
134
+ const last = record(state.last) ? state.last : null;
135
+ let current = null;
136
+ if (last !== null) {
137
+ const provider = text(last.provider);
138
+ const model = text(last.model);
139
+ if (provider !== null && model !== null) {
140
+ current = {
141
+ provider,
142
+ model,
143
+ reasoningEffort: text(last.reasoningEffort),
144
+ reasoningSource: ['explicit', 'adapter-default', 'unspecified'].includes(last.reasoningSource)
145
+ ? last.reasoningSource
146
+ : 'unspecified'
147
+ };
148
+ }
149
+ }
150
+ const switches = Array.isArray(state.switches)
151
+ ? state.switches.map((s) => ({
152
+ fromSeq: Number.isSafeInteger(s.fromSeq) ? s.fromSeq : null,
153
+ fromTurn: Number.isSafeInteger(s.fromTurn) ? s.fromTurn : null,
154
+ fromStep: Number.isSafeInteger(s.fromStep) ? s.fromStep : null,
155
+ from: record(s.from) && text(s.from.provider) !== null && text(s.from.model) !== null
156
+ ? { provider: text(s.from.provider), model: text(s.from.model) }
157
+ : null,
158
+ toSeq: Number.isSafeInteger(s.toSeq) ? s.toSeq : null,
159
+ toTurn: Number.isSafeInteger(s.toTurn) ? s.toTurn : null,
160
+ toStep: Number.isSafeInteger(s.toStep) ? s.toStep : null,
161
+ to: record(s.to) && text(s.to.provider) !== null && text(s.to.model) !== null
162
+ ? { provider: text(s.to.provider), model: text(s.to.model) }
163
+ : null
164
+ }))
165
+ : [];
166
+ return { messages, current, switches };
167
+ }
168
+
169
+ /** Validates an already-computed view (the host calls `parse(view(state))`); never re-derives it. */
170
+ const viewSchema = {
171
+ parse(value) {
172
+ if (value === null) return null;
173
+ if (!record(value) || !record(value.messages)) throw new Error('invalid per-message model metadata');
174
+ if (value.current !== null && !record(value.current)) throw new Error('invalid per-message model metadata');
175
+ if (!Array.isArray(value.switches)) throw new Error('invalid per-message model metadata');
176
+ for (const [id, entry] of Object.entries(value.messages)) {
177
+ if (typeof id !== 'string' || id.length === 0) throw new Error('invalid per-message model metadata');
178
+ if (!record(entry) || typeof entry.provider !== 'string' || typeof entry.model !== 'string') throw new Error('invalid per-message model metadata');
179
+ if (entry.reasoningEffort !== null && typeof entry.reasoningEffort !== 'string') throw new Error('invalid per-message model metadata');
180
+ if (!['explicit', 'adapter-default', 'unspecified'].includes(entry.reasoningSource)) throw new Error('invalid per-message model metadata');
181
+ }
182
+ if (value.current !== null) {
183
+ const c = value.current;
184
+ if (typeof c.provider !== 'string' || typeof c.model !== 'string') throw new Error('invalid per-message model metadata');
185
+ if (c.reasoningEffort !== null && typeof c.reasoningEffort !== 'string') throw new Error('invalid per-message model metadata');
186
+ if (!['explicit', 'adapter-default', 'unspecified'].includes(c.reasoningSource)) throw new Error('invalid per-message model metadata');
187
+ }
188
+ for (const s of value.switches) {
189
+ if (!record(s)) throw new Error('invalid per-message model metadata');
190
+ for (const seqField of ['fromSeq', 'toSeq']) {
191
+ if (s[seqField] !== null && !Number.isSafeInteger(s[seqField])) throw new Error('invalid per-message model metadata');
192
+ }
193
+ for (const posField of ['fromTurn', 'fromStep', 'toTurn', 'toStep']) {
194
+ if (s[posField] !== null && !Number.isSafeInteger(s[posField])) throw new Error('invalid per-message model metadata');
195
+ }
196
+ for (const side of [s.from, s.to]) {
197
+ if (side !== null && (!record(side) || typeof side.provider !== 'string' || typeof side.model !== 'string')) throw new Error('invalid per-message model metadata');
198
+ }
199
+ }
200
+ return value;
201
+ }
202
+ };
203
+
204
+ const definition = {
205
+ key: KEY,
206
+ stateVersion: 2,
207
+ stateSchema: { parse: (v) => record(v) ? v : initialState() },
208
+ init: initialState,
209
+ apply,
210
+ wire: { viewSchema, view }
211
+ };
212
+
213
+ export { KEY, initialState, headerEffort, apply, view, viewSchema, definition };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "dsh-per-message-model",
3
+ "version": "0.1.0",
4
+ "description": "在 DSH 主对话每条 assistant 回复旁展示该条回复实际使用的 provider / model 徽章(可选附带 reasoning effort)",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./route-metadata": "./lib/route-metadata.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib/*.js",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "LICENSE",
18
+ "package.json"
19
+ ],
20
+ "scripts": {
21
+ "check": "node --check lib/index.js && node --check lib/route-metadata.js && node --check lib/client.js",
22
+ "test": "node --test"
23
+ },
24
+ "keywords": [
25
+ "dsh-plugin",
26
+ "deepseek-harness",
27
+ "model",
28
+ "badge"
29
+ ],
30
+ "license": "MIT",
31
+ "dsh": {
32
+ "plugin": true,
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "inject": [
38
+ "@deepseek-ai/dsh-client-runtime",
39
+ "@deepseek-ai/dsh-client-ui-slots",
40
+ "@deepseek-ai/dsh-client-ui-conversation"
41
+ ],
42
+ "platform": "web"
43
+ }
44
+ },
45
+ "peerDependencies": {
46
+ "@deepseek-ai/cordis": ">=4.0.1",
47
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.1-rc.2",
48
+ "@deepseek-ai/dsh-client-ui-conversation": ">=0.1.1-rc.2",
49
+ "@deepseek-ai/dsh-session-projection": ">=0.1.1-rc.2",
50
+ "react": ">=18.2.0"
51
+ },
52
+ "engines": {
53
+ "node": ">=20"
54
+ }
55
+ }