@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/lifeboat",
3
- "version": "0.2.30",
3
+ "version": "0.3.1",
4
4
  "description": "dsh-plus service plugin: 故障救生艇——兄弟插件崩溃时自动隔离(写用户 patch 层禁用),llm-pi 缺席时把其配置临时翻译为官方 llm-pi-ai 路由,恢复后自动还原",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "yaml": "^2.9.0",
34
- "@dsh-plus/shared": "0.1.35"
34
+ "@dsh-plus/shared": "0.1.36"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@deepseek-ai/cordis": "^4.0.5-alpha.1",
@@ -12,8 +12,10 @@ export const healthCss = `
12
12
  .dlb-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;margin:4px 0}
13
13
  .dlb-title{color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:600;margin:10px 0 0}
14
14
  .dlb-notice{color:var(--dsw-alias-label-secondary);font-size:12px;margin:0}
15
- .dlb-fallback{border:1px solid var(--dsw-alias-label-warning,#e0a03c);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:6px;background:var(--dsw-alias-bg-layer-3)}
16
- .dlb-fallbackTitle{color:var(--dsw-alias-label-warning,#e0a03c);font-size:13px;font-weight:600;margin:0}
15
+ .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)}
16
+ .dlb-copyTitle{color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:600;margin:0}
17
+ .dlb-copyWarning{color:var(--dsw-alias-label-warning,#e0a03c);font-size:12px;margin:0}
18
+ .dlb-copyHint{color:var(--dsw-alias-label-tertiary);font-size:12px;margin:0;line-height:1.6}
17
19
  .dlb-kv{display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap}
18
20
  .dlb-kv span{color:var(--dsw-alias-label-tertiary);flex:none}
19
21
  .dlb-kv code{color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;word-break:break-all}
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * lifeboat 健康页:settings.plugins.tab 第三页「救生艇」。
3
- * - 应急翻译状态 banner(激活中显示源路由/临时路由/时间)
3
+ * - 官方应急副本状态卡片(路径/生成时间/覆盖 route/警告 + 应用指引)
4
4
  * - 已隔离插件卡片 + 「恢复」按钮(两段确认)
5
5
  * - journal 时间线(kind 徽标着色)
6
6
  * - dsh-plus 兄弟插件状态点(pluginInventory.list)
@@ -20,17 +20,18 @@ interface JournalEntry {
20
20
  detail: string
21
21
  }
22
22
 
23
- interface FallbackState {
24
- active: boolean
25
- originalProvider: string
26
- originalModel: string
27
- fallbackProvider: string
28
- at: string
23
+ interface CopyState {
24
+ path: string
25
+ exists: boolean
26
+ updatedAt?: number
27
+ routes?: number
28
+ warnings: string[]
29
+ detail?: string
29
30
  }
30
31
 
31
32
  interface StatusResponse {
32
33
  journal: JournalEntry[]
33
- llmFallback: FallbackState | null
34
+ officialCopy?: CopyState
34
35
  quarantined: string[]
35
36
  }
36
37
 
@@ -77,16 +78,19 @@ const KIND_STYLES: Record<string, string> = {
77
78
  alert: 'dlb-kindAlert',
78
79
  quarantine: 'dlb-kindQuarantine',
79
80
  'quarantine-restore': 'dlb-kindRestore',
80
- 'llm-fallback': 'dlb-kindFallback',
81
- 'llm-fallback-revert': 'dlb-kindRestore',
81
+ 'llm-copy-guide': 'dlb-kindFallback',
82
82
  }
83
83
 
84
84
  const zhDict = {
85
85
  tab: '救生艇',
86
- fallbackActive: 'LLM 应急翻译进行中',
87
- fallbackFrom: '源路由',
88
- fallbackTo: '临时路由',
89
- fallbackAt: '启用时间',
86
+ copyTitle: '官方应急 LLM 副本',
87
+ copyPath: '副本路径',
88
+ copyAt: '生成时间',
89
+ copyRoutes: '覆盖 route',
90
+ copyMissing: '未生成',
91
+ copyWarning: '警告',
92
+ copyApplyHint:
93
+ '应用:dsh <profile> --patch 上述路径(重启/重载生效),或把副本条目并入当前 profile 的 cordis.patch.yml(热应用)。应用即禁用 dsh-plus-llm-pi 并切换 llm-pi-ai 配置;回退只需移除 --patch/并入条目。',
90
94
  quarantined: '已隔离插件',
91
95
  restore: '恢复',
92
96
  restoring: '恢复中…',
@@ -106,10 +110,14 @@ const zhDict = {
106
110
 
107
111
  const enDict = {
108
112
  tab: 'Lifeboat',
109
- fallbackActive: 'LLM emergency translation active',
110
- fallbackFrom: 'Source route',
111
- fallbackTo: 'Fallback route',
112
- fallbackAt: 'Since',
113
+ copyTitle: 'Official emergency LLM copy',
114
+ copyPath: 'Path',
115
+ copyAt: 'Generated',
116
+ copyRoutes: 'Routes',
117
+ copyMissing: 'not generated',
118
+ copyWarning: 'Warning',
119
+ copyApplyHint:
120
+ "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.",
113
121
  quarantined: 'Quarantined plugins',
114
122
  restore: 'Restore',
115
123
  restoring: 'Restoring…',
@@ -128,6 +136,41 @@ const enDict = {
128
136
  restoreFailed: 'Restore failed: ',
129
137
  }
130
138
 
139
+ /** 官方应急副本状态卡片(纯展示:路径/生成时间/覆盖 route/警告 + 应用指引)。 */
140
+ function CopyStatusCard(props: { copy: CopyState; t(key: string): string }): ReactElement {
141
+ const { copy, t } = props
142
+ const generated =
143
+ copy.updatedAt === undefined
144
+ ? t('copyMissing')
145
+ : new Date(copy.updatedAt).toISOString().slice(0, 19).replace('T', ' ')
146
+ return (
147
+ <div className="dlb-copy" role="status">
148
+ <p className="dlb-copyTitle">{t('copyTitle')}</p>
149
+ <div className="dlb-kv">
150
+ <span>{t('copyPath')}</span>
151
+ <code>{copy.path}</code>
152
+ </div>
153
+ <div className="dlb-kv">
154
+ <span>{t('copyAt')}</span>
155
+ <code>{generated}</code>
156
+ </div>
157
+ {copy.routes !== undefined ? (
158
+ <div className="dlb-kv">
159
+ <span>{t('copyRoutes')}</span>
160
+ <code>{copy.routes}</code>
161
+ </div>
162
+ ) : null}
163
+ {copy.detail !== undefined ? <p className="dlb-copyWarning">{copy.detail}</p> : null}
164
+ {copy.warnings.map((warning) => (
165
+ <p className="dlb-copyWarning" key={warning}>
166
+ {`${t('copyWarning')}:${warning}`}
167
+ </p>
168
+ ))}
169
+ <p className="dlb-copyHint">{t('copyApplyHint')}</p>
170
+ </div>
171
+ )
172
+ }
173
+
131
174
  function HealthTab(props: { t(key: string): string }): ReactElement {
132
175
  const { t } = props
133
176
  const [status, setStatus] = useState<StatusResponse | null>(null)
@@ -196,24 +239,8 @@ function HealthTab(props: { t(key: string): string }): ReactElement {
196
239
  </p>
197
240
  ) : null}
198
241
 
199
- {status.llmFallback?.active === true ? (
200
- <div className="dlb-fallback" role="status">
201
- <p className="dlb-fallbackTitle">{t('fallbackActive')}</p>
202
- <div className="dlb-kv">
203
- <span>{t('fallbackFrom')}</span>
204
- <code>
205
- {status.llmFallback.originalProvider}/{status.llmFallback.originalModel}
206
- </code>
207
- </div>
208
- <div className="dlb-kv">
209
- <span>{t('fallbackTo')}</span>
210
- <code>{status.llmFallback.fallbackProvider}</code>
211
- </div>
212
- <div className="dlb-kv">
213
- <span>{t('fallbackAt')}</span>
214
- <code>{status.llmFallback.at.slice(0, 19).replace('T', ' ')}</code>
215
- </div>
216
- </div>
242
+ {status.officialCopy !== undefined ? (
243
+ <CopyStatusCard copy={status.officialCopy} t={t} />
217
244
  ) : null}
218
245
 
219
246
  <h3 className="dlb-title">{t('quarantined')}</h3>
package/src/config.ts CHANGED
@@ -17,10 +17,6 @@ export const Config = z.object({
17
17
  '隔离写入的 profile 用户 patch 文件绝对路径;留空自动取当前 profile 的 cordis.patch.yml(profileContext.patchPath,CLI 与桌面端 desktop profile 同源)',
18
18
  )
19
19
  .default(''),
20
- llmFallback: z
21
- .boolean()
22
- .description('llm-pi 缺席时自动翻译其配置到官方 llm-pi-ai 并切换默认模型')
23
- .default(true),
24
20
  alertCooldownMs: z.natural().description('同一插件重复告警的最小间隔毫秒数').default(300000),
25
21
  })
26
22
 
@@ -32,13 +28,3 @@ export interface JournalEntryT {
32
28
  kind: string
33
29
  detail: string
34
30
  }
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
- }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * LLM 应急副本指引:默认模型的 provider 无已注册 adapter 时(典型场景:llm-pi
3
+ * 被隔离/加载失败),读取 @dsh-plus/llm-pi 常备的官方应急副本并告警指引应用
4
+ * 方式(--patch 单次启动,或并入 profile 的 cordis.patch.yml 热应用)。
5
+ *
6
+ * 只告警不写:配置面的唯一写者是 llm-pi 与用户,救生艇不碰配置文件——
7
+ * 旧「出错时自动改写配置」因不稳定、常无效已移除,指引把操作交还给人。
8
+ *
9
+ * 评估时机:启动宽限(兄弟插件就绪竞态)结束后一次 + 默认模型配置变化 /
10
+ * adapter 集变化事件;平时零开销。同一指引受 alertCooldownMs 冷却(内存态,
11
+ * 重启复位——告警是提示不是状态)。
12
+ * @module lifeboat/copy-guide
13
+ */
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ import type {} from '@deepseek-ai/dsh-llm'
16
+ import type {} from '@deepseek-ai/dsh-settings'
17
+
18
+ import type { Alerter } from './notify.ts'
19
+ import { type OfficialCopyStatus, readOfficialCopyStatus } from './official-copy-status.ts'
20
+
21
+ /** 默认模型的 settings 命名空间(dsh 官方 agent 区)。 */
22
+ const NS_AGENT_DEFAULT = 'agent-default-model'
23
+
24
+ /** 启动宽限(ms):llm-pi 等兄弟插件的 adapter 注册晚于 lifeboat 就绪,宽限内
25
+ * 不评估,避免把「还没加载」误判为「缺席」而每次重启都误发告警。 */
26
+ export const BOOT_GRACE_MS = 60_000
27
+
28
+ export interface CopyGuideDeps {
29
+ journal: (kind: string, detail: string) => void
30
+ alert: Alerter
31
+ /** 副本状态读取(缺省读契约路径;测试注入固定值)。 */
32
+ readCopy?: () => Promise<OfficialCopyStatus>
33
+ /** 来源 profile(告警里的应用命令;非 dsh 启动时缺省占位)。 */
34
+ profile: string | undefined
35
+ /** 同一指引告警的最小间隔毫秒数。 */
36
+ cooldownMs: number
37
+ }
38
+
39
+ /** 默认模型的 provider(settings describe 平面视图;未配置返回 undefined)。 */
40
+ function defaultProvider(ctx: Context): string | undefined {
41
+ const value = ctx.settings.describe().find((row) => row.ns === NS_AGENT_DEFAULT)?.value
42
+ return (value as { provider?: unknown } | undefined)?.provider as string | undefined
43
+ }
44
+
45
+ /** 生成时间文案(副本不含时间戳,mtime 即生成时间)。 */
46
+ function generatedText(copy: OfficialCopyStatus): string {
47
+ if (copy.updatedAt === undefined) return '生成时间未知'
48
+ return new Date(copy.updatedAt).toISOString().slice(0, 19).replace('T', ' ')
49
+ }
50
+
51
+ /** 副本指引结果(journal 措辞用)。 */
52
+ type GuideOutcome = 'ready' | 'missing' | 'empty'
53
+
54
+ /** 副本缺失:应用无从谈起,先恢复 llm-pi 侧的生成能力。 */
55
+ function alertMissing(copy: OfficialCopyStatus, provider: string, deps: CopyGuideDeps): void {
56
+ deps.alert(
57
+ '[DSH] LLM 应急副本缺失',
58
+ `默认模型的 provider "${provider}" 没有已注册 adapter,且官方应急副本 ${copy.path} 不存在(dsh-plus-llm-pi 未生成或已被删除)。先恢复 dsh-plus-llm-pi 让它生成副本,或手动修复 LLM 配置。`,
59
+ )
60
+ }
61
+
62
+ /** 副本存在但为空:应用无效果,先修 llm-pi 配置并等待重新生成。 */
63
+ function alertEmpty(copy: OfficialCopyStatus, provider: string, deps: CopyGuideDeps): void {
64
+ deps.alert(
65
+ '[DSH] LLM 应急副本为空',
66
+ `默认模型的 provider "${provider}" 没有已注册 adapter;应急副本 ${copy.path} 存在但无任何 route(llm-pi 未配置或全部不可服务),应用无效果。先修复 dsh-plus-llm-pi 配置并等待副本重新生成。`,
67
+ )
68
+ }
69
+
70
+ /** 就绪副本的应用指引告警。 */
71
+ function alertReady(copy: OfficialCopyStatus, provider: string, deps: CopyGuideDeps): void {
72
+ const profile = deps.profile ?? '<profile>'
73
+ const warnings = copy.warnings.length > 0 ? ` 生成警告:${copy.warnings.join(';')}` : ''
74
+ deps.alert(
75
+ '[DSH] LLM 应急副本可应用',
76
+ `默认模型的 provider "${provider}" 没有已注册 adapter(llm-pi 缺席/故障?)。官方应急副本已就绪:${copy.path}(${generatedText(copy)},${copy.routes ?? 0} 个 route)。` +
77
+ `应用(重启/重载生效):dsh ${profile} --patch ${copy.path};或把副本条目并入该 profile 的 cordis.patch.yml(web 热应用)。` +
78
+ `应用即切换:副本禁用 dsh-plus-llm-pi 并替换 llm-pi-ai 配置;回退只需移除 --patch 参数或并入的条目。${warnings}`,
79
+ )
80
+ }
81
+
82
+ /** 分派指引:按副本形态发出对应告警并返回结果。 */
83
+ function guide(copy: OfficialCopyStatus, provider: string, deps: CopyGuideDeps): GuideOutcome {
84
+ if (!copy.exists) {
85
+ alertMissing(copy, provider, deps)
86
+ return 'missing'
87
+ }
88
+ if ((copy.routes ?? 0) === 0) {
89
+ alertEmpty(copy, provider, deps)
90
+ return 'empty'
91
+ }
92
+ alertReady(copy, provider, deps)
93
+ return 'ready'
94
+ }
95
+
96
+ /** journal 措辞。 */
97
+ function outcomeText(outcome: GuideOutcome, routes: number): string {
98
+ if (outcome === 'ready') return `可应用(${routes} 个 route)`
99
+ if (outcome === 'missing') return '缺失'
100
+ return '为空'
101
+ }
102
+
103
+ /** 装配副本指引:宽限结束后及事件触发评估(并发合并、告警带冷却)。 */
104
+ export function installCopyGuide(
105
+ ctx: Context,
106
+ deps: CopyGuideDeps,
107
+ graceMs: number = BOOT_GRACE_MS,
108
+ ): void {
109
+ const logger = ctx.logger('lifeboat')
110
+ const readCopy = deps.readCopy ?? (() => readOfficialCopyStatus())
111
+ let inFlight: Promise<void> | undefined
112
+ // -Infinity 而非 0:首个事件必须能越过冷却闸(mock 时钟下 Date.now() 从 0 起算)。
113
+ let lastAlertAt = Number.NEGATIVE_INFINITY
114
+ const startedAt = Date.now()
115
+
116
+ async function evaluate(): Promise<void> {
117
+ const provider = defaultProvider(ctx)
118
+ if (provider === undefined) return // 未配置默认模型:无从判定缺席
119
+ if (ctx.llm.listProviders().some((item) => item.id === provider)) return // LLM 正常
120
+ if (Date.now() - lastAlertAt < deps.cooldownMs) return
121
+ const copy = await readCopy()
122
+ lastAlertAt = Date.now()
123
+ const outcome = guide(copy, provider, deps)
124
+ deps.journal(
125
+ 'llm-copy-guide',
126
+ `默认模型 provider "${provider}" 缺席;副本${outcomeText(outcome, copy.routes ?? 0)}`,
127
+ )
128
+ }
129
+
130
+ const run = (): void => {
131
+ if (Date.now() - startedAt < graceMs) return
132
+ if (inFlight !== undefined) return
133
+ inFlight = evaluate()
134
+ .catch((error: unknown) => {
135
+ const message = error instanceof Error ? error.message : String(error)
136
+ logger.warn(`副本指引评估失败: ${message}`)
137
+ deps.journal('llm-copy-guide-error', message)
138
+ })
139
+ .finally(() => {
140
+ inFlight = undefined
141
+ })
142
+ }
143
+
144
+ const bootTimer = setTimeout(run, graceMs)
145
+ ctx.effect(() => () => clearTimeout(bootTimer), 'lifeboat: copy guide boot timer')
146
+ // 只响应默认模型配置变化:journal 写自身数据,不过滤会自触发循环。
147
+ ctx.root.on('settings/document-updated', (ns: unknown) => {
148
+ if (ns === NS_AGENT_DEFAULT) run()
149
+ })
150
+ // 事件名已核对:dsh-llm 在每次 adapter 注册/注销提交点派发(module augmentation
151
+ // 声明随 @deepseek-ai/dsh-llm 类型导入携带,无需 as never)。
152
+ ctx.root.on('llm/adapters-updated', () => run())
153
+ }
package/src/health-api.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * lifeboat 健康面板 host 接收面:
3
- * - GET /dsh-plus/lifeboat/status → journal + 翻译状态 + 当前用户 patch 层已禁用的 dsh-plus 插件
3
+ * - GET /dsh-plus/lifeboat/status → journal + 应急副本状态 + 当前用户 patch 层已禁用的 dsh-plus 插件
4
4
  * - POST /dsh-plus/lifeboat/restore {name} → removeDisable + journal + 告警
5
5
  * rc8 用户 patch 层热应用:恢复无需重启,watchUserPatches 即时重载。
6
- * (0.1.2-alpha.2 基线复核:用户 patch 层热应用机制保留,行为不变。)
7
6
  * @module lifeboat/health-api
8
7
  */
9
8
  import { readFile } from 'node:fs/promises'
@@ -12,6 +11,7 @@ import type { Context } from '@deepseek-ai/cordis'
12
11
  import type {} from '@deepseek-ai/dsh-host-webserver'
13
12
  import { parseDocument } from 'yaml'
14
13
  import type { Alerter } from './notify.ts'
14
+ import type { OfficialCopyStatus } from './official-copy-status.ts'
15
15
  import { listDisabled, type PatchEntry, removeDisable } from './patch-file.ts'
16
16
  import { isGuardedPlugin } from './quarantine.ts'
17
17
 
@@ -22,15 +22,10 @@ export interface HealthDeps {
22
22
  patchFile: string
23
23
  journal: (kind: string, detail: string) => void
24
24
  alert: Alerter
25
- /** journal 文档(settings 命名空间内,最新在前的尾插列表)。 */
25
+ /** journal 文档(最新在前的尾插列表)。 */
26
26
  readJournal(): Array<{ at: string; kind: string; detail: string }>
27
- readFallback(): {
28
- active: boolean
29
- originalProvider: string
30
- originalModel: string
31
- fallbackProvider: string
32
- at: string
33
- } | null
27
+ /** 官方应急副本状态(每次 status 请求现读,mtime 即生成时间)。 */
28
+ readCopy(): Promise<OfficialCopyStatus>
34
29
  }
35
30
 
36
31
  function sendJson(res: ServerResponse, status: number, body: unknown): void {
@@ -72,7 +67,7 @@ export function registerHealthApi(ctx: Context, deps: HealthDeps): void {
72
67
  const quarantined = await readQuarantined(deps.patchFile)
73
68
  sendJson(res, 200, {
74
69
  journal: deps.readJournal(),
75
- llmFallback: deps.readFallback(),
70
+ officialCopy: await deps.readCopy(),
76
71
  quarantined,
77
72
  })
78
73
  } catch (error) {
package/src/index.ts CHANGED
@@ -4,15 +4,17 @@
4
4
  * 1. 故障隔离——兄弟 dsh-plus 插件 fiber FAILED 时(host 侧直接监听,浏览器侧
5
5
  * 经哨兵回报),向 profile 用户 patch 层写入 disabled 覆盖;重启/刷新后
6
6
  * 失败插件缺席、其余正常,修复后删覆盖即恢复。
7
- * 2. LLM 应急翻译——默认模型 provider 无 adapter 时,把 dsh-plus-llm-pi 配置
8
- * 临时翻译为官方 llm-pi-ai 路由并切换默认模型,恢复后自动还原。
7
+ * 2. LLM 应急指引——默认模型 provider 无 adapter 时,读取 @dsh-plus/llm-pi
8
+ * 常备的官方应急副本($DSH_HOME/llm-pi.official-patch.yaml)并告警指引
9
+ * 应用方式;只告警不写(旧「自动改写配置」不稳定、常无效,已移除)。
9
10
  * 3. 告警与 journal——一切动作记录进运行期状态文件(state-file.ts),并经
10
11
  * notify-email 发邮件(缺席降级为日志)。
11
12
  *
12
13
  * 零 dsh-plus 内部依赖铁律:不 import 本仓库任何其他包,防止共享代码故障团灭。
14
+ * 应急副本经文件契约同路径消费(见 official-copy-status.ts)。
13
15
  *
14
- * 接线约束:inject 声明须覆盖本模块全部 ctx.<service> 直接访问(fallback-llm
15
- * 读 settings 命名空间与 llm.listProviders),漏掉任一即运行期
16
+ * 接线约束:inject 声明须覆盖本模块全部 ctx.<service> 直接访问——settings
17
+ * (copy-guide 读默认模型条目)与 llm(判定 provider 缺席),漏掉任一即运行期
16
18
  * "cannot get property without inject" → 插件树加载失败 → dsh boot 中止。
17
19
  * 可选事实(profileContext)经 ctx.get 读取,缺失即降级而非失败。
18
20
  * @module @dsh-plus/lifeboat
@@ -22,10 +24,11 @@ import type { Context } from '@deepseek-ai/cordis'
22
24
  // CLI 与桌面端经同一 profile-boot 提供)。纯类型导入,按开发规范仅需 devDeps。
23
25
  import type {} from '@deepseek-ai/dsh-app-boot'
24
26
 
25
- import { Config, type FallbackStateT, type LifeboatConfig } from './config.ts'
26
- import { installLlmFallback } from './fallback-llm.ts'
27
+ import { Config, type LifeboatConfig } from './config.ts'
28
+ import { installCopyGuide } from './copy-guide.ts'
27
29
  import { registerHealthApi } from './health-api.ts'
28
30
  import { installAlerter } from './notify.ts'
31
+ import { readOfficialCopyStatus } from './official-copy-status.ts'
29
32
  import { createQuarantine, installHostWatch } from './quarantine.ts'
30
33
  import { registerQuarantineApi } from './quarantine-api.ts'
31
34
  import { appendJournal, loadState, type StateDoc, saveState } from './state-file.ts'
@@ -51,10 +54,10 @@ export function resolvePatchFile(ctx: Context, config: LifeboatConfig): string |
51
54
  export function apply(ctx: Context, config: LifeboatConfig): void {
52
55
  const logger = ctx.logger('lifeboat')
53
56
 
54
- // 运行期状态(journal + 翻译状态):文件持久化。加载失败降级为内存态
55
- // (重启丢失 journal,但隔离/翻译功能仍在)。
57
+ // 运行期状态(journal):文件持久化。加载失败降级为内存态
58
+ // (重启丢失 journal,但隔离/告警功能仍在)。
56
59
  const warn = (message: string): void => logger.warn(message)
57
- let doc: StateDoc = { journal: [], llmFallback: null }
60
+ let doc: StateDoc = { journal: [] }
58
61
  void loadState().then((loaded) => {
59
62
  doc = loaded
60
63
  })
@@ -66,11 +69,19 @@ export function apply(ctx: Context, config: LifeboatConfig): void {
66
69
 
67
70
  const alert = installAlerter(ctx, journal)
68
71
 
72
+ // LLM 应急副本指引:只读判定 + 告警,不依赖 patch 文件,独立于隔离开关。
73
+ installCopyGuide(ctx, {
74
+ journal,
75
+ alert,
76
+ profile: ctx.get('profileContext')?.name,
77
+ cooldownMs: config.alertCooldownMs,
78
+ })
79
+
69
80
  if (config.enabled) {
70
81
  const patchFile = resolvePatchFile(ctx, config)
71
82
  if (patchFile === undefined) {
72
83
  // 无 profileContext 的组合树(非 dsh 启动):不猜路径、不写任何 patch,
73
- // 隔离停用但告警/journal/LLM 应急翻译照常(它们不依赖 patch 文件)。
84
+ // 隔离停用但告警/journal 照常。
74
85
  warn('故障隔离停用:当前组合树无 profileContext,无法定位 profile 用户 patch 层')
75
86
  } else {
76
87
  const quarantine = createQuarantine(ctx, {
@@ -87,21 +98,9 @@ export function apply(ctx: Context, config: LifeboatConfig): void {
87
98
  journal,
88
99
  alert,
89
100
  readJournal: () => doc.journal,
90
- readFallback: () => doc.llmFallback,
101
+ readCopy: () => readOfficialCopyStatus(),
91
102
  })
92
103
  })
93
104
  }
94
105
  }
95
-
96
- if (config.llmFallback) {
97
- installLlmFallback(ctx, {
98
- journal,
99
- alert,
100
- readState: () => doc.llmFallback,
101
- writeState: async (state: FallbackStateT | null) => {
102
- doc = { ...doc, llmFallback: state }
103
- await saveState(doc, warn)
104
- },
105
- })
106
- }
107
106
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 官方应急副本的只读状态读取($DSH_HOME/llm-pi.official-patch.yaml)。
3
+ *
4
+ * 文件契约与 @dsh-plus/llm-pi 同路径:对方常备副本、本包只读消费——零
5
+ * dsh-plus 内部依赖铁律下不 import 对方实现,路径按契约文件名同源派生。
6
+ * 读取一切失败都降级为结构化状态(面板展示与告警文案的输入,绝不抛出)。
7
+ * @module lifeboat/official-copy-status
8
+ */
9
+ import { readFile, stat } from 'node:fs/promises'
10
+
11
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
12
+ import { parse } from 'yaml'
13
+
14
+ /** 副本文件名(跨包文件契约:llm-pi 写、本包读,改名须两侧同步)。 */
15
+ export const OFFICIAL_COPY_FILE = 'llm-pi.official-patch.yaml'
16
+
17
+ /** 解析上限:超出只报存在性与时间,不读内容(面板/告警不得被大文件拖垮)。 */
18
+ const MAX_BYTES = 2 * 1024 * 1024
19
+
20
+ /** 头注释里的生成警告行前缀(llm-pi 渲染约定)。 */
21
+ const WARNING_PREFIX = '# 警告:'
22
+
23
+ export interface OfficialCopyStatus {
24
+ path: string
25
+ exists: boolean
26
+ /** mtime 毫秒 = 最近生成时间(副本内容不含时间戳)。 */
27
+ updatedAt?: number
28
+ /** llm-pi-ai 覆盖的 provider 数;不可解析时缺省。 */
29
+ routes?: number
30
+ /** 头注释里的生成警告。 */
31
+ warnings: string[]
32
+ /** 存在但无法解析/超限时的说明。 */
33
+ detail?: string
34
+ }
35
+
36
+ /** 副本契约路径(默认读取点)。 */
37
+ export function officialCopyPath(): string {
38
+ return dshHomePath(OFFICIAL_COPY_FILE)
39
+ }
40
+
41
+ /** 解析副本内容(纯函数):头注释警告 + llm-pi-ai 行的 providers 数。 */
42
+ export function parseCopyText(
43
+ text: string,
44
+ ): Pick<OfficialCopyStatus, 'routes' | 'warnings' | 'detail'> {
45
+ const warnings = text
46
+ .split('\n')
47
+ .filter((line) => line.startsWith(WARNING_PREFIX))
48
+ .map((line) => line.slice(WARNING_PREFIX.length))
49
+ let rows: unknown
50
+ try {
51
+ rows = parse(text)
52
+ } catch (error) {
53
+ return {
54
+ warnings,
55
+ detail: `解析失败:${error instanceof Error ? error.message : String(error)}`,
56
+ }
57
+ }
58
+ if (!Array.isArray(rows)) return { warnings, detail: '内容不是 patch 列表' }
59
+ if (rows.length === 0) return { routes: 0, warnings }
60
+ const row = rows.find((entry) => (entry as { id?: unknown } | null)?.id === 'llm-pi-ai') as
61
+ | { config?: { providers?: unknown } }
62
+ | undefined
63
+ const providers = row?.config?.providers
64
+ if (providers === null || typeof providers !== 'object') {
65
+ return { warnings, detail: '缺 llm-pi-ai 配置行(副本可能只含禁用行)' }
66
+ }
67
+ return { routes: Object.keys(providers).length, warnings }
68
+ }
69
+
70
+ /** 读取副本状态;缺失/损坏/超限均降级为结构化状态,不抛出。 */
71
+ export async function readOfficialCopyStatus(
72
+ path: string = officialCopyPath(),
73
+ ): Promise<OfficialCopyStatus> {
74
+ let size: number
75
+ let mtimeMs: number
76
+ try {
77
+ const info = await stat(path)
78
+ if (!info.isFile()) return { path, exists: false, warnings: [] }
79
+ size = info.size
80
+ mtimeMs = info.mtimeMs
81
+ } catch {
82
+ return { path, exists: false, warnings: [] }
83
+ }
84
+ const base: OfficialCopyStatus = {
85
+ path,
86
+ exists: true,
87
+ updatedAt: Math.trunc(mtimeMs),
88
+ warnings: [],
89
+ }
90
+ if (size > MAX_BYTES) return { ...base, detail: '文件超出解析上限,未读取内容' }
91
+ try {
92
+ const text = await readFile(path, 'utf-8')
93
+ return { ...base, ...parseCopyText(text) }
94
+ } catch (error) {
95
+ return {
96
+ ...base,
97
+ detail: `读取失败:${error instanceof Error ? error.message : String(error)}`,
98
+ }
99
+ }
100
+ }