@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 +61 -37
- package/lib/index.d.ts +0 -2
- package/lib/index.js +166 -242
- package/package.json +2 -2
- package/src/client/health-styles.ts +4 -2
- package/src/client/health-tab.tsx +63 -36
- package/src/config.ts +0 -14
- package/src/copy-guide.ts +153 -0
- package/src/health-api.ts +6 -11
- package/src/index.ts +22 -23
- package/src/official-copy-status.ts +100 -0
- package/src/state-file.ts +8 -26
- package/src/fallback-llm.ts +0 -355
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-
|
|
134
|
-
.dlb-
|
|
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.
|
|
235
|
-
|
|
236
|
-
|
|
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-
|
|
373
|
-
"llm-fallback-revert": "dlb-kindRestore"
|
|
391
|
+
"llm-copy-guide": "dlb-kindFallback"
|
|
374
392
|
};
|
|
375
393
|
zhDict = {
|
|
376
394
|
tab: "救生艇",
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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 {
|
|
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/
|
|
21
|
+
//#region src/official-copy-status.ts
|
|
24
22
|
/**
|
|
25
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* @module lifeboat/fallback-llm
|
|
25
|
+
* 文件契约与 @dsh-plus/llm-pi 同路径:对方常备副本、本包只读消费——零
|
|
26
|
+
* dsh-plus 内部依赖铁律下不 import 对方实现,路径按契约文件名同源派生。
|
|
27
|
+
* 读取一切失败都降级为结构化状态(面板展示与告警文案的输入,绝不抛出)。
|
|
28
|
+
* @module lifeboat/official-copy-status
|
|
35
29
|
*/
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
/**
|
|
136
|
-
async function
|
|
70
|
+
/** 读取副本状态;缺失/损坏/超限均降级为结构化状态,不抛出。 */
|
|
71
|
+
async function readOfficialCopyStatus(path = officialCopyPath()) {
|
|
72
|
+
let size;
|
|
73
|
+
let mtimeMs;
|
|
137
74
|
try {
|
|
138
|
-
await
|
|
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
|
-
|
|
141
|
-
|
|
107
|
+
return {
|
|
108
|
+
...base,
|
|
109
|
+
detail: `读取失败:${error instanceof Error ? error.message : String(error)}`
|
|
110
|
+
};
|
|
142
111
|
}
|
|
143
112
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
/**
|
|
150
|
-
function
|
|
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
|
|
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
|
|
203
|
-
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
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 (
|
|
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(
|
|
219
|
-
deps.journal("llm-
|
|
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:
|
|
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 (
|
|
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 +
|
|
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
|
-
|
|
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
|
|
564
|
+
* journal 的文件持久化($DSH_HOME/dsh-plus/lifeboat/state.json)。
|
|
606
565
|
*
|
|
607
|
-
* 存储规范:运行期状态属非配置数据,不进 settings
|
|
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
|
-
|
|
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 };
|