@dsh-plus/lifeboat 0.2.5 → 0.2.7

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
@@ -17894,20 +17894,20 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
17894
17894
  json_default = [lang$31];
17895
17895
  }));
17896
17896
  //#endregion
17897
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-client-ui-primitives@0.1.3-alpha.2_@deepseek-ai+cordis@4.0.2/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/DiffBlock.module.css
17897
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-client-ui-primitives@0.1.5-alpha.1_@deepseek-ai+cordis@4.0.2/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/DiffBlock.module.css
17898
17898
  var DiffBlock_module_default;
17899
17899
  var init_DiffBlock_module = __esmMin((() => {
17900
17900
  DiffBlock_module_default = {
17901
- "add": "obLtqa_add",
17902
- "block": "obLtqa_block",
17903
- "body": "obLtqa_body",
17904
- "copyButton": "obLtqa_copyButton",
17905
- "del": "obLtqa_del",
17906
- "expand": "obLtqa_expand",
17907
- "footer": "obLtqa_footer",
17908
- "gap": "obLtqa_gap",
17909
- "line": "obLtqa_line",
17910
- "path": "obLtqa_path"
17901
+ "add": "nVxsMW_add",
17902
+ "block": "nVxsMW_block",
17903
+ "body": "nVxsMW_body",
17904
+ "copyButton": "nVxsMW_copyButton",
17905
+ "del": "nVxsMW_del",
17906
+ "expand": "nVxsMW_expand",
17907
+ "footer": "nVxsMW_footer",
17908
+ "gap": "nVxsMW_gap",
17909
+ "line": "nVxsMW_line",
17910
+ "path": "nVxsMW_path"
17911
17911
  };
17912
17912
  }));
17913
17913
  //#endregion
@@ -58054,32 +58054,32 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
58054
58054
  init_types();
58055
58055
  }));
58056
58056
  //#endregion
58057
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-client-ui-primitives@0.1.3-alpha.2_@deepseek-ai+cordis@4.0.2/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/markdown/CodeBlock.module.css
58057
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-client-ui-primitives@0.1.5-alpha.1_@deepseek-ai+cordis@4.0.2/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/markdown/CodeBlock.module.css
58058
58058
  var CodeBlock_module_default;
58059
58059
  var init_CodeBlock_module = __esmMin((() => {
58060
58060
  CodeBlock_module_default = {
58061
- "action": "folzqq_action",
58062
- "banner": "folzqq_banner",
58063
- "bannerWrap": "folzqq_bannerWrap",
58064
- "block": "folzqq_block",
58065
- "copyButton": "folzqq_copyButton",
58066
- "infostring": "folzqq_infostring",
58067
- "plain": "folzqq_plain",
58068
- "shiki": "folzqq_shiki"
58061
+ "action": "Hbqh2W_action",
58062
+ "banner": "Hbqh2W_banner",
58063
+ "bannerWrap": "Hbqh2W_bannerWrap",
58064
+ "block": "Hbqh2W_block",
58065
+ "copyButton": "Hbqh2W_copyButton",
58066
+ "infostring": "Hbqh2W_infostring",
58067
+ "plain": "Hbqh2W_plain",
58068
+ "shiki": "Hbqh2W_shiki"
58069
58069
  };
58070
58070
  }));
58071
58071
  //#endregion
58072
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-client-ui-primitives@0.1.3-alpha.2_@deepseek-ai+cordis@4.0.2/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/markdown/MarkdownText.module.css
58072
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-client-ui-primitives@0.1.5-alpha.1_@deepseek-ai+cordis@4.0.2/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/markdown/MarkdownText.module.css
58073
58073
  var MarkdownText_module_default;
58074
58074
  var init_MarkdownText_module = __esmMin((() => {
58075
58075
  MarkdownText_module_default = {
58076
- "fileMention": "vrxa6G_fileMention",
58077
- "image": "vrxa6G_image",
58078
- "imageAlt": "vrxa6G_imageAlt",
58079
- "linkIcon": "vrxa6G_linkIcon",
58080
- "markdown": "vrxa6G_markdown",
58081
- "tableFill": "vrxa6G_tableFill",
58082
- "tableScroll": "vrxa6G_tableScroll"
58076
+ "fileMention": "BL43XG_fileMention",
58077
+ "image": "BL43XG_image",
58078
+ "imageAlt": "BL43XG_imageAlt",
58079
+ "linkIcon": "BL43XG_linkIcon",
58080
+ "markdown": "BL43XG_markdown",
58081
+ "tableFill": "BL43XG_tableFill",
58082
+ "tableScroll": "BL43XG_tableScroll"
58083
58083
  };
58084
58084
  }));
58085
58085
  //#endregion
@@ -58406,7 +58406,7 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
58406
58406
  less_default = [lang];
58407
58407
  }));
58408
58408
  //#endregion
58409
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-client-ui-primitives@0.1.3-alpha.2_@deepseek-ai+cordis@4.0.2/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/index.js
58409
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-client-ui-primitives@0.1.5-alpha.1_@deepseek-ai+cordis@4.0.2/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/index.js
58410
58410
  /**
58411
58411
  * Write text to the host clipboard, preferring the async Clipboard API and
58412
58412
  * falling back to `execCommand('copy')` on hosts (jsdom, insecure contexts)
@@ -59181,6 +59181,30 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
59181
59181
  return;
59182
59182
  }
59183
59183
  }
59184
+ /** Protocols a vocabulary-rewritten image destination may carry. */
59185
+ function vocabularyImageUrl(url) {
59186
+ try {
59187
+ const protocol = new URL(url).protocol;
59188
+ return protocol === "http:" || protocol === "https:" || protocol === "blob:" || protocol === "data:" ? url : void 0;
59189
+ } catch {
59190
+ return;
59191
+ }
59192
+ }
59193
+ /**
59194
+ * The displayable source for one image destination: absolute HTTP(S) as
59195
+ * authored, otherwise the context's local-path vocabulary when it vouches for
59196
+ * the destination. Either miss leaves the authored fallback (alt text) to the
59197
+ * caller.
59198
+ * @param url - The authored markdown destination.
59199
+ * @param pathImages - Rewriting vocabulary, when the render pass has one.
59200
+ * @returns The displayable image URL, or undefined.
59201
+ */
59202
+ function imageSource(url, pathImages) {
59203
+ const remote = remoteImageUrl(sanitizeUrl(normalizeUri(url)));
59204
+ if (remote !== void 0) return remote;
59205
+ const rewritten = pathImages?.resolve(url);
59206
+ return rewritten === void 0 ? void 0 : vocabularyImageUrl(rewritten);
59207
+ }
59184
59208
  /**
59185
59209
  * Create an empty {@link ReferenceTargets}.
59186
59210
  * @returns Fresh empty maps.
@@ -59295,7 +59319,7 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
59295
59319
  inLink: true
59296
59320
  }), key, !anchorWrapsOnlyImages(node.children));
59297
59321
  case "linkReference": return renderLinkReference(node, key, context);
59298
- case "image": return renderImage(node.url, node.alt ?? "", key);
59322
+ case "image": return renderImage(node.url, node.alt ?? "", key, context);
59299
59323
  case "imageReference": return renderImageReference(node, key, context);
59300
59324
  case "footnoteReference": return renderFootnoteReference(node, key, context);
59301
59325
  case "definition":
@@ -59429,20 +59453,36 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
59429
59453
  return;
59430
59454
  }
59431
59455
  }
59432
- function renderImage(url, alt, key) {
59433
- const imageSrc = remoteImageUrl(sanitizeUrl(normalizeUri(url)));
59456
+ function renderImage(url, alt, key, context) {
59457
+ const imageSrc = imageSource(url, context.pathImages);
59434
59458
  if (imageSrc === void 0) return (0, react_jsx_runtime.jsx)("span", {
59435
59459
  className: MarkdownText_module_default.imageAlt,
59436
59460
  children: alt
59437
59461
  }, key);
59462
+ return (0, react_jsx_runtime.jsx)(MarkdownImage, {
59463
+ src: imageSrc,
59464
+ alt,
59465
+ destination: url
59466
+ }, `${key}:${imageSrc}`);
59467
+ }
59468
+ /** Failed loads retain the authored alt or destination; a new source remounts the image. */
59469
+ function MarkdownImage({ src, alt, destination }) {
59470
+ const [failed, setFailed] = (0, react.useState)(false);
59471
+ if (failed) return (0, react_jsx_runtime.jsx)("span", {
59472
+ className: MarkdownText_module_default.imageAlt,
59473
+ children: alt || destination
59474
+ });
59438
59475
  return (0, react_jsx_runtime.jsx)("img", {
59439
59476
  className: MarkdownText_module_default.image,
59440
- src: imageSrc,
59477
+ src,
59441
59478
  alt,
59479
+ onError: () => {
59480
+ setFailed(true);
59481
+ },
59442
59482
  loading: "lazy",
59443
59483
  decoding: "async",
59444
59484
  referrerPolicy: "no-referrer"
59445
- }, key);
59485
+ });
59446
59486
  }
59447
59487
  /** The bracketed source text a reference reverts to when its definition is missing. */
59448
59488
  function referenceSuffix(node) {
@@ -59466,7 +59506,7 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
59466
59506
  function renderImageReference(node, key, context) {
59467
59507
  const definition = context.targets.definitions.get(node.identifier.toUpperCase());
59468
59508
  if (definition === void 0) return `![${node.alt ?? ""}${referenceSuffix(node)}`;
59469
- return renderImage(definition.url, node.alt ?? "", key);
59509
+ return renderImage(definition.url, node.alt ?? "", key, context);
59470
59510
  }
59471
59511
  function renderFootnoteReference(node, key, context) {
59472
59512
  const id = node.identifier.toUpperCase();
@@ -59527,7 +59567,7 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
59527
59567
  * full parse self-heals it.
59528
59568
  */
59529
59569
  /** One settled full render: parse with math, resolve references, append the footnote section. */
59530
- function renderSettled(text, labels, fileMentions) {
59570
+ function renderSettled(text, labels, fileMentions, pathImages) {
59531
59571
  const root = parseGfmWithMath(text);
59532
59572
  const targets = createReferenceTargets();
59533
59573
  collectReferenceTargets(root.children, targets);
@@ -59535,6 +59575,7 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
59535
59575
  streaming: false,
59536
59576
  labels,
59537
59577
  fileMentions,
59578
+ pathImages,
59538
59579
  targets,
59539
59580
  footnoteOrder: [],
59540
59581
  footnoteCounts: /* @__PURE__ */ new Map()
@@ -60406,6 +60447,7 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
60406
60447
  streaming: true,
60407
60448
  labels: this.labels,
60408
60449
  fileMentions: void 0,
60450
+ pathImages: void 0,
60409
60451
  targets: frameTargets,
60410
60452
  footnoteOrder: this.frozenFootnoteOrder,
60411
60453
  footnoteCounts: this.frozenFootnoteCounts
@@ -60422,6 +60464,7 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
60422
60464
  streaming: true,
60423
60465
  labels: this.labels,
60424
60466
  fileMentions: void 0,
60467
+ pathImages: void 0,
60425
60468
  targets: frameTargets,
60426
60469
  footnoteOrder: [...this.frozenFootnoteOrder],
60427
60470
  footnoteCounts: new Map(this.frozenFootnoteCounts)
@@ -60438,13 +60481,13 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
60438
60481
  return this.lastRendered;
60439
60482
  }
60440
60483
  };
60441
- (0, react.memo)(function MarkdownText({ text, streaming = false, labels, fileMentions }) {
60484
+ (0, react.memo)(function MarkdownText({ text, streaming = false, labels, fileMentions, pathImages }) {
60442
60485
  const streamRef = (0, react.useRef)(null);
60443
60486
  const streamLabelsRef = (0, react.useRef)(labels);
60444
60487
  const children = (0, react.useMemo)(() => {
60445
60488
  if (!streaming) {
60446
60489
  streamRef.current = null;
60447
- return renderSettled(text, labels, fileMentions);
60490
+ return renderSettled(text, labels, fileMentions, pathImages);
60448
60491
  }
60449
60492
  if (streamRef.current === null || streamLabelsRef.current !== labels) {
60450
60493
  streamRef.current = new StreamingRenderer(labels);
@@ -60455,7 +60498,8 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
60455
60498
  text,
60456
60499
  streaming,
60457
60500
  labels,
60458
- fileMentions
60501
+ fileMentions,
60502
+ pathImages
60459
60503
  ]);
60460
60504
  return (0, react_jsx_runtime.jsx)("div", {
60461
60505
  className: MarkdownText_module_default.markdown,
package/lib/index.js CHANGED
@@ -1,20 +1,17 @@
1
1
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { copyFile, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { copyFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
4
4
  import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
5
5
  import { parseDocument } from "yaml";
6
- //#endregion
6
+ import { dirname } from "node:path";
7
7
  //#region src/config.ts
8
8
  /**
9
- * 配置与 journal 单一事实源。
9
+ * 配置单一事实源。
10
10
  * - 行级 Config(cordis 组合层):救生艇自身行为开关,dev/prod patch 层可覆盖。
11
- * - settings 命名空间 dsh-plus-lifeboat:运行期 journal 与 LLM 应急翻译状态,
12
- * 经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml,重启后可据此还原。
13
- * schema 刻意宽松:lifeboat 是最后防线,自身命名空间的数据问题绝不能让它起不来。
11
+ * - journal 与 LLM 应急翻译状态属运行期数据,按存储规范持久化到
12
+ * $DSH_HOME/dsh-plus/lifeboat/state.json(见 state-file.ts),不进 settings。
14
13
  * @module lifeboat/config
15
14
  */
16
- /** settings 命名空间(字面量直供 settings 服务注册/读写,0.1.2-alpha.2 起编译期校验语法)。 */
17
- const SETTINGS_NS = "dsh-plus-lifeboat";
18
15
  /** 行级配置:全部有默认值,正常部署零配置。 */
19
16
  const Config = z.object({
20
17
  enabled: z.boolean().description("故障隔离总开关").default(true),
@@ -22,26 +19,6 @@ const Config = z.object({
22
19
  llmFallback: z.boolean().description("llm-pi 缺席时自动翻译其配置到官方 llm-pi-ai 并切换默认模型").default(true),
23
20
  alertCooldownMs: z.natural().description("同一插件重复告警的最小间隔毫秒数").default(3e5)
24
21
  });
25
- /** journal 单条记录。 */
26
- const JournalEntry = z.object({
27
- at: z.string(),
28
- kind: z.string(),
29
- detail: z.string()
30
- });
31
- /** LLM 应急翻译的持久状态(还原依据)。 */
32
- const FallbackState = z.object({
33
- active: z.boolean(),
34
- originalProvider: z.string(),
35
- originalModel: z.string(),
36
- fallbackProvider: z.string(),
37
- providers: z.array(z.string()),
38
- at: z.string()
39
- });
40
- /** 命名空间 schema:宽松承载 journal 与翻译状态,上限截断防膨胀。 */
41
- const JournalSchema = z.object({
42
- journal: z.array(JournalEntry).description("救生艇操作日志(最新在尾,封顶 50 条)").default([]),
43
- llmFallback: z.union([FallbackState, z.const(null)]).description("LLM 应急翻译状态;null 表示未处于降级").default(null)
44
- });
45
22
  //#endregion
46
23
  //#region src/fallback-llm.ts
47
24
  /**
@@ -616,35 +593,111 @@ function registerQuarantineApi(ctx, q) {
616
593
  });
617
594
  }
618
595
  //#endregion
596
+ //#region src/state-file.ts
597
+ /**
598
+ * journal 与 LLM 应急翻译状态的文件持久化($DSH_HOME/dsh-plus/lifeboat/state.json)。
599
+ *
600
+ * 存储规范:运行期状态属非配置数据,不进 settings.yaml;本模块自带实现,
601
+ * 维持零 dsh-plus 内部依赖铁律(不 import @dsh-plus/shared)。
602
+ * schema 刻意宽松:lifeboat 是最后防线,自身数据问题绝不能让它起不来。
603
+ * @module lifeboat/state-file
604
+ */
605
+ /** 状态文件落点:$DSH_HOME/dsh-plus/lifeboat/state.json。 */
606
+ const STATE_FILE = dshHomePath("dsh-plus", "lifeboat", "state.json");
607
+ /** 文件大小上限:超出即视为损坏,丢弃重来(journal 本身封顶 50 条)。 */
608
+ const MAX_BYTES = 262144;
609
+ /** 未知输入收窄为合法 journal 条目(宽松承载数据问题)。 */
610
+ function asEntry(value) {
611
+ if (typeof value !== "object" || value === null) return null;
612
+ const record = value;
613
+ if (typeof record["at"] !== "string" || typeof record["kind"] !== "string") return null;
614
+ return {
615
+ at: record["at"],
616
+ kind: record["kind"],
617
+ detail: String(record["detail"] ?? "")
618
+ };
619
+ }
620
+ /** 未知输入收窄为合法翻译状态。 */
621
+ function asFallback(value) {
622
+ if (typeof value !== "object" || value === null) return null;
623
+ const record = value;
624
+ if (typeof record["active"] !== "boolean") return null;
625
+ return {
626
+ active: record["active"],
627
+ originalProvider: String(record["originalProvider"] ?? ""),
628
+ originalModel: String(record["originalModel"] ?? ""),
629
+ fallbackProvider: String(record["fallbackProvider"] ?? ""),
630
+ providers: Array.isArray(record["providers"]) ? record["providers"].map((item) => String(item)) : [],
631
+ at: String(record["at"] ?? "")
632
+ };
633
+ }
634
+ /** 未知输入收窄为完整状态文档;任何缺省回落到空文档。 */
635
+ function normalizeState(raw) {
636
+ if (typeof raw !== "object" || raw === null) return {
637
+ journal: [],
638
+ llmFallback: null
639
+ };
640
+ const record = raw;
641
+ return {
642
+ journal: (Array.isArray(record["journal"]) ? record["journal"].map(asEntry).filter((entry) => entry !== null) : []).slice(-50),
643
+ llmFallback: asFallback(record["llmFallback"])
644
+ };
645
+ }
646
+ /** 读取状态文件;缺失/损坏/超限时返回空文档(最后防线不得因自身数据起不来)。 */
647
+ async function loadState() {
648
+ try {
649
+ const info = await stat(STATE_FILE);
650
+ if (!info.isFile() || info.size > MAX_BYTES) return {
651
+ journal: [],
652
+ llmFallback: null
653
+ };
654
+ return normalizeState(JSON.parse(await readFile(STATE_FILE, "utf-8")));
655
+ } catch {
656
+ return {
657
+ journal: [],
658
+ llmFallback: null
659
+ };
660
+ }
661
+ }
662
+ /** 原子写状态文件:临时文件 + rename,写入失败仅告警不抛出。 */
663
+ async function saveState(doc, warn) {
664
+ try {
665
+ await mkdir(dirname(STATE_FILE), { recursive: true });
666
+ const tmp = `${STATE_FILE}.tmp`;
667
+ await writeFile(tmp, `${JSON.stringify(doc, null, 2)}\n`, "utf-8");
668
+ await rename(tmp, STATE_FILE);
669
+ } catch (error) {
670
+ warn(`状态文件写入失败: ${error instanceof Error ? error.message : String(error)}`);
671
+ }
672
+ }
673
+ /** 追加一条 journal(内存文档已更新后调用;落盘失败不阻断动作)。 */
674
+ function appendJournal(doc, kind, detail) {
675
+ return {
676
+ ...doc,
677
+ journal: [...doc.journal, {
678
+ at: (/* @__PURE__ */ new Date()).toISOString(),
679
+ kind,
680
+ detail
681
+ }].slice(-50)
682
+ };
683
+ }
684
+ //#endregion
619
685
  //#region src/index.ts
620
686
  const name = "dsh-plus-lifeboat";
621
687
  const inject = ["settings", "llm"];
622
688
  function apply(ctx, config) {
623
689
  const logger = ctx.logger("lifeboat");
690
+ const warn = (message) => logger.warn(message);
624
691
  let doc = {
625
692
  journal: [],
626
693
  llmFallback: null
627
694
  };
628
- let persist;
629
- try {
630
- const scope = ctx.settings.register(SETTINGS_NS, JournalSchema);
631
- doc = scope.get();
632
- persist = (patch) => scope.update(patch);
633
- } catch (error) {
634
- logger.warn(`journal 命名空间注册失败,降级为内存态: ${error instanceof Error ? error.message : String(error)}`);
635
- }
695
+ loadState().then((loaded) => {
696
+ doc = loaded;
697
+ });
636
698
  const journal = (kind, detail) => {
637
- doc = {
638
- ...doc,
639
- journal: [...doc.journal, {
640
- at: (/* @__PURE__ */ new Date()).toISOString(),
641
- kind,
642
- detail
643
- }].slice(-50)
644
- };
645
- persist?.({ journal: doc.journal }).catch((error) => {
646
- logger.warn(`journal 持久化失败: ${error instanceof Error ? error.message : String(error)}`);
647
- });
699
+ doc = appendJournal(doc, kind, detail);
700
+ saveState(doc, warn);
648
701
  };
649
702
  const alert = installAlerter(ctx, journal);
650
703
  if (config.enabled) {
@@ -676,7 +729,7 @@ function apply(ctx, config) {
676
729
  ...doc,
677
730
  llmFallback: state
678
731
  };
679
- await persist?.({ llmFallback: state });
732
+ await saveState(doc, warn);
680
733
  }
681
734
  });
682
735
  }
package/lib/style.css CHANGED
@@ -1,4 +1,4 @@
1
- .obLtqa_block {
1
+ .nVxsMW_block {
2
2
  --dsl-diff-radius: 12px;
3
3
  --dsl-diff-line-height: 22px;
4
4
  color: var(--dsw-alias-label-primary);
@@ -8,7 +8,7 @@
8
8
  position: relative;
9
9
  }
10
10
 
11
- .obLtqa_copyButton {
11
+ .nVxsMW_copyButton {
12
12
  z-index: 1;
13
13
  color: var(--dsw-alias-label-secondary);
14
14
  cursor: pointer;
@@ -22,46 +22,46 @@
22
22
  right: 12px;
23
23
  }
24
24
 
25
- .obLtqa_body {
25
+ .nVxsMW_body {
26
26
  font: var(--dsw-font-markdown-code-block);
27
27
  padding: 12px 14px;
28
28
  overflow: auto hidden;
29
29
  }
30
30
 
31
- .obLtqa_line {
31
+ .nVxsMW_line {
32
32
  min-height: var(--dsl-diff-line-height);
33
33
  white-space: pre;
34
34
  }
35
35
 
36
- .obLtqa_path {
36
+ .nVxsMW_path {
37
37
  color: var(--dsw-alias-label-primary);
38
38
  padding-right: 56px;
39
39
  font-weight: 600;
40
40
  }
41
41
 
42
- .obLtqa_gap {
42
+ .nVxsMW_gap {
43
43
  color: var(--dsw-alias-label-tertiary);
44
44
  }
45
45
 
46
- .obLtqa_del:before {
46
+ .nVxsMW_del:before {
47
47
  content: "- ";
48
48
  color: var(--dsw-alias-state-error-primary);
49
49
  }
50
50
 
51
- .obLtqa_del {
51
+ .nVxsMW_del {
52
52
  color: var(--dsw-alias-state-error-primary);
53
53
  }
54
54
 
55
- .obLtqa_add:before {
55
+ .nVxsMW_add:before {
56
56
  content: "+ ";
57
57
  color: var(--dsw-alias-state-success-primary);
58
58
  }
59
59
 
60
- .obLtqa_add {
60
+ .nVxsMW_add {
61
61
  color: var(--dsw-alias-state-success-primary);
62
62
  }
63
63
 
64
- .obLtqa_expand {
64
+ .nVxsMW_expand {
65
65
  width: 100%;
66
66
  color: var(--dsw-alias-label-tertiary);
67
67
  cursor: pointer;
@@ -73,16 +73,16 @@
73
73
  display: block;
74
74
  }
75
75
 
76
- .obLtqa_expand:hover {
76
+ .nVxsMW_expand:hover {
77
77
  color: var(--dsw-alias-label-secondary);
78
78
  }
79
79
 
80
- .obLtqa_footer {
80
+ .nVxsMW_footer {
81
81
  font: var(--dsw-font-markdown-code-block);
82
82
  color: var(--dsw-alias-label-tertiary);
83
83
  padding: 0 14px 12px;
84
84
  }
85
- .folzqq_block {
85
+ .Hbqh2W_block {
86
86
  --dsl-code-block-banner-background-color: var(--dsw-alias-markdown-code-block-banner);
87
87
  --dsl-code-block-border-radius: 12px;
88
88
  --dsl-code-block-banner-font: 11px/18px var(--dsw-font-family);
@@ -94,11 +94,11 @@
94
94
  position: relative;
95
95
  }
96
96
 
97
- .folzqq_block:not(:last-child) {
97
+ .Hbqh2W_block:not(:last-child) {
98
98
  margin-bottom: 11px;
99
99
  }
100
100
 
101
- .folzqq_bannerWrap {
101
+ .Hbqh2W_bannerWrap {
102
102
  z-index: 6;
103
103
  background-color: var(--dsw-alias-bg-base);
104
104
  border-top-left-radius: var(--dsl-code-block-border-radius);
@@ -107,7 +107,7 @@
107
107
  top: 0;
108
108
  }
109
109
 
110
- .folzqq_banner {
110
+ .Hbqh2W_banner {
111
111
  background: var(--dsl-code-block-banner-background-color);
112
112
  font: var(--dsl-code-block-banner-font);
113
113
  border-top-left-radius: var(--dsl-code-block-border-radius);
@@ -119,7 +119,7 @@
119
119
  display: flex;
120
120
  }
121
121
 
122
- .folzqq_infostring {
122
+ .Hbqh2W_infostring {
123
123
  color: var(--dsw-alias-label-primary);
124
124
  font-family: var(--ds-font-family-code);
125
125
  text-overflow: ellipsis;
@@ -130,13 +130,13 @@
130
130
  overflow: hidden;
131
131
  }
132
132
 
133
- .folzqq_action {
133
+ .Hbqh2W_action {
134
134
  flex-shrink: 0;
135
135
  align-items: center;
136
136
  display: flex;
137
137
  }
138
138
 
139
- .folzqq_copyButton {
139
+ .Hbqh2W_copyButton {
140
140
  color: inherit;
141
141
  cursor: pointer;
142
142
  font: inherit;
@@ -146,7 +146,7 @@
146
146
  padding: 0;
147
147
  }
148
148
 
149
- .folzqq_block :where(pre) {
149
+ .Hbqh2W_block :where(pre) {
150
150
  font: var(--dsl-code-block-content-font);
151
151
  white-space: pre-wrap;
152
152
  word-break: break-all;
@@ -158,72 +158,72 @@
158
158
  margin: 0 !important;
159
159
  }
160
160
 
161
- .folzqq_block :where(pre.folzqq_shiki) {
161
+ .Hbqh2W_block :where(pre.Hbqh2W_shiki) {
162
162
  background: var(--dsw-alias-markdown-code-block) !important;
163
163
  }
164
164
 
165
- .folzqq_block :where(pre) code {
165
+ .Hbqh2W_block :where(pre) code {
166
166
  font: inherit;
167
167
  background: none;
168
168
  padding: 0;
169
169
  }
170
170
 
171
- .folzqq_plain {
171
+ .Hbqh2W_plain {
172
172
  color: var(--dsw-alias-label-primary);
173
173
  }
174
- .vrxa6G_markdown {
174
+ .BL43XG_markdown {
175
175
  overflow-wrap: anywhere;
176
176
  min-width: 0;
177
177
  font: var(--dsw-font-markdown-base);
178
178
  color: var(--dsw-alias-label-primary);
179
179
  }
180
180
 
181
- .vrxa6G_markdown strong {
181
+ .BL43XG_markdown strong {
182
182
  font-weight: 600;
183
183
  }
184
184
 
185
- .vrxa6G_markdown h1 {
185
+ .BL43XG_markdown h1 {
186
186
  font: var(--dsw-font-markdown-h1);
187
187
  margin: 32px 0 16px;
188
188
  }
189
189
 
190
- .vrxa6G_markdown h2 {
190
+ .BL43XG_markdown h2 {
191
191
  font: var(--dsw-font-markdown-h2);
192
192
  margin: 32px 0 16px;
193
193
  }
194
194
 
195
- .vrxa6G_markdown h3 {
195
+ .BL43XG_markdown h3 {
196
196
  font: var(--dsw-font-markdown-h3);
197
197
  margin: 32px 0 16px;
198
198
  }
199
199
 
200
- .vrxa6G_markdown h4 {
200
+ .BL43XG_markdown h4 {
201
201
  font: var(--dsw-font-markdown-h4);
202
202
  margin: 16px 0;
203
203
  }
204
204
 
205
- .vrxa6G_markdown :where(h5, h6) {
205
+ .BL43XG_markdown :where(h5, h6) {
206
206
  font: var(--dsw-font-markdown-base-strong);
207
207
  margin: 16px 0;
208
208
  }
209
209
 
210
- .vrxa6G_markdown :where(h1, h2, h3, h4, h5, h6) strong {
210
+ .BL43XG_markdown :where(h1, h2, h3, h4, h5, h6) strong {
211
211
  font-weight: inherit;
212
212
  }
213
213
 
214
- .vrxa6G_markdown p {
214
+ .BL43XG_markdown p {
215
215
  margin: 16px 0;
216
216
  }
217
217
 
218
- .vrxa6G_markdown :where(h4, h5, h6) + :where(ul, ol) {
218
+ .BL43XG_markdown :where(h4, h5, h6) + :where(ul, ol) {
219
219
  margin-top: 8px;
220
220
  }
221
221
 
222
- .vrxa6G_markdown :where(h4, h5, h6):has( + :where(ul, ol)) {
222
+ .BL43XG_markdown :where(h4, h5, h6):has( + :where(ul, ol)) {
223
223
  margin-bottom: 8px;
224
224
  }
225
225
 
226
- .vrxa6G_markdown a {
226
+ .BL43XG_markdown a {
227
227
  color: var(--dsw-alias-link);
228
228
  transition: box-shadow var(--ds-transition-duration) var(--ds-ease-in-out);
229
229
  border: 2px solid #fff0;
@@ -235,17 +235,17 @@
235
235
  position: relative;
236
236
  }
237
237
 
238
- .vrxa6G_markdown a:hover, .vrxa6G_markdown a:focus {
238
+ .BL43XG_markdown a:hover, .BL43XG_markdown a:focus {
239
239
  text-decoration: underline dotted var(--dsw-alias-link);
240
240
  text-underline-offset: 3px;
241
241
  outline: none;
242
242
  }
243
243
 
244
- .vrxa6G_markdown a:focus-visible {
244
+ .BL43XG_markdown a:focus-visible {
245
245
  box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
246
246
  }
247
247
 
248
- .vrxa6G_linkIcon {
248
+ .BL43XG_linkIcon {
249
249
  vertical-align: -.25em;
250
250
  flex: none;
251
251
  width: 1.1em;
@@ -253,46 +253,46 @@
253
253
  margin-right: 5px;
254
254
  }
255
255
 
256
- .vrxa6G_markdown :where(ul, ol) {
256
+ .BL43XG_markdown :where(ul, ol) {
257
257
  margin: 16px 0;
258
258
  padding-left: 18px;
259
259
  }
260
260
 
261
- .vrxa6G_markdown li:not(:first-child) {
261
+ .BL43XG_markdown li:not(:first-child) {
262
262
  margin-top: 6px;
263
263
  }
264
264
 
265
- .vrxa6G_markdown li > :where(ul, ol) {
265
+ .BL43XG_markdown li > :where(ul, ol) {
266
266
  margin-top: 4px;
267
267
  }
268
268
 
269
- .vrxa6G_markdown li::marker {
269
+ .BL43XG_markdown li::marker {
270
270
  color: var(--dsw-alias-label-secondary);
271
271
  line-height: 24px;
272
272
  }
273
273
 
274
- .vrxa6G_markdown :where(ul, ol) ol {
274
+ .BL43XG_markdown :where(ul, ol) ol {
275
275
  padding-left: 0;
276
276
  list-style-position: inside;
277
277
  }
278
278
 
279
- .vrxa6G_markdown :where(ul, ol) ol li p {
279
+ .BL43XG_markdown :where(ul, ol) ol li p {
280
280
  display: inline;
281
281
  }
282
282
 
283
- .vrxa6G_markdown li > p {
283
+ .BL43XG_markdown li > p {
284
284
  margin: 8px 0;
285
285
  }
286
286
 
287
- .vrxa6G_markdown li > :first-child {
287
+ .BL43XG_markdown li > :first-child {
288
288
  margin-top: 0;
289
289
  }
290
290
 
291
- .vrxa6G_markdown li > :last-child:not(.md-code-block) {
291
+ .BL43XG_markdown li > :last-child:not(.md-code-block) {
292
292
  margin-bottom: 0;
293
293
  }
294
294
 
295
- .vrxa6G_markdown hr {
295
+ .BL43XG_markdown hr {
296
296
  background: var(--dsw-alias-border-l2);
297
297
  border: none;
298
298
  height: .5px;
@@ -300,19 +300,19 @@
300
300
  display: block;
301
301
  }
302
302
 
303
- .vrxa6G_markdown blockquote {
303
+ .BL43XG_markdown blockquote {
304
304
  border-left: 2px solid var(--dsw-alias-label-caption);
305
305
  margin: 16px 0 0;
306
306
  padding-left: 14px;
307
307
  }
308
308
 
309
- .vrxa6G_markdown pre {
309
+ .BL43XG_markdown pre {
310
310
  font-family: var(--ds-font-family-code);
311
311
  margin: 16px 0;
312
312
  overflow: auto;
313
313
  }
314
314
 
315
- .vrxa6G_markdown :not(pre) > code {
315
+ .BL43XG_markdown :not(pre) > code {
316
316
  box-sizing: border-box;
317
317
  font: var(--dsw-font-markdown-code);
318
318
  font-family: var(--ds-font-family-code);
@@ -325,54 +325,54 @@
325
325
  font-size: .875em !important;
326
326
  }
327
327
 
328
- .vrxa6G_markdown :where(h1, h2, h3, h4, h5, h6) code {
328
+ .BL43XG_markdown :where(h1, h2, h3, h4, h5, h6) code {
329
329
  font: inherit;
330
330
  font-family: var(--ds-font-family-code);
331
331
  }
332
332
 
333
- .vrxa6G_markdown .katex-display {
333
+ .BL43XG_markdown .katex-display {
334
334
  max-width: 100%;
335
335
  overflow: auto hidden;
336
336
  }
337
337
 
338
- .vrxa6G_markdown input[type="checkbox"] {
338
+ .BL43XG_markdown input[type="checkbox"] {
339
339
  accent-color: var(--dsw-alias-label-secondary);
340
340
  margin: 0 8px 0 0;
341
341
  }
342
342
 
343
- .vrxa6G_tableScroll {
343
+ .BL43XG_tableScroll {
344
344
  overscroll-behavior-x: contain;
345
345
  max-width: 100%;
346
346
  overflow-x: auto;
347
347
  }
348
348
 
349
- .vrxa6G_tableScroll.md-table-wide {
349
+ .BL43XG_tableScroll.md-table-wide {
350
350
  padding-bottom: var(--dsh-scrollbar-width, 8px);
351
351
  overflow-x: hidden;
352
352
  }
353
353
 
354
- .vrxa6G_tableScroll.md-table-wide:hover, .vrxa6G_tableScroll.md-table-wide:focus-visible {
354
+ .BL43XG_tableScroll.md-table-wide:hover, .BL43XG_tableScroll.md-table-wide:focus-visible {
355
355
  padding-bottom: 0;
356
356
  overflow-x: auto;
357
357
  }
358
358
 
359
- .vrxa6G_tableScroll:focus-visible {
359
+ .BL43XG_tableScroll:focus-visible {
360
360
  box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
361
361
  outline: none;
362
362
  }
363
363
 
364
- .vrxa6G_tableScroll table {
364
+ .BL43XG_tableScroll table {
365
365
  border-collapse: collapse;
366
366
  width: max-content;
367
367
  max-width: max-content;
368
368
  }
369
369
 
370
- .vrxa6G_tableFill table {
370
+ .BL43XG_tableFill table {
371
371
  width: 100%;
372
372
  max-width: none;
373
373
  }
374
374
 
375
- .vrxa6G_tableScroll th {
375
+ .BL43XG_tableScroll th {
376
376
  text-align: start;
377
377
  border-bottom: .5px solid var(--dsw-alias-border-l3);
378
378
  font: var(--dsw-font-markdown-table-head);
@@ -382,7 +382,7 @@
382
382
  padding: 10px 16px;
383
383
  }
384
384
 
385
- .vrxa6G_tableScroll td {
385
+ .BL43XG_tableScroll td {
386
386
  border-bottom: .5px solid var(--dsw-alias-border-l2);
387
387
  font: var(--dsw-font-markdown-table);
388
388
  min-width: 100px;
@@ -390,32 +390,32 @@
390
390
  padding: 10px 16px;
391
391
  }
392
392
 
393
- .vrxa6G_tableScroll th:first-child, .vrxa6G_tableScroll td:first-child {
393
+ .BL43XG_tableScroll th:first-child, .BL43XG_tableScroll td:first-child {
394
394
  padding-left: 0;
395
395
  }
396
396
 
397
- .vrxa6G_tableScroll td:last-child {
397
+ .BL43XG_tableScroll td:last-child {
398
398
  padding-right: 0;
399
399
  }
400
400
 
401
- .vrxa6G_tableScroll table code {
401
+ .BL43XG_tableScroll table code {
402
402
  font-size: 11px;
403
403
  }
404
404
 
405
- .vrxa6G_markdown > :first-child, .vrxa6G_markdown p:first-child {
405
+ .BL43XG_markdown > :first-child, .BL43XG_markdown p:first-child {
406
406
  margin-top: 0 !important;
407
407
  }
408
408
 
409
- .vrxa6G_markdown > :last-child, .vrxa6G_markdown p:last-child {
409
+ .BL43XG_markdown > :last-child, .BL43XG_markdown p:last-child {
410
410
  margin-bottom: 0 !important;
411
411
  }
412
412
 
413
- .vrxa6G_imageAlt {
413
+ .BL43XG_imageAlt {
414
414
  color: var(--dsw-alias-label-tertiary);
415
415
  font-style: italic;
416
416
  }
417
417
 
418
- .vrxa6G_image {
418
+ .BL43XG_image {
419
419
  background: var(--dsw-alias-bg-base);
420
420
  object-fit: contain;
421
421
  border-radius: 8px;
@@ -426,7 +426,7 @@
426
426
  display: block;
427
427
  }
428
428
 
429
- .vrxa6G_fileMention {
429
+ .BL43XG_fileMention {
430
430
  font: inherit;
431
431
  color: var(--dsw-alias-link);
432
432
  cursor: pointer;
@@ -438,7 +438,7 @@
438
438
  text-decoration: none;
439
439
  }
440
440
 
441
- .vrxa6G_fileMention:hover, .vrxa6G_fileMention:focus {
441
+ .BL43XG_fileMention:hover, .BL43XG_fileMention:focus {
442
442
  text-decoration: underline dotted var(--dsw-alias-link);
443
443
  text-underline-offset: 3px;
444
444
  outline: none;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/lifeboat",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "dsh-plus service plugin: 故障救生艇——兄弟插件崩溃时自动隔离(写用户 patch 层禁用),llm-pi 缺席时把其配置临时翻译为官方 llm-pi-ai 路由,恢复后自动还原",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -30,22 +30,22 @@
30
30
  }
31
31
  },
32
32
  "dependencies": {
33
- "@dsh-plus/shared": "0.1.10",
33
+ "@dsh-plus/shared": "0.1.12",
34
34
  "yaml": "^2.9.0"
35
35
  },
36
36
  "peerDependencies": {
37
- "@deepseek-ai/cordis": "^4.0.1",
38
- "@deepseek-ai/dsh-home-paths": "^0.1.3-alpha.2",
39
- "@deepseek-ai/dsh-llm": "^0.1.3-alpha.2",
40
- "@deepseek-ai/dsh-settings": "^0.1.3-alpha.2",
41
- "@deepseek-ai/schemastery": "^3.18.1"
37
+ "@deepseek-ai/cordis": "^4.0.2",
38
+ "@deepseek-ai/dsh-home-paths": "*",
39
+ "@deepseek-ai/dsh-llm": "*",
40
+ "@deepseek-ai/dsh-settings": "*",
41
+ "@deepseek-ai/schemastery": "^3.18.2"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@deepseek-ai/cordis": "4.0.2",
45
- "@deepseek-ai/dsh-home-paths": "0.1.3-alpha.2",
46
- "@deepseek-ai/dsh-host-webserver": "0.1.3-alpha.2",
47
- "@deepseek-ai/dsh-llm": "0.1.3-alpha.2",
48
- "@deepseek-ai/dsh-settings": "0.1.3-alpha.2",
45
+ "@deepseek-ai/dsh-home-paths": "0.1.5-alpha.1",
46
+ "@deepseek-ai/dsh-host-webserver": "0.1.5-alpha.1",
47
+ "@deepseek-ai/dsh-llm": "0.1.5-alpha.1",
48
+ "@deepseek-ai/dsh-settings": "0.1.5-alpha.1",
49
49
  "@deepseek-ai/schemastery": "3.18.2",
50
50
  "@types/node": "26.4.0",
51
51
  "@types/react": "~18.3.31",
package/src/config.ts CHANGED
@@ -1,19 +1,13 @@
1
1
  /**
2
- * 配置与 journal 单一事实源。
2
+ * 配置单一事实源。
3
3
  * - 行级 Config(cordis 组合层):救生艇自身行为开关,dev/prod patch 层可覆盖。
4
- * - settings 命名空间 dsh-plus-lifeboat:运行期 journal 与 LLM 应急翻译状态,
5
- * 经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml,重启后可据此还原。
6
- * schema 刻意宽松:lifeboat 是最后防线,自身命名空间的数据问题绝不能让它起不来。
4
+ * - journal 与 LLM 应急翻译状态属运行期数据,按存储规范持久化到
5
+ * $DSH_HOME/dsh-plus/lifeboat/state.json(见 state-file.ts),不进 settings。
7
6
  * @module lifeboat/config
8
7
  */
9
8
 
10
9
  import z from '@deepseek-ai/schemastery'
11
10
 
12
- import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
13
-
14
- /** settings 命名空间(字面量直供 settings 服务注册/读写,0.1.2-alpha.2 起编译期校验语法)。 */
15
- export const SETTINGS_NS = NS_LITERAL
16
-
17
11
  /** 行级配置:全部有默认值,正常部署零配置。 */
18
12
  export const Config = z.object({
19
13
  enabled: z.boolean().description('故障隔离总开关').default(true),
@@ -32,33 +26,19 @@ export const Config = z.object({
32
26
 
33
27
  export type LifeboatConfig = Schemastery.TypeT<typeof Config>
34
28
 
35
- /** journal 单条记录。 */
36
- // biome-ignore lint/suspicious/noExplicitAny: dts 可移植性——嵌套 schema 精确类型经消费方断开 cosmokit 推断链
37
- const JournalEntry: any = z.object({
38
- at: z.string(),
39
- kind: z.string(),
40
- detail: z.string(),
41
- })
42
-
43
- /** LLM 应急翻译的持久状态(还原依据)。 */
44
- const FallbackState = z.object({
45
- active: z.boolean(),
46
- originalProvider: z.string(),
47
- originalModel: z.string(),
48
- fallbackProvider: z.string(),
49
- providers: z.array(z.string()),
50
- at: z.string(),
51
- })
52
-
53
- /** 命名空间 schema:宽松承载 journal 与翻译状态,上限截断防膨胀。 */
54
- // biome-ignore lint/suspicious/noExplicitAny: dts 可移植性(同 JournalEntry;上次成功构建未触发是因旧 lockfile,现同 usage-panel 口径)
55
- export const JournalSchema: any = z.object({
56
- journal: z.array(JournalEntry).description('救生艇操作日志(最新在尾,封顶 50 条)').default([]),
57
- llmFallback: z
58
- .union([FallbackState, z.const(null)])
59
- .description('LLM 应急翻译状态;null 表示未处于降级')
60
- .default(null),
61
- })
62
-
63
- export type JournalDoc = Schemastery.TypeT<typeof JournalSchema>
64
- export type FallbackStateT = Schemastery.TypeT<typeof FallbackState>
29
+ /** journal 单条记录(纯类型,磁盘收窄见 state-file.ts)。 */
30
+ export interface JournalEntryT {
31
+ at: string
32
+ kind: string
33
+ detail: string
34
+ }
35
+
36
+ /** LLM 应急翻译的持久状态(还原依据;纯类型,磁盘收窄见 state-file.ts)。 */
37
+ export interface FallbackStateT {
38
+ active: boolean
39
+ originalProvider: string
40
+ originalModel: string
41
+ fallbackProvider: string
42
+ providers: string[]
43
+ at: string
44
+ }
package/src/index.ts CHANGED
@@ -6,28 +6,26 @@
6
6
  * 失败插件缺席、其余正常,修复后删覆盖即恢复。
7
7
  * 2. LLM 应急翻译——默认模型 provider 无 adapter 时,把 dsh-plus-llm-pi 配置
8
8
  * 临时翻译为官方 llm-pi-ai 路由并切换默认模型,恢复后自动还原。
9
- * 3. 告警与 journal——一切动作记录进自身 settings 命名空间,并经 notify-email
10
- * 发邮件(缺席降级为日志)。
9
+ * 3. 告警与 journal——一切动作记录进运行期状态文件(state-file.ts),并经
10
+ * notify-email 发邮件(缺席降级为日志)。
11
11
  *
12
12
  * 零 dsh-plus 内部依赖铁律:不 import 本仓库任何其他包,防止共享代码故障团灭。
13
+ *
14
+ * 接线约束:inject 声明须覆盖本模块全部 ctx.<service> 直接访问(fallback-llm
15
+ * 读 settings 命名空间与 llm.listProviders),漏掉任一即运行期
16
+ * "cannot get property without inject" → 插件树加载失败 → dsh boot 中止。
13
17
  * @module @dsh-plus/lifeboat
14
18
  */
15
19
  import type { Context } from '@deepseek-ai/cordis'
16
20
  import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
17
21
 
18
- import {
19
- Config,
20
- type FallbackStateT,
21
- type JournalDoc,
22
- JournalSchema,
23
- type LifeboatConfig,
24
- SETTINGS_NS,
25
- } from './config.ts'
22
+ import { Config, type FallbackStateT, type LifeboatConfig } from './config.ts'
26
23
  import { installLlmFallback } from './fallback-llm.ts'
27
24
  import { registerHealthApi } from './health-api.ts'
28
25
  import { installAlerter } from './notify.ts'
29
26
  import { createQuarantine, installHostWatch } from './quarantine.ts'
30
27
  import { registerQuarantineApi } from './quarantine-api.ts'
28
+ import { appendJournal, loadState, type StateDoc, saveState } from './state-file.ts'
31
29
 
32
30
  export const name = 'dsh-plus-lifeboat'
33
31
 
@@ -35,34 +33,20 @@ export const inject = ['settings', 'llm'] as const
35
33
 
36
34
  export { Config }
37
35
 
38
- /** journal 上限:只留最近 50 条,防长期运行膨胀。 */
39
- const JOURNAL_CAP = 50
40
-
41
36
  export function apply(ctx: Context, config: LifeboatConfig): void {
42
37
  const logger = ctx.logger('lifeboat')
43
38
 
44
- // 自身命名空间:journal 与翻译状态的持久化。schema 宽松,注册失败也不能
45
- // 拖垮救生艇——降级为内存态(重启丢失 journal,但隔离/翻译功能仍在)。
46
- let doc: JournalDoc = { journal: [], llmFallback: null }
47
- let persist: ((patch: object) => Promise<void>) | undefined
48
- try {
49
- const scope = ctx.settings.register(SETTINGS_NS, JournalSchema)
50
- doc = scope.get()
51
- persist = (patch) => scope.update(patch)
52
- } catch (error) {
53
- logger.warn(
54
- `journal 命名空间注册失败,降级为内存态: ${error instanceof Error ? error.message : String(error)}`,
55
- )
56
- }
39
+ // 运行期状态(journal + 翻译状态):文件持久化。加载失败降级为内存态
40
+ // (重启丢失 journal,但隔离/翻译功能仍在)。
41
+ const warn = (message: string): void => logger.warn(message)
42
+ let doc: StateDoc = { journal: [], llmFallback: null }
43
+ void loadState().then((loaded) => {
44
+ doc = loaded
45
+ })
57
46
 
58
47
  const journal = (kind: string, detail: string): void => {
59
- doc = {
60
- ...doc,
61
- journal: [...doc.journal, { at: new Date().toISOString(), kind, detail }].slice(-JOURNAL_CAP),
62
- }
63
- void persist?.({ journal: doc.journal }).catch((error: unknown) => {
64
- logger.warn(`journal 持久化失败: ${error instanceof Error ? error.message : String(error)}`)
65
- })
48
+ doc = appendJournal(doc, kind, detail)
49
+ void saveState(doc, warn)
66
50
  }
67
51
 
68
52
  const alert = installAlerter(ctx, journal)
@@ -98,7 +82,7 @@ export function apply(ctx: Context, config: LifeboatConfig): void {
98
82
  readState: () => doc.llmFallback,
99
83
  writeState: async (state: FallbackStateT | null) => {
100
84
  doc = { ...doc, llmFallback: state }
101
- await persist?.({ llmFallback: state })
85
+ await saveState(doc, warn)
102
86
  },
103
87
  })
104
88
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * journal 与 LLM 应急翻译状态的文件持久化($DSH_HOME/dsh-plus/lifeboat/state.json)。
3
+ *
4
+ * 存储规范:运行期状态属非配置数据,不进 settings.yaml;本模块自带实现,
5
+ * 维持零 dsh-plus 内部依赖铁律(不 import @dsh-plus/shared)。
6
+ * schema 刻意宽松:lifeboat 是最后防线,自身数据问题绝不能让它起不来。
7
+ * @module lifeboat/state-file
8
+ */
9
+
10
+ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
11
+ import { dirname } from 'node:path'
12
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
13
+ import type { FallbackStateT, JournalEntryT } from './config.ts'
14
+
15
+ /** 状态文件落点:$DSH_HOME/dsh-plus/lifeboat/state.json。 */
16
+ export const STATE_FILE = dshHomePath('dsh-plus', 'lifeboat', 'state.json')
17
+
18
+ /** 磁盘文档形状(与旧 settings 命名空间同构,仅位置不同)。 */
19
+ export interface StateDoc {
20
+ journal: JournalEntryT[]
21
+ llmFallback: FallbackStateT | null
22
+ }
23
+
24
+ /** journal 上限:只留最近 50 条,防长期运行膨胀。 */
25
+ const JOURNAL_CAP = 50
26
+
27
+ /** 文件大小上限:超出即视为损坏,丢弃重来(journal 本身封顶 50 条)。 */
28
+ const MAX_BYTES = 256 * 1024
29
+
30
+ /** 未知输入收窄为合法 journal 条目(宽松承载数据问题)。 */
31
+ function asEntry(value: unknown): JournalEntryT | null {
32
+ if (typeof value !== 'object' || value === null) return null
33
+ const record = value as Record<string, unknown>
34
+ if (typeof record['at'] !== 'string' || typeof record['kind'] !== 'string') return null
35
+ return { at: record['at'], kind: record['kind'], detail: String(record['detail'] ?? '') }
36
+ }
37
+
38
+ /** 未知输入收窄为合法翻译状态。 */
39
+ function asFallback(value: unknown): FallbackStateT | null {
40
+ if (typeof value !== 'object' || value === null) return null
41
+ const record = value as Record<string, unknown>
42
+ if (typeof record['active'] !== 'boolean') return null
43
+ return {
44
+ active: record['active'],
45
+ originalProvider: String(record['originalProvider'] ?? ''),
46
+ originalModel: String(record['originalModel'] ?? ''),
47
+ fallbackProvider: String(record['fallbackProvider'] ?? ''),
48
+ providers: Array.isArray(record['providers'])
49
+ ? record['providers'].map((item) => String(item))
50
+ : [],
51
+ at: String(record['at'] ?? ''),
52
+ }
53
+ }
54
+
55
+ /** 未知输入收窄为完整状态文档;任何缺省回落到空文档。 */
56
+ export function normalizeState(raw: unknown): StateDoc {
57
+ if (typeof raw !== 'object' || raw === null) return { journal: [], llmFallback: null }
58
+ const record = raw as Record<string, unknown>
59
+ const journal = Array.isArray(record['journal'])
60
+ ? record['journal'].map(asEntry).filter((entry): entry is JournalEntryT => entry !== null)
61
+ : []
62
+ return { journal: journal.slice(-JOURNAL_CAP), llmFallback: asFallback(record['llmFallback']) }
63
+ }
64
+
65
+ /** 读取状态文件;缺失/损坏/超限时返回空文档(最后防线不得因自身数据起不来)。 */
66
+ export async function loadState(): Promise<StateDoc> {
67
+ try {
68
+ const info = await stat(STATE_FILE)
69
+ if (!info.isFile() || info.size > MAX_BYTES) return { journal: [], llmFallback: null }
70
+ return normalizeState(JSON.parse(await readFile(STATE_FILE, 'utf-8')))
71
+ } catch {
72
+ return { journal: [], llmFallback: null }
73
+ }
74
+ }
75
+
76
+ /** 原子写状态文件:临时文件 + rename,写入失败仅告警不抛出。 */
77
+ export async function saveState(doc: StateDoc, warn: (message: string) => void): Promise<void> {
78
+ try {
79
+ await mkdir(dirname(STATE_FILE), { recursive: true })
80
+ const tmp = `${STATE_FILE}.tmp`
81
+ await writeFile(tmp, `${JSON.stringify(doc, null, 2)}\n`, 'utf-8')
82
+ await rename(tmp, STATE_FILE)
83
+ } catch (error) {
84
+ warn(`状态文件写入失败: ${error instanceof Error ? error.message : String(error)}`)
85
+ }
86
+ }
87
+
88
+ /** 追加一条 journal(内存文档已更新后调用;落盘失败不阻断动作)。 */
89
+ export function appendJournal(doc: StateDoc, kind: string, detail: string): StateDoc {
90
+ return {
91
+ ...doc,
92
+ journal: [...doc.journal, { at: new Date().toISOString(), kind, detail }].slice(-JOURNAL_CAP),
93
+ }
94
+ }
package/src/ns.ts DELETED
@@ -1,5 +0,0 @@
1
- /**
2
- * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
3
- * @module lifeboat/ns
4
- */
5
- export const SETTINGS_NS = 'dsh-plus-lifeboat'