@arcaneorion/dsh-model-channel-manager 0.3.0 → 0.3.8
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 +71 -6
- package/package.json +10 -2
- package/src/client.js +280 -86
- package/src/health-store.js +169 -0
- package/src/index.js +441 -98
package/src/client.js
CHANGED
|
@@ -38,22 +38,25 @@ window.__ModuleLoader__.load({
|
|
|
38
38
|
{ ns: 'model-channel-health', value: value.health || {}, user: (user && user.health) || {}, revision: (mcm && mcm.revision) || 0 },
|
|
39
39
|
] } } }
|
|
40
40
|
},
|
|
41
|
-
// 旧签名:update({ ns, patch })
|
|
41
|
+
// 旧签名:update({ ns, patch, expectedRevision })——0.3.2 起透传 revision
|
|
42
|
+
// (宿主 settings.update 第三参;冲突抛 settings/conflict,由调用方提示重载)
|
|
42
43
|
update: async (args) => {
|
|
43
44
|
const ns = (args && args.ns) || NS_MCM
|
|
44
45
|
const patch = (args && args.patch) || {}
|
|
45
|
-
|
|
46
|
+
const expected = (args && args.expectedRevision !== undefined) ? args.expectedRevision : undefined
|
|
47
|
+
if (ns === 'model-channels') return wrap(await remote.settings.update(NS_MCM, patch, expected))
|
|
46
48
|
const row = await readMcm()
|
|
47
49
|
const health = (row && row.value && row.value.health) || {}
|
|
48
|
-
return wrap(await remote.settings.update(NS_MCM, { health: Object.assign({}, health, patch) },
|
|
50
|
+
return wrap(await remote.settings.update(NS_MCM, { health: Object.assign({}, health, patch) }, expected))
|
|
49
51
|
},
|
|
50
52
|
// 旧签名:mutate({ ns, ops })——0.2 只对真实行命名空间提供 path-ops;
|
|
51
53
|
// 本插件自身的健康子树用合并写实现等价语义。
|
|
52
54
|
mutate: async (args) => {
|
|
53
55
|
const ns = (args && args.ns) || NS_MCM
|
|
54
56
|
const ops = (args && args.ops) || []
|
|
57
|
+
const expected = (args && args.expectedRevision !== undefined) ? args.expectedRevision : undefined
|
|
55
58
|
if (ns !== 'model-channels' && ns !== 'model-channel-health' && ns !== NS_MCM) {
|
|
56
|
-
return wrap(await remote.settings.mutate(ns, ops,
|
|
59
|
+
return wrap(await remote.settings.mutate(ns, ops, expected))
|
|
57
60
|
}
|
|
58
61
|
const row = await readMcm()
|
|
59
62
|
const health = Object.assign({}, (row && row.value && row.value.health) || {})
|
|
@@ -78,9 +81,9 @@ window.__ModuleLoader__.load({
|
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
if (ns === 'model-channels') {
|
|
81
|
-
return wrap(await remote.settings.update(NS_MCM, { groups: top.groups, providerOrder: top.providerOrder },
|
|
84
|
+
return wrap(await remote.settings.update(NS_MCM, { groups: top.groups, providerOrder: top.providerOrder }, expected))
|
|
82
85
|
}
|
|
83
|
-
return wrap(await remote.settings.update(NS_MCM, { health },
|
|
86
|
+
return wrap(await remote.settings.update(NS_MCM, { health }, expected))
|
|
84
87
|
},
|
|
85
88
|
},
|
|
86
89
|
credentials: {
|
|
@@ -589,13 +592,18 @@ window.__ModuleLoader__.load({
|
|
|
589
592
|
if (!apiRef) return
|
|
590
593
|
const key = provider + '::' + model
|
|
591
594
|
setTestStates((s) => Object.assign({}, s, { [key]: { status: 'running' } }))
|
|
592
|
-
|
|
595
|
+
// F03:Date.now()%1e9 同毫秒可碰撞(碰撞 = 两次测试互相认领/覆盖结果),
|
|
596
|
+
// 换 UUID;host 侧 testRequest.nonce 消费方是 !== 比较,字符串/数字都行
|
|
597
|
+
const nonce = (crypto.randomUUID && crypto.randomUUID()) || (String(Date.now()) + '-' + Math.random().toString(36).slice(2, 10))
|
|
593
598
|
const prompt = localStorage.getItem('mcm_test_prompt') || '用一句话介绍你自己'
|
|
594
599
|
const maxTokens = Number(localStorage.getItem('mcm_test_max_tokens')) || 256
|
|
595
600
|
apiRef.settings.update({
|
|
596
601
|
ns: 'model-channel-health',
|
|
597
602
|
patch: { testRequest: { nonce, provider, model, prompt, maxTokens } }
|
|
598
|
-
}).then(() => {
|
|
603
|
+
}).then((resp) => {
|
|
604
|
+
// F19:RPC resolve ≠ 业务成功——检查信封(旧实现把 ok:false 当「已触发」)
|
|
605
|
+
const r = resp && resp.result ? resp.result : resp
|
|
606
|
+
if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'test request rejected')
|
|
599
607
|
pollTest(nonce, provider, model)
|
|
600
608
|
}).catch((e) => {
|
|
601
609
|
setTestStates((s) => Object.assign({}, s, { [key]: { status: 'error', error: String((e && e.message) || e) } }))
|
|
@@ -704,7 +712,7 @@ window.__ModuleLoader__.load({
|
|
|
704
712
|
el('span', {
|
|
705
713
|
title: '拖动此手柄调整供应商顺序(保存后生效)',
|
|
706
714
|
draggable: true,
|
|
707
|
-
onDragStart: (e) => { e.stopPropagation(); setDragIdx(
|
|
715
|
+
onDragStart: (e) => { e.stopPropagation(); setDragIdx(realIdx); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(realIdx)) } catch (_e) {} },
|
|
708
716
|
onDragEnd: () => { setDragIdx(null); setOverIdx(null) },
|
|
709
717
|
onClick: (e) => e.stopPropagation(),
|
|
710
718
|
style: { cursor: 'grab', color: 'var(--dsw-alias-label-tertiary)', fontSize: 14, padding: '0 6px', userSelect: 'none' }
|
|
@@ -931,6 +939,8 @@ window.__ModuleLoader__.load({
|
|
|
931
939
|
const providers = props._providers || {}
|
|
932
940
|
const [speedState, setSpeedState] = useState({})
|
|
933
941
|
const [expanded, setExpanded] = useState({})
|
|
942
|
+
// 顶层 notice 的写入通道(ModelConfigView 传入;测速失败要上浮显示)
|
|
943
|
+
const setNotice = props._setNotice || (() => {})
|
|
934
944
|
|
|
935
945
|
const addGroup = () => props.setChannelsDraft((d) => {
|
|
936
946
|
const base = d || []
|
|
@@ -965,12 +975,19 @@ window.__ModuleLoader__.load({
|
|
|
965
975
|
|
|
966
976
|
const speedtest = (gid) => {
|
|
967
977
|
if (!apiRef) return
|
|
968
|
-
|
|
978
|
+
// F03/F19 同 fireTest:UUID 防碰撞 + 信封检查(ok:false 不再当「已触发」)
|
|
979
|
+
const nonce = (crypto.randomUUID && crypto.randomUUID()) || (String(Date.now()) + '-' + Math.random().toString(36).slice(2, 10))
|
|
969
980
|
setSpeedState((s) => Object.assign({}, s, { [gid]: 'running' }))
|
|
970
|
-
apiRef.settings.update({ ns: 'model-channel-health', patch: { speedRequest: { group: gid, nonce } } }).then(() => {
|
|
981
|
+
apiRef.settings.update({ ns: 'model-channel-health', patch: { speedRequest: { group: gid, nonce } } }).then((resp) => {
|
|
982
|
+
const r = resp && resp.result ? resp.result : resp
|
|
983
|
+
if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'speed request rejected')
|
|
971
984
|
setSpeedState((s) => Object.assign({}, s, { [gid]: 'sent' }))
|
|
972
985
|
setTimeout(() => setSpeedState((s) => Object.assign({}, s, { [gid]: null })), 3000)
|
|
973
|
-
}).catch(() =>
|
|
986
|
+
}).catch((e) => {
|
|
987
|
+
setSpeedState((s) => Object.assign({}, s, { [gid]: 'err' }))
|
|
988
|
+
setNotice('测速触发失败: ' + String((e && e.message) || e))
|
|
989
|
+
setTimeout(() => setNotice(null), 5000)
|
|
990
|
+
})
|
|
974
991
|
}
|
|
975
992
|
|
|
976
993
|
if (groups.length === 0) return el('div', { className: 'mcm-empty' }, '暂无轮询组,点击下方按钮创建', el('div', { style: { marginTop: 12 } }, btn('+新增轮询组', addGroup, 'primary')))
|
|
@@ -1065,11 +1082,13 @@ window.__ModuleLoader__.load({
|
|
|
1065
1082
|
|
|
1066
1083
|
const now = Date.now()
|
|
1067
1084
|
const windowCutoff = windowMode === '30m' ? now - 30 * 60 * 1000 : windowMode === '24h' ? now - 24 * 3600 * 1000 : 0
|
|
1068
|
-
const recsMap = health.records || {}
|
|
1069
|
-
const rawEvents = Object.values(recsMap).flat()
|
|
1070
|
-
// 时间窗口过滤
|
|
1071
|
-
const allEvents = rawEvents.filter((e) => (e.ts || 0) >= windowCutoff)
|
|
1072
1085
|
|
|
1086
|
+
// ---------- 聚合数据源 ----------
|
|
1087
|
+
// 0.3.1 起优先用 host 下发的 digest 小投影(聚合已上移 host,client 不再拉原始流水)。
|
|
1088
|
+
// digest 是 7 天全窗口聚合;30m/24h 视图按 lastTs 近似(lastTs 在窗口内才计入),
|
|
1089
|
+
// 精细窗口统计属后续增强(需要 host 按窗口出多份 digest)。
|
|
1090
|
+
// 旧 host 兼容:无 digest 字段时走原始 records 路径(升级窗口不断供)。
|
|
1091
|
+
const digestRows = Array.isArray(health.digest) ? health.digest : null
|
|
1073
1092
|
// 按 provider 分组
|
|
1074
1093
|
const byProvider = new Map()
|
|
1075
1094
|
|
|
@@ -1092,7 +1111,10 @@ window.__ModuleLoader__.load({
|
|
|
1092
1111
|
lastOk: null,
|
|
1093
1112
|
lastCode: null,
|
|
1094
1113
|
recentErrors: 0,
|
|
1095
|
-
tokSum: 0
|
|
1114
|
+
tokSum: 0,
|
|
1115
|
+
tokIn: 0,
|
|
1116
|
+
tokOut: 0,
|
|
1117
|
+
tokCache: 0
|
|
1096
1118
|
})
|
|
1097
1119
|
}
|
|
1098
1120
|
}
|
|
@@ -1103,57 +1125,134 @@ window.__ModuleLoader__.load({
|
|
|
1103
1125
|
let totalTokOut = 0
|
|
1104
1126
|
let totalTokCache = 0
|
|
1105
1127
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1128
|
+
if (digestRows !== null) {
|
|
1129
|
+
// ---------- digest 投影路径 ----------
|
|
1130
|
+
for (const row of digestRows) {
|
|
1131
|
+
if (!row || !row.provider || !row.model) continue
|
|
1132
|
+
// 窗口过滤:lastTs 不在窗口内的渠道不计入该窗口(近似口径,见上注)
|
|
1133
|
+
if (windowCutoff > 0 && (row.lastTs || 0) < windowCutoff) continue
|
|
1134
|
+
let pMap = byProvider.get(row.provider)
|
|
1135
|
+
if (!pMap) { pMap = new Map(); byProvider.set(row.provider, pMap) }
|
|
1136
|
+
let a = pMap.get(row.model)
|
|
1137
|
+
if (!a) {
|
|
1138
|
+
a = {
|
|
1139
|
+
provider: row.provider,
|
|
1140
|
+
model: row.model,
|
|
1141
|
+
name: row.model,
|
|
1142
|
+
total: 0,
|
|
1143
|
+
success: 0,
|
|
1144
|
+
fail: 0,
|
|
1145
|
+
ttftSum: 0,
|
|
1146
|
+
latSum: 0,
|
|
1147
|
+
lastTs: 0,
|
|
1148
|
+
lastOk: null,
|
|
1149
|
+
lastCode: null,
|
|
1150
|
+
recentErrors: 0,
|
|
1151
|
+
tokSum: 0,
|
|
1152
|
+
tokIn: 0,
|
|
1153
|
+
tokOut: 0,
|
|
1154
|
+
tokCache: 0
|
|
1155
|
+
}
|
|
1156
|
+
pMap.set(row.model, a)
|
|
1127
1157
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
a.
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
a.fail++
|
|
1144
|
-
if (e.code) a.lastCode = e.code
|
|
1158
|
+
a.total = row.total || 0
|
|
1159
|
+
a.success = row.success || 0
|
|
1160
|
+
a.fail = a.total - a.success
|
|
1161
|
+
a.ttftSum = (row.ttftAvg || 0) * a.success
|
|
1162
|
+
a.latSum = (row.latAvg || 0) * a.success
|
|
1163
|
+
a.lastTs = row.lastTs || 0
|
|
1164
|
+
a.lastOk = row.lastOk
|
|
1165
|
+
a.lastCode = row.lastCode || null
|
|
1166
|
+
a.tokIn = row.tokIn || 0
|
|
1167
|
+
a.tokOut = row.tokOut || 0
|
|
1168
|
+
a.tokCache = row.tokCache || 0
|
|
1169
|
+
a.tokSum = a.tokIn + a.tokOut + a.tokCache
|
|
1170
|
+
totalTokIn += a.tokIn
|
|
1171
|
+
totalTokOut += a.tokOut
|
|
1172
|
+
totalTokCache += a.tokCache
|
|
1145
1173
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1174
|
+
} else {
|
|
1175
|
+
// ---------- 旧 host 原始流水路径(兼容) ----------
|
|
1176
|
+
const recsMap = health.records || {}
|
|
1177
|
+
const rawEvents = Object.values(recsMap).flat()
|
|
1178
|
+
// 时间窗口过滤(赋给函数级 allEvents,供顶部指标卡使用)
|
|
1179
|
+
allEvents = rawEvents.filter((e) => (e.ts || 0) >= windowCutoff)
|
|
1180
|
+
// 累加时间窗口内的真实流水记录
|
|
1181
|
+
for (const e of allEvents) {
|
|
1182
|
+
if (!e || !e.provider || !e.model) continue
|
|
1183
|
+
let pMap = byProvider.get(e.provider)
|
|
1184
|
+
if (!pMap) { pMap = new Map(); byProvider.set(e.provider, pMap) }
|
|
1185
|
+
let a = pMap.get(e.model)
|
|
1186
|
+
if (!a) {
|
|
1187
|
+
a = {
|
|
1188
|
+
provider: e.provider,
|
|
1189
|
+
model: e.model,
|
|
1190
|
+
name: e.model,
|
|
1191
|
+
total: 0,
|
|
1192
|
+
success: 0,
|
|
1193
|
+
fail: 0,
|
|
1194
|
+
ttftSum: 0,
|
|
1195
|
+
latSum: 0,
|
|
1196
|
+
lastTs: 0,
|
|
1197
|
+
lastOk: null,
|
|
1198
|
+
lastCode: null,
|
|
1199
|
+
recentErrors: 0,
|
|
1200
|
+
tokSum: 0,
|
|
1201
|
+
tokIn: 0,
|
|
1202
|
+
tokOut: 0,
|
|
1203
|
+
tokCache: 0
|
|
1204
|
+
}
|
|
1205
|
+
pMap.set(e.model, a)
|
|
1206
|
+
}
|
|
1207
|
+
a.total++
|
|
1208
|
+
const tokIn = (e && e.inputTokens) || 0
|
|
1209
|
+
const tokOut = (e && e.outputTokens) || 0
|
|
1210
|
+
const tokCache = ((e && e.cacheReadTokens) || 0) + ((e && e.cacheWriteTokens) || 0)
|
|
1211
|
+
totalTokIn += tokIn
|
|
1212
|
+
totalTokOut += tokOut
|
|
1213
|
+
totalTokCache += tokCache
|
|
1214
|
+
a.tokIn += tokIn
|
|
1215
|
+
a.tokOut += tokOut
|
|
1216
|
+
a.tokCache += tokCache
|
|
1217
|
+
a.tokSum += tokIn + tokOut + tokCache
|
|
1218
|
+
if (e.ok) {
|
|
1219
|
+
a.success++
|
|
1220
|
+
if (e.ttftMs != null && e.ttftMs >= 0) a.ttftSum += e.ttftMs
|
|
1221
|
+
if (e.latencyMs != null && e.latencyMs >= 0) a.latSum += e.latencyMs
|
|
1222
|
+
} else {
|
|
1223
|
+
a.fail++
|
|
1224
|
+
if (e.code) a.lastCode = e.code
|
|
1225
|
+
}
|
|
1226
|
+
if ((e.ts || 0) > a.lastTs) {
|
|
1227
|
+
a.lastTs = e.ts || 0
|
|
1228
|
+
a.lastOk = e.ok
|
|
1229
|
+
}
|
|
1149
1230
|
}
|
|
1150
1231
|
}
|
|
1151
1232
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1233
|
+
// 顶部指标卡:digest 路径用聚合值求和,旧路径用原始事件计数
|
|
1234
|
+
// (allEvents 提升到函数级:旧 host 回落路径的 else 块在此作用域外定义它)
|
|
1235
|
+
let allEvents = []
|
|
1236
|
+
let totalRequests = 0
|
|
1237
|
+
let totalSuccess = 0
|
|
1238
|
+
let ttftWeightedSum = 0
|
|
1239
|
+
if (digestRows !== null) {
|
|
1240
|
+
for (const pMap of byProvider.values()) {
|
|
1241
|
+
for (const a of pMap.values()) {
|
|
1242
|
+
totalRequests += a.total
|
|
1243
|
+
totalSuccess += a.success
|
|
1244
|
+
ttftWeightedSum += a.ttftSum
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
} else {
|
|
1248
|
+
totalRequests = allEvents.length
|
|
1249
|
+
totalSuccess = allEvents.filter((x) => x.ok).length
|
|
1250
|
+
for (const pMap of byProvider.values()) {
|
|
1251
|
+
for (const a of pMap.values()) ttftWeightedSum += a.ttftSum
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1154
1254
|
const globalRate = totalRequests > 0 ? ((totalSuccess / totalRequests) * 100).toFixed(1) + '%' : '100%'
|
|
1155
|
-
const
|
|
1156
|
-
const avgGlobalTtft = validTtfts.length > 0 ? (validTtfts.reduce((a, b) => a + b, 0) / validTtfts.length / 1000).toFixed(2) + 's' : '—'
|
|
1255
|
+
const avgGlobalTtft = totalSuccess > 0 ? (ttftWeightedSum / totalSuccess / 1000).toFixed(2) + 's' : '—'
|
|
1157
1256
|
|
|
1158
1257
|
const providerGroups = [...byProvider.entries()].map(([provName, modelMap]) => {
|
|
1159
1258
|
const models = [...modelMap.values()].map((m) => {
|
|
@@ -1212,7 +1311,9 @@ window.__ModuleLoader__.load({
|
|
|
1212
1311
|
el('div', { className: 'mcm-metric-card' },
|
|
1213
1312
|
el('span', { className: 'mcm-metric-label' }, windowMode === '30m' ? '近 30 分钟请求' : windowMode === '24h' ? '近 24 小时请求' : '7 天全周期请求'),
|
|
1214
1313
|
el('span', { className: 'mcm-metric-value' }, totalRequests),
|
|
1215
|
-
|
|
1314
|
+
// digest 聚合是 7 天窗口口径;30m/24h 只按「最近活跃」过滤渠道,数值为该渠道 7 天累计
|
|
1315
|
+
el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
1316
|
+
digestRows !== null && windowMode !== '7d' ? '窗口内活跃渠道的 7 天累计(聚合口径)' : '真实上游交互捕获')
|
|
1216
1317
|
),
|
|
1217
1318
|
el('div', { className: 'mcm-metric-card' },
|
|
1218
1319
|
el('span', { className: 'mcm-metric-label' }, '总 Token 用量'),
|
|
@@ -1284,11 +1385,23 @@ window.__ModuleLoader__.load({
|
|
|
1284
1385
|
const [notice, setNotice] = useState(null)
|
|
1285
1386
|
const [saving, setSaving] = useState(false)
|
|
1286
1387
|
const [tab, setTab] = useState('config')
|
|
1388
|
+
// ready 门(审计 F04):首次 describe 完成前保存不可用——旧实现
|
|
1389
|
+
// channelsDraft || [] 把「还没读到」当「空」,加载前点保存会把轮询组清空
|
|
1390
|
+
const [ready, setReady] = useState(false)
|
|
1287
1391
|
|
|
1288
1392
|
const renameProviderInChannels = (oldId, newId) => {
|
|
1393
|
+
// F17(审计 C05):改名要同步全部引用——主 candidates + 每个 preset 的
|
|
1394
|
+
// candidates。宿主优先使用 activePreset.candidates,漏改它 = 删旧 provider
|
|
1395
|
+
// 后组仍访问不存在的 ID。
|
|
1396
|
+
const renameCands = (cands) => (cands || []).map((c) => (c && c.provider === oldId) ? Object.assign({}, c, { provider: newId }) : c)
|
|
1289
1397
|
setChannelsDraft((d) => (d || []).map((g) => {
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1398
|
+
const next = Object.assign({}, g, { candidates: renameCands(g.candidates) })
|
|
1399
|
+
if (Array.isArray(g.presets)) {
|
|
1400
|
+
next.presets = g.presets.map((p) => (p && Array.isArray(p.candidates))
|
|
1401
|
+
? Object.assign({}, p, { candidates: renameCands(p.candidates) })
|
|
1402
|
+
: p)
|
|
1403
|
+
}
|
|
1404
|
+
return next
|
|
1292
1405
|
}))
|
|
1293
1406
|
}
|
|
1294
1407
|
const [channels, setChannels] = useState(null)
|
|
@@ -1328,14 +1441,22 @@ window.__ModuleLoader__.load({
|
|
|
1328
1441
|
const providers = applyProviderOrder((ns && ns.value && ns.value.providers) || {}, savedOrder)
|
|
1329
1442
|
// user 层 = 仅用户手工声明的路由(用于真正删除与重排序的 mutate 依据)
|
|
1330
1443
|
const userProviders = (ns && ns.user && ns.user.providers) || {}
|
|
1331
|
-
|
|
1444
|
+
// revision 记录(审计 F05):保存时作为 expectedRevision 传给宿主,
|
|
1445
|
+
// 别处(另一个标签页/host 侧)改过配置则宿主拒绝,防止旧快照覆盖
|
|
1446
|
+
setState({ providers, userProviders, providerOrder: savedOrder || [], nsRevisions: { piAi: (ns && ns.revision) || 0, channels: (cn && cn.revision) || 0 } })
|
|
1332
1447
|
setDraft((prev) => prev || clone(providers))
|
|
1333
1448
|
const ch = (cn && cn.value && cn.value.groups) || []
|
|
1334
1449
|
setChannels(ch)
|
|
1335
1450
|
setChannelsDraft((prev) => prev || clone(ch))
|
|
1336
1451
|
const hn = findNs('model-channel-health')
|
|
1337
|
-
|
|
1338
|
-
|
|
1452
|
+
// 0.3.1:健康权威数据在 host 的 storageDomain;client 只接收小投影 digest。
|
|
1453
|
+
// 兼容旧 host(无 digest 字段)时回落到 records 原始视图,避免升级窗口白屏。
|
|
1454
|
+
const hv = (hn && hn.value) || {}
|
|
1455
|
+
setHealth(hv.digest || (hv.digestAt !== undefined)
|
|
1456
|
+
? { digest: hv.digest || [], digestAt: hv.digestAt || 0, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} }
|
|
1457
|
+
: (hn ? { records: hv.records || {}, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} } : null))
|
|
1458
|
+
setReady(true) // describe 成功:保存解禁(F04 的门)
|
|
1459
|
+
}).catch((e) => { setNotice('加载失败: ' + String(e)); setReady(false) })
|
|
1339
1460
|
}
|
|
1340
1461
|
|
|
1341
1462
|
useEffect(() => {
|
|
@@ -1350,11 +1471,10 @@ window.__ModuleLoader__.load({
|
|
|
1350
1471
|
const d = unwrap(resp)
|
|
1351
1472
|
const hn = ((d && d.namespaces) || []).find((n) => n && n.ns === 'model-channel-health')
|
|
1352
1473
|
if (hn && hn.value) {
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
})
|
|
1474
|
+
const hv = hn.value
|
|
1475
|
+
setHealth(hv.digest || (hv.digestAt !== undefined)
|
|
1476
|
+
? { digest: hv.digest || [], digestAt: hv.digestAt || 0, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} }
|
|
1477
|
+
: { records: hv.records || {}, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} })
|
|
1358
1478
|
}
|
|
1359
1479
|
}).catch(() => {})
|
|
1360
1480
|
}
|
|
@@ -1365,6 +1485,36 @@ window.__ModuleLoader__.load({
|
|
|
1365
1485
|
|
|
1366
1486
|
const save = () => {
|
|
1367
1487
|
if (!apiRef) return
|
|
1488
|
+
// F04 门:describe 未完成时 draft/channelsDraft 都是 null——旧实现
|
|
1489
|
+
// (channelsDraft || []) 把「还没读到」当「空」,这里直接拦截而不是兜底空数组
|
|
1490
|
+
if (!ready) {
|
|
1491
|
+
setNotice('配置尚未加载完成,请稍候再保存')
|
|
1492
|
+
setTimeout(() => setNotice(null), 4000)
|
|
1493
|
+
return
|
|
1494
|
+
}
|
|
1495
|
+
// F16 预检:非法/重复组 ID、空候选直接拒绝提交(旧实现保存后 host 静默丢弃,
|
|
1496
|
+
// 界面仍显示「已保存」——H08:3 组保存实际只注册 1 条路由)
|
|
1497
|
+
const groupsToSave = channelsDraft || []
|
|
1498
|
+
const problems = []
|
|
1499
|
+
const seenIds = new Set()
|
|
1500
|
+
for (const g of groupsToSave) {
|
|
1501
|
+
const gid = (g && g.id) || ''
|
|
1502
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(gid)) {
|
|
1503
|
+
problems.push('组「' + (gid || '(未命名)') + '」ID 非法(仅小写字母/数字/连字符,字母或数字开头)')
|
|
1504
|
+
} else if (seenIds.has(gid)) {
|
|
1505
|
+
problems.push('组 ID「' + gid + '」重复')
|
|
1506
|
+
} else {
|
|
1507
|
+
seenIds.add(gid)
|
|
1508
|
+
}
|
|
1509
|
+
const candCount = (g && Array.isArray(g.candidates)) ? g.candidates.filter((c) => c && c.provider && c.model).length : 0
|
|
1510
|
+
if (candCount === 0 && gid)
|
|
1511
|
+
problems.push('组「' + gid + '」没有可用候选(候选需同时选择 Provider 和模型)')
|
|
1512
|
+
}
|
|
1513
|
+
if (problems.length > 0) {
|
|
1514
|
+
setNotice('保存被拒绝:' + problems[0] + (problems.length > 1 ? '(共 ' + problems.length + ' 个问题)' : ''))
|
|
1515
|
+
setTimeout(() => setNotice(null), 8000)
|
|
1516
|
+
return
|
|
1517
|
+
}
|
|
1368
1518
|
setSaving(true)
|
|
1369
1519
|
setNotice(null)
|
|
1370
1520
|
// Compat 全量透传:rc.2 PiAiCompatProfile 的 20 个字段全部真实生效——此前
|
|
@@ -1406,6 +1556,10 @@ window.__ModuleLoader__.load({
|
|
|
1406
1556
|
if (Array.isArray(cleaned.input) && cleaned.input.length > 0) cleaned.input = cleaned.input.slice()
|
|
1407
1557
|
else delete cleaned.input
|
|
1408
1558
|
if (cleaned.reasoningEfforts != null && typeof cleaned.reasoningEfforts === 'object' && Object.keys(cleaned.reasoningEfforts).length > 0) cleaned.reasoningEfforts = Object.assign({}, cleaned.reasoningEfforts)
|
|
1559
|
+
else if (cleaned.reasoningEfforts === false) {
|
|
1560
|
+
// F07(审计 C02):false = 显式关闭推理(继承 catalog 默认的对立面)。
|
|
1561
|
+
// 删掉它 = 静默恢复继承,语义被改变。原样保留。
|
|
1562
|
+
}
|
|
1409
1563
|
else delete cleaned.reasoningEfforts
|
|
1410
1564
|
const mc = cleanCompat(cleaned.compat)
|
|
1411
1565
|
if (mc) cleaned.compat = mc
|
|
@@ -1413,7 +1567,12 @@ window.__ModuleLoader__.load({
|
|
|
1413
1567
|
return cleaned
|
|
1414
1568
|
})
|
|
1415
1569
|
const pObj = Object.assign({}, pVal)
|
|
1416
|
-
|
|
1570
|
+
// F07(审计 C08):api 缺省 = 继承 catalog 协议;强塞 openai-completions
|
|
1571
|
+
// 会把「继承」变成「显式指定」,materialize 时显式 api 优先于 catalog——
|
|
1572
|
+
// 协议被静默切换。UI 下拉仍显示默认值(渲染默认与持久化默认分离),
|
|
1573
|
+
// 但保存不写显式值。新增供应商时 addGroup 路径已显式给 api。
|
|
1574
|
+
if (typeof pVal.api === 'string' && pVal.api) pObj.api = pVal.api
|
|
1575
|
+
else delete pObj.api
|
|
1417
1576
|
pObj.models = cleanModels
|
|
1418
1577
|
if (!(pVal.baseURL && String(pVal.baseURL).trim())) delete pObj.baseURL
|
|
1419
1578
|
else pObj.baseURL = String(pVal.baseURL).trim()
|
|
@@ -1473,9 +1632,14 @@ window.__ModuleLoader__.load({
|
|
|
1473
1632
|
for (const k of allKeys) ops.push({ op: 'unset', path: ['providers', k] })
|
|
1474
1633
|
for (const k of keys) ops.push({ op: 'set', path: ['providers', k], value: cleanProviders[k] })
|
|
1475
1634
|
if (ops.length === 0) return Promise.resolve()
|
|
1476
|
-
|
|
1635
|
+
// F05:带 revision 保存——llm-pi-ai 是独立 settings 行,冲突由宿主以
|
|
1636
|
+
// settings/conflict 拒绝;提示里给「刷新后重试」指引而不是静默覆盖
|
|
1637
|
+
return apiRef.settings.mutate({ ns: 'llm-pi-ai', ops, expectedRevision: (state && state.nsRevisions && state.nsRevisions.piAi) || undefined }).then((resp) => {
|
|
1477
1638
|
const r = resp && resp.result ? resp.result : resp
|
|
1478
|
-
if (r && r.ok === false)
|
|
1639
|
+
if (r && r.ok === false) {
|
|
1640
|
+
const conflict = r.error && (r.error.code === 'settings/conflict' || /conflict/i.test(String(r.error.message || '')))
|
|
1641
|
+
throw new Error(conflict ? 'llm-pi-ai 配置已被其他页面修改(版本冲突),请点「刷新」后重试' : ((r.error && (r.error.message || r.error)) || 'llm-pi-ai save failed'))
|
|
1642
|
+
}
|
|
1479
1643
|
return r
|
|
1480
1644
|
})
|
|
1481
1645
|
})() : Promise.resolve()
|
|
@@ -1484,22 +1648,46 @@ window.__ModuleLoader__.load({
|
|
|
1484
1648
|
const orderPatch = draft ? { providerOrder: Object.keys(cleanProviders) } : {}
|
|
1485
1649
|
const p2 = apiRef.settings.update({
|
|
1486
1650
|
ns: 'model-channels',
|
|
1651
|
+
expectedRevision: (state && state.nsRevisions && state.nsRevisions.channels) || undefined,
|
|
1487
1652
|
// 命名单一身份:写入时统一 virtualModel.name = 组 id——旧的独立呈现名
|
|
1488
1653
|
// (如遗留的 group-1)在下一次保存时自动归一,无需迁移
|
|
1489
1654
|
patch: Object.assign({ groups: (channelsDraft || []).map((g) => Object.assign({}, g, { virtualModel: Object.assign({}, g.virtualModel, { name: g.id }) })) }, orderPatch)
|
|
1490
1655
|
}).then((resp) => {
|
|
1491
1656
|
const r = resp && resp.result ? resp.result : resp
|
|
1492
|
-
if (r && r.ok === false)
|
|
1657
|
+
if (r && r.ok === false) {
|
|
1658
|
+
const conflict = r.error && (r.error.code === 'settings/conflict' || /conflict/i.test(String(r.error.message || '')))
|
|
1659
|
+
throw new Error(conflict ? '轮询组配置已被其他页面修改(版本冲突),请点「刷新」后重试' : ((r.error && (r.error.message || r.error)) || 'model-channels save failed'))
|
|
1660
|
+
}
|
|
1493
1661
|
return r
|
|
1494
1662
|
})
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1663
|
+
// F06(审计 C10,0.3.8):两域用 allSettled 而非 all——半成功不再「整单失败」
|
|
1664
|
+
// 掩盖已提交的那半。各自报告,失败域给出明确指引;部分成功时提示里列明
|
|
1665
|
+
// 哪域成功哪域失败,用户知道实际落盘了什么(宿主无跨命名空间事务,
|
|
1666
|
+
// 这是审计建议的务实路线:可观察的分域成败 + 冲突重试)。
|
|
1667
|
+
const settleOf = (label, p) => p.then(() => ({ label, ok: true }), (e) => ({ label, ok: false, error: String((e && e.message) || e) }))
|
|
1668
|
+
Promise.allSettled([settleOf('提供商配置', p1), settleOf('轮询组配置', p2)]).then((results) => {
|
|
1669
|
+
const done = results.map((r) => r.value)
|
|
1670
|
+
const failed = done.filter((d) => d && !d.ok)
|
|
1671
|
+
const okCount = done.length - failed.length
|
|
1672
|
+
if (failed.length === 0) {
|
|
1673
|
+
setNotice('已全部保存(即时生效)')
|
|
1674
|
+
}
|
|
1675
|
+
else if (okCount > 0) {
|
|
1676
|
+
// 部分成功:明确告知哪域已落盘、哪域失败(旧实现统一「保存失败」,
|
|
1677
|
+
// 用户不知道 providers 其实已经提交了)
|
|
1678
|
+
setNotice('部分保存:' + done.filter((d) => d && d.ok).map((d) => d.label).join('、') + ' 已生效;' + failed.map((d) => d.label).join('、') + ' 失败——' + failed[0].error)
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
setNotice('保存失败: ' + failed[0].error)
|
|
1682
|
+
}
|
|
1683
|
+
// 两域都成功才同步本地镜像(半成功时以 refresh 拉回真实状态为准)
|
|
1684
|
+
if (failed.length === 0) {
|
|
1685
|
+
setDraft(clone(cleanProviders))
|
|
1686
|
+
setState((s) => Object.assign({}, s, { providers: cleanProviders, userProviders: cleanProviders, providerOrder: Object.keys(cleanProviders) }))
|
|
1687
|
+
}
|
|
1688
|
+
setTimeout(() => setNotice(null), failed.length > 0 ? 8000 : 3000)
|
|
1501
1689
|
refresh()
|
|
1502
|
-
}).
|
|
1690
|
+
}).finally(() => setSaving(false))
|
|
1503
1691
|
}
|
|
1504
1692
|
|
|
1505
1693
|
return el('div', { className: 'mcm-root' },
|
|
@@ -1512,12 +1700,18 @@ window.__ModuleLoader__.load({
|
|
|
1512
1700
|
el('div', { className: 'mcm-actions' },
|
|
1513
1701
|
notice ? el('span', { style: { fontSize: 12, color: notice.includes('失败') ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-success-primary)' } }, notice) : null,
|
|
1514
1702
|
btn('刷新', refresh),
|
|
1515
|
-
|
|
1703
|
+
el('button', {
|
|
1704
|
+
className: 'mcm-btn primary',
|
|
1705
|
+
// F04:未就绪/保存中禁用;disabled 态样式弱化
|
|
1706
|
+
disabled: !ready || saving,
|
|
1707
|
+
style: (!ready || saving) ? { opacity: 0.55, cursor: 'not-allowed' } : undefined,
|
|
1708
|
+
onClick: save
|
|
1709
|
+
}, !ready ? '加载中…' : saving ? '保存中…' : '保存全部变更')
|
|
1516
1710
|
)
|
|
1517
1711
|
),
|
|
1518
1712
|
el('div', { className: 'mcm-body' },
|
|
1519
1713
|
tab === 'config' ? el(ModelConfigPanel, { _state: state, _draft: draft, _setDraft: setDraft, _setNotice: setNotice, _renameProviderInChannels: renameProviderInChannels }) :
|
|
1520
|
-
tab === 'roundrobin' ? el(RoundrobinPanel, { channels, channelsDraft, setChannelsDraft, _providers: (draft || (state ? state.providers : {})) }) :
|
|
1714
|
+
tab === 'roundrobin' ? el(RoundrobinPanel, { channels, channelsDraft, setChannelsDraft, _providers: (draft || (state ? state.providers : {})), _setNotice: setNotice }) :
|
|
1521
1715
|
el(HealthPanel, { health, _providers: (draft || (state ? state.providers : {})) })
|
|
1522
1716
|
)
|
|
1523
1717
|
)
|