@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/lib/client.js +22 -3
- package/lib/index.d.ts +3 -3
- package/lib/index.js +464 -24
- package/package.json +3 -2
- package/src/client/api.ts +8 -0
- package/src/client/client.ts +2 -2
- package/src/client/i18n.ts +8 -0
- package/src/client/root-fields.tsx +11 -0
- package/src/compat.ts +2 -2
- package/src/config-api.ts +1 -1
- package/src/config.ts +6 -17
- package/src/deepseek-routes.ts +2 -2
- package/src/index.ts +5 -2
- package/src/inherit.ts +1 -1
- package/src/ns.ts +1 -1
- package/src/official-copy-writer.ts +207 -0
- package/src/official-copy.ts +293 -0
- package/src/official-surface.ts +45 -4
- package/src/profiles-deepseek.ts +1 -11
- package/src/profiles.ts +34 -10
- package/src/resolve-dsh.ts +19 -4
- package/src/service.ts +35 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/llm-pi",
|
|
3
|
-
"version": "0.1
|
|
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
|
-
"
|
|
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
|
|
package/src/client/client.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 类型说明:浏览器半只用到 slots/locale/remote 的很窄一面,
|
|
9
9
|
* scope 与基础控件走 @dsh-plus/shared/client 套件(收编自本插件原实现)。
|
|
10
|
-
* 配置读写经 ctx.remote.settings 直连(
|
|
11
|
-
* 已移除;
|
|
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。
|
package/src/client/i18n.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
package/src/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 配置单一事实源:cordis 行级 Config(组合默认值)与 settings namespace
|
|
3
|
-
|
|
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
|
|
20
|
+
/** settings 命名空间(字面量即合法命名空间,编译期校验;webui 配置卡片与插件运行期读取同一份)。 */
|
|
21
21
|
export const SETTINGS_NS = NS_LITERAL
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
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
|
-
//
|
|
325
|
+
// 根字段全 `.volatile()`——条目进入 settings describe 视图(卡片可读写),
|
|
337
326
|
// loader 原位提交活动引用;输出面标注活动字段声明面(结构兼容 volatile 引用),
|
|
338
327
|
// 平面消费契约保持 LlmPiConfig 接口不变(服务侧经 unwrapVolatile 解包现取)。
|
|
339
|
-
/** 活动字段形态(
|
|
328
|
+
/** 活动字段形态(loader 解析产物:volatile 字段为活动引用)。 */
|
|
340
329
|
export type LlmPiConfigFields = VolatileFields<LlmPiConfig>
|
|
341
330
|
|
|
342
331
|
export const Config: z<LlmPiConfigInput, LlmPiConfigFields> = z.object({
|
package/src/deepseek-routes.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 请求生效);retryPolicy 是注册期捕获事实,变化时 handle.replace 原地重注册
|
|
12
12
|
* (官方同款模式);route 移除即 dispose 释放路由名。
|
|
13
13
|
*
|
|
14
|
-
*
|
|
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
|
-
//
|
|
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
|
|
6
|
+
* 必填字段由 route 级配置或兜底给出。models.dev 兜底源已移除——
|
|
7
7
|
* 版本漂移靠「现场推导 + 状态行显示生效版本」处理,不引入第二份可能过期的目录。
|
|
8
8
|
*
|
|
9
9
|
* 引用语法:`"provider/model"` 显式引用;裸 `"model"` 随 route 级 extends 源;
|
package/src/ns.ts
CHANGED
|
@@ -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
|
+
}
|