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 +21 -0
- package/README.md +62 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +286 -0
- package/lib/index.js +9 -0
- package/lib/route-metadata.js +213 -0
- package/package.json +55 -0
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
|
package/cordis.patch.yml
ADDED
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
|
+
}
|