@mzzsfy/dsh-model-capability-editor 0.3.0 → 0.4.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/README.md +7 -2
- package/package.json +1 -1
- package/src/client.js +106 -26
- package/src/logic.mjs +41 -14
- package/test/client-boot.test.mjs +147 -12
- package/test/parity.test.mjs +7 -7
- package/test/wire-face.test.mjs +90 -2
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ DeepSeek Harness 模型能力编辑插件:编辑 `llm-pi-ai` 管理的第三方
|
|
|
23
23
|
| off 勾选拼写填值 | 对象形态 `off: "拼写"` |
|
|
24
24
|
|
|
25
25
|
- input 四态:未声明(删除字段)/ 仅文本(`["text"]`)/ 文本+图像(`["text", "image"]`)/ 仅图片(`["image"]`,纯视觉网关不收文本输入)。
|
|
26
|
-
- 保存前本地校验:非 off 档勾选但拼写空白时拒绝保存并提示具体模型与档位(空白拼写会静默以档位名作线上值,易被误当作已填;off 档留空有专门语义,不参与校验)
|
|
26
|
+
- 保存前本地校验:非 off 档勾选但拼写空白时拒绝保存并提示具体模型与档位(空白拼写会静默以档位名作线上值,易被误当作已填;off 档留空有专门语义,不参与校验)。卡片与行内编辑块均拦截。保存与校验反馈经全站浮出通知(`@mzzsfy/dsh-toast` 可选依赖,缺失时降级 console)。
|
|
27
27
|
- 一键草稿填充:卡片底部「填充草稿」对当前 provider 内未声明任何档位的模型填入七档全勾、拼写留空(线上值=档位名)的草稿;只改内存草稿,不触碰 inputMode,已声明档位的模型不受影响,写回仍需手动保存。
|
|
28
28
|
- 保存 = mutate set `providers.<route>.models` 整个数组:以 describe 读到的数组为基线,未编辑条目原样保留,settings 未声明的(自动发现的)模型不被删除;providers 缺失或 models 非数组时拒绝保存(防静默覆写为空数组)。
|
|
29
29
|
- 修订冲突:重读 describe 取新 revision,按字段级 diff 仅重放本次修改(仅用户改过的模型条目的 `reasoningEfforts` / `input` 两字段,其余字段保留最新文档值),重试一次;再冲突报错终止并保留用户输入,绝不静默覆盖。"本次修改"以草稿加载时点冻结的种子为参照:加载后他方对基线的修改,不会被零编辑或仅改单一字段的保存静默回滚(该保证覆盖保存开始后的并发写,revision 乐观锁语义)。
|
|
@@ -40,7 +40,12 @@ dsh plugin --profile web add @mzzsfy/dsh-model-capability-editor
|
|
|
40
40
|
|
|
41
41
|
## 前置:dsh 本体版本
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
支持 dsh 0.1.1-rc.2 / 0.1.2-rc.1 / 0.1.5-alpha.2(实测)。settings 传输按宿主代际自适应:
|
|
44
|
+
|
|
45
|
+
- 0.1.2+(boot wire 带 `batches` 批次调度):`remote.settings` 以点分 inject 声明交由 cordis 门控,fiber 等 namespace `$mount` 完成才激活,激活即就绪。宿主 `parseBootManifest` 将 `batches` 校验为必填数组(缺失即整页拒绝启动),判据被上游 schema 钉死;宿主未来若移除该字段,本插件退化为 api 面或恒定禁用,不阻塞 boot。
|
|
46
|
+
- 0.1.1(旧 boot wire,无该 namespace):点分声明会永远 pending 拖垮整页 web boot,故声明两代基座 `remote` + `connection`,settings 传输在 apply 内异步定面轮询:从 `connection.api` 面起等,面在首拍即定型;缺失持续满等待窗(1s)则恒定禁用并告警,不影响 web 启动。
|
|
47
|
+
|
|
48
|
+
注意 0.1.1 本体的 `llm-pi-ai` schema 不认识 0.1.5 引入的 `compat.chatTemplateArgs` 字段,settings.yaml 中该字段会让 0.1.1 的 `llm-pi-ai` settings 注册整体失败(host 侧行为),编辑器随之无数据;从 yaml 删除该字段即恢复。
|
|
44
49
|
|
|
45
50
|
## 前置:移除 dsh-better-reasoning-effort
|
|
46
51
|
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// 模型能力编辑 Client
|
|
1
|
+
// 模型能力编辑 Client 半区:官方模型页行内注入,锚点破坏时浮动入口回退。
|
|
2
2
|
// 以 DSH client-modules 自注册格式发布:__ModuleLoader__.load({id, factory}),
|
|
3
3
|
// factory(require) 中 require('react') 与 require('react-dom/client') 由 DSH client
|
|
4
4
|
// runtime 的模块表解析(宿主种子一级键,缺失即整个宿主 UI 不存在,不降级)。
|
|
@@ -32,6 +32,12 @@ window.__ModuleLoader__.load({
|
|
|
32
32
|
// 插件实例代际:宿主热重载/禁用重建时区分新旧实例,样式清理只认自有属主
|
|
33
33
|
let instanceSeq = 0
|
|
34
34
|
|
|
35
|
+
// 宿主代际判据:0.1.2+ 的 client-modules boot wire 带 batches 批次调度
|
|
36
|
+
// (宿主 parseBootManifest 将其校验为必填数组,缺失即整页拒绝启动,判据
|
|
37
|
+
// 被上游 schema 钉死);0.1.1 的 wire 无此字段。宿主未来若移除 batches,
|
|
38
|
+
// 退化为 api 面或恒定禁用,不阻塞 boot
|
|
39
|
+
const hasBatchesWire = typeof window !== 'undefined' && window.__DSH_BOOT__?.batches !== undefined
|
|
40
|
+
|
|
35
41
|
const CSS = [
|
|
36
42
|
'.mce-card { display:flex; flex-direction:column; gap:10px; color:inherit; font-size:13px;',
|
|
37
43
|
' border:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)); border-radius:10px; padding:12px;',
|
|
@@ -309,23 +315,50 @@ function anchorsBroken({ titleMatched, hasEditor, modelIdInputCount }) {
|
|
|
309
315
|
return titleMatched === true && hasEditor === true && modelIdInputCount === 0
|
|
310
316
|
}
|
|
311
317
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
318
|
+
// settings 传输 → 插件内部 settings 面(describe() / mutate(ns, ops, revision))。
|
|
319
|
+
// 两种宿主传输形态,能力面在 0.1.1 即存在:
|
|
320
|
+
// - typed remote 面(dsh 0.1.2+):方法直返 RemoteResult 信封 {ok,value|error};
|
|
321
|
+
// - connection.api 面(dsh 0.1.1+):settings.describe() 无参,settings.mutate({ns,ops,...})
|
|
322
|
+
// 单对象参数,返回 {rpcId,result:{ok,value|error}} 信封。
|
|
323
|
+
// 面缺失或形状不完整返回 null,由调用方降级呈现只读原因。
|
|
324
|
+
function unwrapEnvelope(envelope) {
|
|
325
|
+
if (envelope !== null && typeof envelope === 'object' && envelope.ok === true) return envelope.value
|
|
326
|
+
if (envelope !== null && typeof envelope === 'object' &&
|
|
327
|
+
envelope.result !== null && typeof envelope.result === 'object') {
|
|
328
|
+
if (envelope.result.ok === true) return envelope.result.value
|
|
329
|
+
return rejectRpc(envelope.result.error)
|
|
330
|
+
}
|
|
331
|
+
return rejectRpc(envelope && envelope.error)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function rejectRpc(error) {
|
|
335
|
+
const rpcError = new Error(error && error.message ? error.message : 'settings RPC 调用失败')
|
|
336
|
+
rpcError.code = error ? error.code : undefined
|
|
337
|
+
throw rpcError
|
|
319
338
|
}
|
|
320
339
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
function
|
|
324
|
-
if (
|
|
325
|
-
|
|
340
|
+
function makeSettingsFace(transport) {
|
|
341
|
+
if (transport === null || typeof transport !== 'object') return null
|
|
342
|
+
const typed = typeof transport.describe === 'function' && typeof transport.mutate === 'function'
|
|
343
|
+
if (typed) {
|
|
344
|
+
return {
|
|
345
|
+
describe: async () => unwrapEnvelope(await transport.describe()),
|
|
346
|
+
mutate: async (ns, ops, expectedRevision) =>
|
|
347
|
+
unwrapEnvelope(await transport.mutate(ns, ops, expectedRevision)),
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const api = transport.api
|
|
351
|
+
if (api === null || typeof api !== 'object' ||
|
|
352
|
+
api.settings === null || typeof api.settings !== 'object' ||
|
|
353
|
+
typeof api.settings.describe !== 'function' || typeof api.settings.mutate !== 'function') return null
|
|
326
354
|
return {
|
|
327
|
-
describe: async () =>
|
|
328
|
-
mutate: async (ns, ops, expectedRevision) =>
|
|
355
|
+
describe: async () => unwrapEnvelope(await api.settings.describe({})),
|
|
356
|
+
mutate: async (ns, ops, expectedRevision) =>
|
|
357
|
+
unwrapEnvelope(await api.settings.mutate({
|
|
358
|
+
ns,
|
|
359
|
+
ops,
|
|
360
|
+
...(expectedRevision === undefined ? {} : { expectedRevision }),
|
|
361
|
+
})),
|
|
329
362
|
}
|
|
330
363
|
}
|
|
331
364
|
|
|
@@ -520,7 +553,7 @@ function CapabilityCard(props) {
|
|
|
520
553
|
patch({ phase: 'readonly', reason: 'remote.settings 服务面缺失,无法读写模型声明' })
|
|
521
554
|
return
|
|
522
555
|
}
|
|
523
|
-
const value =
|
|
556
|
+
const value = await settings.describe()
|
|
524
557
|
if (value.writable !== true) {
|
|
525
558
|
patch({ phase: 'readonly', reason: 'settings 当前只读,模型能力编辑不可用' })
|
|
526
559
|
return
|
|
@@ -559,7 +592,7 @@ function CapabilityCard(props) {
|
|
|
559
592
|
patch({ route: nextRoute, phase: 'ready' })
|
|
560
593
|
void (async () => {
|
|
561
594
|
try {
|
|
562
|
-
const value =
|
|
595
|
+
const value = await props.settings.describe()
|
|
563
596
|
if (seq !== routeSeqRef.current) return
|
|
564
597
|
const ns = findNsEntry(value)
|
|
565
598
|
if (ns === undefined) {
|
|
@@ -610,7 +643,7 @@ function CapabilityCard(props) {
|
|
|
610
643
|
? '已保存,但模型 ' + droppedDraftIds.join(', ') + ' 已被其他写者删除,对应修改未写入'
|
|
611
644
|
: '已保存并写回 settings.yaml', droppedDraftIds.length > 0 ? 'error' : 'ok')
|
|
612
645
|
try {
|
|
613
|
-
const value =
|
|
646
|
+
const value = await props.settings.describe()
|
|
614
647
|
const ns = findNsEntry(value)
|
|
615
648
|
if (ns === undefined) {
|
|
616
649
|
// 保存后命名空间被他方移除:明确告知刷新,不再裸抛
|
|
@@ -736,7 +769,7 @@ function RowEditor(props) {
|
|
|
736
769
|
patch({ phase: 'error', notice: 'remote.settings 服务面缺失,无法读写模型声明' })
|
|
737
770
|
return
|
|
738
771
|
}
|
|
739
|
-
const value =
|
|
772
|
+
const value = await settings.describe()
|
|
740
773
|
const ns = findNsEntry(value)
|
|
741
774
|
if (!alive) return
|
|
742
775
|
if (ns === undefined) { patch({ phase: 'hidden' }); return }
|
|
@@ -762,13 +795,13 @@ function RowEditor(props) {
|
|
|
762
795
|
// S3:官方行内 ID 输入是活动状态,改名已落盘则以新 ID 为目标,否则回落原 ID
|
|
763
796
|
const el = props.idInputEl
|
|
764
797
|
const liveId = el && el.isConnected ? el.value : modelId
|
|
765
|
-
const first =
|
|
798
|
+
const first = await settings.describe()
|
|
766
799
|
const nsFirst = findNsEntry(first)
|
|
767
800
|
const baselineIds = new Set(nsFirst !== undefined ? modelsOf(nsFirst.value, route).map((entry) => String(entry.id)) : [])
|
|
768
801
|
const targetId = resolveTargetId(liveId, modelId, baselineIds)
|
|
769
802
|
const { models: written, droppedDraftIds } = await saveModels(settings, route, new Map([[targetId, state.draft]]))
|
|
770
803
|
// S4:保存后重读重建草稿,基线新鲜,保留他方词汇表外档位
|
|
771
|
-
const second =
|
|
804
|
+
const second = await settings.describe()
|
|
772
805
|
const nsSecond = findNsEntry(second)
|
|
773
806
|
const latest = nsSecond !== undefined
|
|
774
807
|
? modelsOf(nsSecond.value, route).find((entry) => String(entry.id) === String(targetId))
|
|
@@ -839,11 +872,15 @@ function RowEditor(props) {
|
|
|
839
872
|
}
|
|
840
873
|
|
|
841
874
|
return {
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
|
|
875
|
+
// inject 按宿主代际二选一(判据 hasBatchesWire 见上):0.1.2+ 以点分
|
|
876
|
+
// 声明交由 cordis 门控(fiber 等 namespace $mount 完成才激活,激活即
|
|
877
|
+
// 就绪;不声明则点分读取永远抛错,实测);0.1.1 的 boot wire 无该
|
|
878
|
+
// namespace,点分声明会永远 pending 拖垮整页 web boot
|
|
879
|
+
// (assertEntriesActive 对 pending throw,实测),故声明两代都具备的
|
|
880
|
+
// 基座服务,settings 传输在 apply 内异步定面轮询。
|
|
881
|
+
inject: hasBatchesWire ? ['remote', 'remote.settings'] : ['remote', 'connection'],
|
|
845
882
|
apply(ctx) {
|
|
846
|
-
|
|
883
|
+
let settings = null
|
|
847
884
|
|
|
848
885
|
// 行内注入器:MutationObserver 监听官方设置页,reconcile 把编辑块
|
|
849
886
|
// 挂进已展开的模型行;官方结构变化导致锚点全失时,在模型页右侧注入
|
|
@@ -864,6 +901,47 @@ function RowEditor(props) {
|
|
|
864
901
|
// 本实例代际号:样式属主判定用
|
|
865
902
|
const instanceId = ++instanceSeq
|
|
866
903
|
|
|
904
|
+
// settings 面按代际取形:0.1.2+ 点分声明下 fiber 等挂载完成才激活,
|
|
905
|
+
// apply 时已就绪,直接取形;0.1.1 无 typed 面,轮询从 api 相位起——
|
|
906
|
+
// connection.api 面是 0.1.1 唯一 settings RPC 通道。两代路径对读取
|
|
907
|
+
// 抛错/面缺失均收敛为恒定只读降级:定面完成前 settings 为 null,
|
|
908
|
+
// reconcile 早退
|
|
909
|
+
const FACE_POLL_INTERVAL_MS = 50
|
|
910
|
+
// 等待窗:各代宿主上面在首拍即定型,窗口只是防御性上界,非真实时延
|
|
911
|
+
const API_FACE_WAIT_MS = 1000
|
|
912
|
+
const FACE_UNAVAILABLE_WARN = '[dsh-model-capability-editor] settings RPC 面不可用,编辑功能禁用'
|
|
913
|
+
// 0.1.1 轮询:读取抛错(cordis get trap)与面缺失同义;窗口判定前置,
|
|
914
|
+
// 任何路径都不绕过终止性——满窗即恒定禁用,不再重排
|
|
915
|
+
const faceStartedAt = Date.now()
|
|
916
|
+
const facePoll = () => {
|
|
917
|
+
if (disposed) return
|
|
918
|
+
const expired = Date.now() - faceStartedAt >= API_FACE_WAIT_MS
|
|
919
|
+
try {
|
|
920
|
+
settings = makeSettingsFace(ctx.connection)
|
|
921
|
+
if (settings !== null) { scheduleScan(); return }
|
|
922
|
+
} catch {
|
|
923
|
+
// namespace/服务读取未就绪:按轮询节拍重试
|
|
924
|
+
}
|
|
925
|
+
if (expired) {
|
|
926
|
+
console.warn(FACE_UNAVAILABLE_WARN)
|
|
927
|
+
return
|
|
928
|
+
}
|
|
929
|
+
setTimeout(facePoll, FACE_POLL_INTERVAL_MS)
|
|
930
|
+
}
|
|
931
|
+
if (hasBatchesWire) {
|
|
932
|
+
try {
|
|
933
|
+
settings = makeSettingsFace(ctx.remote !== undefined ? ctx.remote.settings : undefined)
|
|
934
|
+
} catch (error) {
|
|
935
|
+
// 一次性终态判定,失败原因随告警留痕
|
|
936
|
+
console.warn(FACE_UNAVAILABLE_WARN, error)
|
|
937
|
+
settings = null
|
|
938
|
+
}
|
|
939
|
+
if (settings === null) console.warn(FACE_UNAVAILABLE_WARN)
|
|
940
|
+
else scheduleScan()
|
|
941
|
+
} else {
|
|
942
|
+
facePoll()
|
|
943
|
+
}
|
|
944
|
+
|
|
867
945
|
function docInfo() {
|
|
868
946
|
const outlet = document.querySelector('[data-slot="settings.section"]')
|
|
869
947
|
if (outlet === null) return null
|
|
@@ -941,6 +1019,8 @@ function RowEditor(props) {
|
|
|
941
1019
|
|
|
942
1020
|
function reconcile() {
|
|
943
1021
|
if (disposed) return
|
|
1022
|
+
// 定面未完成:不做任何 RPC 与注入,面就绪后 scheduleScan 会再触发
|
|
1023
|
+
if (settings === null) return
|
|
944
1024
|
// 已脱离文档的挂载点:官方页卸载或重建了行,释放对应 root
|
|
945
1025
|
for (const [container, root] of roots) {
|
|
946
1026
|
if (!container.isConnected) {
|
|
@@ -979,7 +1059,7 @@ function RowEditor(props) {
|
|
|
979
1059
|
}
|
|
980
1060
|
void (async () => {
|
|
981
1061
|
try {
|
|
982
|
-
const value =
|
|
1062
|
+
const value = await settings.describe()
|
|
983
1063
|
if (disposed || seq !== reconcileSeq) return
|
|
984
1064
|
const ns = findNsEntry(value)
|
|
985
1065
|
if (ns === undefined) return
|
package/src/logic.mjs
CHANGED
|
@@ -208,23 +208,50 @@ export function anchorsBroken({ titleMatched, hasEditor, modelIdInputCount }) {
|
|
|
208
208
|
return titleMatched === true && hasEditor === true && modelIdInputCount === 0
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
211
|
+
// settings 传输 → 插件内部 settings 面(describe() / mutate(ns, ops, revision))。
|
|
212
|
+
// 两种宿主传输形态,能力面在 0.1.1 即存在:
|
|
213
|
+
// - typed remote 面(dsh 0.1.2+):方法直返 RemoteResult 信封 {ok,value|error};
|
|
214
|
+
// - connection.api 面(dsh 0.1.1+):settings.describe() 无参,settings.mutate({ns,ops,...})
|
|
215
|
+
// 单对象参数,返回 {rpcId,result:{ok,value|error}} 信封。
|
|
216
|
+
// 面缺失或形状不完整返回 null,由调用方降级呈现只读原因。
|
|
217
|
+
function unwrapEnvelope(envelope) {
|
|
218
|
+
if (envelope !== null && typeof envelope === 'object' && envelope.ok === true) return envelope.value
|
|
219
|
+
if (envelope !== null && typeof envelope === 'object' &&
|
|
220
|
+
envelope.result !== null && typeof envelope.result === 'object') {
|
|
221
|
+
if (envelope.result.ok === true) return envelope.result.value
|
|
222
|
+
return rejectRpc(envelope.result.error)
|
|
223
|
+
}
|
|
224
|
+
return rejectRpc(envelope && envelope.error)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function rejectRpc(error) {
|
|
228
|
+
const rpcError = new Error(error && error.message ? error.message : 'settings RPC 调用失败')
|
|
229
|
+
rpcError.code = error ? error.code : undefined
|
|
230
|
+
throw rpcError
|
|
218
231
|
}
|
|
219
232
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (
|
|
224
|
-
|
|
233
|
+
export function makeSettingsFace(transport) {
|
|
234
|
+
if (transport === null || typeof transport !== 'object') return null
|
|
235
|
+
const typed = typeof transport.describe === 'function' && typeof transport.mutate === 'function'
|
|
236
|
+
if (typed) {
|
|
237
|
+
return {
|
|
238
|
+
describe: async () => unwrapEnvelope(await transport.describe()),
|
|
239
|
+
mutate: async (ns, ops, expectedRevision) =>
|
|
240
|
+
unwrapEnvelope(await transport.mutate(ns, ops, expectedRevision)),
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const api = transport.api
|
|
244
|
+
if (api === null || typeof api !== 'object' ||
|
|
245
|
+
api.settings === null || typeof api.settings !== 'object' ||
|
|
246
|
+
typeof api.settings.describe !== 'function' || typeof api.settings.mutate !== 'function') return null
|
|
225
247
|
return {
|
|
226
|
-
describe: async () =>
|
|
227
|
-
mutate: async (ns, ops, expectedRevision) =>
|
|
248
|
+
describe: async () => unwrapEnvelope(await api.settings.describe({})),
|
|
249
|
+
mutate: async (ns, ops, expectedRevision) =>
|
|
250
|
+
unwrapEnvelope(await api.settings.mutate({
|
|
251
|
+
ns,
|
|
252
|
+
ops,
|
|
253
|
+
...(expectedRevision === undefined ? {} : { expectedRevision }),
|
|
254
|
+
})),
|
|
228
255
|
}
|
|
229
256
|
}
|
|
230
257
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
// client 注入契约 BDD:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// client 注入契约 BDD:inject 按宿主代际二选一——0.1.2+(带 __DSH_BOOT__
|
|
2
|
+
// wire)点分声明 remote.settings 由 cordis 门控等挂载,0.1.1(无 wire)声明
|
|
3
|
+
// 两代基座 remote+connection,settings 传输在 apply 内按面形状异步轮询——
|
|
4
|
+
// 0.1.2+ 的 remote.settings 直返 RemoteResult 信封,0.1.1 的 connection.api
|
|
5
|
+
// 走 HTTP 面({rpcId,result} 信封);面形状由 makeSettingsFace 判定,缺失面
|
|
6
|
+
// 降级只读呈现,不阻塞 fiber。
|
|
4
7
|
// require 桩采用宿主语义:未知模块名 throw(宿主 client-modules 对种子表外
|
|
5
8
|
// 键直接抛错),使顶层硬依赖与可选消费的降级路径都被真实执行。
|
|
6
9
|
|
|
@@ -45,13 +48,130 @@ function loadFactory() {
|
|
|
45
48
|
return factoryPromise
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
// apply 的定面轮询(settle→scheduleScan)会在测试结束后的一拍触达 document;
|
|
52
|
+
// 桩 querySelector 恒 null,扫描回调即静默返回,异步尾巴不抛错
|
|
53
|
+
globalThis.document ??= { querySelector: () => null, getElementById: () => null }
|
|
54
|
+
// applyTracked 真实执行 effect fn(其中 new MutationObserver),桩不派发变更
|
|
55
|
+
globalThis.MutationObserver ??= class { observe() {} disconnect() {} }
|
|
56
|
+
|
|
57
|
+
// cordis effect 语义:注册即执行,返回的 disposer 由宿主持有;测试以
|
|
58
|
+
// applyTracked 持有 disposer,用例结束前统一清算,停掉面轮询的定时器链。
|
|
59
|
+
// 在原对象上临时替换 effect(禁止 spread):spread 会把 getter 展开成
|
|
60
|
+
// 数据属性,面读取计数桩(getter)从此对 apply 内不可见,断言恒真失效
|
|
61
|
+
function applyTracked(entry, ctx) {
|
|
62
|
+
const disposers = []
|
|
63
|
+
const userEffect = ctx.effect
|
|
64
|
+
ctx.effect = (fn, name) => {
|
|
65
|
+
if (userEffect !== undefined) userEffect(fn, name)
|
|
66
|
+
disposers.push(fn())
|
|
67
|
+
return name
|
|
68
|
+
}
|
|
69
|
+
try { entry.apply(ctx) } finally {
|
|
70
|
+
if (userEffect === undefined) delete ctx.effect
|
|
71
|
+
else ctx.effect = userEffect
|
|
72
|
+
}
|
|
73
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// apply 期面已就绪路径走 batches wire 快取;0.1.1 路径走 facePoll。测试环境
|
|
77
|
+
// 无 __DSH_BOOT__,默认全走 0.1.1 分支;带 wire 场景由 wire 测试覆盖
|
|
78
|
+
// 0.1.2+ wire 判据 = batches 批次调度字段(0.1.1 wire 无)
|
|
79
|
+
const BOOT_WIRE = { rev: 'test', batches: [] }
|
|
80
|
+
function withBootWire(value, fn) {
|
|
81
|
+
const had = Object.prototype.hasOwnProperty.call(globalThis.window, '__DSH_BOOT__')
|
|
82
|
+
const previous = globalThis.window.__DSH_BOOT__
|
|
83
|
+
if (value === undefined) delete globalThis.window.__DSH_BOOT__
|
|
84
|
+
else globalThis.window.__DSH_BOOT__ = value
|
|
85
|
+
try { return fn() } finally {
|
|
86
|
+
if (had) globalThis.window.__DSH_BOOT__ = previous
|
|
87
|
+
else delete globalThis.window.__DSH_BOOT__
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test('注入契约:0.1.2+(wire.batches)点分声明 remote.settings,0.1.1 声明基座', async () => {
|
|
49
92
|
const factory = await loadFactory()
|
|
50
|
-
|
|
51
|
-
|
|
93
|
+
withBootWire(BOOT_WIRE, () => {
|
|
94
|
+
const entry = factory(requireOf())
|
|
95
|
+
assert.deepEqual(entry.inject, ['remote', 'remote.settings'])
|
|
96
|
+
})
|
|
97
|
+
withBootWire(undefined, () => {
|
|
98
|
+
const entry = factory(requireOf())
|
|
99
|
+
assert.deepEqual(entry.inject, ['remote', 'connection'])
|
|
100
|
+
})
|
|
52
101
|
})
|
|
53
102
|
|
|
54
|
-
test('
|
|
103
|
+
test('面选择:0.1.1 形态(无 wire)typed 缺失走 connection.api 面', async () => {
|
|
104
|
+
const factory = await loadFactory()
|
|
105
|
+
withBootWire(undefined, () => {
|
|
106
|
+
const effects = []
|
|
107
|
+
const dispose = applyTracked(factory(requireOf()), {
|
|
108
|
+
remote: {},
|
|
109
|
+
connection: { api: { settings: { describe() {}, mutate() {} } } },
|
|
110
|
+
effect: (fn, name) => effects.push(name),
|
|
111
|
+
})
|
|
112
|
+
assert.deepEqual(effects, ['model-capability-editor: models-page injector'])
|
|
113
|
+
dispose()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('面选择:0.1.2+(wire)typed 面挂载完成,apply 期直接消费', async () => {
|
|
118
|
+
const factory = await loadFactory()
|
|
119
|
+
withBootWire(BOOT_WIRE, () => {
|
|
120
|
+
const effects = []
|
|
121
|
+
const dispose = applyTracked(factory(requireOf()), {
|
|
122
|
+
remote: { settings: { describe() {}, mutate() {} } },
|
|
123
|
+
connection: null,
|
|
124
|
+
effect: (fn, name) => effects.push(name),
|
|
125
|
+
})
|
|
126
|
+
assert.deepEqual(effects, ['model-capability-editor: models-page injector'])
|
|
127
|
+
dispose()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('定面轮询:0.1.1 直接走 api 面定面成功;面缺失满窗恒定禁用并停轮询', async (t) => {
|
|
132
|
+
const factory = await loadFactory()
|
|
133
|
+
// mock 定时器:轮询/扫描链与 Date.now() 全部确定性推进,不真等超时窗口。
|
|
134
|
+
// settings 面经 connection.api 属性读取,以 getter 计数锁定"读了多少拍":
|
|
135
|
+
// 首拍定面 ⇒ 计数 1 且不再增长;恒定禁用 ⇒ 计数随窗口耗尽停止
|
|
136
|
+
const trackedConnection = (settingsFace) => ({
|
|
137
|
+
get connection() {
|
|
138
|
+
this.reads = (this.reads ?? 0) + 1
|
|
139
|
+
return { api: { settings: settingsFace } }
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] })
|
|
143
|
+
withBootWire(undefined, () => {
|
|
144
|
+
// 场景一(0.1.1 主干路径):api 面可定面 → 首拍定型,零告警,不再读
|
|
145
|
+
const warnOk = t.mock.method(console, 'warn', () => {})
|
|
146
|
+
const okConn = trackedConnection({ describe() {}, mutate() {} })
|
|
147
|
+
const disposeOk = applyTracked(factory(requireOf()), okConn)
|
|
148
|
+
assert.equal(okConn.reads, 1, 'api 面必须首拍定面')
|
|
149
|
+
t.mock.timers.tick(1000 + 50)
|
|
150
|
+
assert.equal(okConn.reads, 1, '定面后不得继续轮询')
|
|
151
|
+
assert.equal(warnOk.mock.callCount(), 0, 'api 面可定面时不得有降级告警')
|
|
152
|
+
disposeOk()
|
|
153
|
+
warnOk.mock.restore()
|
|
154
|
+
// 场景二(两代皆无):窗口耗尽 → 恒定禁用告警,轮询终止(计数停止增长)
|
|
155
|
+
t.mock.timers.reset()
|
|
156
|
+
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] })
|
|
157
|
+
const warns = []
|
|
158
|
+
const warnNone = t.mock.method(console, 'warn', (text) => warns.push(text))
|
|
159
|
+
const noneConn = trackedConnection(undefined)
|
|
160
|
+
const disposeNone = applyTracked(factory(requireOf()), noneConn)
|
|
161
|
+
t.mock.timers.tick(1000 + 50)
|
|
162
|
+
assert.ok(
|
|
163
|
+
warns.some((text) => text.includes('settings RPC 面不可用')),
|
|
164
|
+
'两代面皆缺必须留下禁用告警',
|
|
165
|
+
)
|
|
166
|
+
const readsAtExhaust = noneConn.reads
|
|
167
|
+
t.mock.timers.tick(1000 + 50)
|
|
168
|
+
disposeNone()
|
|
169
|
+
assert.equal(noneConn.reads, readsAtExhaust, '恒定禁用后轮询必须终止')
|
|
170
|
+
assert.equal(warns.length, 1, '禁用告警必须只出现一次')
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('禁用分支清偿:client.js 不再含面缺失探测告警', () => {
|
|
55
175
|
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
56
176
|
assert.ok(!source.includes('缺少 remote.settings'), '残留面缺失探测告警分支')
|
|
57
177
|
})
|
|
@@ -88,14 +208,29 @@ test('toast 可选消费:模块表有 toast 走 show,缺失降级 console.warn',
|
|
|
88
208
|
assert.equal(typeof withoutToast.apply, 'function')
|
|
89
209
|
})
|
|
90
210
|
|
|
91
|
-
test('apply
|
|
211
|
+
test('apply 注册注入 effect:两代路径均成立', async () => {
|
|
92
212
|
const effects = []
|
|
93
213
|
const factory = await loadFactory()
|
|
94
214
|
const entry = factory(requireOf())
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
215
|
+
withBootWire(undefined, () => {
|
|
216
|
+
// 0.1.1:typed 面在场也不被消费(版本排他,轮询只读 connection)
|
|
217
|
+
let typedReads = 0
|
|
218
|
+
const remote = {}
|
|
219
|
+
Object.defineProperty(remote, 'settings', { get() { typedReads += 1; return { describe() {}, mutate() {} } } })
|
|
220
|
+
const disposeLegacy = applyTracked(entry, { remote, connection: null, effect: (fn, name) => effects.push(name) })
|
|
221
|
+
assert.equal(typedReads, 0, '0.1.1 路径不得消费 typed 面')
|
|
222
|
+
disposeLegacy()
|
|
223
|
+
})
|
|
224
|
+
withBootWire(undefined, () => {
|
|
225
|
+
// 两代路径下 effect 注册契约一致
|
|
226
|
+
const effectsWire = []
|
|
227
|
+
const dispose = applyTracked(entry, {
|
|
228
|
+
remote: { settings: { describe() {}, mutate() {} } },
|
|
229
|
+
connection: null,
|
|
230
|
+
effect: (fn, name) => effectsWire.push(name),
|
|
231
|
+
})
|
|
232
|
+
assert.deepEqual(effectsWire, ['model-capability-editor: models-page injector'])
|
|
233
|
+
dispose()
|
|
98
234
|
})
|
|
99
|
-
assert.equal(result, undefined)
|
|
100
235
|
assert.deepEqual(effects, ['model-capability-editor: models-page injector'])
|
|
101
236
|
})
|
package/test/parity.test.mjs
CHANGED
|
@@ -24,7 +24,7 @@ function clientLogic() {
|
|
|
24
24
|
+ ' COMPETITOR_MARKERS, effortsToDrafts, draftsToEfforts, isExpressibleEfforts, inputToMode, modeToInput,'
|
|
25
25
|
+ ' invalidReasoningLevels, fillDrafts,'
|
|
26
26
|
+ ' applyDraft, mergeBaselineModels, detectCompetitorTraces, stashDrafts, restoreDrafts, isModelsTitle,'
|
|
27
|
-
+ ' anchorsBroken, resolveTargetId,
|
|
27
|
+
+ ' anchorsBroken, resolveTargetId, unwrapEnvelope, rejectRpc, makeSettingsFace, describeNs, modelsOf, findNsEntry,'
|
|
28
28
|
+ ' writeModels, saveModels, draftsFromModels };',
|
|
29
29
|
)
|
|
30
30
|
return factory()
|
|
@@ -282,14 +282,14 @@ test('client.js 语法可被 node 解析', () => {
|
|
|
282
282
|
execFileSync(process.execPath, ['--check', join(PKG_ROOT, 'src', 'client.js')])
|
|
283
283
|
})
|
|
284
284
|
|
|
285
|
-
// 保存流双副本守卫:client.js 的
|
|
286
|
-
// writeModels/saveModels/draftsFromModels 全段与 logic.mjs 必须逐字符一致
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
285
|
+
// 保存流双副本守卫:client.js 的 unwrapEnvelope/rejectRpc/makeSettingsFace/describeNs/
|
|
286
|
+
// modelsOf/writeModels/saveModels/draftsFromModels 全段与 logic.mjs 必须逐字符一致
|
|
287
|
+
// (单文件格式无法 require,靠此测试防漂移;client 终点 LOGIC-END,logic 侧到文件尾)。
|
|
288
|
+
// 锚点即 `function unwrapEnvelope` 本身:它是全部 RPC 信封解包路径的唯一入口;
|
|
289
|
+
// 旧锚点 `function unwrapResult` 已随函数重命名废止。
|
|
290
290
|
test('client.js wire 适配与保存流段与 logic.mjs 同源', () => {
|
|
291
291
|
const sectionOf = (source, label) => {
|
|
292
|
-
const begin = source.indexOf('function
|
|
292
|
+
const begin = source.indexOf('function unwrapEnvelope')
|
|
293
293
|
assert.ok(begin >= 0, label + ' 缺少守卫段起点')
|
|
294
294
|
// 终点:client 侧守卫段以 LOGIC-END 收束;logic 侧守卫段即文件尾
|
|
295
295
|
const end = source.indexOf('/* LOGIC-END */')
|
package/test/wire-face.test.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
// BDD:
|
|
2
|
-
//
|
|
1
|
+
// BDD:settings 传输适配 — 两种宿主形态统一为插件内部 RPC 面:
|
|
2
|
+
// - dsh 0.1.2+ typed remote 面(remote.settings):方法直返 RemoteResult 信封 {ok,value|error}
|
|
3
|
+
// - dsh 0.1.1+ connection.api 面:settings.describe() 无参,settings.mutate({ns,ops,...})
|
|
4
|
+
// 单对象参数,返回 {rpcId,result:{ok,value|error}} 信封
|
|
5
|
+
// 保存流经适配层端到端保持冲突重放语义。
|
|
3
6
|
|
|
4
7
|
import test from 'node:test'
|
|
5
8
|
import assert from 'node:assert/strict'
|
|
@@ -21,6 +24,91 @@ test('适配面:transport 抛错原样上抛,不被吞成假信封', async () =>
|
|
|
21
24
|
await assert.rejects(() => face.mutate('llm-pi-ai', [], 1), (error) => error === boom)
|
|
22
25
|
})
|
|
23
26
|
|
|
27
|
+
test('connection.api 面(0.1.1):describe 走 {rpcId,result} 信封', async () => {
|
|
28
|
+
let calls = 0
|
|
29
|
+
const face = makeSettingsFace({ api: { settings: {
|
|
30
|
+
describe: async (payload) => {
|
|
31
|
+
calls += 1
|
|
32
|
+
assert.deepEqual(payload, {})
|
|
33
|
+
return { rpcId: 'r1', result: { ok: true, value: { writable: true, namespaces: [] } } }
|
|
34
|
+
},
|
|
35
|
+
mutate: async () => { throw new Error('不应调用') },
|
|
36
|
+
} } })
|
|
37
|
+
const value = await face.describe()
|
|
38
|
+
assert.equal(calls, 1)
|
|
39
|
+
assert.deepEqual(value, { writable: true, namespaces: [] })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('connection.api 面(0.1.1):mutate 单对象参数,result.ok 解包', async () => {
|
|
43
|
+
const calls = []
|
|
44
|
+
const face = makeSettingsFace({ api: { settings: {
|
|
45
|
+
describe: async () => ({ rpcId: 'r', result: { ok: true, value: {} } }),
|
|
46
|
+
mutate: async (payload) => {
|
|
47
|
+
calls.push(payload)
|
|
48
|
+
return { rpcId: 'r', result: { ok: true, value: { ns: payload.ns, revision: 8 } } }
|
|
49
|
+
},
|
|
50
|
+
} } })
|
|
51
|
+
const written = await face.mutate('llm-pi-ai', [{ op: 'set', path: ['a'], value: 1 }], 7)
|
|
52
|
+
await face.mutate('llm-pi-ai', [{ op: 'unset', path: ['b'] }], undefined)
|
|
53
|
+
assert.deepEqual(calls, [
|
|
54
|
+
{ ns: 'llm-pi-ai', ops: [{ op: 'set', path: ['a'], value: 1 }], expectedRevision: 7 },
|
|
55
|
+
{ ns: 'llm-pi-ai', ops: [{ op: 'unset', path: ['b'] }] },
|
|
56
|
+
])
|
|
57
|
+
assert.deepEqual(written, { ns: 'llm-pi-ai', revision: 8 })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('connection.api 面:result.ok false 以 code 透传冲突', async () => {
|
|
61
|
+
const face = makeSettingsFace({ api: { settings: {
|
|
62
|
+
describe: async () => ({ rpcId: 'r', result: { ok: true, value: {} } }),
|
|
63
|
+
mutate: async () => ({ rpcId: 'r', result: { ok: false, error: { code: CONFLICT_CODE, message: 'stale' } } }),
|
|
64
|
+
} } })
|
|
65
|
+
await assert.rejects(
|
|
66
|
+
() => face.mutate('llm-pi-ai', [], 1),
|
|
67
|
+
(error) => error.code === CONFLICT_CODE,
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('面选择:describe+mutate 双全即 typed 面,api 形状不再探测', async () => {
|
|
72
|
+
let apiCalls = 0
|
|
73
|
+
const face = makeSettingsFace({
|
|
74
|
+
describe: async () => ({ ok: true, value: {} }),
|
|
75
|
+
mutate: async () => ({ ok: true, value: {} }),
|
|
76
|
+
api: { settings: { describe: async () => { apiCalls += 1; return {} }, mutate: async () => ({}) } },
|
|
77
|
+
})
|
|
78
|
+
await face.describe()
|
|
79
|
+
assert.equal(apiCalls, 0, 'typed 面命中后不得触达 connection.api')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('connection.api 端到端:保存流走 HTTP 信封,冲突一次重放成功', async () => {
|
|
83
|
+
let describeCount = 0
|
|
84
|
+
let mutateCount = 0
|
|
85
|
+
const face = makeSettingsFace({ api: { settings: {
|
|
86
|
+
describe: async () => {
|
|
87
|
+
describeCount += 1
|
|
88
|
+
const revision = describeCount === 1 ? 1 : 2
|
|
89
|
+
return {
|
|
90
|
+
rpcId: 'r',
|
|
91
|
+
result: {
|
|
92
|
+
ok: true,
|
|
93
|
+
value: {
|
|
94
|
+
writable: true,
|
|
95
|
+
namespaces: [{ ns: 'llm-pi-ai', revision, value: { providers: { 'new-api': { models: [{ id: 'auto' }] } } } }],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
mutate: async (payload) => {
|
|
101
|
+
mutateCount += 1
|
|
102
|
+
if (mutateCount === 1) return { rpcId: 'r', result: { ok: false, error: { code: CONFLICT_CODE, message: 'stale' } } }
|
|
103
|
+
assert.equal(payload.expectedRevision, 2)
|
|
104
|
+
return { rpcId: 'r', result: { ok: true, value: { ns: payload.ns, revision: 3 } } }
|
|
105
|
+
},
|
|
106
|
+
} } })
|
|
107
|
+
const result = await saveModels(face, 'new-api', new Map([['auto', draft]]))
|
|
108
|
+
assert.equal(mutateCount, 2)
|
|
109
|
+
assert.deepEqual(result.models[0].reasoningEfforts, { high: 'ultra' })
|
|
110
|
+
})
|
|
111
|
+
|
|
24
112
|
test('makeSettingsFace:remote 缺失或形状不完整返回 null', () => {
|
|
25
113
|
for (const remote of [null, undefined, {}, { describe: () => {} }, { mutate: () => {} }]) {
|
|
26
114
|
assert.equal(makeSettingsFace(remote), null)
|