@dsh-plus/lifeboat 0.1.3 → 0.1.5

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/index.js CHANGED
@@ -3,18 +3,18 @@ import { SettingsConflictError, settingsNamespace } from "@deepseek-ai/dsh-setti
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import { copyFile, readFile, rename, writeFile } from "node:fs/promises";
5
5
  import { parseDocument } from "yaml";
6
-
7
- //#region src/ns.ts
8
- /**
9
- * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
10
- * @module lifeboat/ns
11
- */
12
- const SETTINGS_NS$1 = "dsh-plus-lifeboat";
13
-
14
6
  //#endregion
15
7
  //#region src/config.ts
8
+ /**
9
+ * 配置与 journal 单一事实源。
10
+ * - 行级 Config(cordis 组合层):救生艇自身行为开关,dev/prod patch 层可覆盖。
11
+ * - settings 命名空间 dsh-plus-lifeboat:运行期 journal 与 LLM 应急翻译状态,
12
+ * 经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml,重启后可据此还原。
13
+ * schema 刻意宽松:lifeboat 是最后防线,自身命名空间的数据问题绝不能让它起不来。
14
+ * @module lifeboat/config
15
+ */
16
16
  /** settings 命名空间(品牌化后供 settings 服务注册/读写)。 */
17
- const SETTINGS_NS = settingsNamespace(SETTINGS_NS$1);
17
+ const SETTINGS_NS = settingsNamespace("dsh-plus-lifeboat");
18
18
  /** 行级配置:全部有默认值,正常部署零配置。 */
19
19
  const Config = z.object({
20
20
  enabled: z.boolean().description("故障隔离总开关").default(true),
@@ -42,18 +42,27 @@ const JournalSchema = z.object({
42
42
  journal: z.array(JournalEntry).description("救生艇操作日志(最新在尾,封顶 50 条)").default([]),
43
43
  llmFallback: z.union([FallbackState, z.const(null)]).description("LLM 应急翻译状态;null 表示未处于降级").default(null)
44
44
  });
45
-
46
45
  //#endregion
47
46
  //#region src/fallback-llm.ts
47
+ /**
48
+ * LLM 应急翻译:默认模型路由的 provider 无已注册 adapter 时(典型场景:llm-pi
49
+ * 被隔离/加载失败),把 settings.yaml 里 dsh-plus-llm-pi 的 provider 配置翻译为
50
+ * 官方 llm-pi-ai 命名空间的原生格式(键加 -fb 后缀避开 DUPLICATE_ADAPTER 冲突),
51
+ * 默认模型指到翻译路由;源 provider 恢复健康后按 journal 自动还原。
52
+ *
53
+ * 为什么读原始 settings.yaml 而不是 settings 服务:插件被禁用后其命名空间随之
54
+ * 注销,服务层读不到——但数据仍在文件里(只读,不修改)。
55
+ * 降级语义:翻译只覆盖官方物化的字段子集,dsh-plus 扩展的全量 compat 丢弃;
56
+ * 协议推断是启发式(显式 api > 路由名 > 模型继承源 > openai-completions 兜底)。
57
+ * @module lifeboat/fallback-llm
58
+ */
48
59
  const NS_PI_AI = settingsNamespace("llm-pi-ai");
49
60
  const NS_AGENT_DEFAULT = settingsNamespace("agent-default-model");
50
61
  const RAW_NS_LLM_PI = "dsh-plus-llm-pi";
51
- /** 翻译路由键后缀:与源路由错开,避免 llm-pi 恢复瞬间撞 DUPLICATE_ADAPTER。 */
52
- const FALLBACK_SUFFIX = "-fb";
53
62
  /** 官方配置路径物化的 compat 子集(其余字段降级期丢弃)。 */
54
63
  const COMPAT_WHITELIST = ["thinkingFormat", "supportsReasoningEffort"];
55
64
  /** 官方 reasoning 词表(ModelThinkingLevel 的保守子集),不在其中的值丢弃。 */
56
- const REASONING_LEVELS = new Set([
65
+ const REASONING_LEVELS = /* @__PURE__ */ new Set([
57
66
  "off",
58
67
  "minimal",
59
68
  "low",
@@ -120,12 +129,13 @@ function translateProvider(routeKey, raw) {
120
129
  /** 整表翻译:键加 -fb 后缀(纯函数)。 */
121
130
  function translateProviders(providers) {
122
131
  const out = {};
123
- for (const [key, raw] of Object.entries(providers)) out[`${key}${FALLBACK_SUFFIX}`] = translateProvider(key, raw);
132
+ for (const [key, raw] of Object.entries(providers)) out[`${key}-fb`] = translateProvider(key, raw);
124
133
  return out;
125
134
  }
126
135
  /** 读 settings.yaml 原始文档的指定段落(只读;文件/段落缺失返回 undefined)。 */
127
136
  async function readRawSection(settingsFile, ns) {
128
- const section = parseDocument(await readFile(settingsFile, "utf-8")).toJS()[ns];
137
+ const text = await readFile(settingsFile, "utf-8");
138
+ const section = parseDocument(text).toJS()[ns];
129
139
  if (typeof section !== "object" || section === null) return void 0;
130
140
  return section;
131
141
  }
@@ -154,7 +164,7 @@ function installLlmFallback(ctx, deps) {
154
164
  return;
155
165
  }
156
166
  const translated = translateProviders(providers);
157
- const fbProvider = `${provider}${FALLBACK_SUFFIX}`;
167
+ const fbProvider = `${provider}-fb`;
158
168
  await writeWithRetry(() => writes.update(NS_PI_AI, { providers: translated }));
159
169
  await writeWithRetry(() => writes.update(NS_AGENT_DEFAULT, {
160
170
  provider: fbProvider,
@@ -210,7 +220,7 @@ function installLlmFallback(ctx, deps) {
210
220
  const bootTimer = setTimeout(run, 1e4);
211
221
  ctx.effect(() => () => clearTimeout(bootTimer), "lifeboat: fallback boot timer");
212
222
  ctx.on("ready", run);
213
- const WATCHED_NS = new Set([
223
+ const WATCHED_NS = /* @__PURE__ */ new Set([
214
224
  "agent-default-model",
215
225
  "llm-pi-ai",
216
226
  RAW_NS_LLM_PI
@@ -220,42 +230,15 @@ function installLlmFallback(ctx, deps) {
220
230
  });
221
231
  ctx.root.on("llm/adapters-updated", run);
222
232
  }
223
-
224
233
  //#endregion
225
- //#region src/notify.ts
234
+ //#region src/patch-file.ts
226
235
  /**
227
- * 装配告警出口。返回同步派单函数:内部异步发送,失败只记日志。
228
- * notify-email 出现时升级为邮件;全程 journal 兜底可溯。
236
+ * profile 用户 patch 文件(cordis.patch.yml)的安全写入。
237
+ * 官方预留的逃生门:用户层支持 id 定向 disabled,覆盖 bundle 层 insert。
238
+ * 写入纪律:先备份、幂等(已禁用跳过)、原子落盘(tmp + rename),失败抛错由调用方告警,
239
+ * 绝不写半个文件、绝不改动既有条目内容。
240
+ * @module lifeboat/patch-file
229
241
  */
230
- function installAlerter(ctx, journal) {
231
- const logger = ctx.logger("lifeboat");
232
- let mailer;
233
- ctx.inject(["notifyEmail"], (mailCtx) => {
234
- mailer = mailCtx.notifyEmail;
235
- return () => {
236
- mailer = void 0;
237
- };
238
- });
239
- return (subject, text) => {
240
- journal("alert", `${subject} | ${text}`);
241
- const active = mailer;
242
- if (!active?.sendNotice) {
243
- logger.warn(`${subject} —— ${text}`);
244
- return;
245
- }
246
- active.sendNotice({
247
- subject,
248
- text
249
- }).then((result) => {
250
- if (!result.ok) logger.warn(`邮件告警投递失败: ${result.detail}`);
251
- }, (error) => {
252
- logger.warn(`邮件告警异常: ${error instanceof Error ? error.message : String(error)}`);
253
- });
254
- };
255
- }
256
-
257
- //#endregion
258
- //#region src/patch-file.ts
259
242
  /** 条目(含 insert 子列表)中是否已有针对 targetId 的 disabled 覆盖。 */
260
243
  function hasDisable(entries, targetId) {
261
244
  for (const entry of entries) {
@@ -269,7 +252,8 @@ function hasDisable(entries, targetId) {
269
252
  * @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
270
253
  */
271
254
  async function appendDisable(patchFile, targetId) {
272
- const doc = parseDocument(await readFile(patchFile, "utf-8"));
255
+ const text = await readFile(patchFile, "utf-8");
256
+ const doc = parseDocument(text);
273
257
  const root = doc.toJS();
274
258
  if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
275
259
  if (hasDisable(root, targetId)) return false;
@@ -283,42 +267,92 @@ async function appendDisable(patchFile, targetId) {
283
267
  await rename(tmp, patchFile);
284
268
  return true;
285
269
  }
286
-
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
+ }
287
311
  //#endregion
288
312
  //#region src/quarantine.ts
289
313
  /** Runtime mirror: FiberState 是 cordis 跨包 const enum(官方 dsh-host-plugin-inventory 同款做法)。 */
290
314
  const FIBER_FAILED = 3;
291
315
  /** 永不隔离的条目:救生艇自身与聚合 bundle(禁了它们等于关掉整个防线/全家)。 */
292
- const EXCLUDED = new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
316
+ const EXCLUDED = /* @__PURE__ */ new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
293
317
  const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/;
294
318
  /** 名字是否属于可隔离的 dsh-plus 兄弟插件(entry id 与插件 name 同字面量是本仓库约定)。 */
295
- function isGuardedPlugin(name$1) {
296
- return typeof name$1 === "string" && GUARD_RE.test(name$1) && !EXCLUDED.has(name$1);
319
+ function isGuardedPlugin(name) {
320
+ return typeof name === "string" && GUARD_RE.test(name) && !EXCLUDED.has(name);
321
+ }
322
+ /** 告警/journal 里的失败原因摘录上限,防长堆栈淹没通知渠道。 */
323
+ const REASON_CAP = 500;
324
+ /** 提取 fiber 失败原因的短文本(无则空串);cordis 把错误挂在 fiber._error。 */
325
+ function reasonText(reason) {
326
+ if (reason === void 0 || reason === null) return "";
327
+ const text = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
328
+ return text.length > REASON_CAP ? `${text.slice(0, REASON_CAP)}…` : text;
297
329
  }
298
330
  /** 装配隔离执行器(纯逻辑,可测)。 */
299
331
  function createQuarantine(ctx, deps) {
300
332
  const logger = ctx.logger("lifeboat");
301
333
  const lastAlertAt = /* @__PURE__ */ new Map();
302
334
  const inFlight = /* @__PURE__ */ new Set();
303
- async function quarantine(name$1, origin) {
304
- if (!isGuardedPlugin(name$1)) return;
305
- if (inFlight.has(name$1)) return;
306
- inFlight.add(name$1);
335
+ async function quarantine(name, origin, reason) {
336
+ if (!isGuardedPlugin(name)) return;
337
+ if (inFlight.has(name)) return;
338
+ inFlight.add(name);
339
+ const reasonBlock = reasonText(reason);
340
+ const reasonSuffix = reasonBlock === "" ? "" : `\n失败原因:\n${reasonBlock}`;
307
341
  try {
308
- const written = await appendDisable(deps.patchFile, name$1);
309
- deps.journal("quarantine", `${name$1}(来源 ${origin},${written ? "已写入禁用" : "已存在禁用"})`);
342
+ const written = await appendDisable(deps.patchFile, name);
343
+ deps.journal("quarantine", `${name}(来源 ${origin},${written ? "已写入禁用" : "已存在禁用"})${reasonSuffix}`);
310
344
  const now = Date.now();
311
- if (now - (lastAlertAt.get(name$1) ?? 0) >= deps.alertCooldownMs) {
312
- lastAlertAt.set(name$1, now);
313
- deps.alert(`[DSH] 插件 ${name$1} 加载失败,已自动隔离`, `检测到插件 ${name$1} 的 fiber 进入 FAILED(来源:${origin})。\n已向 ${deps.patchFile} 写入 disabled 覆盖,重启/刷新后该插件不再加载,其余插件不受影响。\n修复后删除该条覆盖即可恢复。`);
345
+ if (now - (lastAlertAt.get(name) ?? 0) >= deps.alertCooldownMs) {
346
+ lastAlertAt.set(name, now);
347
+ deps.alert(`[DSH] 插件 ${name} 加载失败,已自动隔离`, `检测到插件 ${name} 的 fiber 进入 FAILED(来源:${origin})。\n已向 ${deps.patchFile} 写入 disabled 覆盖,重启/刷新后该插件不再加载,其余插件不受影响。\n修复后删除该条覆盖即可恢复。${reasonSuffix}`);
314
348
  }
315
349
  } catch (error) {
316
350
  const message = error instanceof Error ? error.message : String(error);
317
- logger.warn(`隔离 ${name$1} 失败: ${message}`);
318
- deps.journal("quarantine-error", `${name$1}: ${message}`);
319
- deps.alert(`[DSH] 插件 ${name$1} 加载失败,自动隔离未果`, `隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`);
351
+ logger.warn(`隔离 ${name} 失败: ${message}`);
352
+ deps.journal("quarantine-error", `${name}: ${message}`);
353
+ deps.alert(`[DSH] 插件 ${name} 加载失败,自动隔离未果`, `隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`);
320
354
  } finally {
321
- inFlight.delete(name$1);
355
+ inFlight.delete(name);
322
356
  }
323
357
  }
324
358
  return { quarantine };
@@ -331,10 +365,125 @@ function installHostWatch(ctx, q) {
331
365
  ctx.root.on("internal/status", (fiber) => {
332
366
  if (fiber.state !== FIBER_FAILED) return;
333
367
  if (!isGuardedPlugin(fiber.name)) return;
334
- q.quarantine(fiber.name, "host");
368
+ q.quarantine(fiber.name, "host", fiber._error);
369
+ });
370
+ }
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
+ }
335
453
  });
336
454
  }
337
-
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
+ }
338
487
  //#endregion
339
488
  //#region src/quarantine-api.ts
340
489
  const ROUTE = "/dsh-plus/lifeboat/quarantine";
@@ -378,13 +527,10 @@ function registerQuarantineApi(ctx, q) {
378
527
  }
379
528
  });
380
529
  }
381
-
382
530
  //#endregion
383
531
  //#region src/index.ts
384
532
  const name = "dsh-plus-lifeboat";
385
533
  const inject = ["settings", "llm"];
386
- /** journal 上限:只留最近 50 条,防长期运行膨胀。 */
387
- const JOURNAL_CAP = 50;
388
534
  function apply(ctx, config) {
389
535
  const logger = ctx.logger("lifeboat");
390
536
  let doc = {
@@ -406,7 +552,7 @@ function apply(ctx, config) {
406
552
  at: (/* @__PURE__ */ new Date()).toISOString(),
407
553
  kind,
408
554
  detail
409
- }].slice(-JOURNAL_CAP)
555
+ }].slice(-50)
410
556
  };
411
557
  persist?.({ journal: doc.journal }).catch((error) => {
412
558
  logger.warn(`journal 持久化失败: ${error instanceof Error ? error.message : String(error)}`);
@@ -414,8 +560,9 @@ function apply(ctx, config) {
414
560
  };
415
561
  const alert = installAlerter(ctx, journal);
416
562
  if (config.enabled) {
563
+ const patchFile = config.patchFile.length > 0 ? config.patchFile : dshHomePath("profiles", "web", "cordis.patch.yml");
417
564
  const quarantine = createQuarantine(ctx, {
418
- patchFile: config.patchFile.length > 0 ? config.patchFile : dshHomePath("profiles", "web", "cordis.patch.yml"),
565
+ patchFile,
419
566
  alertCooldownMs: config.alertCooldownMs,
420
567
  journal,
421
568
  alert
@@ -423,6 +570,13 @@ function apply(ctx, config) {
423
570
  installHostWatch(ctx, quarantine);
424
571
  ctx.inject(["webServer"], (webCtx) => {
425
572
  registerQuarantineApi(webCtx, quarantine);
573
+ registerHealthApi(webCtx, {
574
+ patchFile,
575
+ journal,
576
+ alert,
577
+ readJournal: () => doc.journal,
578
+ readFallback: () => doc.llmFallback
579
+ });
426
580
  });
427
581
  }
428
582
  if (config.llmFallback) installLlmFallback(ctx, {
@@ -438,6 +592,5 @@ function apply(ctx, config) {
438
592
  }
439
593
  });
440
594
  }
441
-
442
595
  //#endregion
443
- export { Config, apply, inject, name };
596
+ export { Config, apply, inject, name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/lifeboat",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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.3"
31
35
  },
32
36
  "peerDependencies": {
33
37
  "@deepseek-ai/cordis": "^4.0.1",
@@ -38,11 +42,14 @@
38
42
  },
39
43
  "devDependencies": {
40
44
  "@deepseek-ai/cordis": "4.0.1",
41
- "@deepseek-ai/dsh-home-paths": "0.1.0-rc.8",
42
- "@deepseek-ai/dsh-host-webserver": "0.1.0-rc.8",
43
- "@deepseek-ai/dsh-llm": "0.1.0-rc.8",
44
- "@deepseek-ai/dsh-settings": "0.1.0-rc.8",
45
- "@deepseek-ai/schemastery": "3.18.1"
45
+ "@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
46
+ "@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
47
+ "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
48
+ "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
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
+ }