@dsh-plus/llm-pi 0.1.45 → 0.2.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/llm-pi",
3
- "version": "0.1.45",
3
+ "version": "0.2.1",
4
4
  "description": "dsh-plus service+ui plugin: 基于 PiAiAdapter 的自定义 LLM 路由(全量 compat、模型继承、pi-ai 内置目录;协议/compat/字段集现场推导,自动跟随已装 dsh)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -31,7 +31,8 @@
31
31
  }
32
32
  },
33
33
  "dependencies": {
34
- "@dsh-plus/shared": "0.1.35"
34
+ "yaml": "^2.9.0",
35
+ "@dsh-plus/shared": "0.1.36"
35
36
  },
36
37
  "peerDependencies": {
37
38
  "@deepseek-ai/cordis": "^4.0.5-alpha.1",
package/src/client/api.ts CHANGED
@@ -18,6 +18,14 @@ export interface WireKitInfo {
18
18
  protocolSource: string
19
19
  catalog: { providers: number; models: number; generatedAt?: number }
20
20
  compatSource: string
21
+ /** 官方应急副本状态(服务端 official-copy-writer 的 OfficialCopyStatus 镜像)。 */
22
+ officialCopy: {
23
+ path: string
24
+ updatedAt?: number
25
+ routes: number
26
+ warnings: string[]
27
+ error?: string
28
+ }
21
29
  diagnostics: string[]
22
30
  }
23
31
 
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * 类型说明:浏览器半只用到 slots/locale/remote 的很窄一面,
9
9
  * scope 与基础控件走 @dsh-plus/shared/client 套件(收编自本插件原实现)。
10
- * 配置读写经 ctx.remote.settings 直连(0.1.2-alpha.1 起 connection.api.settings
11
- * 已移除;0.1.7 起 settingsScope 服务亦删除(configForms 取代)——插件卡片自始
10
+ * 配置读写经 ctx.remote.settings 直连(connection.api.settings
11
+ * 已移除; settingsScope 服务亦删除(configForms 取代)——插件卡片自始
12
12
  * 直连 remote.settings,见 @dsh-plus/shared/client scope.ts);自定义端点只剩
13
13
  * `/catalog`(api.ts:运行期套件事实 + pi-ai 内置目录搜索,浏览器半读不到 pi-ai 包)。
14
14
  * 卡片 key/rowId 约定见 @dsh-plus/shared/client/config-slots。
@@ -23,6 +23,10 @@ const ownZh = {
23
23
  runtimeCatalog: '内置模型目录',
24
24
  runtimeProtocols: '可服务协议',
25
25
  runtimeCompat: 'compat 门控表',
26
+ runtimeOfficialCopy: '应急副本',
27
+ officialCopyPending: '尚未生成',
28
+ officialCopyFailed: '生成失败:',
29
+ officialCopyRoutes: '个 route',
26
30
  runtimeDiagnostics: '诊断',
27
31
  runtimeVersionUnknown: '未知',
28
32
  runtimeCatalogUnit: '个 provider /',
@@ -160,6 +164,10 @@ const ownEn = {
160
164
  runtimeCatalog: 'Built-in catalog',
161
165
  runtimeProtocols: 'Servable protocols',
162
166
  runtimeCompat: 'compat gates',
167
+ runtimeOfficialCopy: 'Emergency copy',
168
+ officialCopyPending: 'not generated yet',
169
+ officialCopyFailed: 'generation failed: ',
170
+ officialCopyRoutes: 'routes',
163
171
  runtimeDiagnostics: 'Diagnostics',
164
172
  runtimeVersionUnknown: 'unknown',
165
173
  runtimeCatalogUnit: 'providers /',
@@ -36,6 +36,14 @@ function sourceLabel(t: Translate, source: string): string {
36
36
  return source === 'official' ? t('runtimeDerived') : t('runtimeFallback')
37
37
  }
38
38
 
39
+ /** 应急副本状态行:路径 · route 数 · 生成时间;未生成/失败显式提示。 */
40
+ function officialCopyText(kit: WireKitInfo, t: Translate): string {
41
+ const copy = kit.officialCopy
42
+ if (copy.error !== undefined) return `${t('officialCopyFailed')}${copy.error}`
43
+ if (copy.updatedAt === undefined) return t('officialCopyPending')
44
+ return `${copy.path} · ${copy.routes} ${t('officialCopyRoutes')} · ${formatTime(copy.updatedAt, t('runtimeVersionUnknown'))}`
45
+ }
46
+
39
47
  export interface RootFieldsProps {
40
48
  t: Translate
41
49
  draft: Draft
@@ -80,6 +88,9 @@ export function RootFields(props: RootFieldsProps): ReactElement {
80
88
  <p className="lpc-statusRow">
81
89
  {`${t('runtimeCompat')}:${sourceLabel(t, kit.compatSource)}`}
82
90
  </p>
91
+ <p className="lpc-statusRow">
92
+ {`${t('runtimeOfficialCopy')}:${officialCopyText(kit, t)}`}
93
+ </p>
83
94
  {kit.versionNotice !== undefined ? (
84
95
  <p className="lpc-invalid">{`${t('runtimeNotice')}:${kit.versionNotice}`}</p>
85
96
  ) : null}
package/src/compat.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * assertOfferedCompatFields:"写了但没生效"的表面状态不允许)。
11
11
  * - **fallback(推导失败,用最后已知快照)**:只拒绝快照中明确 withhold 的键,
12
12
  * **未知键放行**交给官方适配器自身校验——表落后时"误拒官方新字段"比"多放一个键"
13
- * 严重得多(0.1.5-rc.1 教训:静默功能缺失、无任何报错)。
13
+ * 严重得多(教训:静默功能缺失、无任何报错)。
14
14
  *
15
15
  * pi-ai 侧消费语义:getCompat 逐字段 `??` 覆盖 detectCompat 的 baseURL/名称猜测;
16
16
  * undefined 视为未设置(无法显式清空检测值)。
@@ -122,7 +122,7 @@ function checkValue(field: string, spec: CompatValue, value: unknown, where: str
122
122
 
123
123
  /**
124
124
  * 校验一份 compat 字典对指定协议合法(对齐官方门控语义 + schema 值约束):
125
- * - 未知键/withhold 字段拒绝(官方写时拒绝,替代旧版静默丢弃);
125
+ * - 未知键/withhold 字段拒绝(与官方写时拒绝同款);
126
126
  * - 值类型/枚举按官方 schema 校验;
127
127
  * - 无值键(null/undefined)拒绝(官方 assertOfferedCompatFields 同款)。
128
128
  */
package/src/config-api.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 自定义端点 HTTP 通道:配置读写走官方 remote.settings 直连
3
- * (0.1.2-alpha.1 起第三方命名空间全量开放),这里只提供浏览器半拿不到的两样东西:
3
+ * (第三方命名空间全量开放),这里只提供浏览器半拿不到的两样东西:
4
4
  * - 运行期套件事实(生效版本、协议集合、目录规模、逐项降级诊断);
5
5
  * - pi-ai 内置目录的搜索/筛选/分页(浏览器半读不到 pi-ai 包)。
6
6
  *
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 配置单一事实源:cordis 行级 Config(组合默认值)与 settings namespace
3
- *(用户层,经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml)共用同一
3
+ *(用户层=profile 行级覆盖层 cordis.patch.yml,热生效)共用同一
4
4
  * schemastery schema。无密钥字段(apiKeyEnv 是凭据引用名而非密钥本身)。
5
5
  *
6
6
  * 与官方 llm-pi-ai 的差异:
@@ -17,11 +17,11 @@ import type { VolatileFields } from '@dsh-plus/shared'
17
17
 
18
18
  import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
19
19
 
20
- /** settings 命名空间(字面量即合法命名空间,0.1.2-alpha.2 起编译期校验;webui 配置卡片与插件运行期读取同一份)。 */
20
+ /** settings 命名空间(字面量即合法命名空间,编译期校验;webui 配置卡片与插件运行期读取同一份)。 */
21
21
  export const SETTINGS_NS = NS_LITERAL
22
22
 
23
23
  /**
24
- * 官方适配器不可用/推导失败时的协议兜底三元组(自 0.1.2-alpha 线以来的稳定集)。
24
+ * 官方适配器不可用/推导失败时的协议兜底三元组。
25
25
  * 运行期生效集合来自已安装官方包的 `supportedProtocols()`(见 resolve-dsh.ts),
26
26
  * 本常量只用于 schema 描述、浏览器半首帧兜底与推导失败回退。
27
27
  */
@@ -45,9 +45,6 @@ export type AdapterKind = (typeof ADAPTER_KINDS)[number]
45
45
  /** deepseek 路由的思考/推理档位(线协议枚举,对齐官方 llm-deepseek)。 */
46
46
  export const DEEPSEEK_THINKING = ['enabled', 'disabled'] as const
47
47
  export const DEEPSEEK_REASONING_EFFORTS = ['off', 'low', 'high', 'max'] as const
48
- /** 旧版 imageDetail 枚举(0.1.2-alpha.1 已移除该字段;仅用于 schema 透传陷阱的取值集)。 */
49
- export const DEEPSEEK_IMAGE_DETAILS = ['auto', 'low'] as const
50
-
51
48
  export const DEFAULT_CONTEXT_WINDOW = 262144
52
49
  export const DEFAULT_MAX_TOKENS = 32768
53
50
  export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300000
@@ -201,12 +198,6 @@ const modelEntry = z.object({
201
198
  .step(1)
202
199
  .min(1)
203
200
  .description('(仅 adapter: deepseek)单张请求图片的编码字节上限;缺省继承官方目录'),
204
- // 0.1.2-alpha.1 移除字段的 schema 透传陷阱:官方已删除 imageDetail(改用
205
- // imagePixelBudget/imageMaxBytes),此处保留取值 union 让旧配置通过 schema、
206
- // 在写时校验(assertServiceable)以中文迁移提示明确拒绝(见 profiles-deepseek.ts)。
207
- imageDetail: z
208
- .union(DEEPSEEK_IMAGE_DETAILS)
209
- .description('(已移除)图片细节档位;0.1.2-alpha.1 起请改用 imagePixelBudget/imageMaxBytes'),
210
201
  })
211
202
 
212
203
  const providerProfile = z.object({
@@ -254,9 +245,7 @@ const providerProfile = z.object({
254
245
  .description('单支流式读取的最大空闲间隔毫秒'),
255
246
  maxRequestImageBytes: z
256
247
  .natural()
257
- .description(
258
- '单请求 base64 图片载荷上限字节;缺省 20MiB(0.1.2-alpha.1 起官方必需字段,旧配置免改)',
259
- ),
248
+ .description('单请求 base64 图片载荷上限字节;缺省 20MiB(官方必需字段,旧配置免改)'),
260
249
  requestImagePixelBudget: z
261
250
  .number()
262
251
  .step(1)
@@ -333,10 +322,10 @@ const providerProfile = z.object({
333
322
  .description('本 route 的模型目录;缺省且 provider 有 extends 时继承该源全部模型'),
334
323
  })
335
324
 
336
- // 0.1.7:根字段全 `.volatile()`——条目进入 settings describe 视图(卡片可读写),
325
+ // 根字段全 `.volatile()`——条目进入 settings describe 视图(卡片可读写),
337
326
  // loader 原位提交活动引用;输出面标注活动字段声明面(结构兼容 volatile 引用),
338
327
  // 平面消费契约保持 LlmPiConfig 接口不变(服务侧经 unwrapVolatile 解包现取)。
339
- /** 活动字段形态(0.1.7 loader 解析产物:volatile 字段为活动引用)。 */
328
+ /** 活动字段形态(loader 解析产物:volatile 字段为活动引用)。 */
340
329
  export type LlmPiConfigFields = VolatileFields<LlmPiConfig>
341
330
 
342
331
  export const Config: z<LlmPiConfigInput, LlmPiConfigFields> = z.object({
@@ -11,7 +11,7 @@
11
11
  * 请求生效);retryPolicy 是注册期捕获事实,变化时 handle.replace 原地重注册
12
12
  * (官方同款模式);route 移除即 dispose 释放路由名。
13
13
  *
14
- * 0.1.2-alpha 线:DeepSeekAdapterOptions 必需 prepareExtensions(官方接线
14
+ * DeepSeekAdapterOptions 必需 prepareExtensions(官方接线
15
15
  * ctx.get('deepseekLlmApiExtensions'),缺省空实现)与可选 resolveImageAccess。
16
16
  * @module llm-pi/deepseek-routes
17
17
  */
@@ -158,7 +158,7 @@ export class DeepseekRouteRegistrar {
158
158
  )?.processPathFromHostPath(hostPath),
159
159
  ref,
160
160
  ) as never,
161
- // 0.1.2-alpha 线必需:官方扩展注册表(deepseekLlmApiExtensions)缺席时
161
+ // 必需项:官方扩展注册表(deepseekLlmApiExtensions)缺席时
162
162
  // 给空实现(官方接线范式,llm-deepseek/src/index.ts:404-470)。
163
163
  prepareExtensions: (request) => {
164
164
  const extensions = ctx.get('deepseekLlmApiExtensions') as
package/src/index.ts CHANGED
@@ -11,7 +11,10 @@
11
11
  *
12
12
  * 接线约束:inject 须含 credentials(auth-inline/deepseek-routes 经
13
13
  * ctx.get('credentials') 读 seam;凭据解析为请求时惰性调用,硬声明保证
14
- * 时序,消除启动早窗口的一次性 MISSING_CREDENTIAL)。
14
+ * 时序,消除启动早窗口的一次性 MISSING_CREDENTIAL)与 settings
15
+ * (应急副本手写条目来源:readManualProviders 经 ctx.settings.describe
16
+ * 读官方 llm-pi-ai 生效视图;漏声明即启动期 "cannot get property without
17
+ * inject" → 插件树加载失败)。
15
18
  * @module @dsh-plus/llm-pi
16
19
  */
17
20
  import type { Context } from '@deepseek-ai/cordis'
@@ -22,7 +25,7 @@ import { startRuntime } from './service.ts'
22
25
 
23
26
  export const name = 'dsh-plus-llm-pi'
24
27
 
25
- export const inject = ['credentials', 'llm'] as const
28
+ export const inject = ['credentials', 'llm', 'settings'] as const
26
29
 
27
30
  export type {
28
31
  LlmPiConfig,
package/src/inherit.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 数据源只有一个:**pi-ai 内置目录**(随已装 dsh 的 pi-ai 版本自动跟随,
5
5
  * 含官方校正的 compat/thinkingLevelMap/模态)。未命中即「手写条目」,
6
- * 必填字段由 route 级配置或兜底给出。models.dev 兜底源已随 0.1.43 移除——
6
+ * 必填字段由 route 级配置或兜底给出。models.dev 兜底源已移除——
7
7
  * 版本漂移靠「现场推导 + 状态行显示生效版本」处理,不引入第二份可能过期的目录。
8
8
  *
9
9
  * 引用语法:`"provider/model"` 显式引用;裸 `"model"` 随 route 级 extends 源;
package/src/ns.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
3
- * 服务端直接以字面量注册(0.1.2-alpha.2 起 dsh-settings 对命名空间做编译期
3
+ * 服务端直接以字面量注册(dsh-settings 对命名空间做编译期
4
4
  * 语法校验,见 config.ts);浏览器半把它作为配置卡片的注册键
5
5
  * (插件页配置卡片挂在 row/bundle config 槽位上,与命名空间解耦,
6
6
  * 见 shared/config-slots),
@@ -0,0 +1,207 @@
1
+ /**
2
+ * 官方应急副本的生成编排:装配(原始配置 → 物化 → 翻译)→ 逐 route 官方校验
3
+ * → 备忘跳过 / 原子落盘 → 状态供配置卡与 lifeboat 面板消费。
4
+ *
5
+ * 触发时机:插件启动、配置热更新(loader/volatile-update)、adapter 注册变化
6
+ * (llm/adapters-updated,官方 llm-pi-ai 晚注册时手写条目在此补入)。无定时器、
7
+ * 无 watcher——平时零开销;写失败只降级为状态与日志,绝不影响路由注册。
8
+ *
9
+ * 逐 route 官方校验是「副本必然官方可识别」的执行点:官方 Config schema 解析
10
+ * (非法协议/越界取值在此抛错)+ 官方 resolveProfiles 可服务性链;任一不过即
11
+ * 剔除该 route 并记 warning——写不进官方的东西绝不落盘。
12
+ * @module llm-pi/official-copy-writer
13
+ */
14
+ import { stat } from 'node:fs/promises'
15
+ import type { PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
16
+ import { atomicWriteFile, isVolatileRef } from '@dsh-plus/shared'
17
+
18
+ import type { LlmPiConfig } from './config.ts'
19
+ import {
20
+ buildOfficialCopy,
21
+ deriveOfficialRouteFields,
22
+ FALLBACK_ROUTE_FIELDS,
23
+ OFFICIAL_COPY_PATH,
24
+ type OfficialCopy,
25
+ renderOfficialCopy,
26
+ } from './official-copy.ts'
27
+ import { normalizeOfficialRoutes } from './profiles.ts'
28
+ import { buildDeepseekRoutes } from './profiles-deepseek.ts'
29
+ import type { DshKit } from './resolve-dsh.ts'
30
+
31
+ /** 官方 llm-pi-ai 的 settings 命名空间(与插件 NS 同字面量,官方 0.1.x 起稳定)。 */
32
+ const NS_OFFICIAL_PI_AI = 'llm-pi-ai'
33
+
34
+ /** 副本状态(配置卡状态行与诊断的 JSON 面;updatedAt 缺省 = 尚未生成)。 */
35
+ export interface OfficialCopyStatus {
36
+ path: string
37
+ updatedAt?: number
38
+ routes: number
39
+ warnings: string[]
40
+ error?: string
41
+ }
42
+
43
+ /** 手写官方条目读取结果:providers 缺省 + warning 说明降级原因。 */
44
+ export interface ManualSourceResult {
45
+ providers?: Record<string, unknown>
46
+ warning?: string
47
+ }
48
+
49
+ /** settings 服务窄面(describe 的描述符子集;测试可注入固定值)。 */
50
+ export interface SettingsDescribeLike {
51
+ describe(): Array<{ ns: string; value?: unknown }>
52
+ }
53
+
54
+ /**
55
+ * 手写官方条目来源:settings describe 的 llm-pi-ai 生效视图(composition base
56
+ * = profile patch 行 + settings 用户层,schema 已解析、已过官方 validate)——
57
+ * 官方 seam,无需自行解析 patch 文件。ns 未注册/读取失败时降级为「仅本插件
58
+ * 改写条目」并附 warning,不阻断生成。
59
+ */
60
+ export function readManualProviders(settings: SettingsDescribeLike): ManualSourceResult {
61
+ try {
62
+ const row = settings.describe().find((entry) => entry.ns === NS_OFFICIAL_PI_AI)
63
+ if (row === undefined) {
64
+ return { warning: '官方 llm-pi-ai 未注册(插件缺席),副本仅含本插件改写条目' }
65
+ }
66
+ const raw = (row.value as { providers?: unknown } | undefined)?.providers
67
+ const providers = isVolatileRef(raw) ? raw.get() : raw
68
+ if (providers === null || typeof providers !== 'object') return {}
69
+ return { providers: providers as Record<string, unknown> }
70
+ } catch (error) {
71
+ const message = error instanceof Error ? error.message : String(error)
72
+ return { warning: `读取官方 llm-pi-ai 生效配置失败(${message}),副本仅含本插件改写条目` }
73
+ }
74
+ }
75
+
76
+ export interface OfficialCopyWriterDeps {
77
+ kit: DshKit
78
+ /** 当前生效的本插件配置(volatile 已解包的平面快照)。 */
79
+ current: () => LlmPiConfig
80
+ /** 手写官方条目读取(生产接 readManualProviders(ctx.settings))。 */
81
+ readManual: () => ManualSourceResult
82
+ /** 来源 profile 名(头注释应用指引用)。 */
83
+ profile: string | undefined
84
+ /** 落盘路径(缺省 .dsh 根的 OFFICIAL_COPY_PATH;测试注入临时路径)。 */
85
+ path?: string
86
+ logger: { warn(message: unknown): void }
87
+ }
88
+
89
+ export interface OfficialCopyWriter {
90
+ /** 生成(或确认最新)副本:串行化执行,失败降级为状态,绝不抛出。 */
91
+ ensureOfficialCopy(): Promise<void>
92
+ /** 最近一次生成的状态。 */
93
+ status(): OfficialCopyStatus
94
+ }
95
+
96
+ /** 文件是否已存在(内容备忘跳过后自愈外部删除)。 */
97
+ async function fileExists(path: string): Promise<boolean> {
98
+ try {
99
+ return (await stat(path)).isFile()
100
+ } catch {
101
+ return false
102
+ }
103
+ }
104
+
105
+ /** 组装 + 翻译:返回含全部 warning 的副本内容(尚未官方校验)。 */
106
+ function assemble(deps: OfficialCopyWriterDeps, warnings: string[]): OfficialCopy {
107
+ const routeFields = deriveOfficialRouteFields(deps.kit.officialConfig)
108
+ if (routeFields === undefined) {
109
+ warnings.push('官方 Config schema 布局不可识别,route 字段回退内置键集')
110
+ }
111
+ const providers = deps.current().providers ?? {}
112
+ const normalizedPi = normalizeOfficialRoutes(providers, {
113
+ kit: deps.kit,
114
+ lenient: true,
115
+ warn: (message) => warnings.push(message),
116
+ })
117
+ const deepseekResolved = buildDeepseekRoutes(providers, {
118
+ kit: deps.kit,
119
+ lenient: true,
120
+ warn: (message) => warnings.push(message),
121
+ })
122
+ const manual = deps.readManual()
123
+ if (manual.warning !== undefined) warnings.push(manual.warning)
124
+ const copy = buildOfficialCopy({
125
+ providers,
126
+ normalizedPi,
127
+ deepseekResolved,
128
+ manualProviders: manual.providers,
129
+ routeFields: routeFields ?? [...FALLBACK_ROUTE_FIELDS],
130
+ modelFields: deps.kit.officialModelFields,
131
+ })
132
+ warnings.push(...copy.warnings)
133
+ return { providers: copy.providers, warnings }
134
+ }
135
+
136
+ /** 逐 route 官方校验:schema 解析 + 可服务性链;不过即剔除并记 warning。 */
137
+ function acceptOfficial(
138
+ deps: OfficialCopyWriterDeps,
139
+ copy: OfficialCopy,
140
+ warnings: string[],
141
+ ): Record<string, unknown> {
142
+ const accepted: Record<string, unknown> = {}
143
+ for (const [route, profile] of Object.entries(copy.providers)) {
144
+ try {
145
+ deps.kit.officialConfig({ providers: { [route]: profile } })
146
+ deps.kit.resolveProfiles({ [route]: profile as PiAiProviderProfile })
147
+ } catch (error) {
148
+ const message = error instanceof Error ? error.message : String(error)
149
+ warnings.push(`route "${route}" 未通过官方校验,已从副本剔除:${message}`)
150
+ continue
151
+ }
152
+ accepted[route] = profile
153
+ }
154
+ return accepted
155
+ }
156
+
157
+ /** 生成态:内容备忘(跳过同内容重写)与最近生成时间(mtime 语义)。 */
158
+ interface CopyState {
159
+ lastContent?: string
160
+ updatedAt?: number
161
+ }
162
+
163
+ /** 落盘:内容未变且文件仍在则跳过(并自愈外部删除);返回最近生成时间。 */
164
+ async function persist(path: string, content: string, state: CopyState): Promise<number> {
165
+ if (content === state.lastContent && (await fileExists(path)))
166
+ return state.updatedAt ?? Date.now()
167
+ await atomicWriteFile(path, content)
168
+ state.lastContent = content
169
+ state.updatedAt = Date.now()
170
+ return state.updatedAt
171
+ }
172
+
173
+ /** 生成一次副本(错误上抛,由调用方降级为 status.error)。 */
174
+ async function runOnce(
175
+ deps: OfficialCopyWriterDeps,
176
+ path: string,
177
+ state: CopyState,
178
+ ): Promise<OfficialCopyStatus> {
179
+ const warnings: string[] = []
180
+ const assembled = assemble(deps, warnings)
181
+ const providers = acceptOfficial(deps, assembled, warnings)
182
+ const content = renderOfficialCopy({ providers, warnings }, { profile: deps.profile })
183
+ const updatedAt = await persist(path, content, state)
184
+ return { path, updatedAt, routes: Object.keys(providers).length, warnings }
185
+ }
186
+
187
+ /** 装配写入器:启动/热更新/adapter 事件驱动,串行执行(promise 链)。 */
188
+ export function createOfficialCopyWriter(deps: OfficialCopyWriterDeps): OfficialCopyWriter {
189
+ const path = deps.path ?? OFFICIAL_COPY_PATH
190
+ const state: CopyState = {}
191
+ let status: OfficialCopyStatus = { path, routes: 0, warnings: [] }
192
+ let chain: Promise<void> = Promise.resolve()
193
+ const ensureOfficialCopy = (): Promise<void> => {
194
+ chain = chain.then(async () => {
195
+ try {
196
+ status = await runOnce(deps, path, state)
197
+ } catch (error) {
198
+ // 落盘/装配失败:磁盘上仍是上一次成功的副本,保留其状态并标注错误。
199
+ const message = error instanceof Error ? error.message : String(error)
200
+ deps.logger.warn(`llm-pi: 应急副本生成失败:${message}`)
201
+ status = { ...status, error: message }
202
+ }
203
+ })
204
+ return chain
205
+ }
206
+ return { ensureOfficialCopy, status: () => status }
207
+ }