dsh-expert-group 0.1.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/LICENSE +38 -0
- package/README.md +355 -0
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/assets/notification-whoosh.wav +0 -0
- package/bin/prompt-inspector.mjs +63 -0
- package/client-bundle.js +14891 -0
- package/client-expert-group-model.js +426 -0
- package/client-expert-group-sidebar.js +5401 -0
- package/client-freshness.js +364 -0
- package/client-model-config.js +2155 -0
- package/client-notifications.js +38 -0
- package/client.js +6487 -0
- package/cordis.patch.yml +10 -0
- package/fanout-host.js +1507 -0
- package/fanout.js +2981 -0
- package/host/composition.js +155 -0
- package/host/expert-group-runtime.js +822 -0
- package/host/expert-group-snapshot.js +450 -0
- package/host/fanout-runtime.js +822 -0
- package/host/handoff.js +80 -0
- package/host/lineage.js +274 -0
- package/host/notifications.js +189 -0
- package/host/persistence-health.js +389 -0
- package/host/recovery.js +242 -0
- package/host/rpc-transport.js +573 -0
- package/host/settings-runtime.js +168 -0
- package/host/settings-source.js +265 -0
- package/host/storage-binding.js +94 -0
- package/host/system-toast.js +916 -0
- package/image-routing.js +263 -0
- package/index.js +9327 -0
- package/install.ps1 +219 -0
- package/orchestrator.js +239 -0
- package/package.json +142 -0
- package/prompt-contracts.js +148 -0
- package/prompt-inspector.js +584 -0
- package/prompt-sidecar.js +610 -0
- package/resource-budget.js +540 -0
- package/role-registry.js +286 -0
- package/rpc-host.js +310 -0
- package/scripts/assemble-client.mjs +605 -0
- package/scripts/build.mjs +124 -0
- package/shared.js +4003 -0
- package/storage.js +190 -0
- package/uninstall.ps1 +47 -0
package/image-routing.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图片路由与 PNG 规范化的无依赖纯函数。
|
|
3
|
+
*
|
|
4
|
+
* 此模块不访问浏览器、文件系统或 DSH 私有实现;Host 仅把公开 attachments
|
|
5
|
+
* 服务的 read/save 包装为回调传入,因此所有边界均可独立 Node 测试。
|
|
6
|
+
* @module dsh-expert-group/image-routing
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** PNG 文件签名;转码回调的结果必须通过此校验,禁止把 JPEG/WebP/GIF 仅改为 .png 后缀。 */
|
|
10
|
+
export const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 图片上传输入的客户端错误(HTTP 400 语义)。
|
|
14
|
+
*
|
|
15
|
+
* 与 ImageTranscodeUnavailableError(宿主能力缺失 => 503)区分:这里的问题完全来自
|
|
16
|
+
* 客户端提交的字节——声明的 mediaType 与真实字节不符、字节不是有效 base64 等。
|
|
17
|
+
* Host 的 RPC 层按 code/validation 标记映射为 400 invalid-selection。
|
|
18
|
+
*/
|
|
19
|
+
export class ImageInputError extends Error {
|
|
20
|
+
constructor(message, { code = "INVALID_IMAGE_INPUT", ...details } = {}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "ImageInputError";
|
|
23
|
+
this.code = code;
|
|
24
|
+
// validation=true 让 RPC 错误映射无需依赖具体类身份即可识别为客户端输入问题。
|
|
25
|
+
this.validation = true;
|
|
26
|
+
Object.assign(this, details);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 非 PNG 图片缺少真实转码器时的明确、可恢复失败。调用方必须保留原始输入。 */
|
|
31
|
+
export class ImageTranscodeUnavailableError extends Error {
|
|
32
|
+
constructor(mediaType) {
|
|
33
|
+
super(`无法将 ${String(mediaType)} 转换为 PNG:当前插件没有已声明的图片转码器;已保留原始图片,未创建伪造的 .png 副本。`);
|
|
34
|
+
this.name = "ImageTranscodeUnavailableError";
|
|
35
|
+
this.code = "IMAGE_TRANSCODER_UNAVAILABLE";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 只接受 DSH 当前支持的四种图片 MIME;未知值在路由层保持显式失败。 */
|
|
40
|
+
const IMAGE_MEDIA_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
|
41
|
+
|
|
42
|
+
function bytesOf(value, label) {
|
|
43
|
+
if (!(value instanceof Uint8Array)) throw new TypeError(`${label} 必须是 Uint8Array`);
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasPngSignature(data) {
|
|
48
|
+
return data.byteLength >= PNG_SIGNATURE.byteLength
|
|
49
|
+
&& PNG_SIGNATURE.every((byte, index) => data[index] === byte);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasDeclaredImageSignature(mediaType, data) {
|
|
53
|
+
if (mediaType === "image/png") return hasPngSignature(data);
|
|
54
|
+
if (mediaType === "image/jpeg") {
|
|
55
|
+
return data.byteLength >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
|
|
56
|
+
}
|
|
57
|
+
if (mediaType === "image/webp") {
|
|
58
|
+
return data.byteLength >= 12
|
|
59
|
+
&& data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46
|
|
60
|
+
&& data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50;
|
|
61
|
+
}
|
|
62
|
+
if (mediaType === "image/gif") {
|
|
63
|
+
return data.byteLength >= 6
|
|
64
|
+
&& data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46
|
|
65
|
+
&& data[3] === 0x38 && (data[4] === 0x37 || data[4] === 0x39) && data[5] === 0x61;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 判断一个用户可见名称是否带文件扩展名。opaque attachmentId 不是文件名,
|
|
73
|
+
* 因此没有 name 或 basename 不含有效后缀时均返回 false。
|
|
74
|
+
*/
|
|
75
|
+
export function hasFileExtension(name) {
|
|
76
|
+
if (typeof name !== "string") return false;
|
|
77
|
+
const base = name.trim().split(/[\\/]/).pop() ?? "";
|
|
78
|
+
const dot = base.lastIndexOf(".");
|
|
79
|
+
return dot > 0 && dot < base.length - 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 用安全、稳定的显示名称记录无扩展名输入生成的 PNG durable reference。 */
|
|
83
|
+
export function pngCopyName(name) {
|
|
84
|
+
if (typeof name !== "string" || name.trim().length === 0) return "image.png";
|
|
85
|
+
const base = name.trim().split(/[\\/]/).pop() ?? "image";
|
|
86
|
+
const stem = base.replace(/[\x00-\x1f<>:\"|?*]/g, "_").replace(/\.+$/, "");
|
|
87
|
+
return (stem.length > 0 ? stem : "image") + ".png";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 纯函数决定图片是否需要 PNG 副本以及副本操作类型。
|
|
92
|
+
* unknown mediaType 仍返回 transcode 分支,由规范化流程做显式校验/拒绝。
|
|
93
|
+
*/
|
|
94
|
+
export function decidePngCopy({ mediaType }) {
|
|
95
|
+
// name 只是显示元数据;DSH durable attachmentId 没有可供 read_image 使用的扩展名,
|
|
96
|
+
// 所以任何进入 Observer 的持久图片都必须建立 PNG 副本。
|
|
97
|
+
return { copy: true, operation: mediaType === "image/png" ? "copy-bytes" : "transcode" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 将缺少扩展名的图片规范化为一个新的 PNG durable reference。
|
|
102
|
+
* image/png 只复制经过签名校验的原始字节;其他格式必须由调用方提供真实转码器。
|
|
103
|
+
* 此函数不会修改传入的 attachment ref,也不会写任意宿主路径。
|
|
104
|
+
*/
|
|
105
|
+
export async function toPngBytes({ mediaType, data, transcodeToPng }) {
|
|
106
|
+
if (!IMAGE_MEDIA_TYPES.has(mediaType)) throw new TypeError("attachment mediaType 无效");
|
|
107
|
+
const source = bytesOf(data, "图片字节");
|
|
108
|
+
if (!hasDeclaredImageSignature(mediaType, source)) {
|
|
109
|
+
// 客户端输入问题:声明的 mediaType 与真实字节不符。必须显式 400,不得创建副本。
|
|
110
|
+
throw new ImageInputError("图片字节与声明的 mediaType 不匹配", { mediaType, code: "IMAGE_BYTES_MISMATCH" });
|
|
111
|
+
}
|
|
112
|
+
if (mediaType === "image/png") {
|
|
113
|
+
return source.slice();
|
|
114
|
+
}
|
|
115
|
+
if (typeof transcodeToPng !== "function") throw new ImageTranscodeUnavailableError(mediaType);
|
|
116
|
+
const png = bytesOf(await transcodeToPng(source, mediaType), "PNG 转码结果");
|
|
117
|
+
// 转码器是宿主能力:它返回非 PNG 属于宿主侧缺陷,保持 TypeError(500)而非客户端错误。
|
|
118
|
+
if (!hasPngSignature(png)) throw new TypeError("图片转码器没有返回真实 PNG 字节");
|
|
119
|
+
return png;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function normalizeImageToPng({ attachment, data, transcodeToPng, savePng, forceCopy = false }) {
|
|
123
|
+
if (!attachment || typeof attachment !== "object") throw new TypeError("attachment 无效");
|
|
124
|
+
if (!IMAGE_MEDIA_TYPES.has(attachment.mediaType)) throw new TypeError("attachment mediaType 无效");
|
|
125
|
+
// DSH 本地附件对象本身没有扩展名;Host 传 forceCopy 时即使显示名带后缀也必须复制。
|
|
126
|
+
const copyDecision = decidePngCopy(attachment);
|
|
127
|
+
if (!forceCopy && copyDecision.copy === false) return { attachment, copied: false };
|
|
128
|
+
if (typeof savePng !== "function") throw new TypeError("savePng 必须是函数");
|
|
129
|
+
const png = await toPngBytes({ mediaType: attachment.mediaType, data, transcodeToPng });
|
|
130
|
+
const copied = await savePng({ data: png, mediaType: "image/png", name: pngCopyName(attachment.name) });
|
|
131
|
+
if (!copied || typeof copied !== "object" || copied.mediaType !== "image/png") {
|
|
132
|
+
throw new TypeError("附件服务没有返回 image/png durable reference");
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
attachment: copied,
|
|
136
|
+
copied: true,
|
|
137
|
+
// 附件服务没有公开 delete API;副本随 durable session 引用保留,由 Host 的
|
|
138
|
+
// attachment retention/GC 回收不可达对象,插件不会擅自删除或改写原始 ref。
|
|
139
|
+
lifecycle: "durable-until-unreferenced",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 将目录元数据转换为三态能力,异常、缺失和格式异常都保留 unknown。 */
|
|
144
|
+
export function imageCapability(modelInfo) {
|
|
145
|
+
const modalities = modelInfo?.inputModalities;
|
|
146
|
+
if (!Array.isArray(modalities) || !modalities.every((item) => typeof item === "string")) return "unknown";
|
|
147
|
+
return modalities.includes("image") ? "supported" : "unsupported";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 从一条模型选择/请求头记录里取出可用的 provider/model 路由。
|
|
152
|
+
*
|
|
153
|
+
* 任何兼容读取都必须做结构校验:DSH 的 ModelSelection 是 `{provider, model,
|
|
154
|
+
* reasoningEffort?}`,但这里的输入可能来自旧宿主、事件快照的冻结对象或测试替身,
|
|
155
|
+
* 因此非对象、空串、非字符串一律视为"没有可用路由",绝不猜测。
|
|
156
|
+
* @param {unknown} value - 候选选择对象(ModelSelection 或 LlmCallConfig)。
|
|
157
|
+
* @returns {{provider: string, model: string}|undefined} 归一后的路由,或 undefined。
|
|
158
|
+
*/
|
|
159
|
+
function modelRouteOf(value) {
|
|
160
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
161
|
+
const provider = typeof value.provider === "string" ? value.provider.trim() : "";
|
|
162
|
+
const model = typeof value.model === "string" ? value.model.trim() : "";
|
|
163
|
+
if (provider.length === 0 || model.length === 0) return undefined;
|
|
164
|
+
return { provider, model };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 判定"下一个请求将使用的模型路由",解决切换模型后的能力时序问题。
|
|
169
|
+
*
|
|
170
|
+
* 背景:DSH 的 `session.requestHeader()` 返回**已发出请求**的历史配置。用户在 UI 里
|
|
171
|
+
* 切换模型后立即发图时,新模型要等到下一次 prompt assembly 才生效,此时 requestHeader
|
|
172
|
+
* 仍是旧模型——按旧模型能力路由会把图片错误地交给 Observer(或错误地留在主模型)。
|
|
173
|
+
*
|
|
174
|
+
* 公开 seam:`session.snapshotEvents()`(DSH Session 的公开方法)会按 seq 顺序给出事件;
|
|
175
|
+
* `model/selection` 是"已校验、待生效"的用户选择,`request/header` 是"已发出请求"的快照。
|
|
176
|
+
* 二者取**seq 更大**的那个即"下一个请求"的模型,无需复制 DSH 的完整 selection 状态机:
|
|
177
|
+
* 从尾部向前找到第一条这两类事件即可(后写入的 seq 更大)。
|
|
178
|
+
*
|
|
179
|
+
* fail-safe:若最新的 `model/selection` 结构无效(既不是完整 provider/model,也无法证明
|
|
180
|
+
* 与旧 header 一致),则返回 source="none" 且 staleHeaderIgnored=true —— 调用方必须
|
|
181
|
+
* 放弃旧 header(那是被取代的模型),按 unknown 走保守的 main-first,而不是把旧能力
|
|
182
|
+
* 当成新模型的能力。旧宿主没有 snapshotEvents 时退回 requestHeader 语义(保持可用)。
|
|
183
|
+
*
|
|
184
|
+
* @param {{events?: unknown, headerConfig?: unknown}} [evidence]
|
|
185
|
+
* events 为 `session.snapshotEvents()` 的返回值(可缺失);headerConfig 为
|
|
186
|
+
* `session.requestHeader()?.config`(可缺失)。
|
|
187
|
+
* @returns {{provider?: string, model?: string, source: "model-selection"|"request-header"|"none", staleHeaderIgnored: boolean}}
|
|
188
|
+
* 能力判定必须使用的路由;source="none" 表示"无法确定下一个请求的模型"。
|
|
189
|
+
*/
|
|
190
|
+
export function effectiveModelRoute({ events, headerConfig } = {}) {
|
|
191
|
+
const header = modelRouteOf(headerConfig);
|
|
192
|
+
if (Array.isArray(events)) {
|
|
193
|
+
// 反向扫描:第一条命中的事件就是两类事件里 seq 最大的那条。
|
|
194
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
195
|
+
const event = events[index];
|
|
196
|
+
if (event === null || typeof event !== "object") continue;
|
|
197
|
+
const type = event.type;
|
|
198
|
+
if (type !== "model/selection" && type !== "request/header") continue;
|
|
199
|
+
if (type === "model/selection") {
|
|
200
|
+
const pending = modelRouteOf(event.data);
|
|
201
|
+
if (pending === undefined) {
|
|
202
|
+
// 用户确实切换过,但这条选择读不出 provider/model:旧 header 已被取代,
|
|
203
|
+
// 不能继续用它做能力判定,也不能假装它是新模型。
|
|
204
|
+
return { source: "none", staleHeaderIgnored: true };
|
|
205
|
+
}
|
|
206
|
+
return { ...pending, source: "model-selection", staleHeaderIgnored: false };
|
|
207
|
+
}
|
|
208
|
+
// 最新一条是 request/header:它重建出的就是当前请求头(与 fold 同源)。
|
|
209
|
+
const logged = modelRouteOf(event.data?.header?.config);
|
|
210
|
+
const route = logged ?? header;
|
|
211
|
+
return route === undefined
|
|
212
|
+
? { source: "none", staleHeaderIgnored: false }
|
|
213
|
+
: { ...route, source: "request-header", staleHeaderIgnored: false };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// 旧宿主没有事件快照:保持既有 requestHeader 语义(不引入新的失败模式)。
|
|
217
|
+
return header === undefined
|
|
218
|
+
? { source: "none", staleHeaderIgnored: false }
|
|
219
|
+
: { ...header, source: "request-header", staleHeaderIgnored: false };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 按产品规则作出不猜测的路由选择。unknown 绝不被偷偷视为 main 不支持;
|
|
224
|
+
* Observer 优先时则必须先证明 Observer 支持 image,避免丢失用户输入。
|
|
225
|
+
*/
|
|
226
|
+
export function decideImageRoute({ mainImage, observerImage, observerPreferred }) {
|
|
227
|
+
if (observerPreferred === true) {
|
|
228
|
+
if (observerImage === "supported") return { target: "observer" };
|
|
229
|
+
return { target: "error", code: "OBSERVER_IMAGE_UNSUPPORTED", message: "已开启 Observer优先,但 Observer 模型未明确支持 image;原始输入已保留。" };
|
|
230
|
+
}
|
|
231
|
+
if (mainImage !== "unsupported") return { target: "main" };
|
|
232
|
+
if (observerImage === "supported") return { target: "observer" };
|
|
233
|
+
return { target: "error", code: "OBSERVER_IMAGE_UNSUPPORTED", message: "主模型明确不支持 image,且 Observer 模型未明确支持 image;原始输入已保留。" };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 构造传给任意专家 role 的原生 ContentBlock[];图片本身必须已是 Host 创建的 PNG ref。
|
|
238
|
+
* text 只在有实际内容时写入一次,attachments 可用于保持多图的输入顺序。
|
|
239
|
+
* role 仅用于生成可诊断的边界错误,不参与图片数据处理。
|
|
240
|
+
*/
|
|
241
|
+
export function buildImageContent({ attachment, attachments, text, role = "专家" }) {
|
|
242
|
+
const refs = Array.isArray(attachments) ? attachments : [attachment];
|
|
243
|
+
const label = typeof role === "string" && role.trim().length > 0 ? role.trim() : "专家";
|
|
244
|
+
if (refs.length === 0) throw new TypeError(`${label} 图片输入不能为空`);
|
|
245
|
+
for (const ref of refs) {
|
|
246
|
+
if (!ref || typeof ref !== "object"
|
|
247
|
+
|| ref.mediaType !== "image/png"
|
|
248
|
+
|| typeof ref.attachmentId !== "string"
|
|
249
|
+
|| ref.attachmentId.length === 0
|
|
250
|
+
|| Object.prototype.hasOwnProperty.call(ref, "pngPath")) {
|
|
251
|
+
throw new TypeError(`${label} 只能接收 Host 创建的 image/png durable attachment,不能接收 pngPath`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return [
|
|
255
|
+
...(typeof text === "string" && text.trim().length > 0 ? [{ type: "text", text }] : []),
|
|
256
|
+
...refs.map((ref) => ({ type: "image", attachment: ref })),
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 保留 Observer 的既有公开入口;实现已泛化到所有支持 image 的专家 role。 */
|
|
261
|
+
export function buildObserverImageContent({ attachment, attachments, text }) {
|
|
262
|
+
return buildImageContent({ attachment, attachments, text, role: "Observer" });
|
|
263
|
+
}
|