@dsh-plus/lifeboat 0.1.4 → 0.1.6

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 CHANGED
@@ -4,8 +4,406 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ //#region \0rolldown/runtime.js
8
+ var __defProp = Object.defineProperty;
9
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
10
+ var __getOwnPropNames = Object.getOwnPropertyNames;
11
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
12
+ var __esmMin = (fn, res, err) => () => {
13
+ if (err) throw err[0];
14
+ try {
15
+ return fn && (res = fn(fn = 0)), res;
16
+ } catch (e) {
17
+ throw err = [e], e;
18
+ }
19
+ };
20
+ var __exportAll = (all, no_symbols) => {
21
+ let target = {};
22
+ for (var name in all) __defProp(target, name, {
23
+ get: all[name],
24
+ enumerable: true
25
+ });
26
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
27
+ return target;
28
+ };
29
+ var __copyProps = (to, from, except, desc) => {
30
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
31
+ key = keys[i];
32
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
33
+ get: ((k) => from[k]).bind(null, key),
34
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
35
+ });
36
+ }
37
+ return to;
38
+ };
39
+ var __toCommonJS = (mod) => __hasOwnProp.call(mod, "module.exports") ? mod["module.exports"] : __copyProps(__defProp({}, "__esModule", { value: true }), mod);
40
+ //#endregion
41
+ let react_jsx_runtime = require("react/jsx-runtime");
42
+ let react = require("react");
43
+ //#region ../shared/src/client/icons.tsx
44
+ var init_icons = __esmMin((() => {}));
45
+ //#endregion
46
+ //#region ../shared/src/client/card.tsx
47
+ var init_card = __esmMin((() => {
48
+ init_icons();
49
+ }));
50
+ //#endregion
51
+ //#region ../shared/src/client/fetch.ts
52
+ /**
53
+ * 同源自定义端点的最小 fetch 封装(各插件 client/api.ts 的公共收编版)。
54
+ * 仅同源(credentials: same-origin),错误统一抛 Error(body.error)。
55
+ * @module @dsh-plus/shared/client/fetch
56
+ */
57
+ async function parse(res) {
58
+ const body = await res.json();
59
+ if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
60
+ return body;
61
+ }
62
+ /** GET 同源 JSON 端点。 */
63
+ async function getJson(route) {
64
+ return parse(await fetch(route, { credentials: "same-origin" }));
65
+ }
66
+ /** POST 同源 JSON 端点(body 可省略)。 */
67
+ async function postJson(route, body) {
68
+ return parse(await fetch(route, {
69
+ method: "POST",
70
+ credentials: "same-origin",
71
+ headers: { "content-type": "application/json" },
72
+ body: body === void 0 ? void 0 : JSON.stringify(body)
73
+ }));
74
+ }
75
+ var init_fetch = __esmMin((() => {}));
76
+ //#endregion
77
+ //#region ../shared/src/client/fields.tsx
78
+ var init_fields = __esmMin((() => {}));
79
+ //#endregion
80
+ //#region ../shared/src/client/styles.ts
81
+ /**
82
+ * 幂等注入样式标签(官方 data-plugin-css 约定);返回新标签,
83
+ * 已存在或环境无 document 时返回 null(调用方 effect 卸载用)。
84
+ */
85
+ function injectCardStyle(pluginId, css) {
86
+ if (typeof document === "undefined") return null;
87
+ const tagId = `${pluginId}/card.css`;
88
+ if (document.querySelector(`style[data-plugin-css=${JSON.stringify(tagId)}]`) !== null) return null;
89
+ const tag = document.createElement("style");
90
+ tag.dataset.plugin = pluginId;
91
+ tag.dataset.pluginCss = tagId;
92
+ tag.textContent = css;
93
+ document.head.appendChild(tag);
94
+ return tag;
95
+ }
96
+ var init_styles = __esmMin((() => {}));
97
+ //#endregion
98
+ //#region ../shared/src/client/index.ts
99
+ var init_client = __esmMin((() => {
100
+ init_card();
101
+ init_fetch();
102
+ init_fields();
103
+ init_icons();
104
+ init_styles();
105
+ }));
106
+ //#endregion
107
+ //#region src/client/health-styles.ts
108
+ /** 幂等注入健康页样式标签。 */
109
+ function injectHealthStyle() {
110
+ return injectCardStyle(PLUGIN_ID, healthCss);
111
+ }
112
+ var PLUGIN_ID, healthCss;
113
+ var init_health_styles = __esmMin((() => {
114
+ init_client();
115
+ PLUGIN_ID = "@dsh-plus/lifeboat";
116
+ healthCss = `
117
+ .dlb-tab{display:flex;flex-direction:column;gap:14px;min-width:0}
118
+ .dlb-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;margin:4px 0}
119
+ .dlb-title{color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:600;margin:10px 0 0}
120
+ .dlb-notice{color:var(--dsw-alias-label-secondary);font-size:12px;margin:0}
121
+ .dlb-fallback{border:1px solid var(--dsw-alias-label-warning,#e0a03c);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:6px;background:var(--dsw-alias-bg-layer-3)}
122
+ .dlb-fallbackTitle{color:var(--dsw-alias-label-warning,#e0a03c);font-size:13px;font-weight:600;margin:0}
123
+ .dlb-kv{display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap}
124
+ .dlb-kv span{color:var(--dsw-alias-label-tertiary);flex:none}
125
+ .dlb-kv code{color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;word-break:break-all}
126
+ .dlb-cards{display:flex;flex-direction:column;gap:8px}
127
+ .dlb-quarantineCard{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:var(--dsw-alias-bg-layer-3)}
128
+ .dlb-pluginName{color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;flex:1;min-width:0;word-break:break-all}
129
+ .dlb-confirm{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex-basis:100%}
130
+ .dlb-confirmText{color:var(--dsw-alias-label-secondary);font-size:12px;flex:1;min-width:160px;line-height:1.5}
131
+ .dlb-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5;flex:none}
132
+ .dlb-btnGhost{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}
133
+ .dlb-btnGhost:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}
134
+ .dlb-btnPrimary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
135
+ .dlb-btn:disabled{opacity:.4;cursor:default}
136
+ .dlb-journal{display:flex;flex-direction:column;gap:10px}
137
+ .dlb-journalDay{display:flex;flex-direction:column;gap:2px}
138
+ .dlb-dayLabel{color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:600;margin-bottom:2px}
139
+ .dlb-entry{display:flex;align-items:baseline;gap:8px;font-size:12px;line-height:1.6;flex-wrap:wrap}
140
+ .dlb-kind{flex:none;border-radius:999px;padding:0 8px;font-size:10px;font-weight:600;line-height:17px;white-space:nowrap}
141
+ .dlb-kindInfo{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
142
+ .dlb-kindAlert{background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-bg-base)}
143
+ .dlb-kindQuarantine{background:var(--dsw-alias-label-warning,#e0a03c);color:var(--dsw-alias-bg-base)}
144
+ .dlb-kindRestore{background:var(--dsw-alias-state-success,#3d9a50);color:var(--dsw-alias-bg-base)}
145
+ .dlb-kindFallback{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
146
+ .dlb-entryAt{color:var(--dsw-alias-label-tertiary);flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
147
+ .dlb-entryDetail{color:var(--dsw-alias-label-primary);min-width:0;word-break:break-word;white-space:pre-wrap;flex:1;flex-basis:320px}
148
+ @media (max-width:767px){
149
+ .dlb-btn{min-height:44px}
150
+ .dlb-quarantineCard{flex-direction:column;align-items:stretch}
151
+ .dlb-confirm{flex-direction:column;align-items:stretch}
152
+ .dlb-entryDetail{flex-basis:100%}
153
+ }
154
+ `;
155
+ }));
156
+ //#endregion
157
+ //#region src/client/health-tab.tsx
158
+ var health_tab_exports = /* @__PURE__ */ __exportAll({ mountHealthTab: () => mountHealthTab });
159
+ function HealthTab(props) {
160
+ const { t } = props;
161
+ const [status, setStatus] = (0, react.useState)(null);
162
+ const [failed, setFailed] = (0, react.useState)(false);
163
+ const [busyName, setBusyName] = (0, react.useState)(null);
164
+ const [notice, setNotice] = (0, react.useState)(null);
165
+ const [confirming, setConfirming] = (0, react.useState)(null);
166
+ const load = (0, react.useCallback)(() => {
167
+ getJson(ROUTE_STATUS).then((data) => {
168
+ setStatus(data);
169
+ setFailed(false);
170
+ }).catch(() => setFailed(true));
171
+ }, []);
172
+ (0, react.useEffect)(() => {
173
+ load();
174
+ }, [load]);
175
+ const onRestore = (name) => {
176
+ setBusyName(name);
177
+ setConfirming(null);
178
+ postJson(ROUTE_RESTORE, { name }).then(() => {
179
+ setNotice(t("restored"));
180
+ load();
181
+ }).catch((error) => {
182
+ setNotice(`${t("restoreFailed")}${error instanceof Error ? error.message : ""}`);
183
+ }).finally(() => setBusyName(null));
184
+ };
185
+ const journalGroups = (0, react.useMemo)(() => {
186
+ if (status === null) return [];
187
+ const groups = /* @__PURE__ */ new Map();
188
+ for (const entry of [...status.journal].reverse()) {
189
+ const day = entry.at.slice(0, 10);
190
+ const list = groups.get(day) ?? [];
191
+ list.push(entry);
192
+ groups.set(day, list);
193
+ }
194
+ return [...groups.entries()];
195
+ }, [status]);
196
+ if (status === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
197
+ className: "dlb-tab",
198
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
199
+ className: "dlb-empty",
200
+ children: failed ? t("loadFailed") : t("pluginOk") === "OK" ? "…" : "…"
201
+ }), failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
202
+ type: "button",
203
+ className: "dlb-btn dlb-btnGhost",
204
+ onClick: load,
205
+ children: t("retry")
206
+ }) : null]
207
+ });
208
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
209
+ className: "dlb-tab",
210
+ children: [
211
+ notice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
212
+ className: "dlb-notice",
213
+ role: "status",
214
+ children: notice
215
+ }) : null,
216
+ status.llmFallback?.active === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
217
+ className: "dlb-fallback",
218
+ role: "status",
219
+ children: [
220
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
221
+ className: "dlb-fallbackTitle",
222
+ children: t("fallbackActive")
223
+ }),
224
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
225
+ className: "dlb-kv",
226
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("fallbackFrom") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("code", { children: [
227
+ status.llmFallback.originalProvider,
228
+ "/",
229
+ status.llmFallback.originalModel
230
+ ] })]
231
+ }),
232
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
233
+ className: "dlb-kv",
234
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("fallbackTo") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: status.llmFallback.fallbackProvider })]
235
+ }),
236
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
237
+ className: "dlb-kv",
238
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("fallbackAt") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: status.llmFallback.at.slice(0, 19).replace("T", " ") })]
239
+ })
240
+ ]
241
+ }) : null,
242
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
243
+ className: "dlb-title",
244
+ children: t("quarantined")
245
+ }),
246
+ status.quarantined.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
247
+ className: "dlb-empty",
248
+ children: t("emptyQuarantined")
249
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
250
+ className: "dlb-cards",
251
+ children: status.quarantined.map((name) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
252
+ className: "dlb-quarantineCard",
253
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
254
+ className: "dlb-pluginName",
255
+ children: name
256
+ }), confirming === name ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
257
+ className: "dlb-confirm",
258
+ children: [
259
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
260
+ className: "dlb-confirmText",
261
+ children: t("restoreConfirm")
262
+ }),
263
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
264
+ type: "button",
265
+ className: "dlb-btn dlb-btnGhost",
266
+ disabled: busyName === name,
267
+ onClick: () => setConfirming(null),
268
+ children: "×"
269
+ }),
270
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
271
+ type: "button",
272
+ className: "dlb-btn dlb-btnPrimary",
273
+ disabled: busyName === name,
274
+ onClick: () => onRestore(name),
275
+ children: busyName === name ? t("restoring") : t("restore")
276
+ })
277
+ ]
278
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
279
+ type: "button",
280
+ className: "dlb-btn dlb-btnPrimary",
281
+ disabled: busyName === name,
282
+ onClick: () => setConfirming(name),
283
+ children: busyName === name ? t("restoring") : t("restore")
284
+ })]
285
+ }, name))
286
+ }),
287
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
288
+ className: "dlb-title",
289
+ children: t("journalTitle")
290
+ }),
291
+ journalGroups.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
292
+ className: "dlb-empty",
293
+ children: t("emptyJournal")
294
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
295
+ className: "dlb-journal",
296
+ children: journalGroups.map(([day, entries]) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
297
+ className: "dlb-journalDay",
298
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
299
+ className: "dlb-dayLabel",
300
+ children: day
301
+ }), entries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
302
+ className: "dlb-entry",
303
+ children: [
304
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
305
+ className: `dlb-kind ${KIND_STYLES[entry.kind] ?? "dlb-kindInfo"}`,
306
+ children: entry.kind
307
+ }),
308
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
309
+ className: "dlb-entryAt",
310
+ children: entry.at.slice(11, 19)
311
+ }),
312
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
313
+ className: "dlb-entryDetail",
314
+ children: entry.detail
315
+ })
316
+ ]
317
+ }, `${entry.at}-${entry.kind}`))]
318
+ }, day))
319
+ })
320
+ ]
321
+ });
322
+ }
323
+ /** 挂载健康页(由 client.ts 延迟 require;任何异常由调用方捕获)。 */
324
+ function mountHealthTab(ctx) {
325
+ const c = ctx;
326
+ const tag = injectHealthStyle();
327
+ c.effect(() => () => {
328
+ tag?.remove();
329
+ }, "lifeboat: health style");
330
+ c.effect(() => c.locale.register(NS, {
331
+ zh: zhDict,
332
+ en: enDict
333
+ }), "lifeboat: locale");
334
+ const t = c.locale.bind(NS);
335
+ c.slots.inject("settings.plugins.tab", () => c.slots.register({
336
+ name: "settings.plugins.tab",
337
+ id: "dsh-plus-lifeboat",
338
+ order: 20,
339
+ label: () => t("tab"),
340
+ inject: () => ({ t })
341
+ }, HealthTab));
342
+ }
343
+ var NS, ROUTE_STATUS, ROUTE_RESTORE, KIND_STYLES, zhDict, enDict;
344
+ var init_health_tab = __esmMin((() => {
345
+ init_client();
346
+ init_health_styles();
347
+ NS = "dsh-plus-lifeboat";
348
+ ROUTE_STATUS = "/dsh-plus/lifeboat/status";
349
+ ROUTE_RESTORE = "/dsh-plus/lifeboat/restore";
350
+ KIND_STYLES = {
351
+ alert: "dlb-kindAlert",
352
+ quarantine: "dlb-kindQuarantine",
353
+ "quarantine-restore": "dlb-kindRestore",
354
+ "llm-fallback": "dlb-kindFallback",
355
+ "llm-fallback-revert": "dlb-kindRestore"
356
+ };
357
+ zhDict = {
358
+ tab: "救生艇",
359
+ fallbackActive: "LLM 应急翻译进行中",
360
+ fallbackFrom: "源路由",
361
+ fallbackTo: "临时路由",
362
+ fallbackAt: "启用时间",
363
+ quarantined: "已隔离插件",
364
+ restore: "恢复",
365
+ restoring: "恢复中…",
366
+ restoreConfirm: "确认恢复该插件?将移除用户 patch 层的禁用覆盖,热应用即时生效。",
367
+ journalTitle: "操作日志",
368
+ pluginsTitle: "dsh-plus 插件状态",
369
+ pluginFailed: "FAILED",
370
+ pluginOk: "正常",
371
+ pluginDisabled: "已禁用",
372
+ emptyJournal: "暂无记录。",
373
+ emptyQuarantined: "当前无隔离插件。",
374
+ loadFailed: "状态加载失败:",
375
+ retry: "重试",
376
+ restored: "已恢复,热应用即时生效。",
377
+ restoreFailed: "恢复失败:"
378
+ };
379
+ enDict = {
380
+ tab: "Lifeboat",
381
+ fallbackActive: "LLM emergency translation active",
382
+ fallbackFrom: "Source route",
383
+ fallbackTo: "Fallback route",
384
+ fallbackAt: "Since",
385
+ quarantined: "Quarantined plugins",
386
+ restore: "Restore",
387
+ restoring: "Restoring…",
388
+ restoreConfirm: "Restore this plugin? The disable override in the user patch layer will be removed and hot-applied.",
389
+ journalTitle: "Journal",
390
+ pluginsTitle: "dsh-plus plugin status",
391
+ pluginFailed: "FAILED",
392
+ pluginOk: "OK",
393
+ pluginDisabled: "Disabled",
394
+ emptyJournal: "No entries yet.",
395
+ emptyQuarantined: "No quarantined plugins.",
396
+ loadFailed: "Failed to load: ",
397
+ retry: "Retry",
398
+ restored: "Restored; hot-applied immediately.",
399
+ restoreFailed: "Restore failed: "
400
+ };
401
+ }));
402
+ //#endregion
7
403
  //#region src/client/client.ts
8
404
  const name = "dsh-plus-lifeboat";
405
+ /** 健康页需要的 cordis 服务 key(哨兵段只用 root ctx 原生面,不依赖这些)。 */
406
+ const inject = ["slots", "locale"];
9
407
  /** Runtime mirror: FiberState 是 cordis 跨包 const enum(FAILED = 3,cordis 4.0.1)。 */
10
408
  const FIBER_FAILED = 3;
11
409
  /** 永不回报的条目:救生艇自身与聚合 bundle。 */
@@ -28,9 +426,13 @@ window.__ModuleLoader__.load({
28
426
  } catch {}
29
427
  });
30
428
  } catch {}
429
+ try {
430
+ (init_health_tab(), __toCommonJS(health_tab_exports)).mountHealthTab(ctx);
431
+ } catch {}
31
432
  }
32
433
  //#endregion
33
434
  exports.apply = apply;
435
+ exports.inject = inject;
34
436
  exports.name = name;
35
437
  return module.exports;
36
438
  }
package/lib/index.js CHANGED
@@ -231,38 +231,6 @@ function installLlmFallback(ctx, deps) {
231
231
  ctx.root.on("llm/adapters-updated", run);
232
232
  }
233
233
  //#endregion
234
- //#region src/notify.ts
235
- /**
236
- * 装配告警出口。返回同步派单函数:内部异步发送,失败只记日志。
237
- * notify-email 出现时升级为邮件;全程 journal 兜底可溯。
238
- */
239
- function installAlerter(ctx, journal) {
240
- const logger = ctx.logger("lifeboat");
241
- let mailer;
242
- ctx.inject(["notifyEmail"], (mailCtx) => {
243
- mailer = mailCtx.notifyEmail;
244
- return () => {
245
- mailer = void 0;
246
- };
247
- });
248
- return (subject, text) => {
249
- journal("alert", `${subject} | ${text}`);
250
- const active = mailer;
251
- if (!active?.sendNotice) {
252
- logger.warn(`${subject} —— ${text}`);
253
- return;
254
- }
255
- active.sendNotice({
256
- subject,
257
- text
258
- }).then((result) => {
259
- if (!result.ok) logger.warn(`邮件告警投递失败: ${result.detail}`);
260
- }, (error) => {
261
- logger.warn(`邮件告警异常: ${error instanceof Error ? error.message : String(error)}`);
262
- });
263
- };
264
- }
265
- //#endregion
266
234
  //#region src/patch-file.ts
267
235
  /**
268
236
  * profile 用户 patch 文件(cordis.patch.yml)的安全写入。
@@ -299,6 +267,47 @@ async function appendDisable(patchFile, targetId) {
299
267
  await rename(tmp, patchFile);
300
268
  return true;
301
269
  }
270
+ /** 顶层条目中已禁用的 id 列表(不含 insert 子列表;用户层 disabled 语义只在顶层)。 */
271
+ function listDisabled(entries) {
272
+ const out = [];
273
+ for (const entry of entries) if (typeof entry.id === "string" && entry.disabled === true) out.push(entry.id);
274
+ return out;
275
+ }
276
+ /**
277
+ * 从 patch 文件移除针对 targetId 的禁用覆盖(恢复插件);返回是否实际写入。
278
+ * 只移除「id 定向且 disabled: true」的行,其余内容(注释经 yaml 文档保留、
279
+ * 其他条目)原样不动;无该覆盖时幂等返回 false。
280
+ * @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
281
+ */
282
+ async function removeDisable(patchFile, targetId) {
283
+ const text = await readFile(patchFile, "utf-8");
284
+ const doc = parseDocument(text);
285
+ const root = doc.toJS();
286
+ if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
287
+ let removed = false;
288
+ const items = doc.contents;
289
+ if (items?.items !== void 0 && Array.isArray(items.items)) {
290
+ const headComment = items.items[0]?.commentBefore;
291
+ doc.contents.items = items.items.filter((item) => {
292
+ const value = item.toJS?.(doc);
293
+ if (value !== void 0 && typeof value === "object" && value.id === targetId && value.disabled === true) {
294
+ removed = true;
295
+ return false;
296
+ }
297
+ return true;
298
+ });
299
+ if (removed && headComment !== void 0 && doc.contents.items.length > 0) {
300
+ const next = doc.contents.items[0];
301
+ if (next.commentBefore === void 0) next.commentBefore = headComment;
302
+ }
303
+ }
304
+ if (!removed) return false;
305
+ await copyFile(patchFile, `${patchFile}.lifeboat.bak`);
306
+ const tmp = `${patchFile}.lifeboat.tmp`;
307
+ await writeFile(tmp, doc.toString(), "utf-8");
308
+ await rename(tmp, patchFile);
309
+ return true;
310
+ }
302
311
  //#endregion
303
312
  //#region src/quarantine.ts
304
313
  /** Runtime mirror: FiberState 是 cordis 跨包 const enum(官方 dsh-host-plugin-inventory 同款做法)。 */
@@ -360,6 +369,122 @@ function installHostWatch(ctx, q) {
360
369
  });
361
370
  }
362
371
  //#endregion
372
+ //#region src/health-api.ts
373
+ /**
374
+ * lifeboat 健康面板 host 接收面:
375
+ * - GET /dsh-plus/lifeboat/status → journal + 翻译状态 + 当前用户 patch 层已禁用的 dsh-plus 插件
376
+ * - POST /dsh-plus/lifeboat/restore {name} → removeDisable + journal + 告警
377
+ * rc8 用户 patch 层热应用:恢复无需重启,watchUserPatches 即时重载。
378
+ * @module lifeboat/health-api
379
+ */
380
+ const ROUTE_STATUS = "/dsh-plus/lifeboat/status";
381
+ const ROUTE_RESTORE = "/dsh-plus/lifeboat/restore";
382
+ function sendJson$1(res, status, body) {
383
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
384
+ res.end(JSON.stringify(body));
385
+ }
386
+ function readBody$1(req) {
387
+ return new Promise((resolve, reject) => {
388
+ const chunks = [];
389
+ req.on("data", (chunk) => chunks.push(chunk));
390
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
391
+ req.on("error", reject);
392
+ });
393
+ }
394
+ /** 解析用户 patch 层中已被禁用且属于 dsh-plus 兄弟域的插件名。 */
395
+ async function readQuarantined(patchFile) {
396
+ let text;
397
+ try {
398
+ text = await readFile(patchFile, "utf-8");
399
+ } catch {
400
+ return [];
401
+ }
402
+ const root = parseDocument(text).toJS();
403
+ if (!Array.isArray(root)) return [];
404
+ return listDisabled(root).filter((id) => isGuardedPlugin(id) || id.startsWith("dsh-plus-"));
405
+ }
406
+ /** 注册健康面板路由(webServer 缺席时由调用方保证不调用)。 */
407
+ function registerHealthApi(ctx, deps) {
408
+ const logger = ctx.logger("lifeboat");
409
+ ctx.webServer.register({
410
+ kind: "prefix",
411
+ path: ROUTE_STATUS,
412
+ handler: async (_req, res) => {
413
+ try {
414
+ const quarantined = await readQuarantined(deps.patchFile);
415
+ sendJson$1(res, 200, {
416
+ journal: deps.readJournal(),
417
+ llmFallback: deps.readFallback(),
418
+ quarantined
419
+ });
420
+ } catch (error) {
421
+ logger.warn(`status endpoint failed: ${error instanceof Error ? error.message : String(error)}`);
422
+ sendJson$1(res, 500, { error: "internal" });
423
+ }
424
+ }
425
+ });
426
+ ctx.webServer.register({
427
+ kind: "prefix",
428
+ path: ROUTE_RESTORE,
429
+ handler: async (req, res) => {
430
+ try {
431
+ if (req.method !== "POST") {
432
+ sendJson$1(res, 405, { error: "POST only" });
433
+ return;
434
+ }
435
+ const body = JSON.parse(await readBody$1(req));
436
+ if (typeof body.name !== "string" || !body.name.startsWith("dsh-plus-")) {
437
+ sendJson$1(res, 400, { error: "invalid-name" });
438
+ return;
439
+ }
440
+ const written = await removeDisable(deps.patchFile, body.name);
441
+ deps.journal("quarantine-restore", written ? `${body.name}:已移除用户 patch 层的禁用覆盖(热应用即时恢复)` : `${body.name}:无需恢复(patch 层无禁用覆盖)`);
442
+ deps.alert("[DSH] 插件恢复", `插件 ${body.name} 的隔离已解除(移除 patch 层禁用覆盖)。${written ? "rc8 热应用即时生效,无需重启。" : "patch 层本无覆盖,可能已被手动恢复。"}`);
443
+ sendJson$1(res, 200, {
444
+ ok: true,
445
+ written
446
+ });
447
+ } catch (error) {
448
+ const message = error instanceof Error ? error.message : String(error);
449
+ logger.warn(`restore endpoint failed: ${message}`);
450
+ sendJson$1(res, 500, { error: message });
451
+ }
452
+ }
453
+ });
454
+ }
455
+ //#endregion
456
+ //#region src/notify.ts
457
+ /**
458
+ * 装配告警出口。返回同步派单函数:内部异步发送,失败只记日志。
459
+ * notify-email 出现时升级为邮件;全程 journal 兜底可溯。
460
+ */
461
+ function installAlerter(ctx, journal) {
462
+ const logger = ctx.logger("lifeboat");
463
+ let mailer;
464
+ ctx.inject(["notifyEmail"], (mailCtx) => {
465
+ mailer = mailCtx.notifyEmail;
466
+ return () => {
467
+ mailer = void 0;
468
+ };
469
+ });
470
+ return (subject, text) => {
471
+ journal("alert", `${subject} | ${text}`);
472
+ const active = mailer;
473
+ if (!active?.sendNotice) {
474
+ logger.warn(`${subject} —— ${text}`);
475
+ return;
476
+ }
477
+ active.sendNotice({
478
+ subject,
479
+ text
480
+ }).then((result) => {
481
+ if (!result.ok) logger.warn(`邮件告警投递失败: ${result.detail}`);
482
+ }, (error) => {
483
+ logger.warn(`邮件告警异常: ${error instanceof Error ? error.message : String(error)}`);
484
+ });
485
+ };
486
+ }
487
+ //#endregion
363
488
  //#region src/quarantine-api.ts
364
489
  const ROUTE = "/dsh-plus/lifeboat/quarantine";
365
490
  function sendJson(res, status, body) {
@@ -435,8 +560,9 @@ function apply(ctx, config) {
435
560
  };
436
561
  const alert = installAlerter(ctx, journal);
437
562
  if (config.enabled) {
563
+ const patchFile = config.patchFile.length > 0 ? config.patchFile : dshHomePath("profiles", "web", "cordis.patch.yml");
438
564
  const quarantine = createQuarantine(ctx, {
439
- patchFile: config.patchFile.length > 0 ? config.patchFile : dshHomePath("profiles", "web", "cordis.patch.yml"),
565
+ patchFile,
440
566
  alertCooldownMs: config.alertCooldownMs,
441
567
  journal,
442
568
  alert
@@ -444,6 +570,13 @@ function apply(ctx, config) {
444
570
  installHostWatch(ctx, quarantine);
445
571
  ctx.inject(["webServer"], (webCtx) => {
446
572
  registerQuarantineApi(webCtx, quarantine);
573
+ registerHealthApi(webCtx, {
574
+ patchFile,
575
+ journal,
576
+ alert,
577
+ readJournal: () => doc.journal,
578
+ readFallback: () => doc.llmFallback
579
+ });
447
580
  });
448
581
  }
449
582
  if (config.llmFallback) installLlmFallback(ctx, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/lifeboat",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "dsh-plus service plugin: 故障救生艇——兄弟插件崩溃时自动隔离(写用户 patch 层禁用),llm-pi 缺席时把其配置临时翻译为官方 llm-pi-ai 路由,恢复后自动还原",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -22,12 +22,16 @@
22
22
  ],
23
23
  "dsh": {
24
24
  "client": {
25
- "inject": [],
25
+ "inject": [
26
+ "@deepseek-ai/dsh-client-runtime",
27
+ "@deepseek-ai/dsh-client-locale"
28
+ ],
26
29
  "platform": "web"
27
30
  }
28
31
  },
29
32
  "dependencies": {
30
- "yaml": "^2.6.0"
33
+ "yaml": "^2.6.0",
34
+ "@dsh-plus/shared": "0.1.5"
31
35
  },
32
36
  "peerDependencies": {
33
37
  "@deepseek-ai/cordis": "^4.0.1",
@@ -42,7 +46,10 @@
42
46
  "@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
43
47
  "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
44
48
  "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
45
- "@deepseek-ai/schemastery": "3.18.1"
49
+ "@deepseek-ai/schemastery": "3.18.1",
50
+ "@types/react": "~18.3.1",
51
+ "react": "^18.2.0",
52
+ "@types/node": "26.2.0"
46
53
  },
47
54
  "publishConfig": {
48
55
  "access": "public"
@@ -5,12 +5,18 @@
5
5
  *
6
6
  * 零依赖铁律:不 import 任何包(含 cordis/shared),FiberState.FAILED 以字面量
7
7
  * 3 镜像一个道理(官方 dsh-host-plugin-inventory 同款做法);任何异常止步自身。
8
+ *
9
+ * 健康页挂载在 mountHealthTab(./health-tab.ts,react 系依赖隔离在独立模块):
10
+ * 哨兵 try/catch 之外先行注册,UI 故障不拖垮哨兵。
8
11
  * @module @dsh-plus/lifeboat/client
9
12
  */
10
13
  import type { Context } from '@deepseek-ai/cordis'
11
14
 
12
15
  export const name = 'dsh-plus-lifeboat'
13
16
 
17
+ /** 健康页需要的 cordis 服务 key(哨兵段只用 root ctx 原生面,不依赖这些)。 */
18
+ export const inject = ['slots', 'locale'] as const
19
+
14
20
  /** Runtime mirror: FiberState 是 cordis 跨包 const enum(FAILED = 3,cordis 4.0.1)。 */
15
21
  const FIBER_FAILED = 3
16
22
 
@@ -20,6 +26,7 @@ const EXCLUDED = new Set(['dsh-plus-lifeboat', 'dsh-plus-bundle-main'])
20
26
  const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/
21
27
 
22
28
  export function apply(ctx: Context): void {
29
+ // ── 哨兵(零依赖,先于一切 UI)────────────────────────────────
23
30
  try {
24
31
  const reported = new Set<string>()
25
32
  ctx.root.on('internal/status', (fiber: { state: number; name: string }) => {
@@ -42,4 +49,14 @@ export function apply(ctx: Context): void {
42
49
  } catch {
43
50
  // apply 自身同理。
44
51
  }
52
+
53
+ // ── 健康页(UI 故障不拖垮哨兵;react 系依赖隔离在独立模块)────
54
+ try {
55
+ const health = (require as (id: string) => { mountHealthTab(ctx: Context): void })(
56
+ './health-tab.js',
57
+ )
58
+ health.mountHealthTab(ctx)
59
+ } catch {
60
+ // 健康页缺席不影响哨兵;上游插槽失配时优雅缺席(reload/降级原则同 web-files)。
61
+ }
45
62
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 健康页样式:视觉对齐官方设置页(--dsw-alias-*),响应式同套件约定
3
+ * (≤767px 堆叠、44px 热区)。
4
+ * @module lifeboat/client/health-styles
5
+ */
6
+ import { injectCardStyle } from '@dsh-plus/shared/client'
7
+
8
+ export const PLUGIN_ID = '@dsh-plus/lifeboat'
9
+
10
+ export const healthCss = `
11
+ .dlb-tab{display:flex;flex-direction:column;gap:14px;min-width:0}
12
+ .dlb-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;margin:4px 0}
13
+ .dlb-title{color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:600;margin:10px 0 0}
14
+ .dlb-notice{color:var(--dsw-alias-label-secondary);font-size:12px;margin:0}
15
+ .dlb-fallback{border:1px solid var(--dsw-alias-label-warning,#e0a03c);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:6px;background:var(--dsw-alias-bg-layer-3)}
16
+ .dlb-fallbackTitle{color:var(--dsw-alias-label-warning,#e0a03c);font-size:13px;font-weight:600;margin:0}
17
+ .dlb-kv{display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap}
18
+ .dlb-kv span{color:var(--dsw-alias-label-tertiary);flex:none}
19
+ .dlb-kv code{color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;word-break:break-all}
20
+ .dlb-cards{display:flex;flex-direction:column;gap:8px}
21
+ .dlb-quarantineCard{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:var(--dsw-alias-bg-layer-3)}
22
+ .dlb-pluginName{color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;flex:1;min-width:0;word-break:break-all}
23
+ .dlb-confirm{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex-basis:100%}
24
+ .dlb-confirmText{color:var(--dsw-alias-label-secondary);font-size:12px;flex:1;min-width:160px;line-height:1.5}
25
+ .dlb-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5;flex:none}
26
+ .dlb-btnGhost{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}
27
+ .dlb-btnGhost:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}
28
+ .dlb-btnPrimary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
29
+ .dlb-btn:disabled{opacity:.4;cursor:default}
30
+ .dlb-journal{display:flex;flex-direction:column;gap:10px}
31
+ .dlb-journalDay{display:flex;flex-direction:column;gap:2px}
32
+ .dlb-dayLabel{color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:600;margin-bottom:2px}
33
+ .dlb-entry{display:flex;align-items:baseline;gap:8px;font-size:12px;line-height:1.6;flex-wrap:wrap}
34
+ .dlb-kind{flex:none;border-radius:999px;padding:0 8px;font-size:10px;font-weight:600;line-height:17px;white-space:nowrap}
35
+ .dlb-kindInfo{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
36
+ .dlb-kindAlert{background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-bg-base)}
37
+ .dlb-kindQuarantine{background:var(--dsw-alias-label-warning,#e0a03c);color:var(--dsw-alias-bg-base)}
38
+ .dlb-kindRestore{background:var(--dsw-alias-state-success,#3d9a50);color:var(--dsw-alias-bg-base)}
39
+ .dlb-kindFallback{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
40
+ .dlb-entryAt{color:var(--dsw-alias-label-tertiary);flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
41
+ .dlb-entryDetail{color:var(--dsw-alias-label-primary);min-width:0;word-break:break-word;white-space:pre-wrap;flex:1;flex-basis:320px}
42
+ @media (max-width:767px){
43
+ .dlb-btn{min-height:44px}
44
+ .dlb-quarantineCard{flex-direction:column;align-items:stretch}
45
+ .dlb-confirm{flex-direction:column;align-items:stretch}
46
+ .dlb-entryDetail{flex-basis:100%}
47
+ }
48
+ `
49
+
50
+ /** 幂等注入健康页样式标签。 */
51
+ export function injectHealthStyle(): HTMLStyleElement | null {
52
+ return injectCardStyle(PLUGIN_ID, healthCss)
53
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * lifeboat 健康页:settings.plugins.tab 第三页「救生艇」。
3
+ * - 应急翻译状态 banner(激活中显示源路由/临时路由/时间)
4
+ * - 已隔离插件卡片 + 「恢复」按钮(两段确认)
5
+ * - journal 时间线(kind 徽标着色)
6
+ * - dsh-plus 兄弟插件状态点(pluginInventory.list)
7
+ * 依赖隔离:本模块被 client.ts 延迟 require,react 系异常不拖垮哨兵。
8
+ * @module lifeboat/client/health-tab
9
+ */
10
+
11
+ import { getJson, postJson } from '@dsh-plus/shared/client'
12
+ import { type ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
13
+
14
+ import { injectHealthStyle } from './health-styles.ts'
15
+
16
+ interface JournalEntry {
17
+ at: string
18
+ kind: string
19
+ detail: string
20
+ }
21
+
22
+ interface FallbackState {
23
+ active: boolean
24
+ originalProvider: string
25
+ originalModel: string
26
+ fallbackProvider: string
27
+ at: string
28
+ }
29
+
30
+ interface StatusResponse {
31
+ journal: JournalEntry[]
32
+ llmFallback: FallbackState | null
33
+ quarantined: string[]
34
+ }
35
+
36
+ interface InventoryRow {
37
+ id: string
38
+ enabled: boolean
39
+ status?: number
40
+ }
41
+
42
+ interface RemoteLike {
43
+ pluginInventory: { list(): Promise<{ entries?: InventoryRow[] }> }
44
+ }
45
+
46
+ interface SlotsLike {
47
+ inject(key: string, callback: () => unknown): unknown
48
+ register(options: Record<string, unknown>, component: unknown): () => void
49
+ }
50
+
51
+ interface LocaleLike {
52
+ register(ns: string, dict: { zh: Record<string, string>; en: Record<string, string> }): () => void
53
+ bind(ns: string): (key: string) => string
54
+ }
55
+
56
+ interface HealthContext {
57
+ slots: SlotsLike
58
+ locale: LocaleLike
59
+ get(key: 'remote'): RemoteLike
60
+ effect(execute: () => () => void, label?: string): unknown
61
+ }
62
+
63
+ const NS = 'dsh-plus-lifeboat'
64
+ const ROUTE_STATUS = '/dsh-plus/lifeboat/status'
65
+ const ROUTE_RESTORE = '/dsh-plus/lifeboat/restore'
66
+
67
+ /** journal kind → 徽标语义色。 */
68
+ const KIND_STYLES: Record<string, string> = {
69
+ alert: 'dlb-kindAlert',
70
+ quarantine: 'dlb-kindQuarantine',
71
+ 'quarantine-restore': 'dlb-kindRestore',
72
+ 'llm-fallback': 'dlb-kindFallback',
73
+ 'llm-fallback-revert': 'dlb-kindRestore',
74
+ }
75
+
76
+ const zhDict = {
77
+ tab: '救生艇',
78
+ fallbackActive: 'LLM 应急翻译进行中',
79
+ fallbackFrom: '源路由',
80
+ fallbackTo: '临时路由',
81
+ fallbackAt: '启用时间',
82
+ quarantined: '已隔离插件',
83
+ restore: '恢复',
84
+ restoring: '恢复中…',
85
+ restoreConfirm: '确认恢复该插件?将移除用户 patch 层的禁用覆盖,热应用即时生效。',
86
+ journalTitle: '操作日志',
87
+ pluginsTitle: 'dsh-plus 插件状态',
88
+ pluginFailed: 'FAILED',
89
+ pluginOk: '正常',
90
+ pluginDisabled: '已禁用',
91
+ emptyJournal: '暂无记录。',
92
+ emptyQuarantined: '当前无隔离插件。',
93
+ loadFailed: '状态加载失败:',
94
+ retry: '重试',
95
+ restored: '已恢复,热应用即时生效。',
96
+ restoreFailed: '恢复失败:',
97
+ }
98
+
99
+ const enDict = {
100
+ tab: 'Lifeboat',
101
+ fallbackActive: 'LLM emergency translation active',
102
+ fallbackFrom: 'Source route',
103
+ fallbackTo: 'Fallback route',
104
+ fallbackAt: 'Since',
105
+ quarantined: 'Quarantined plugins',
106
+ restore: 'Restore',
107
+ restoring: 'Restoring…',
108
+ restoreConfirm:
109
+ 'Restore this plugin? The disable override in the user patch layer will be removed and hot-applied.',
110
+ journalTitle: 'Journal',
111
+ pluginsTitle: 'dsh-plus plugin status',
112
+ pluginFailed: 'FAILED',
113
+ pluginOk: 'OK',
114
+ pluginDisabled: 'Disabled',
115
+ emptyJournal: 'No entries yet.',
116
+ emptyQuarantined: 'No quarantined plugins.',
117
+ loadFailed: 'Failed to load: ',
118
+ retry: 'Retry',
119
+ restored: 'Restored; hot-applied immediately.',
120
+ restoreFailed: 'Restore failed: ',
121
+ }
122
+
123
+ function HealthTab(props: { t(key: string): string }): ReactElement {
124
+ const { t } = props
125
+ const [status, setStatus] = useState<StatusResponse | null>(null)
126
+ const [failed, setFailed] = useState(false)
127
+ const [busyName, setBusyName] = useState<string | null>(null)
128
+ const [notice, setNotice] = useState<string | null>(null)
129
+ const [confirming, setConfirming] = useState<string | null>(null)
130
+
131
+ const load = useCallback((): void => {
132
+ getJson<StatusResponse>(ROUTE_STATUS)
133
+ .then((data) => {
134
+ setStatus(data)
135
+ setFailed(false)
136
+ })
137
+ .catch(() => setFailed(true))
138
+ }, [])
139
+
140
+ useEffect(() => {
141
+ load()
142
+ }, [load])
143
+
144
+ const onRestore = (name: string): void => {
145
+ setBusyName(name)
146
+ setConfirming(null)
147
+ postJson<{ ok: boolean; error?: string }>(ROUTE_RESTORE, { name })
148
+ .then(() => {
149
+ setNotice(t('restored'))
150
+ load()
151
+ })
152
+ .catch((error: unknown) => {
153
+ setNotice(`${t('restoreFailed')}${error instanceof Error ? error.message : ''}`)
154
+ })
155
+ .finally(() => setBusyName(null))
156
+ }
157
+
158
+ const journalGroups = useMemo(() => {
159
+ if (status === null) return []
160
+ const groups = new Map<string, JournalEntry[]>()
161
+ for (const entry of [...status.journal].reverse()) {
162
+ const day = entry.at.slice(0, 10)
163
+ const list = groups.get(day) ?? []
164
+ list.push(entry)
165
+ groups.set(day, list)
166
+ }
167
+ return [...groups.entries()]
168
+ }, [status])
169
+
170
+ if (status === null) {
171
+ return (
172
+ <div className="dlb-tab">
173
+ <p className="dlb-empty">{failed ? t('loadFailed') : t('pluginOk') === 'OK' ? '…' : '…'}</p>
174
+ {failed ? (
175
+ <button type="button" className="dlb-btn dlb-btnGhost" onClick={load}>
176
+ {t('retry')}
177
+ </button>
178
+ ) : null}
179
+ </div>
180
+ )
181
+ }
182
+
183
+ return (
184
+ <div className="dlb-tab">
185
+ {notice !== null ? (
186
+ <p className="dlb-notice" role="status">
187
+ {notice}
188
+ </p>
189
+ ) : null}
190
+
191
+ {status.llmFallback?.active === true ? (
192
+ <div className="dlb-fallback" role="status">
193
+ <p className="dlb-fallbackTitle">{t('fallbackActive')}</p>
194
+ <div className="dlb-kv">
195
+ <span>{t('fallbackFrom')}</span>
196
+ <code>
197
+ {status.llmFallback.originalProvider}/{status.llmFallback.originalModel}
198
+ </code>
199
+ </div>
200
+ <div className="dlb-kv">
201
+ <span>{t('fallbackTo')}</span>
202
+ <code>{status.llmFallback.fallbackProvider}</code>
203
+ </div>
204
+ <div className="dlb-kv">
205
+ <span>{t('fallbackAt')}</span>
206
+ <code>{status.llmFallback.at.slice(0, 19).replace('T', ' ')}</code>
207
+ </div>
208
+ </div>
209
+ ) : null}
210
+
211
+ <h3 className="dlb-title">{t('quarantined')}</h3>
212
+ {status.quarantined.length === 0 ? (
213
+ <p className="dlb-empty">{t('emptyQuarantined')}</p>
214
+ ) : (
215
+ <div className="dlb-cards">
216
+ {status.quarantined.map((name) => (
217
+ <div className="dlb-quarantineCard" key={name}>
218
+ <code className="dlb-pluginName">{name}</code>
219
+ {confirming === name ? (
220
+ <div className="dlb-confirm">
221
+ <span className="dlb-confirmText">{t('restoreConfirm')}</span>
222
+ <button
223
+ type="button"
224
+ className="dlb-btn dlb-btnGhost"
225
+ disabled={busyName === name}
226
+ onClick={() => setConfirming(null)}
227
+ >
228
+ ×
229
+ </button>
230
+ <button
231
+ type="button"
232
+ className="dlb-btn dlb-btnPrimary"
233
+ disabled={busyName === name}
234
+ onClick={() => onRestore(name)}
235
+ >
236
+ {busyName === name ? t('restoring') : t('restore')}
237
+ </button>
238
+ </div>
239
+ ) : (
240
+ <button
241
+ type="button"
242
+ className="dlb-btn dlb-btnPrimary"
243
+ disabled={busyName === name}
244
+ onClick={() => setConfirming(name)}
245
+ >
246
+ {busyName === name ? t('restoring') : t('restore')}
247
+ </button>
248
+ )}
249
+ </div>
250
+ ))}
251
+ </div>
252
+ )}
253
+
254
+ <h3 className="dlb-title">{t('journalTitle')}</h3>
255
+ {journalGroups.length === 0 ? (
256
+ <p className="dlb-empty">{t('emptyJournal')}</p>
257
+ ) : (
258
+ <div className="dlb-journal">
259
+ {journalGroups.map(([day, entries]) => (
260
+ <div className="dlb-journalDay" key={day}>
261
+ <span className="dlb-dayLabel">{day}</span>
262
+ {entries.map((entry) => (
263
+ <div className="dlb-entry" key={`${entry.at}-${entry.kind}`}>
264
+ <span className={`dlb-kind ${KIND_STYLES[entry.kind] ?? 'dlb-kindInfo'}`}>
265
+ {entry.kind}
266
+ </span>
267
+ <span className="dlb-entryAt">{entry.at.slice(11, 19)}</span>
268
+ <span className="dlb-entryDetail">{entry.detail}</span>
269
+ </div>
270
+ ))}
271
+ </div>
272
+ ))}
273
+ </div>
274
+ )}
275
+ </div>
276
+ )
277
+ }
278
+
279
+ /** 挂载健康页(由 client.ts 延迟 require;任何异常由调用方捕获)。 */
280
+ export function mountHealthTab(ctx: Context): void {
281
+ const c = ctx as unknown as HealthContext
282
+ const tag = injectHealthStyle()
283
+ c.effect(
284
+ () => () => {
285
+ tag?.remove()
286
+ },
287
+ 'lifeboat: health style',
288
+ )
289
+ c.effect(() => c.locale.register(NS, { zh: zhDict, en: enDict }), 'lifeboat: locale')
290
+ const t = c.locale.bind(NS)
291
+ c.slots.inject('settings.plugins.tab', () =>
292
+ c.slots.register(
293
+ {
294
+ name: 'settings.plugins.tab',
295
+ id: 'dsh-plus-lifeboat',
296
+ order: 20,
297
+ label: () => t('tab'),
298
+ inject: () => ({ t }),
299
+ },
300
+ HealthTab,
301
+ ),
302
+ )
303
+ }
package/src/config.ts CHANGED
@@ -34,7 +34,8 @@ export const Config = z.object({
34
34
  export type LifeboatConfig = Schemastery.TypeT<typeof Config>
35
35
 
36
36
  /** journal 单条记录。 */
37
- const JournalEntry = z.object({
37
+ // biome-ignore lint/suspicious/noExplicitAny: dts 可移植性——嵌套 schema 精确类型经消费方断开 cosmokit 推断链
38
+ const JournalEntry: any = z.object({
38
39
  at: z.string(),
39
40
  kind: z.string(),
40
41
  detail: z.string(),
@@ -51,7 +52,8 @@ const FallbackState = z.object({
51
52
  })
52
53
 
53
54
  /** 命名空间 schema:宽松承载 journal 与翻译状态,上限截断防膨胀。 */
54
- export const JournalSchema = z.object({
55
+ // biome-ignore lint/suspicious/noExplicitAny: dts 可移植性(同 JournalEntry;上次成功构建未触发是因旧 lockfile,现同 usage-panel 口径)
56
+ export const JournalSchema: any = z.object({
55
57
  journal: z.array(JournalEntry).description('救生艇操作日志(最新在尾,封顶 50 条)').default([]),
56
58
  llmFallback: z
57
59
  .union([FallbackState, z.const(null)])
@@ -0,0 +1,120 @@
1
+ /**
2
+ * lifeboat 健康面板 host 接收面:
3
+ * - GET /dsh-plus/lifeboat/status → journal + 翻译状态 + 当前用户 patch 层已禁用的 dsh-plus 插件
4
+ * - POST /dsh-plus/lifeboat/restore {name} → removeDisable + journal + 告警
5
+ * rc8 用户 patch 层热应用:恢复无需重启,watchUserPatches 即时重载。
6
+ * @module lifeboat/health-api
7
+ */
8
+ import { readFile } from 'node:fs/promises'
9
+ import type { IncomingMessage, ServerResponse } from 'node:http'
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import type {} from '@deepseek-ai/dsh-host-webserver'
12
+ import { parseDocument } from 'yaml'
13
+ import type { Alerter } from './notify.ts'
14
+ import { listDisabled, type PatchEntry, removeDisable } from './patch-file.ts'
15
+ import { isGuardedPlugin } from './quarantine.ts'
16
+
17
+ const ROUTE_STATUS = '/dsh-plus/lifeboat/status'
18
+ const ROUTE_RESTORE = '/dsh-plus/lifeboat/restore'
19
+
20
+ export interface HealthDeps {
21
+ patchFile: string
22
+ journal: (kind: string, detail: string) => void
23
+ alert: Alerter
24
+ /** journal 文档(settings 命名空间内,最新在前的尾插列表)。 */
25
+ readJournal(): Array<{ at: string; kind: string; detail: string }>
26
+ readFallback(): {
27
+ active: boolean
28
+ originalProvider: string
29
+ originalModel: string
30
+ fallbackProvider: string
31
+ at: string
32
+ } | null
33
+ }
34
+
35
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
36
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
37
+ res.end(JSON.stringify(body))
38
+ }
39
+
40
+ function readBody(req: IncomingMessage): Promise<string> {
41
+ return new Promise((resolve, reject) => {
42
+ const chunks: Buffer[] = []
43
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
44
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
45
+ req.on('error', reject)
46
+ })
47
+ }
48
+
49
+ /** 解析用户 patch 层中已被禁用且属于 dsh-plus 兄弟域的插件名。 */
50
+ export async function readQuarantined(patchFile: string): Promise<string[]> {
51
+ let text: string
52
+ try {
53
+ text = await readFile(patchFile, 'utf-8')
54
+ } catch {
55
+ return []
56
+ }
57
+ const root = parseDocument(text).toJS() as unknown
58
+ if (!Array.isArray(root)) return []
59
+ const ids = listDisabled(root as PatchEntry[])
60
+ return ids.filter((id) => isGuardedPlugin(id) || id.startsWith('dsh-plus-'))
61
+ }
62
+
63
+ /** 注册健康面板路由(webServer 缺席时由调用方保证不调用)。 */
64
+ export function registerHealthApi(ctx: Context, deps: HealthDeps): void {
65
+ const logger = ctx.logger('lifeboat')
66
+ ctx.webServer.register({
67
+ kind: 'prefix',
68
+ path: ROUTE_STATUS,
69
+ handler: async (_req: IncomingMessage, res: ServerResponse) => {
70
+ try {
71
+ const quarantined = await readQuarantined(deps.patchFile)
72
+ sendJson(res, 200, {
73
+ journal: deps.readJournal(),
74
+ llmFallback: deps.readFallback(),
75
+ quarantined,
76
+ })
77
+ } catch (error) {
78
+ logger.warn(
79
+ `status endpoint failed: ${error instanceof Error ? error.message : String(error)}`,
80
+ )
81
+ sendJson(res, 500, { error: 'internal' })
82
+ }
83
+ },
84
+ })
85
+ ctx.webServer.register({
86
+ kind: 'prefix',
87
+ path: ROUTE_RESTORE,
88
+ handler: async (req: IncomingMessage, res: ServerResponse) => {
89
+ try {
90
+ if (req.method !== 'POST') {
91
+ sendJson(res, 405, { error: 'POST only' })
92
+ return
93
+ }
94
+ const body = JSON.parse(await readBody(req)) as { name?: unknown }
95
+ if (typeof body.name !== 'string' || !body.name.startsWith('dsh-plus-')) {
96
+ sendJson(res, 400, { error: 'invalid-name' })
97
+ return
98
+ }
99
+ const written = await removeDisable(deps.patchFile, body.name)
100
+ deps.journal(
101
+ 'quarantine-restore',
102
+ written
103
+ ? `${body.name}:已移除用户 patch 层的禁用覆盖(热应用即时恢复)`
104
+ : `${body.name}:无需恢复(patch 层无禁用覆盖)`,
105
+ )
106
+ deps.alert(
107
+ '[DSH] 插件恢复',
108
+ `插件 ${body.name} 的隔离已解除(移除 patch 层禁用覆盖)。${
109
+ written ? 'rc8 热应用即时生效,无需重启。' : 'patch 层本无覆盖,可能已被手动恢复。'
110
+ }`,
111
+ )
112
+ sendJson(res, 200, { ok: true, written })
113
+ } catch (error) {
114
+ const message = error instanceof Error ? error.message : String(error)
115
+ logger.warn(`restore endpoint failed: ${message}`)
116
+ sendJson(res, 500, { error: message })
117
+ }
118
+ },
119
+ })
120
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  SETTINGS_NS,
25
25
  } from './config.ts'
26
26
  import { installLlmFallback } from './fallback-llm.ts'
27
+ import { registerHealthApi } from './health-api.ts'
27
28
  import { installAlerter } from './notify.ts'
28
29
  import { createQuarantine, installHostWatch } from './quarantine.ts'
29
30
  import { registerQuarantineApi } from './quarantine-api.ts'
@@ -80,6 +81,13 @@ export function apply(ctx: Context, config: LifeboatConfig): void {
80
81
  installHostWatch(ctx, quarantine)
81
82
  ctx.inject(['webServer'], (webCtx) => {
82
83
  registerQuarantineApi(webCtx, quarantine)
84
+ registerHealthApi(webCtx, {
85
+ patchFile,
86
+ journal,
87
+ alert,
88
+ readJournal: () => doc.journal,
89
+ readFallback: () => doc.llmFallback,
90
+ })
83
91
  })
84
92
  }
85
93
 
package/src/patch-file.ts CHANGED
@@ -43,3 +43,57 @@ export async function appendDisable(patchFile: string, targetId: string): Promis
43
43
  await rename(tmp, patchFile)
44
44
  return true
45
45
  }
46
+
47
+ /** 顶层条目中已禁用的 id 列表(不含 insert 子列表;用户层 disabled 语义只在顶层)。 */
48
+ export function listDisabled(entries: PatchEntry[]): string[] {
49
+ const out: string[] = []
50
+ for (const entry of entries) {
51
+ if (typeof entry.id === 'string' && entry.disabled === true) out.push(entry.id)
52
+ }
53
+ return out
54
+ }
55
+
56
+ /**
57
+ * 从 patch 文件移除针对 targetId 的禁用覆盖(恢复插件);返回是否实际写入。
58
+ * 只移除「id 定向且 disabled: true」的行,其余内容(注释经 yaml 文档保留、
59
+ * 其他条目)原样不动;无该覆盖时幂等返回 false。
60
+ * @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
61
+ */
62
+ export async function removeDisable(patchFile: string, targetId: string): Promise<boolean> {
63
+ const text = await readFile(patchFile, 'utf-8')
64
+ const doc = parseDocument(text)
65
+ const root = doc.toJS() as unknown
66
+ if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`)
67
+ let removed = false
68
+ const items = doc.contents
69
+ if (items?.items !== undefined && Array.isArray(items.items)) {
70
+ // 头注释挂在首个条目的 commentBefore 上:被移除项若为首项,注释迁移给
71
+ // 新的首项,避免整文件头注释丢失。
72
+ const headComment = (items.items[0] as unknown as { commentBefore?: string })?.commentBefore
73
+ doc.contents.items = items.items.filter((item) => {
74
+ const value = (item as unknown as { toJS?(doc: unknown): unknown }).toJS?.(doc) as
75
+ | PatchEntry
76
+ | undefined
77
+ if (
78
+ value !== undefined &&
79
+ typeof value === 'object' &&
80
+ value.id === targetId &&
81
+ value.disabled === true
82
+ ) {
83
+ removed = true
84
+ return false
85
+ }
86
+ return true
87
+ })
88
+ if (removed && headComment !== undefined && doc.contents.items.length > 0) {
89
+ const next = doc.contents.items[0] as unknown as { commentBefore?: string }
90
+ if (next.commentBefore === undefined) next.commentBefore = headComment
91
+ }
92
+ }
93
+ if (!removed) return false
94
+ await copyFile(patchFile, `${patchFile}.lifeboat.bak`)
95
+ const tmp = `${patchFile}.lifeboat.tmp`
96
+ await writeFile(tmp, doc.toString(), 'utf-8')
97
+ await rename(tmp, patchFile)
98
+ return true
99
+ }