dsh-decision-engine 0.0.0-stage → 0.5.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/lib/client.js ADDED
@@ -0,0 +1,1248 @@
1
+ /**
2
+ * dsh-decision-engine 前端面板(浏览器端 bundle)。
3
+ *
4
+ * 加载协议与 DSH 其他插件一致:`window.__ModuleLoader__.load({ id, factory })`,
5
+ * `factory` 内用 `require()` 拿 react,导出 `apply` / `inject`。
6
+ *
7
+ * 关键取舍:**不碰 settings 服务**。旧插件正是在 `settings.installSection`
8
+ * (DSH 中不存在的方法)上抛错被吞,导致面板里永远看不到表单。
9
+ * 这里全部走本插件自己的 `/api/decision-engine/*`:状态、保存、测试、日志。
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: "dsh-decision-engine",
13
+ factory: (require) => {
14
+ var module = { exports: {} };
15
+ var exports = module.exports;
16
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
17
+ // 诊断探针:确认 client bundle 是否真的被执行(浏览器 Console 可见)
18
+ try {
19
+ console.log("[dsh-decision-engine] client bundle loaded");
20
+ globalThis.__DECISION_ENGINE_CLIENT_LOADED__ = true;
21
+ } catch { /* 忽略 */ }
22
+
23
+ const react = require("react");
24
+ const h = react.createElement;
25
+
26
+ /** 侧边栏与主面板共用的命名空间 / id。 */
27
+ const NS = "decisionEngine";
28
+ const PANEL_ID = "decision-engine";
29
+ const BASE = "/api/decision-engine";
30
+
31
+ const S = {
32
+ wrap: {
33
+ maxWidth: "620px",
34
+ height: "100%",
35
+ overflowY: "auto",
36
+ boxSizing: "border-box",
37
+ padding: "20px 24px",
38
+ fontSize: "13px",
39
+ },
40
+ title: { margin: "0 0 4px", fontSize: "16px", fontWeight: 600 },
41
+ sub: { margin: "0 0 16px", fontSize: "12px", opacity: 0.6 },
42
+ card: {
43
+ margin: "0 0 12px",
44
+ padding: "12px 14px",
45
+ border: "1px solid rgba(128,128,128,.22)",
46
+ borderRadius: "8px",
47
+ background: "rgba(128,128,128,.04)",
48
+ },
49
+ label: { fontSize: "12px", opacity: 0.85, marginBottom: "6px", display: "block" },
50
+ // —— 仿内置 rewind-plugin 配置卡的布局 ——
51
+ /** 一行:左标题/说明,右控件。 */
52
+ rowLine: {
53
+ display: "flex",
54
+ alignItems: "center",
55
+ justifyContent: "space-between",
56
+ gap: "16px",
57
+ padding: "12px 0",
58
+ borderBottom: "1px solid rgba(128,128,128,.14)",
59
+ },
60
+ rowText: { display: "flex", flexDirection: "column", gap: "4px", minWidth: 0 },
61
+ rowTitle: { fontSize: "13px", fontWeight: 500, lineHeight: 1.3 },
62
+ rowHint: { fontSize: "12px", opacity: 0.55, lineHeight: 1.4 },
63
+ /** 开关轨道。 */
64
+ track: {
65
+ position: "relative",
66
+ flexShrink: 0,
67
+ width: "38px",
68
+ height: "22px",
69
+ borderRadius: "11px",
70
+ border: "none",
71
+ padding: 0,
72
+ cursor: "pointer",
73
+ transition: "background .15s ease",
74
+ },
75
+ /** 开关圆点。 */
76
+ knob: {
77
+ position: "absolute",
78
+ top: "3px",
79
+ left: "3px",
80
+ width: "16px",
81
+ height: "16px",
82
+ borderRadius: "50%",
83
+ background: "#fff",
84
+ transition: "transform .15s ease",
85
+ },
86
+ /** 保存按钮。 */
87
+ saveBtn: {
88
+ padding: "6px 14px",
89
+ borderRadius: "6px",
90
+ border: "1px solid rgba(128,128,128,.32)",
91
+ background: "rgba(128,128,128,.18)",
92
+ color: "inherit",
93
+ fontSize: "12px",
94
+ fontWeight: 500,
95
+ },
96
+ /** 底部说明文本。 */
97
+ meta: {
98
+ marginTop: "12px",
99
+ fontSize: "11px",
100
+ lineHeight: 1.6,
101
+ opacity: 0.5,
102
+ wordBreak: "break-all",
103
+ },
104
+ row: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" },
105
+ input: {
106
+ flex: 1,
107
+ minWidth: "200px",
108
+ padding: "7px 10px",
109
+ borderRadius: "6px",
110
+ border: "1px solid rgba(128,128,128,.32)",
111
+ background: "transparent",
112
+ color: "inherit",
113
+ fontSize: "12px",
114
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
115
+ },
116
+ btn: (active, tone) => ({
117
+ padding: "6px 12px",
118
+ borderRadius: "6px",
119
+ border: `1px solid ${tone ?? "rgba(128,128,128,.32)"}`,
120
+ background: active ? "rgba(99,102,241,.16)" : "transparent",
121
+ color: active ? "#818cf8" : "inherit",
122
+ cursor: "pointer",
123
+ fontSize: "12px",
124
+ }),
125
+ kv: { display: "flex", gap: "8px", padding: "3px 0", fontSize: "12px" },
126
+ k: { width: "86px", opacity: 0.6, flexShrink: 0 },
127
+ v: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", wordBreak: "break-all" },
128
+ /** 决策点小节标题。 */
129
+ sectionTitle: {
130
+ marginTop: "18px",
131
+ marginBottom: "4px",
132
+ fontSize: "12px",
133
+ fontWeight: 600,
134
+ opacity: 0.75,
135
+ letterSpacing: ".02em",
136
+ },
137
+ /** 单个决策点卡片。 */
138
+ pointCard: (dim) => ({
139
+ display: "flex",
140
+ alignItems: "flex-start",
141
+ gap: "10px",
142
+ padding: "9px 10px",
143
+ marginTop: "6px",
144
+ borderRadius: "8px",
145
+ border: "1px solid rgba(128,128,128,.2)",
146
+ background: "rgba(128,128,128,.06)",
147
+ opacity: dim ? 0.5 : 1,
148
+ }),
149
+ pointText: { flex: 1, minWidth: 0 },
150
+ pointName: { fontSize: "12px", fontWeight: 600 },
151
+ pointHint: { fontSize: "11px", opacity: 0.55, lineHeight: 1.45, marginTop: "2px" },
152
+ pointMeta: {
153
+ fontSize: "10px",
154
+ opacity: 0.45,
155
+ marginTop: "3px",
156
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
157
+ },
158
+ /** 三态分段控件。 */
159
+ seg: {
160
+ display: "flex",
161
+ flexShrink: 0,
162
+ gap: "2px",
163
+ padding: "2px",
164
+ borderRadius: "7px",
165
+ border: "1px solid rgba(128,128,128,.24)",
166
+ },
167
+ segItem: (active, tone) => ({
168
+ padding: "3px 8px",
169
+ borderRadius: "5px",
170
+ border: "none",
171
+ background: active ? (tone ?? "rgba(99,102,241,.28)") : "transparent",
172
+ color: active ? "#fff" : "inherit",
173
+ cursor: "pointer",
174
+ fontSize: "11px",
175
+ whiteSpace: "nowrap",
176
+ }),
177
+ /** 「即将支持」角标。 */
178
+ badge: {
179
+ flexShrink: 0,
180
+ alignSelf: "center",
181
+ padding: "3px 8px",
182
+ borderRadius: "6px",
183
+ border: "1px dashed rgba(128,128,128,.4)",
184
+ fontSize: "10px",
185
+ opacity: 0.55,
186
+ whiteSpace: "nowrap",
187
+ },
188
+ pre: {
189
+ margin: "8px 0 0",
190
+ padding: "8px 10px",
191
+ borderRadius: "6px",
192
+ background: "rgba(0,0,0,.22)",
193
+ fontSize: "11px",
194
+ lineHeight: 1.5,
195
+ whiteSpace: "pre-wrap",
196
+ wordBreak: "break-all",
197
+ maxHeight: "180px",
198
+ overflow: "auto",
199
+ },
200
+ };
201
+
202
+ /** 统一 fetch:带 content-type、no-store,并把错误转成可展示文本。 */
203
+ async function call(path, init) {
204
+ const response = await fetch(BASE + path, {
205
+ ...init,
206
+ headers: { "cache-control": "no-store", ...(init?.headers ?? {}) },
207
+ });
208
+ const text = await response.text();
209
+ let data;
210
+ try {
211
+ data = JSON.parse(text);
212
+ } catch {
213
+ data = text;
214
+ }
215
+ if (!response.ok) throw new Error(typeof data === "object" ? data.error ?? `HTTP ${response.status}` : String(data));
216
+ return data;
217
+ }
218
+
219
+ /** 主面板:配置 + 状态 + 一键测试 + 最近决策。 */
220
+ /**
221
+ * 配置页:仿照内置 dsh-rewind-plugin 的卡片布局——
222
+ * 每行左侧是「标题 + 灰色说明」,右侧是开关;底部一个「保存」按钮。
223
+ *
224
+ * 数据全部走本插件自己的 `/api/decision-engine/*`,**不用** settings 服务
225
+ *(旧插件死在 `settings.installSection` 上)。保存即改写 config.yml。
226
+ *
227
+ * @param props - 槽位属性;`view === "summary"` 时只渲染一行摘要文本。
228
+ */
229
+ function StartluxPage(props) {
230
+ const [status, setStatus] = react.useState(null);
231
+ // 密钥输入按 provider 分槽:切到别的 provider 时,这一格不该带过去,
232
+ // 否则容易把 A 的 key 存到 B 名下。
233
+ const [keyInputs, setKeyInputs] = react.useState({ systemone: "", bamboonode: "", custom: "" });
234
+ const [busy, setBusy] = react.useState("");
235
+ const [msg, setMsg] = react.useState(null);
236
+ const [loading, setLoading] = react.useState(true);
237
+ // 草稿初值是 null(= 还没有用户改动)。渲染时从服务端状态派生,
238
+ // 而不是在这里写一份硬编码默认:否则「status 已回来但 useEffect 还没跑」
239
+ // 的那一帧会显示错的值(例如把 custom 显示成 bamboonode)。
240
+ const [draftState, setDraft] = react.useState(null);
241
+ const [dirty, setDirty] = react.useState(false);
242
+ // provider 草稿:与主配置分开保存,因为 custom 的五个字段是「先填后切」,
243
+ // 混进主 draft 会让「只切 provider」也顺手把没填完的 custom 值写进去。
244
+ const [providerDraftState, setProviderDraft] = react.useState(null);
245
+ // 保存位置:credentials(默认)| config
246
+ const [storageDraft, setStorageDraft] = react.useState(null);
247
+ // 服务端校验失败的原因(如 base URL 非法)单独展示,与「已保存」区分。
248
+ const [providerError, setProviderError] = react.useState("");
249
+ // 可选模型列表:从 /models 拉取(只含决策端点真正接受的模型)。
250
+ const [models, setModels] = react.useState([]);
251
+ const [modelsState, setModelsState] = react.useState("idle");
252
+ // 决策点:元数据与实测分账都由服务端下发,前端不硬编码清单。
253
+ const [decisions, setDecisions] = react.useState(null);
254
+ // 待保存的模式改动(id → mode);null 表示该行未改动,沿用服务端值。
255
+ const [pointsDraft, setPointsDraft] = react.useState({});
256
+ const [budgetDraft, setBudgetDraft] = react.useState(null);
257
+
258
+ // 用 ref 保存「用户是否有未保存改动」,使 refresh 不因 dirty 变化而重建。
259
+ // 否则保存时 setDirty(false) → refresh 换新引用 → useEffect 再跑一次,
260
+ // 与 save 里的 setStatus 竞争,可能用旧值把刚保存的开关覆盖回去(表现为回弹)。
261
+ const dirtyRef = react.useRef(false);
262
+ const savingRef = react.useRef(false);
263
+
264
+ const refresh = react.useCallback(async () => {
265
+ // 保存进行中不拉状态,避免用旧快照覆盖刚写入的结果。
266
+ if (savingRef.current === true) return;
267
+ try {
268
+ const next = await call("/status");
269
+ setStatus(next);
270
+ if (dirtyRef.current !== true) {
271
+ setDraft({
272
+ enabled: next.enabled === true,
273
+ mode: next.mode ?? "shadow",
274
+ thinkMode: next.thinkMode ?? "never",
275
+ model: next.rawModel ?? "",
276
+ systemoneModel: next.rawSystemoneModel ?? "",
277
+ });
278
+ // provider 回显:保存后重新打开必须看到自己选的那一项与 custom 值。
279
+ // 全部来自服务端 status,前端不缓存、不推断。
280
+ setProviderDraft({
281
+ provider: next.provider ?? "bamboonode",
282
+ customBaseUrl: next.customBaseUrl ?? "",
283
+ customEndpointStyle: next.customEndpointStyle ?? "auto",
284
+ customModel: next.customModel ?? "",
285
+ customHeaders: next.customHeaders ?? "",
286
+ customApiKeyEnv: next.customApiKeyEnv ?? "",
287
+ });
288
+ setProviderError(next.providerValid === false ? next.providerError ?? "配置不可用" : "");
289
+ }
290
+ setMsg(null);
291
+ } catch (error) {
292
+ setMsg({ ok: false, text: error.message });
293
+ } finally {
294
+ setLoading(false);
295
+ }
296
+ }, []);
297
+
298
+ // 决策点与模型列表都拉一次;失败不阻塞页面渲染。
299
+ react.useEffect(() => {
300
+ refresh();
301
+ loadDecisions();
302
+ }, [refresh]);
303
+
304
+ /** 拉取可用决策模型;失败不阻塞页面,只标记状态供 UI 提示。 */
305
+ const loadModels = react.useCallback(async () => {
306
+ setModelsState("loading");
307
+ try {
308
+ const result = await call("/models");
309
+ const list = Array.isArray(result.models) ? result.models : [];
310
+ setModels(list);
311
+ setModelsState(list.length > 0 ? "ok" : "empty");
312
+ } catch {
313
+ setModels([]);
314
+ setModelsState("error");
315
+ }
316
+ }, []);
317
+
318
+ /** 拉决策点总览(含每个点的实测 token / 延迟 / 缓存与自身预算)。 */
319
+ const loadDecisions = react.useCallback(async () => {
320
+ try {
321
+ const result = await call("/decisions");
322
+ setDecisions(result);
323
+ } catch {
324
+ // 拉不到就不显示这一节,而不是显示一排空值误导用户。
325
+ setDecisions(null);
326
+ }
327
+ }, []);
328
+
329
+ // summary 视图只输出一行纯文本(插件列表/包详情用它做摘要)。
330
+ if (props !== null && props !== undefined && props.view === "summary") {
331
+ return "Decision Engine 守卫";
332
+ }
333
+
334
+ // 即使状态还没加载回来(或 API 失败),也**照常渲染表单**——
335
+ // API Key 输入框必须在任何情况下都可见可填,否则一旦 status 请求失败
336
+ // 用户就永远看不到输入框。status 缺失时用安全默认值兜底。
337
+ //
338
+ // 兜底值刻意**不含任何具体服务商地址**:地址一律由服务端按 provider
339
+ // 预设下发(providerCatalog / baseUrl)。前端自己写一份默认地址,
340
+ // 就会出现「界面显示 A、实际请求 B」这类最难排查的偏差。
341
+ const view = status ?? {
342
+ enabled: false,
343
+ mode: "shadow",
344
+ provider: "bamboonode",
345
+ providerLabel: "bamboonode",
346
+ providerValid: true,
347
+ providerError: "",
348
+ providerCatalog: [],
349
+ baseUrl: "",
350
+ model: "",
351
+ timeoutMs: 20000,
352
+ keyConfigured: false,
353
+ keyHint: "",
354
+ keyStorage: "credentials",
355
+ keyStatus: {},
356
+ // 兜底也要给出思考模式取值:status 请求失败时下拉框仍要能显示。
357
+ thinkMode: "never",
358
+ thinkModes: ["never", "auto", "always"],
359
+ stats: { requests: 0, cached: 0 },
360
+ };
361
+
362
+ /** 生效草稿:没有用户改动时一律从服务端状态派生。 */
363
+ const draft = draftState ?? {
364
+ enabled: view.enabled === true,
365
+ mode: view.mode ?? "shadow",
366
+ // 思考模式也走 draft:改完点保存才落盘,与开关语义一致。
367
+ thinkMode: view.thinkMode ?? "never",
368
+ model: view.rawModel ?? "",
369
+ systemoneModel: view.rawSystemoneModel ?? "",
370
+ };
371
+ const providerDraft = providerDraftState ?? {
372
+ provider: view.provider ?? "bamboonode",
373
+ customBaseUrl: view.customBaseUrl ?? "",
374
+ customEndpointStyle: view.customEndpointStyle ?? "auto",
375
+ customModel: view.customModel ?? "",
376
+ customHeaders: view.customHeaders ?? "",
377
+ customApiKeyEnv: view.customApiKeyEnv ?? "",
378
+ };
379
+
380
+ /**
381
+ * 把服务端返回的完整状态写回本地草稿。
382
+ *
383
+ * 保存成功后**必须**用它而不是各自 setDraft:否则 provider、custom 字段、
384
+ * 主配置三处会各自漂移,表现为「保存后界面显示的还是旧值」。
385
+ */
386
+ const applyServerState = (next) => {
387
+ setStatus(next);
388
+ setDraft({
389
+ enabled: next.enabled === true,
390
+ mode: next.mode ?? "shadow",
391
+ thinkMode: next.thinkMode ?? "never",
392
+ model: next.rawModel ?? "",
393
+ systemoneModel: next.rawSystemoneModel ?? "",
394
+ });
395
+ setProviderDraft({
396
+ provider: next.provider ?? "bamboonode",
397
+ customBaseUrl: next.customBaseUrl ?? "",
398
+ customEndpointStyle: next.customEndpointStyle ?? "auto",
399
+ customModel: next.customModel ?? "",
400
+ customHeaders: next.customHeaders ?? "",
401
+ customApiKeyEnv: next.customApiKeyEnv ?? "",
402
+ });
403
+ setProviderError(next.providerValid === false ? next.providerError ?? "配置不可用" : "");
404
+ setDirty(false);
405
+ dirtyRef.current = false;
406
+ };
407
+
408
+ /**
409
+ * provider 相关改动是否待保存。
410
+ *
411
+ * 逐字段比较而不是维护一个独立的 dirty 标记:切到 custom 又切回来、
412
+ * 或者填了字又删掉,都应当回到「无改动」而不是永远亮着保存按钮。
413
+ */
414
+ const providerChanged = (() => {
415
+ const server = {
416
+ provider: view.provider ?? "bamboonode",
417
+ customBaseUrl: view.customBaseUrl ?? "",
418
+ customEndpointStyle: view.customEndpointStyle ?? "auto",
419
+ customModel: view.customModel ?? "",
420
+ customHeaders: view.customHeaders ?? "",
421
+ customApiKeyEnv: view.customApiKeyEnv ?? "",
422
+ };
423
+ const storageChanged = storageDraft !== null && storageDraft !== (view.keyStorage ?? "credentials");
424
+ const draftChanged = Object.keys(server).some(
425
+ (key) => (providerDraft[key] ?? "") !== (server[key] ?? ""),
426
+ );
427
+ return draftChanged || storageChanged;
428
+ })();
429
+
430
+ const save = react.useCallback(
431
+ async (patch) => {
432
+ setBusy("save");
433
+ setMsg(null);
434
+ savingRef.current = true;
435
+ try {
436
+ const next = await call("/config", {
437
+ method: "PUT",
438
+ headers: { "content-type": "application/json" },
439
+ body: JSON.stringify(patch),
440
+ });
441
+ applyServerState(next);
442
+ setMsg({ ok: true, text: "已保存" });
443
+ if (Object.hasOwn(patch, "apiKey")) setKeyInput("");
444
+ } catch (error) {
445
+ // 校验失败(非法 URL / 缺必填)走这里:保留用户已填内容,
446
+ // 只把原因显示出来——清空表单会让用户重填一遍。
447
+ setMsg({ ok: false, text: error.message });
448
+ setProviderError(error.message);
449
+ } finally {
450
+ savingRef.current = false;
451
+ setBusy("");
452
+ }
453
+ },
454
+ [],
455
+ );
456
+
457
+ // 决策点/预算是否有待保存改动。必须在 saveDecisions 之前算:
458
+ // useCallback 的依赖数组会在本次渲染立即求值,放到后面会踩 TDZ。
459
+ const pointsChanged = Object.keys(pointsDraft).some((id) => {
460
+ const original = (decisions?.points ?? []).find((item) => item.id === id);
461
+ return original !== undefined && pointsDraft[id] !== original.mode;
462
+ });
463
+ const budgetChanged = budgetDraft !== null && budgetDraft !== (decisions?.budget?.limit ?? 0);
464
+ const decisionsChanged = pointsChanged || budgetChanged;
465
+
466
+ /**
467
+ * 保存决策点与预算。
468
+ *
469
+ * 走独立的 `/decisions` 而不是复用 `/config`:那两份配置的校验规则不同
470
+ *(决策点要拒绝未实装的点、预算要 clamp),混在一起容易互相放宽。
471
+ */
472
+ const saveDecisions = react.useCallback(async () => {
473
+ const body = {};
474
+ if (pointsChanged) body.points = pointsDraft;
475
+ if (budgetChanged) body.budgetTokens = budgetDraft;
476
+ if (Object.keys(body).length === 0) return true;
477
+ const next = await call("/decisions", {
478
+ method: "PUT",
479
+ headers: { "content-type": "application/json" },
480
+ body: JSON.stringify(body),
481
+ });
482
+ setDecisions(next);
483
+ setPointsDraft({});
484
+ setBudgetDraft(null);
485
+ return true;
486
+ }, [pointsChanged, budgetChanged, pointsDraft, budgetDraft]);
487
+
488
+ /** 一次点击提交两半:主配置 + 决策点。任一失败都要报错,不能静默半保存。 */
489
+ const saveAll = react.useCallback(async () => {
490
+ setBusy("save");
491
+ setMsg(null);
492
+ savingRef.current = true;
493
+ setProviderError("");
494
+ try {
495
+ const patch = {
496
+ enabled: draft.enabled === true,
497
+ mode: draft.mode,
498
+ // 思考模式总是提交:它是全局项(与 provider 无关),
499
+ // 服务端还会再校验一次合法取值。
500
+ thinkMode: draft.thinkMode ?? "never",
501
+ // 模型字段只写当前 provider 自己那一格(model / systemoneModel /
502
+ // customModel)。混写会让「切过去再切回来」丢掉原值,也会把
503
+ // bamboonode 的模型名发给别的服务。
504
+ ...(providerDraft.provider === "custom"
505
+ ? { customModel: (providerDraft.customModel ?? "").trim() }
506
+ : providerDraft.provider === "systemone"
507
+ ? { systemoneModel: (draft.systemoneModel ?? "").trim() }
508
+ : { model: (draft.model ?? "").trim() }),
509
+ provider: providerDraft.provider,
510
+ // custom 的字段**总是**一起提交:用户可能先填地址再切 provider,
511
+ // 分两次保存会让中间状态被当成最终配置。值为空也照写,
512
+ // 便于用户清空后立刻纠正(服务端只在 provider=custom 时校验必填)。
513
+ customBaseUrl: (providerDraft.customBaseUrl ?? "").trim(),
514
+ customEndpointStyle: providerDraft.customEndpointStyle ?? "auto",
515
+ customHeaders: (providerDraft.customHeaders ?? "").trim(),
516
+ customApiKeyEnv: (providerDraft.customApiKeyEnv ?? "").trim(),
517
+ };
518
+ // 只提交「当前 provider」那一格:切过去时不会顺手存下别的 provider 的 key。
519
+ const activeKey = (keyInputs[providerDraft.provider] ?? "").trim();
520
+ if (activeKey.length >= 10) {
521
+ if (providerDraft.provider === "systemone") patch.systemoneApiKey = activeKey;
522
+ else if (providerDraft.provider === "custom") patch.customApiKey = activeKey;
523
+ else patch.bamboonodeApiKey = activeKey;
524
+ }
525
+ // 保存位置:只在用户改过时才提交,避免每次保存都覆盖。
526
+ if (storageDraft !== null) patch.keyStorage = storageDraft;
527
+ const next = await call("/config", {
528
+ method: "PUT",
529
+ headers: { "content-type": "application/json" },
530
+ body: JSON.stringify(patch),
531
+ });
532
+ applyServerState(next);
533
+ if (Object.hasOwn(patch, "systemoneApiKey") || Object.hasOwn(patch, "bamboonodeApiKey") || Object.hasOwn(patch, "customApiKey")) {
534
+ setKeyInputs({ ...keyInputs, [providerDraft.provider]: "" });
535
+ }
536
+ await saveDecisions();
537
+ setMsg({ ok: true, text: "已保存" });
538
+ } catch (error) {
539
+ setMsg({ ok: false, text: error.message });
540
+ setProviderError(error.message);
541
+ } finally {
542
+ savingRef.current = false;
543
+ setBusy("");
544
+ }
545
+ }, [draft, keyInputs, storageDraft, providerDraft, saveDecisions]);
546
+
547
+ const runTest = react.useCallback(async () => {
548
+ setBusy("test");
549
+ setMsg(null);
550
+ setProviderError("");
551
+ try {
552
+ const result = await call("/test", { method: "POST" });
553
+ // 服务端返回的整段诊断(provider / 端点 / HTTP 码 / 请求 id / 响应体)
554
+ // 原样展示:只报「失败」而不给细节,用户无法判断该改地址还是改 key。
555
+ setMsg({
556
+ ok: result.ok === true,
557
+ text: result.ok === true ? `端点连通 ✅\n${result.text ?? ""}` : `${result.text ?? "端点返回失败"}`,
558
+ });
559
+ if (result.ok !== true) setProviderError("端点不可用:请对照上方诊断检查 provider、base URL 与 API key");
560
+ } catch (error) {
561
+ setMsg({ ok: false, text: error.message });
562
+ setProviderError(error.message);
563
+ } finally {
564
+ setBusy("");
565
+ }
566
+ }, []);
567
+
568
+ /** 一行:左标题+说明,右控件。 */
569
+ const row = (title, hint, control, key) =>
570
+ h(
571
+ "div",
572
+ { style: S.rowLine, key },
573
+ h(
574
+ "div",
575
+ { style: S.rowText },
576
+ h("div", { style: S.rowTitle }, title),
577
+ hint === null ? null : h("div", { style: S.rowHint }, hint),
578
+ ),
579
+ control,
580
+ );
581
+
582
+ /** 开关:仿内置样式(轨道 + 圆点)。 */
583
+ const toggle = (on, onClick) =>
584
+ h(
585
+ "button",
586
+ {
587
+ type: "button",
588
+ onClick,
589
+ disabled: busy !== "",
590
+ style: {
591
+ ...S.track,
592
+ background: on ? "rgba(74,222,128,.55)" : "rgba(128,128,128,.28)",
593
+ },
594
+ "aria-pressed": on,
595
+ },
596
+ h("span", {
597
+ style: { ...S.knob, transform: on ? "translateX(16px)" : "translateX(0)" },
598
+ }),
599
+ );
600
+
601
+ /**
602
+ * 密钥保存位置:用户改过就用草稿,否则用服务端的值。
603
+ *
604
+ * 默认 credentials —— 与 DSH 其他服务统一托管,不必在插件目录留第二份明文。
605
+ */
606
+ const storageValue = storageDraft ?? view.keyStorage ?? "credentials";
607
+
608
+ // 每个 provider 自己的 key 状态(服务端下发的 keyStatus)。
609
+ const keyStatusOf = (id) => (view.keyStatus ?? {})[id] ?? null;
610
+ const activeKeyStatus = keyStatusOf(providerDraft.provider);
611
+ const keyOk = activeKeyStatus !== null ? activeKeyStatus.configured === true : view.keyConfigured === true;
612
+
613
+ /** provider 预设清单(服务端下发;拿不到时只显示当前项)。 */
614
+ const catalog = Array.isArray(view.providerCatalog) ? view.providerCatalog : [];
615
+ const activeProvider = catalog.find((item) => item.id === providerDraft.provider) ?? null;
616
+ const isCustom = providerDraft.provider === "custom";
617
+
618
+ /**
619
+ * 模型字段按 provider 分派。
620
+ *
621
+ * bamboonode -> model (老版本唯一的模型键)
622
+ * systemone -> systemoneModel
623
+ * custom -> customModel
624
+ *
625
+ * 三者分开存,切换 provider 才不会把 A 的模型名发给 B。
626
+ */
627
+ const isSystemOne = providerDraft.provider === "systemone";
628
+ const modelValue = isCustom
629
+ ? providerDraft.customModel ?? ""
630
+ : isSystemOne
631
+ ? draft.systemoneModel ?? ""
632
+ : draft.model ?? "";
633
+ const setModelValue = (value) => {
634
+ if (isCustom) setProviderDraft({ ...providerDraft, customModel: value });
635
+ else if (isSystemOne) setDraft({ ...draft, systemoneModel: value });
636
+ else setDraft({ ...draft, model: value });
637
+ setDirty(true);
638
+ dirtyRef.current = true;
639
+ };
640
+
641
+ /**
642
+ * 展示用的错误文案:本地保存失败的提示优先,其次是服务端回报的
643
+ * 「当前配置不可用」。后者必须能在首帧就显示——它是升级后配置坏掉的
644
+ * 唯一线索,等用户点一次保存才出现就太晚了。
645
+ */
646
+ const providerErrorText =
647
+ providerError.length > 0
648
+ ? providerError
649
+ : view.providerValid === false
650
+ ? view.providerError ?? "当前 provider 配置不可用"
651
+ : "";
652
+
653
+ /**
654
+ * 本地预校验:与服务端同规则,只是为了让用户在点保存之前就看到问题。
655
+ * 服务端仍会独立校验(前端校验不可信),这里只求「早一点提示」。
656
+ */
657
+ const customUrlError = (() => {
658
+ if (!isCustom) return "";
659
+ const raw = (providerDraft.customBaseUrl ?? "").trim();
660
+ if (raw.length === 0) return "必填:请填写 system-one 风格服务的 base URL";
661
+ if (/\s/.test(raw)) return "不能包含空格或换行";
662
+ if (!/^https?:\/\//i.test(raw)) return "必须以 http:// 或 https:// 开头";
663
+ try {
664
+ const url = new URL(raw);
665
+ if (url.search.length > 0) return "不要带查询串(? 之后的部分)";
666
+ } catch {
667
+ return "不是合法地址(示例:https://api.example.com/v1)";
668
+ }
669
+ return "";
670
+ })();
671
+
672
+ /** provider 提示文案:优先用服务端下发的说明,避免前端另写一份。 */
673
+ const providerHint =
674
+ loading === true
675
+ ? "正在读取服务端配置…"
676
+ : activeProvider === null
677
+ ? "服务端未返回 provider 列表;当前选择仍会生效"
678
+ : isCustom
679
+ ? "自定义 system-one 风格服务:自建 llama.cpp / vLLM、企业网关或第三方中转"
680
+ : activeProvider.hint ?? "";
681
+
682
+ return h(
683
+ "div",
684
+ { style: S.wrap },
685
+ // 0) 服务提供方:最高层的选择,决定地址与鉴权从哪来
686
+ row(
687
+ "服务提供方",
688
+ providerHint,
689
+ h(
690
+ "select",
691
+ {
692
+ style: { ...S.input, minWidth: "180px", flex: "none" },
693
+ value: providerDraft.provider,
694
+ disabled: busy !== "",
695
+ onChange: (event) => {
696
+ const next = event.target.value;
697
+ setProviderDraft({ ...providerDraft, provider: next });
698
+ // 切换 provider 只改「当前用哪个」,不动其它 provider 已保存的值:
699
+ // custom 的字段留在 providerDraft 里,切回来还在。
700
+ setProviderError("");
701
+ setDirty(true);
702
+ dirtyRef.current = true;
703
+ },
704
+ },
705
+ ...(catalog.length === 0
706
+ ? [h("option", { value: providerDraft.provider, key: providerDraft.provider }, providerDraft.provider)]
707
+ : catalog.map((item) =>
708
+ h("option", { value: item.id, key: item.id }, item.label),
709
+ )),
710
+ ),
711
+ "provider",
712
+ ),
713
+ // 0.1) 当前 provider 的生效端点:让「实际打到哪」始终可见
714
+ row(
715
+ "生效端点",
716
+ isCustom
717
+ ? "由上方 base URL + 端点风格拼接;已带 /decisions 或 /systemone 时原样使用"
718
+ : "内置预设的默认地址(可用 config.yml 的 baseUrl 覆盖 bamboonode)",
719
+ h(
720
+ "span",
721
+ { style: { ...S.v, fontSize: "11px", opacity: 0.75, maxWidth: "300px" } },
722
+ view.providerValid === false
723
+ ? "配置不可用"
724
+ : view.baseUrl || "(服务端未返回)",
725
+ ),
726
+ "endpoint",
727
+ ),
728
+ // 1) 启用开关
729
+ row(
730
+ "启用守卫",
731
+ "开启后在每次工具调用前做风险判断",
732
+ toggle(draft.enabled === true, () => {
733
+ const next = draft.enabled !== true;
734
+ setDraft({ ...draft, enabled: next });
735
+ setDirty(true);
736
+ dirtyRef.current = true;
737
+ }),
738
+ "enabled",
739
+ ),
740
+ // 2) 拦截开关(shadow / enforce 等价于「只记录」与「真拦截」)
741
+ row(
742
+ "真正拦截",
743
+ "关闭时只记录判断结果(shadow);开启后按判断执行 deny / ask",
744
+ toggle(draft.mode === "enforce", () => {
745
+ const next = draft.mode === "enforce" ? "shadow" : "enforce";
746
+ setDraft({ ...draft, mode: next });
747
+ setDirty(true);
748
+ dirtyRef.current = true;
749
+ }),
750
+ "mode",
751
+ ),
752
+ // 2.1) 思考模式:never 最省延迟,always 判断更充分但每次多等约 5s。
753
+ // 这一项以前只能手改配置文件,界面没有入口(后端一直支持三档)。
754
+ row(
755
+ "思考模式",
756
+ draft.thinkMode === "always"
757
+ ? "总是思考:判断更充分,但每次工具调用约多等 5 秒"
758
+ : draft.thinkMode === "auto"
759
+ ? "仅在模型自己判断需要时思考(折中)"
760
+ : "不思考:最快(约 1.9s),热路径首选",
761
+ h(
762
+ "select",
763
+ {
764
+ style: { ...S.input, minWidth: "190px", flex: "none" },
765
+ value: draft.thinkMode ?? "never",
766
+ disabled: busy !== "",
767
+ onChange: (event) => {
768
+ setDraft({ ...draft, thinkMode: event.target.value });
769
+ setDirty(true);
770
+ dirtyRef.current = true;
771
+ },
772
+ },
773
+ // 选项由服务端下发(thinkModes),前端不硬编码取值,避免漂移。
774
+ ...(Array.isArray(view.thinkModes) && view.thinkModes.length > 0
775
+ ? view.thinkModes
776
+ : ["never", "auto", "always"]
777
+ ).map((value) =>
778
+ h(
779
+ "option",
780
+ { key: value, value },
781
+ value === "never"
782
+ ? "never(不思考)"
783
+ : value === "auto"
784
+ ? "auto(按需思考)"
785
+ : "always(总是思考)",
786
+ ),
787
+ ),
788
+ ),
789
+ "thinkMode",
790
+ ),
791
+ // 3) 模型:下拉选择,含「自动(服务端默认)」
792
+ // custom 用自己那一格(customModel),内置预设共用 model——
793
+ // 分开存才能做到「切 provider 不覆盖其它 provider 已保存的模型」。
794
+ row(
795
+ "模型",
796
+ modelsState === "ok"
797
+ ? "从服务商实时获取;选「自动」则由服务端决定,换名也不会失效"
798
+ : modelsState === "loading"
799
+ ? "正在获取模型列表…"
800
+ : modelsState === "error"
801
+ ? "获取失败,可手填或留空用服务端默认"
802
+ : "未获取到列表,可手填或留空用服务端默认",
803
+ h(
804
+ "div",
805
+ { style: { display: "flex", gap: "6px", alignItems: "center", flex: 1, minWidth: 0 } },
806
+ h(
807
+ "select",
808
+ {
809
+ style: { ...S.input, minWidth: "220px" },
810
+ value: modelValue ?? "",
811
+ onChange: (event) => setModelValue(event.target.value),
812
+ },
813
+ h("option", { value: "" }, "自动(服务端默认)"),
814
+ // 当前值不在列表里时也保留,避免下拉把已配置的值吞掉。
815
+ ...((modelValue ?? "") === "" || models.some((item) => item.id === modelValue)
816
+ ? []
817
+ : [h("option", { value: modelValue, key: "current-model" }, `${modelValue}(当前值)`)]),
818
+ ...models.map((item) => h("option", { value: item.id, key: item.id }, item.id)),
819
+ ),
820
+ h(
821
+ "button",
822
+ {
823
+ type: "button",
824
+ onClick: loadModels,
825
+ disabled: modelsState === "loading",
826
+ style: { ...S.saveBtn, flexShrink: 0, padding: "6px 10px" },
827
+ },
828
+ modelsState === "loading" ? "…" : "获取",
829
+ ),
830
+ ),
831
+ "model",
832
+ ),
833
+ // 4) API Key:每个 provider 一格,各自保存各自的 key
834
+ row(
835
+ `API Key${keyOk ? `(已配置 ${activeKeyStatus?.hint ?? view.keyHint ?? ""})` : "(未配置)"}`,
836
+ activeKeyStatus !== null && activeKeyStatus.refName
837
+ ? `存到「${storageValue === "config" ? "插件配置" : ".credentials.yaml"}」的 ${activeKeyStatus.refName}`
838
+ : "留空则不修改;粘贴新 key 后点保存",
839
+ h("input", {
840
+ type: "password",
841
+ style: S.input,
842
+ placeholder: keyOk ? "sk-…(留空不改)" : "sk-…",
843
+ value: keyInputs[providerDraft.provider] ?? "",
844
+ onChange: (event) =>
845
+ setKeyInputs({ ...keyInputs, [providerDraft.provider]: event.target.value }),
846
+ onKeyDown: (event) => {
847
+ if (event.key !== "Enter") return;
848
+ if ((keyInputs[providerDraft.provider] ?? "").trim().length >= 10) void saveAll();
849
+ },
850
+ }),
851
+ "key",
852
+ ),
853
+ // 4.1) 密钥保存位置:默认托管到 .credentials.yaml
854
+ row(
855
+ "密钥保存位置",
856
+ storageValue === "config"
857
+ ? "存进插件配置 config.yml(自包含,便于随目录备份;注意是明文)"
858
+ : "存进 ~/.dsh/.credentials.yaml(默认,与其他服务统一托管)",
859
+ h(
860
+ "select",
861
+ {
862
+ style: { ...S.input, minWidth: "190px", flex: "none" },
863
+ value: storageValue,
864
+ disabled: busy !== "",
865
+ onChange: (event) => {
866
+ setStorageDraft(event.target.value);
867
+ setDirty(true);
868
+ dirtyRef.current = true;
869
+ },
870
+ },
871
+ h("option", { value: "credentials" }, ".credentials.yaml(默认)"),
872
+ h("option", { value: "config" }, "插件配置 config.yml"),
873
+ ),
874
+ "keyStorage",
875
+ ),
876
+ // 5) custom 专属字段:只有选了 custom 才出现,避免内置预设被无用输入干扰
877
+ isCustom
878
+ ? h(
879
+ "div",
880
+ { key: "custom" },
881
+ h("div", { style: S.sectionTitle }, "自定义 provider"),
882
+ row(
883
+ "Base URL(必填)",
884
+ customUrlError.length > 0
885
+ ? customUrlError
886
+ : "任意 system-one 风格服务;末尾斜杠会自动规范化,端点段不会重复",
887
+ h("input", {
888
+ type: "text",
889
+ style: {
890
+ ...S.input,
891
+ borderColor: customUrlError.length > 0 ? "rgba(248,113,113,.75)" : "rgba(128,128,128,.32)",
892
+ },
893
+ placeholder: "https://api.example.com/v1",
894
+ value: providerDraft.customBaseUrl ?? "",
895
+ onChange: (event) => {
896
+ setProviderDraft({ ...providerDraft, customBaseUrl: event.target.value });
897
+ setProviderError("");
898
+ setDirty(true);
899
+ dirtyRef.current = true;
900
+ },
901
+ }),
902
+ "custom-url",
903
+ ),
904
+ row(
905
+ "端点风格",
906
+ "auto:地址已带 /decisions 或 /systemone 就原样用,否则按 /decisions 拼;也可强制指定",
907
+ h(
908
+ "select",
909
+ {
910
+ style: { ...S.input, minWidth: "150px", flex: "none" },
911
+ value: providerDraft.customEndpointStyle ?? "auto",
912
+ onChange: (event) => {
913
+ setProviderDraft({ ...providerDraft, customEndpointStyle: event.target.value });
914
+ setDirty(true);
915
+ dirtyRef.current = true;
916
+ },
917
+ },
918
+ h("option", { value: "auto" }, "auto(自动)"),
919
+ h("option", { value: "decisions" }, "decisions"),
920
+ h("option", { value: "systemone" }, "systemone"),
921
+ ),
922
+ "custom-style",
923
+ ),
924
+ row(
925
+ "附加请求头",
926
+ "可留空。格式 `名称: 值`,多项用分号分隔;authorization / content-type 由插件固定",
927
+ h("input", {
928
+ type: "text",
929
+ style: S.input,
930
+ placeholder: "X-Tenant: team-a; X-Trace: dsh",
931
+ value: providerDraft.customHeaders ?? "",
932
+ onChange: (event) => {
933
+ setProviderDraft({ ...providerDraft, customHeaders: event.target.value });
934
+ setDirty(true);
935
+ dirtyRef.current = true;
936
+ },
937
+ }),
938
+ "custom-headers",
939
+ ),
940
+ row(
941
+ "密钥变量名",
942
+ "可留空。填了就从这个环境变量或 ~/.dsh/.credentials.yaml 的 refs 段取 key",
943
+ h("input", {
944
+ type: "text",
945
+ style: S.input,
946
+ placeholder: "MY_DECISION_API_KEY",
947
+ value: providerDraft.customApiKeyEnv ?? "",
948
+ onChange: (event) => {
949
+ setProviderDraft({ ...providerDraft, customApiKeyEnv: event.target.value });
950
+ setDirty(true);
951
+ dirtyRef.current = true;
952
+ },
953
+ }),
954
+ "custom-env",
955
+ ),
956
+ )
957
+ : null,
958
+ // 6) provider 校验错误:必须显式显示,否则用户只会看到「保存失败」
959
+ providerErrorText.length > 0
960
+ ? h(
961
+ "div",
962
+ {
963
+ key: "provider-error",
964
+ style: {
965
+ ...S.pointHint,
966
+ marginTop: "8px",
967
+ color: "#f87171",
968
+ fontSize: "12px",
969
+ opacity: 1,
970
+ },
971
+ },
972
+ providerErrorText,
973
+ )
974
+ : null,
975
+ // 4.5) 决策点:让决策模型进入哪些环节
976
+ decisions === null
977
+ ? null
978
+ : h(
979
+ "div",
980
+ { key: "points" },
981
+ h("div", { style: S.sectionTitle }, "决策模型参与点"),
982
+ ...(decisions.points ?? []).map((point) => {
983
+ const current = Object.hasOwn(pointsDraft, point.id) ? pointsDraft[point.id] : point.mode;
984
+ const changed = Object.hasOwn(pointsDraft, point.id) && pointsDraft[point.id] !== point.mode;
985
+ const st = point.stats ?? {};
986
+ // 「可用」与「已挂载」是两件事:点已实装(available),但宿主
987
+ // 缺少它依赖的服务时挂载会失败(mounted=false)。两者都真才能切。
988
+ const dim = point.available !== true || point.mounted === false;
989
+ const meta =
990
+ `${point.hook} · ${point.trigger}` +
991
+ (st.requests
992
+ ? ` · 已判 ${st.requests} 次,缓存 ${st.cached},${st.tokens} token,均 ${st.avgLatencyMs}ms`
993
+ : "");
994
+ return h(
995
+ "div",
996
+ { style: S.pointCard(dim), key: point.id },
997
+ h(
998
+ "div",
999
+ { style: S.pointText },
1000
+ h(
1001
+ "div",
1002
+ { style: S.pointName },
1003
+ point.label,
1004
+ // 热路径标记:这些点每次请求/每步都触发,必须靠配置的闸控量。
1005
+ point.hotPath === true
1006
+ ? h("span", { style: { ...S.badge, marginLeft: "6px", opacity: 0.7 } }, "热路径")
1007
+ : null,
1008
+ ),
1009
+ h("div", { style: S.pointHint }, point.summary),
1010
+ // 闸:为什么这个点不是「每次都问」。用户需要知道它何时真的会发请求。
1011
+ point.gate === undefined || point.gate === null
1012
+ ? null
1013
+ : h("div", { style: S.pointHint }, `触发闸:${point.gate}`),
1014
+ h("div", { style: S.pointMeta }, meta),
1015
+ ),
1016
+ dim
1017
+ ? h(
1018
+ "span",
1019
+ { style: S.badge },
1020
+ point.mounted === false ? "宿主缺服务" : "即将支持",
1021
+ )
1022
+ : h(
1023
+ "div",
1024
+ { style: S.seg },
1025
+ ...(decisions.modes ?? ["off", "observe", "enforce"]).map((mode) =>
1026
+ h(
1027
+ "button",
1028
+ {
1029
+ type: "button",
1030
+ key: mode,
1031
+ disabled: busy !== "",
1032
+ onClick: () => setPointsDraft({ ...pointsDraft, [point.id]: mode }),
1033
+ style: S.segItem(
1034
+ current === mode,
1035
+ mode === "enforce"
1036
+ ? "rgba(74,222,128,.35)"
1037
+ : mode === "observe"
1038
+ ? "rgba(245,158,11,.32)"
1039
+ : void 0,
1040
+ ),
1041
+ },
1042
+ mode === "off" ? "关闭" : mode === "observe" ? "观察" : "生效",
1043
+ ),
1044
+ ),
1045
+ ),
1046
+ changed ? h("span", { style: { ...S.badge, borderStyle: "solid", opacity: 0.9 } }, "待保存") : null,
1047
+ );
1048
+ }),
1049
+ // 自身预算:决策模型自己也烧 token,不设上限会变成主要消耗方。
1050
+ h(
1051
+ "div",
1052
+ { style: { ...S.rowLine, marginTop: "10px" }, key: "budget" },
1053
+ h(
1054
+ "div",
1055
+ { style: S.rowText },
1056
+ h("div", { style: S.rowTitle }, "自身 token 预算"),
1057
+ h(
1058
+ "div",
1059
+ { style: S.rowHint },
1060
+ `每小时上限,超出后自动退回启发式并放行(当前已用 ${decisions.budget?.used ?? 0})`,
1061
+ ),
1062
+ ),
1063
+ h("input", {
1064
+ type: "number",
1065
+ min: 0,
1066
+ step: 10000,
1067
+ style: { ...S.input, maxWidth: "130px", flex: "none" },
1068
+ defaultValue: decisions.budget?.limit ?? 0,
1069
+ onChange: (event) => {
1070
+ const value = Number(event.target.value);
1071
+ setBudgetDraft(Number.isFinite(value) ? Math.max(0, Math.floor(value)) : null);
1072
+ },
1073
+ }),
1074
+ ),
1075
+ // 缓存命中:0 命中说明白名单外的调用每次都真发请求,是主要浪费。
1076
+ h(
1077
+ "div",
1078
+ { style: S.pointMeta, key: "cache" },
1079
+ `缓存 ${decisions.cache?.size ?? 0} 条 · 精确命中 ${decisions.cache?.exactHits ?? 0} · ` +
1080
+ `同类复用 ${decisions.cache?.softHits ?? 0} · 命中率 ${Math.round((decisions.cache?.hitRate ?? 0) * 100)}%`,
1081
+ ),
1082
+ ),
1083
+ // 4) 保存按钮(同时提交开关、provider 与 key)
1084
+ h(
1085
+ "div",
1086
+ { style: { display: "flex", gap: "8px", margin: "14px 0 4px" }, key: "actions" },
1087
+ h(
1088
+ "button",
1089
+ {
1090
+ type: "button",
1091
+ // 保存按钮的可用条件必须包含 providerChanged 与 custom key:
1092
+ // 少一项就会出现「改了 provider 但按钮是灰的」。
1093
+ disabled:
1094
+ busy !== "" ||
1095
+ (dirty !== true &&
1096
+ (keyInputs[providerDraft.provider] ?? "").trim().length < 10 &&
1097
+ decisionsChanged !== true &&
1098
+ providerChanged !== true),
1099
+ onClick: () => void saveAll(),
1100
+ style: {
1101
+ ...S.saveBtn,
1102
+ opacity:
1103
+ busy !== "" ||
1104
+ (dirty !== true &&
1105
+ (keyInputs[providerDraft.provider] ?? "").trim().length < 10 &&
1106
+ decisionsChanged !== true &&
1107
+ providerChanged !== true)
1108
+ ? 0.5
1109
+ : 1,
1110
+ cursor: busy !== "" ? "default" : "pointer",
1111
+ },
1112
+ },
1113
+ busy === "save" ? "保存中…" : "保存",
1114
+ ),
1115
+ h(
1116
+ "button",
1117
+ {
1118
+ type: "button",
1119
+ disabled: busy !== "",
1120
+ onClick: runTest,
1121
+ style: { ...S.saveBtn, background: "transparent", color: "inherit" },
1122
+ },
1123
+ busy === "test" ? "测试中…" : "测试端点",
1124
+ ),
1125
+ ),
1126
+ // 5) 状态信息(紧凑)
1127
+ h(
1128
+ "div",
1129
+ { style: S.meta, key: "meta" },
1130
+ `提供方 ${view.providerLabel ?? view.provider ?? "-"} · 端点 ${view.baseUrl || "(未配置)"}`,
1131
+ h("br", null),
1132
+ `模型 ${view.model || "(服务端默认)"} · 超时 ${view.timeoutMs}ms · 请求 ${view.stats?.requests ?? 0} 次(缓存 ${view.stats?.cached ?? 0})`,
1133
+ // 密钥来源一律取「当前 provider 自己那一条」:用顶层 keyEnvName 会
1134
+ // 在切换后短暂显示上一家的名字(服务端字段与本地选择不同步时)。
1135
+ activeKeyStatus?.refName ? h("br", null) : null,
1136
+ activeKeyStatus?.refName
1137
+ ? `密钥来源 ${activeKeyStatus.refName}${activeKeyStatus.configured === true ? "" : "(未读到)"}`
1138
+ : null,
1139
+ ),
1140
+ msg !== null
1141
+ ? h(
1142
+ "div",
1143
+ {
1144
+ style: {
1145
+ ...S.meta,
1146
+ marginTop: "10px",
1147
+ color: msg.ok ? "#4ade80" : "#f87171",
1148
+ },
1149
+ key: "msg",
1150
+ },
1151
+ msg.text,
1152
+ )
1153
+ : null,
1154
+ );
1155
+ }
1156
+
1157
+ const inject = ["slots", "locale"];
1158
+
1159
+ /** 本插件的包名:`plugins.bundle.config` 用包名作为 key 匹配。 */
1160
+ const PACKAGE_NAME = "dsh-decision-engine";
1161
+
1162
+ /**
1163
+ * 入口注册:**只进插件信息页,不进 Sidebar**。
1164
+ *
1165
+ * 与内置插件保持一致的交互——配置入口放在插件自身的详情页,而不是在
1166
+ * Sidebar 占一格。
1167
+ *
1168
+ * 为什么不注册 `sidebar.panellist` + `main`:
1169
+ * `sidebar.panellist` 的点击会 `selectPanel(id)`,而 layout 的
1170
+ * `retainMainPanels` 只在 `main` slot 的 key 已存在时才保留该 id
1171
+ *(layout.js: `if (activePanelId !== null && !panelIds.includes(activePanelId)) activePanelId = null`)。
1172
+ * 两个 slot 是异步注册的,等 `main` 就位时 activePanelId 已被重置为 null,
1173
+ * 于是点 Sidebar 得到一张空白页——这正是要修掉的第二个问题。
1174
+ *
1175
+ * 只注册包详情自己的配置槽位 `plugins.bundle.config`(key = 包名)。
1176
+ *
1177
+ * **刻意不注册 `plugins.row.config`**:该槽位会让「包含的组件」列表里的
1178
+ * 本插件行变成可点击的「配置」入口,并打开一个独立的行详情页。但本插件是
1179
+ * 单一组合包,包详情页本身已经列出了全部组件,再给这一行一个配置入口属于
1180
+ * 重复入口,且会让用户误以为它是可单独配置的子组件。因此行标题保持纯文本。
1181
+ *
1182
+ * 同理不注册 `plugins.item`:那是官方内置插件在「内置插件配置」分组里的
1183
+ * 位置,本插件不属于官方内置插件。
1184
+ */
1185
+ function apply(ctx) {
1186
+ const log = (...args) => { try { console.log("[dsh-decision-engine]", ...args); } catch { /* 忽略 */ } };
1187
+ log("apply() called");
1188
+
1189
+ /**
1190
+ * 完全照搬 `dsh-rewind-plugin`(一个在这台机器上确认可用的插件)的写法:
1191
+ *
1192
+ * ctx.effect(function* () {
1193
+ * yield ctx.locale.register(NS, { zh, en });
1194
+ * yield slots.inject("plugins.bundle.config", () => slots.register({...}, Card));
1195
+ * });
1196
+ *
1197
+ * 三个要点,缺一不可:
1198
+ *
1199
+ * 1. `ctx.effect` 接收的是 **generator 函数**,不是普通函数。generator 里
1200
+ * `yield` 出去的值由 cordis 作为 disposer 管理——`yield slots.inject(...)`
1201
+ * 才是正确的挂载方式。我之前写成普通函数 + 直接调 `inject`,回调
1202
+ * 从未被触发(Console 里只有 `apply() called`,没有 `registered`)。
1203
+ *
1204
+ * 2. `locale.register` 也要 `yield`,且必须在注册槽位之前——`locale: NS`
1205
+ * 声明的命名空间需要有字典,否则部分渲染路径拿不到文案。
1206
+ *
1207
+ * 3. 槽位是 `plugins.bundle.config`,key = 包名。这正是你截图里
1208
+ * dsh-rewind-plugin 那个「快照备份 / 自动清理 / 保存」配置区的槽位,
1209
+ * 它渲染在「包含的组件」**上方**。
1210
+ */
1211
+ ctx.effect(
1212
+ function* startluxLifecycle() {
1213
+ // 1) 先注册字典(yield 使其可被回收)
1214
+ try {
1215
+ yield ctx.locale.register(NS, {
1216
+ zh: { title: "StartLux 守卫" },
1217
+ en: { title: "StartLux Guard" },
1218
+ });
1219
+ log("locale registered");
1220
+ } catch (error) {
1221
+ log("locale.register failed:", error?.message ?? String(error));
1222
+ }
1223
+
1224
+ // 2) 主入口:包详情页配置区(与 rewind-plugin 同一槽位、同一写法)
1225
+ try {
1226
+ yield ctx.slots.inject("plugins.bundle.config", () => {
1227
+ log("inject fired for plugins.bundle.config");
1228
+ return ctx.slots.register(
1229
+ { name: "plugins.bundle.config", key: PACKAGE_NAME, locale: NS },
1230
+ StartluxPage,
1231
+ );
1232
+ });
1233
+ log("bundle.config mounted, key =", PACKAGE_NAME);
1234
+ } catch (error) {
1235
+ log("bundle.config mount failed:", error?.message ?? String(error));
1236
+ }
1237
+ },
1238
+ "dsh-decision-engine client lifecycle",
1239
+ );
1240
+ }
1241
+
1242
+ exports.NS = NS;
1243
+ exports.PANEL_ID = PANEL_ID;
1244
+ exports.apply = apply;
1245
+ exports.inject = inject;
1246
+ return module.exports;
1247
+ },
1248
+ });