@dsh-plus/lifeboat 0.2.30 → 0.3.1

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
@@ -130,8 +130,10 @@ window.__ModuleLoader__.load({
130
130
  .dlb-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;margin:4px 0}
131
131
  .dlb-title{color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:600;margin:10px 0 0}
132
132
  .dlb-notice{color:var(--dsw-alias-label-secondary);font-size:12px;margin:0}
133
- .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)}
134
- .dlb-fallbackTitle{color:var(--dsw-alias-label-warning,#e0a03c);font-size:13px;font-weight:600;margin:0}
133
+ .dlb-copy{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:6px;background:var(--dsw-alias-bg-layer-3)}
134
+ .dlb-copyTitle{color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:600;margin:0}
135
+ .dlb-copyWarning{color:var(--dsw-alias-label-warning,#e0a03c);font-size:12px;margin:0}
136
+ .dlb-copyHint{color:var(--dsw-alias-label-tertiary);font-size:12px;margin:0;line-height:1.6}
135
137
  .dlb-kv{display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap}
136
138
  .dlb-kv span{color:var(--dsw-alias-label-tertiary);flex:none}
137
139
  .dlb-kv code{color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;word-break:break-all}
@@ -174,6 +176,45 @@ window.__ModuleLoader__.load({
174
176
  const status = value;
175
177
  return Array.isArray(status.journal) && Array.isArray(status.quarantined);
176
178
  }
179
+ /** 官方应急副本状态卡片(纯展示:路径/生成时间/覆盖 route/警告 + 应用指引)。 */
180
+ function CopyStatusCard(props) {
181
+ const { copy, t } = props;
182
+ const generated = copy.updatedAt === void 0 ? t("copyMissing") : new Date(copy.updatedAt).toISOString().slice(0, 19).replace("T", " ");
183
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
184
+ className: "dlb-copy",
185
+ role: "status",
186
+ children: [
187
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
188
+ className: "dlb-copyTitle",
189
+ children: t("copyTitle")
190
+ }),
191
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
192
+ className: "dlb-kv",
193
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("copyPath") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: copy.path })]
194
+ }),
195
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
196
+ className: "dlb-kv",
197
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("copyAt") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: generated })]
198
+ }),
199
+ copy.routes !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
200
+ className: "dlb-kv",
201
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("copyRoutes") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: copy.routes })]
202
+ }) : null,
203
+ copy.detail !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
204
+ className: "dlb-copyWarning",
205
+ children: copy.detail
206
+ }) : null,
207
+ copy.warnings.map((warning) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
208
+ className: "dlb-copyWarning",
209
+ children: `${t("copyWarning")}:${warning}`
210
+ }, warning)),
211
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
212
+ className: "dlb-copyHint",
213
+ children: t("copyApplyHint")
214
+ })
215
+ ]
216
+ });
217
+ }
177
218
  function HealthTab(props) {
178
219
  const { t } = props;
179
220
  const [status, setStatus] = (0, react.useState)(null);
@@ -231,31 +272,9 @@ window.__ModuleLoader__.load({
231
272
  role: "status",
232
273
  children: notice
233
274
  }) : null,
234
- status.llmFallback?.active === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
235
- className: "dlb-fallback",
236
- role: "status",
237
- children: [
238
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
239
- className: "dlb-fallbackTitle",
240
- children: t("fallbackActive")
241
- }),
242
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
243
- className: "dlb-kv",
244
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("fallbackFrom") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("code", { children: [
245
- status.llmFallback.originalProvider,
246
- "/",
247
- status.llmFallback.originalModel
248
- ] })]
249
- }),
250
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
251
- className: "dlb-kv",
252
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("fallbackTo") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: status.llmFallback.fallbackProvider })]
253
- }),
254
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
255
- className: "dlb-kv",
256
- 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", " ") })]
257
- })
258
- ]
275
+ status.officialCopy !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyStatusCard, {
276
+ copy: status.officialCopy,
277
+ t
259
278
  }) : null,
260
279
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
261
280
  className: "dlb-title",
@@ -369,15 +388,17 @@ window.__ModuleLoader__.load({
369
388
  alert: "dlb-kindAlert",
370
389
  quarantine: "dlb-kindQuarantine",
371
390
  "quarantine-restore": "dlb-kindRestore",
372
- "llm-fallback": "dlb-kindFallback",
373
- "llm-fallback-revert": "dlb-kindRestore"
391
+ "llm-copy-guide": "dlb-kindFallback"
374
392
  };
375
393
  zhDict = {
376
394
  tab: "救生艇",
377
- fallbackActive: "LLM 应急翻译进行中",
378
- fallbackFrom: "源路由",
379
- fallbackTo: "临时路由",
380
- fallbackAt: "启用时间",
395
+ copyTitle: "官方应急 LLM 副本",
396
+ copyPath: "副本路径",
397
+ copyAt: "生成时间",
398
+ copyRoutes: "覆盖 route",
399
+ copyMissing: "未生成",
400
+ copyWarning: "警告",
401
+ copyApplyHint: "应用:dsh <profile> --patch 上述路径(重启/重载生效),或把副本条目并入当前 profile 的 cordis.patch.yml(热应用)。应用即禁用 dsh-plus-llm-pi 并切换 llm-pi-ai 配置;回退只需移除 --patch/并入条目。",
381
402
  quarantined: "已隔离插件",
382
403
  restore: "恢复",
383
404
  restoring: "恢复中…",
@@ -396,10 +417,13 @@ window.__ModuleLoader__.load({
396
417
  };
397
418
  enDict = {
398
419
  tab: "Lifeboat",
399
- fallbackActive: "LLM emergency translation active",
400
- fallbackFrom: "Source route",
401
- fallbackTo: "Fallback route",
402
- fallbackAt: "Since",
420
+ copyTitle: "Official emergency LLM copy",
421
+ copyPath: "Path",
422
+ copyAt: "Generated",
423
+ copyRoutes: "Routes",
424
+ copyMissing: "not generated",
425
+ copyWarning: "Warning",
426
+ copyApplyHint: "Apply: dsh <profile> --patch <path above> (restart/reload), or merge the rows into this profile's cordis.patch.yml (hot-applied). Applying disables dsh-plus-llm-pi and switches llm-pi-ai; revert by removing the --patch flag/rows.",
403
427
  quarantined: "Quarantined plugins",
404
428
  restore: "Restore",
405
429
  restoring: "Restoring…",
package/lib/index.d.ts CHANGED
@@ -5,12 +5,10 @@ import { Context } from "@deepseek-ai/cordis";
5
5
  declare const Config: z<Schemastery.ObjectS<NoInfer<{
6
6
  enabled: z<boolean, boolean, "defined">;
7
7
  patchFile: z<string, string, "defined">;
8
- llmFallback: z<boolean, boolean, "defined">;
9
8
  alertCooldownMs: z<number, number, "defined">;
10
9
  }>>, Schemastery.ObjectT<NoInfer<{
11
10
  enabled: z<boolean, boolean, "defined">;
12
11
  patchFile: z<string, string, "defined">;
13
- llmFallback: z<boolean, boolean, "defined">;
14
12
  alertCooldownMs: z<number, number, "defined">;
15
13
  }>>, "plain">;
16
14
  type LifeboatConfig = Schemastery.TypeT<typeof Config>;
package/lib/index.js CHANGED
@@ -1,8 +1,7 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
2
  import { copyFile, readFile, stat } from "node:fs/promises";
3
3
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
4
- import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
5
- import { isSeq, parseDocument } from "yaml";
4
+ import { isSeq, parse, parseDocument } from "yaml";
6
5
  import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
7
6
  //#region src/config.ts
8
7
  /**
@@ -16,223 +15,184 @@ import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
16
15
  const Config = z.object({
17
16
  enabled: z.boolean().description("故障隔离总开关").default(true),
18
17
  patchFile: z.string().description("隔离写入的 profile 用户 patch 文件绝对路径;留空自动取当前 profile 的 cordis.patch.yml(profileContext.patchPath,CLI 与桌面端 desktop profile 同源)").default(""),
19
- llmFallback: z.boolean().description("llm-pi 缺席时自动翻译其配置到官方 llm-pi-ai 并切换默认模型").default(true),
20
18
  alertCooldownMs: z.natural().description("同一插件重复告警的最小间隔毫秒数").default(3e5)
21
19
  });
22
20
  //#endregion
23
- //#region src/fallback-llm.ts
21
+ //#region src/official-copy-status.ts
24
22
  /**
25
- * LLM 应急翻译:默认模型路由的 provider 无已注册 adapter 时(典型场景:llm-pi
26
- * 被隔离/加载失败),把 settings.yaml 里 dsh-plus-llm-pi 的 provider 配置翻译为
27
- * 官方 llm-pi-ai 命名空间的原生格式(键加 -fb 后缀避开 DUPLICATE_ADAPTER 冲突),
28
- * 默认模型指到翻译路由;源 provider 恢复健康后按 journal 自动还原。
23
+ * 官方应急副本的只读状态读取($DSH_HOME/llm-pi.official-patch.yaml)。
29
24
  *
30
- * 为什么读原始 settings.yaml 而不是 settings 服务:插件被禁用后其命名空间随之
31
- * 注销,服务层读不到——但数据仍在文件里(只读,不修改)。
32
- * 降级语义:翻译只覆盖官方物化的字段子集,dsh-plus 扩展的全量 compat 丢弃;
33
- * 协议推断是启发式(显式 api > 路由名 > 模型继承源 > openai-completions 兜底)。
34
- * @module lifeboat/fallback-llm
25
+ * 文件契约与 @dsh-plus/llm-pi 同路径:对方常备副本、本包只读消费——零
26
+ * dsh-plus 内部依赖铁律下不 import 对方实现,路径按契约文件名同源派生。
27
+ * 读取一切失败都降级为结构化状态(面板展示与告警文案的输入,绝不抛出)。
28
+ * @module lifeboat/official-copy-status
35
29
  */
36
- const NS_PI_AI = "llm-pi-ai";
37
- const NS_AGENT_DEFAULT = "agent-default-model";
38
- const RAW_NS_LLM_PI = "dsh-plus-llm-pi";
39
- /** 官方配置路径物化的 compat 子集(其余字段降级期丢弃)。 */
40
- const COMPAT_WHITELIST = ["thinkingFormat", "supportsReasoningEffort"];
41
- /**
42
- * master 官方 llm-pi-ai 的 COMPAT_GATES(llm-pi-ai/src/catalog.ts):每个 compat
43
- * 字段按协议门控,写时经 assertOfferedCompatFields 拒绝不适配字段——thinkingFormat /
44
- * supportsReasoningEffort 仅 openai-completions 提供。翻译按推断 api 过滤,避免
45
- * 整段 compat 写入被官方校验拒绝。
46
- */
47
- const COMPAT_OFFER_APIS = {
48
- thinkingFormat: /* @__PURE__ */ new Set(["openai-completions"]),
49
- supportsReasoningEffort: /* @__PURE__ */ new Set(["openai-completions"])
50
- };
51
- /** 官方 reasoning 词表(ModelThinkingLevel 的保守子集),不在其中的值丢弃。 */
52
- const REASONING_LEVELS = /* @__PURE__ */ new Set([
53
- "off",
54
- "minimal",
55
- "low",
56
- "medium",
57
- "high",
58
- "xhigh",
59
- "max"
60
- ]);
61
- /** 模型继承源(extends 的 provider 前缀)→ 协议的已知映射。 */
62
- const EXTENDS_PROTOCOL = {
63
- anthropic: "anthropic-messages",
64
- "kimi-coding": "anthropic-messages",
65
- deepseek: "openai-completions",
66
- openai: "openai-completions"
67
- };
68
- /** 路由名里的协议线索。 */
69
- const ROUTE_PROTOCOL = {
70
- chat: "openai-completions",
71
- completions: "openai-completions",
72
- response: "openai-responses",
73
- responses: "openai-responses",
74
- anthropic: "anthropic-messages",
75
- claude: "anthropic-messages"
76
- };
77
- /**
78
- * 推断路由协议:显式 api > 路由名线索 > 首个模型的继承源 > openai-completions。
79
- * 兜底选 openai-completions 因为多数网关提供 OpenAI 兼容面;推错时告警里注明。
80
- */
81
- function inferApi(routeKey, provider) {
82
- if (typeof provider.api === "string" && provider.api.length > 0) return provider.api;
83
- const routeHint = ROUTE_PROTOCOL[routeKey.toLowerCase()];
84
- if (routeHint !== void 0) return routeHint;
85
- const source = (provider.models?.find((m) => typeof m.extends === "string")?.extends)?.split("/")[0];
86
- if (source !== void 0 && EXTENDS_PROTOCOL[source] !== void 0) return EXTENDS_PROTOCOL[source];
87
- return "openai-completions";
88
- }
89
- /** 单个 provider 翻译(纯函数)。 */
90
- function translateProvider(routeKey, raw) {
91
- const out = {
92
- displayName: `${raw.displayName ?? routeKey} [fallback]`,
93
- api: inferApi(routeKey, raw)
94
- };
95
- if (raw.apiKeyEnv !== void 0) out.apiKeyEnv = raw.apiKeyEnv;
96
- if (raw.baseURL !== void 0) out.baseURL = raw.baseURL;
97
- if (raw.headers !== void 0) out.headers = raw.headers;
98
- if (raw.reasoning !== void 0 && REASONING_LEVELS.has(raw.reasoning)) out.reasoning = raw.reasoning;
99
- if (raw.thinkingBudgets !== void 0) out.thinkingBudgets = raw.thinkingBudgets;
100
- if (Array.isArray(raw.defaultInput) && raw.defaultInput.length > 0) out.defaultInput = raw.defaultInput;
101
- if (raw.compat !== void 0) {
102
- const api = inferApi(routeKey, raw);
103
- const compat = {};
104
- for (const key of COMPAT_WHITELIST) {
105
- const value = raw.compat[key];
106
- if (value === void 0 || value === null) continue;
107
- if (!COMPAT_OFFER_APIS[key]?.has(api)) continue;
108
- compat[key] = value;
109
- }
110
- if (Object.keys(compat).length > 0) out.compat = compat;
30
+ /** 副本文件名(跨包文件契约:llm-pi 写、本包读,改名须两侧同步)。 */
31
+ const OFFICIAL_COPY_FILE = "llm-pi.official-patch.yaml";
32
+ /** 解析上限:超出只报存在性与时间,不读内容(面板/告警不得被大文件拖垮)。 */
33
+ const MAX_BYTES$1 = 2097152;
34
+ /** 头注释里的生成警告行前缀(llm-pi 渲染约定)。 */
35
+ const WARNING_PREFIX = "# 警告:";
36
+ /** 副本契约路径(默认读取点)。 */
37
+ function officialCopyPath() {
38
+ return dshHomePath(OFFICIAL_COPY_FILE);
39
+ }
40
+ /** 解析副本内容(纯函数):头注释警告 + llm-pi-ai 行的 providers 数。 */
41
+ function parseCopyText(text) {
42
+ const warnings = text.split("\n").filter((line) => line.startsWith(WARNING_PREFIX)).map((line) => line.slice(5));
43
+ let rows;
44
+ try {
45
+ rows = parse(text);
46
+ } catch (error) {
47
+ return {
48
+ warnings,
49
+ detail: `解析失败:${error instanceof Error ? error.message : String(error)}`
50
+ };
111
51
  }
112
- if (Array.isArray(raw.models) && raw.models.length > 0) out.models = raw.models.map((m) => {
113
- const model = { id: m.id };
114
- if (m.name !== void 0) model.name = m.name;
115
- if (m.contextWindow !== void 0) model.contextWindow = m.contextWindow;
116
- if (m.maxTokens !== void 0) model.maxTokens = m.maxTokens;
117
- if (Array.isArray(m.input) && m.input.length > 0) model.input = m.input;
118
- return model;
119
- });
120
- return out;
121
- }
122
- /** 整表翻译:键加 -fb 后缀(纯函数)。 */
123
- function translateProviders(providers) {
124
- const out = {};
125
- for (const [key, raw] of Object.entries(providers)) out[`${key}-fb`] = translateProvider(key, raw);
126
- return out;
127
- }
128
- /** 读 settings.yaml 原始文档的指定段落(只读;文件/段落缺失返回 undefined)。 */
129
- async function readRawSection(settingsFile, ns) {
130
- const text = await readFile(settingsFile, "utf-8");
131
- const section = parseDocument(text).toJS()[ns];
132
- if (typeof section !== "object" || section === null) return void 0;
133
- return section;
52
+ if (!Array.isArray(rows)) return {
53
+ warnings,
54
+ detail: "内容不是 patch 列表"
55
+ };
56
+ if (rows.length === 0) return {
57
+ routes: 0,
58
+ warnings
59
+ };
60
+ const providers = rows.find((entry) => entry?.id === "llm-pi-ai")?.config?.providers;
61
+ if (providers === null || typeof providers !== "object") return {
62
+ warnings,
63
+ detail: "缺 llm-pi-ai 配置行(副本可能只含禁用行)"
64
+ };
65
+ return {
66
+ routes: Object.keys(providers).length,
67
+ warnings
68
+ };
134
69
  }
135
- /** 写 settings 带一次冲突重试(用户同时改配置时不抢,放弃并告警)。 */
136
- async function writeWithRetry(write) {
70
+ /** 读取副本状态;缺失/损坏/超限均降级为结构化状态,不抛出。 */
71
+ async function readOfficialCopyStatus(path = officialCopyPath()) {
72
+ let size;
73
+ let mtimeMs;
137
74
  try {
138
- await write();
75
+ const info = await stat(path);
76
+ if (!info.isFile()) return {
77
+ path,
78
+ exists: false,
79
+ warnings: []
80
+ };
81
+ size = info.size;
82
+ mtimeMs = info.mtimeMs;
83
+ } catch {
84
+ return {
85
+ path,
86
+ exists: false,
87
+ warnings: []
88
+ };
89
+ }
90
+ const base = {
91
+ path,
92
+ exists: true,
93
+ updatedAt: Math.trunc(mtimeMs),
94
+ warnings: []
95
+ };
96
+ if (size > MAX_BYTES$1) return {
97
+ ...base,
98
+ detail: "文件超出解析上限,未读取内容"
99
+ };
100
+ try {
101
+ const text = await readFile(path, "utf-8");
102
+ return {
103
+ ...base,
104
+ ...parseCopyText(text)
105
+ };
139
106
  } catch (error) {
140
- if (!(error instanceof SettingsConflictError)) throw error;
141
- await write();
107
+ return {
108
+ ...base,
109
+ detail: `读取失败:${error instanceof Error ? error.message : String(error)}`
110
+ };
142
111
  }
143
112
  }
144
- /** 启动宽限(ms):llm-pi 等兄弟插件的 adapter 注册晚于 lifeboat 就绪,
145
- * 启动竞态下立即评估会把"还没加载"误判为"缺席"——每次重启都误发
146
- * 「应急翻译 + 已还原」两条通知。宽限期内一律不评估,宽限结束自动评估
147
- * 一次,事件订阅覆盖后续变化;真故障(llm-pi 缺席)在宽限后照常告警。 */
113
+ //#endregion
114
+ //#region src/copy-guide.ts
115
+ /** 默认模型的 settings 命名空间(dsh 官方 agent 区)。 */
116
+ const NS_AGENT_DEFAULT = "agent-default-model";
117
+ /** 启动宽限(ms):llm-pi 等兄弟插件的 adapter 注册晚于 lifeboat 就绪,宽限内
118
+ * 不评估,避免把「还没加载」误判为「缺席」而每次重启都误发告警。 */
148
119
  const BOOT_GRACE_MS = 6e4;
149
- /** 装配 LLM 应急翻译:启动宽限结束后及 settings/llm 变化时评估一次(并发合并)。 */
150
- function installLlmFallback(ctx, deps, graceMs = BOOT_GRACE_MS) {
120
+ /** 默认模型的 provider(settings describe 平面视图;未配置返回 undefined)。 */
121
+ function defaultProvider(ctx) {
122
+ return (ctx.settings.describe().find((row) => row.ns === NS_AGENT_DEFAULT)?.value)?.provider;
123
+ }
124
+ /** 生成时间文案(副本不含时间戳,mtime 即生成时间)。 */
125
+ function generatedText(copy) {
126
+ if (copy.updatedAt === void 0) return "生成时间未知";
127
+ return new Date(copy.updatedAt).toISOString().slice(0, 19).replace("T", " ");
128
+ }
129
+ /** 副本缺失:应用无从谈起,先恢复 llm-pi 侧的生成能力。 */
130
+ function alertMissing(copy, provider, deps) {
131
+ deps.alert("[DSH] LLM 应急副本缺失", `默认模型的 provider "${provider}" 没有已注册 adapter,且官方应急副本 ${copy.path} 不存在(dsh-plus-llm-pi 未生成或已被删除)。先恢复 dsh-plus-llm-pi 让它生成副本,或手动修复 LLM 配置。`);
132
+ }
133
+ /** 副本存在但为空:应用无效果,先修 llm-pi 配置并等待重新生成。 */
134
+ function alertEmpty(copy, provider, deps) {
135
+ deps.alert("[DSH] LLM 应急副本为空", `默认模型的 provider "${provider}" 没有已注册 adapter;应急副本 ${copy.path} 存在但无任何 route(llm-pi 未配置或全部不可服务),应用无效果。先修复 dsh-plus-llm-pi 配置并等待副本重新生成。`);
136
+ }
137
+ /** 就绪副本的应用指引告警。 */
138
+ function alertReady(copy, provider, deps) {
139
+ const profile = deps.profile ?? "<profile>";
140
+ const warnings = copy.warnings.length > 0 ? ` 生成警告:${copy.warnings.join(";")}` : "";
141
+ deps.alert("[DSH] LLM 应急副本可应用", `默认模型的 provider "${provider}" 没有已注册 adapter(llm-pi 缺席/故障?)。官方应急副本已就绪:${copy.path}(${generatedText(copy)},${copy.routes ?? 0} 个 route)。应用(重启/重载生效):dsh ${profile} --patch ${copy.path};或把副本条目并入该 profile 的 cordis.patch.yml(web 热应用)。应用即切换:副本禁用 dsh-plus-llm-pi 并替换 llm-pi-ai 配置;回退只需移除 --patch 参数或并入的条目。${warnings}`);
142
+ }
143
+ /** 分派指引:按副本形态发出对应告警并返回结果。 */
144
+ function guide(copy, provider, deps) {
145
+ if (!copy.exists) {
146
+ alertMissing(copy, provider, deps);
147
+ return "missing";
148
+ }
149
+ if ((copy.routes ?? 0) === 0) {
150
+ alertEmpty(copy, provider, deps);
151
+ return "empty";
152
+ }
153
+ alertReady(copy, provider, deps);
154
+ return "ready";
155
+ }
156
+ /** journal 措辞。 */
157
+ function outcomeText(outcome, routes) {
158
+ if (outcome === "ready") return `可应用(${routes} 个 route)`;
159
+ if (outcome === "missing") return "缺失";
160
+ return "为空";
161
+ }
162
+ /** 装配副本指引:宽限结束后及事件触发评估(并发合并、告警带冷却)。 */
163
+ function installCopyGuide(ctx, deps, graceMs = BOOT_GRACE_MS) {
151
164
  const logger = ctx.logger("lifeboat");
152
- const writes = ctx.settings;
153
- const settingsFile = dshHomePath("settings.yaml");
165
+ const readCopy = deps.readCopy ?? (() => readOfficialCopyStatus());
154
166
  let inFlight;
167
+ let lastAlertAt = Number.NEGATIVE_INFINITY;
155
168
  const startedAt = Date.now();
156
- const withinGrace = () => Date.now() - startedAt < graceMs;
157
- const listProviderIds = () => new Set(ctx.llm.listProviders().map((p) => p.id));
158
- async function activate(provider, model) {
159
- const providers = (await readRawSection(settingsFile, RAW_NS_LLM_PI).catch(() => void 0))?.providers;
160
- if (providers === void 0 || providers[provider] === void 0) {
161
- deps.alert("[DSH] 默认模型不可用且无配置可翻译", `默认模型的 provider "${provider}" 没有已注册 adapter,且 ${RAW_NS_LLM_PI} 配置中找不到对应条目,无法应急翻译。请手动修复。`);
162
- return;
163
- }
164
- const translated = translateProviders(providers);
165
- const fbProvider = `${provider}-fb`;
166
- try {
167
- await writes.update(NS_PI_AI, { providers: translated });
168
- await writes.update(NS_AGENT_DEFAULT, {
169
- provider: fbProvider,
170
- model
171
- });
172
- } catch (error) {
173
- const message = error instanceof Error ? error.message : String(error);
174
- throw new Error(`翻译失败(官方 llm-pi-ai 拒绝写入翻译后的配置): ${message}`);
175
- }
176
- await deps.writeState({
177
- active: true,
178
- originalProvider: provider,
179
- originalModel: model,
180
- fallbackProvider: fbProvider,
181
- providers: Object.keys(translated),
182
- at: (/* @__PURE__ */ new Date()).toISOString()
183
- });
184
- deps.journal("llm-fallback", `默认模型切换到 ${fbProvider}/${model}(翻译 ${Object.keys(translated).length} 个 provider)`);
185
- deps.alert("[DSH] LLM 配置已应急翻译", `默认模型的 provider "${provider}" 不可用(llm-pi 缺席?),已把 ${RAW_NS_LLM_PI} 配置翻译为官方 llm-pi-ai 路由(-fb 后缀)并切换默认模型为 ${fbProvider}/${model}。源插件恢复后将自动还原。`);
186
- }
187
- async function revert(state) {
188
- const ops = state.providers.map((key) => ({
189
- op: "unset",
190
- path: ["providers", key]
191
- }));
192
- await writeWithRetry(() => writes.mutate(NS_PI_AI, ops));
193
- await writeWithRetry(() => writes.update(NS_AGENT_DEFAULT, {
194
- provider: state.originalProvider,
195
- model: state.originalModel
196
- }));
197
- await deps.writeState(null);
198
- deps.journal("llm-fallback-revert", `默认模型还原为 ${state.originalProvider}/${state.originalModel}`);
199
- deps.alert("[DSH] LLM 应急翻译已还原", `provider "${state.originalProvider}" 恢复健康,默认模型已还原,临时翻译路由已清除。`);
200
- }
201
169
  async function evaluate() {
202
- const state = deps.readState();
203
- const ids = listProviderIds();
204
- if (state?.active) {
205
- if (ids.has(state.originalProvider)) await revert(state);
206
- return;
207
- }
208
- const def = ctx.settings.describe().find((row) => row.ns === NS_AGENT_DEFAULT)?.value;
209
- if (def?.provider === void 0 || def.model === void 0) return;
210
- if (ids.has(def.provider)) return;
211
- await activate(def.provider, def.model);
170
+ const provider = defaultProvider(ctx);
171
+ if (provider === void 0) return;
172
+ if (ctx.llm.listProviders().some((item) => item.id === provider)) return;
173
+ if (Date.now() - lastAlertAt < deps.cooldownMs) return;
174
+ const copy = await readCopy();
175
+ lastAlertAt = Date.now();
176
+ const outcome = guide(copy, provider, deps);
177
+ deps.journal("llm-copy-guide", `默认模型 provider "${provider}" 缺席;副本${outcomeText(outcome, copy.routes ?? 0)}`);
212
178
  }
213
179
  const run = () => {
214
- if (withinGrace()) return;
180
+ if (Date.now() - startedAt < graceMs) return;
215
181
  if (inFlight !== void 0) return;
216
182
  inFlight = evaluate().catch((error) => {
217
183
  const message = error instanceof Error ? error.message : String(error);
218
- logger.warn(`llm fallback 评估失败: ${message}`);
219
- deps.journal("llm-fallback-error", message);
184
+ logger.warn(`副本指引评估失败: ${message}`);
185
+ deps.journal("llm-copy-guide-error", message);
220
186
  }).finally(() => {
221
187
  inFlight = void 0;
222
188
  });
223
189
  };
224
190
  const bootTimer = setTimeout(run, graceMs);
225
- ctx.effect(() => () => clearTimeout(bootTimer), "lifeboat: fallback boot timer");
226
- ctx.events.on("ready", run);
227
- const WATCHED_NS = /* @__PURE__ */ new Set([
228
- "agent-default-model",
229
- "llm-pi-ai",
230
- RAW_NS_LLM_PI
231
- ]);
191
+ ctx.effect(() => () => clearTimeout(bootTimer), "lifeboat: copy guide boot timer");
232
192
  ctx.root.on("settings/document-updated", (ns) => {
233
- if (typeof ns === "string" && WATCHED_NS.has(ns)) run();
193
+ if (ns === NS_AGENT_DEFAULT) run();
234
194
  });
235
- ctx.root.on("llm/adapters-updated", run);
195
+ ctx.root.on("llm/adapters-updated", () => run());
236
196
  }
237
197
  //#endregion
238
198
  //#region src/patch-file.ts
@@ -443,10 +403,9 @@ function installHostWatch(ctx, q) {
443
403
  //#region src/health-api.ts
444
404
  /**
445
405
  * lifeboat 健康面板 host 接收面:
446
- * - GET /dsh-plus/lifeboat/status → journal + 翻译状态 + 当前用户 patch 层已禁用的 dsh-plus 插件
406
+ * - GET /dsh-plus/lifeboat/status → journal + 应急副本状态 + 当前用户 patch 层已禁用的 dsh-plus 插件
447
407
  * - POST /dsh-plus/lifeboat/restore {name} → removeDisable + journal + 告警
448
408
  * rc8 用户 patch 层热应用:恢复无需重启,watchUserPatches 即时重载。
449
- * (0.1.2-alpha.2 基线复核:用户 patch 层热应用机制保留,行为不变。)
450
409
  * @module lifeboat/health-api
451
410
  */
452
411
  const ROUTE_STATUS = "/dsh-plus/lifeboat/status";
@@ -486,7 +445,7 @@ function registerHealthApi(ctx, deps) {
486
445
  const quarantined = await readQuarantined(deps.patchFile);
487
446
  sendJson$1(res, 200, {
488
447
  journal: deps.readJournal(),
489
- llmFallback: deps.readFallback(),
448
+ officialCopy: await deps.readCopy(),
490
449
  quarantined
491
450
  });
492
451
  } catch (error) {
@@ -602,9 +561,9 @@ function registerQuarantineApi(ctx, q) {
602
561
  //#endregion
603
562
  //#region src/state-file.ts
604
563
  /**
605
- * journal 与 LLM 应急翻译状态的文件持久化($DSH_HOME/dsh-plus/lifeboat/state.json)。
564
+ * journal 的文件持久化($DSH_HOME/dsh-plus/lifeboat/state.json)。
606
565
  *
607
- * 存储规范:运行期状态属非配置数据,不进 settings.yaml;本模块自带实现,
566
+ * 存储规范:运行期状态属非配置数据,不进 settings 用户层;本模块自带实现,
608
567
  * 维持零 dsh-plus 内部依赖铁律(不 import @dsh-plus/shared)。
609
568
  * schema 刻意宽松:lifeboat 是最后防线,自身数据问题绝不能让它起不来。
610
569
  * @module lifeboat/state-file
@@ -624,46 +583,20 @@ function asEntry(value) {
624
583
  detail: String(record["detail"] ?? "")
625
584
  };
626
585
  }
627
- /** 未知输入收窄为合法翻译状态。 */
628
- function asFallback(value) {
629
- if (typeof value !== "object" || value === null) return null;
630
- const record = value;
631
- if (typeof record["active"] !== "boolean") return null;
632
- return {
633
- active: record["active"],
634
- originalProvider: String(record["originalProvider"] ?? ""),
635
- originalModel: String(record["originalModel"] ?? ""),
636
- fallbackProvider: String(record["fallbackProvider"] ?? ""),
637
- providers: Array.isArray(record["providers"]) ? record["providers"].map((item) => String(item)) : [],
638
- at: String(record["at"] ?? "")
639
- };
640
- }
641
586
  /** 未知输入收窄为完整状态文档;任何缺省回落到空文档。 */
642
587
  function normalizeState(raw) {
643
- if (typeof raw !== "object" || raw === null) return {
644
- journal: [],
645
- llmFallback: null
646
- };
588
+ if (typeof raw !== "object" || raw === null) return { journal: [] };
647
589
  const record = raw;
648
- return {
649
- journal: (Array.isArray(record["journal"]) ? record["journal"].map(asEntry).filter((entry) => entry !== null) : []).slice(-50),
650
- llmFallback: asFallback(record["llmFallback"])
651
- };
590
+ return { journal: (Array.isArray(record["journal"]) ? record["journal"].map(asEntry).filter((entry) => entry !== null) : []).slice(-50) };
652
591
  }
653
592
  /** 读取状态文件;缺失/损坏/超限时返回空文档(最后防线不得因自身数据起不来)。 */
654
593
  async function loadState() {
655
594
  try {
656
595
  const info = await stat(STATE_FILE);
657
- if (!info.isFile() || info.size > MAX_BYTES) return {
658
- journal: [],
659
- llmFallback: null
660
- };
596
+ if (!info.isFile() || info.size > MAX_BYTES) return { journal: [] };
661
597
  return normalizeState(JSON.parse(await readFile(STATE_FILE, "utf-8")));
662
598
  } catch {
663
- return {
664
- journal: [],
665
- llmFallback: null
666
- };
599
+ return { journal: [] };
667
600
  }
668
601
  }
669
602
  /**
@@ -706,10 +639,7 @@ function resolvePatchFile(ctx, config) {
706
639
  function apply(ctx, config) {
707
640
  const logger = ctx.logger("lifeboat");
708
641
  const warn = (message) => logger.warn(message);
709
- let doc = {
710
- journal: [],
711
- llmFallback: null
712
- };
642
+ let doc = { journal: [] };
713
643
  loadState().then((loaded) => {
714
644
  doc = loaded;
715
645
  });
@@ -718,6 +648,12 @@ function apply(ctx, config) {
718
648
  saveState(doc, warn);
719
649
  };
720
650
  const alert = installAlerter(ctx, journal);
651
+ installCopyGuide(ctx, {
652
+ journal,
653
+ alert,
654
+ profile: ctx.get("profileContext")?.name,
655
+ cooldownMs: config.alertCooldownMs
656
+ });
721
657
  if (config.enabled) {
722
658
  const patchFile = resolvePatchFile(ctx, config);
723
659
  if (patchFile === void 0) warn("故障隔离停用:当前组合树无 profileContext,无法定位 profile 用户 patch 层");
@@ -736,23 +672,11 @@ function apply(ctx, config) {
736
672
  journal,
737
673
  alert,
738
674
  readJournal: () => doc.journal,
739
- readFallback: () => doc.llmFallback
675
+ readCopy: () => readOfficialCopyStatus()
740
676
  });
741
677
  });
742
678
  }
743
679
  }
744
- if (config.llmFallback) installLlmFallback(ctx, {
745
- journal,
746
- alert,
747
- readState: () => doc.llmFallback,
748
- writeState: async (state) => {
749
- doc = {
750
- ...doc,
751
- llmFallback: state
752
- };
753
- await saveState(doc, warn);
754
- }
755
- });
756
680
  }
757
681
  //#endregion
758
682
  export { Config, apply, inject, name, resolvePatchFile };