@lokvis/runtime 0.2.2 → 0.4.2
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/dist/asset-store.d.ts +13 -0
- package/dist/asset-store.d.ts.map +1 -1
- package/dist/asset-store.js +15 -2
- package/dist/asset-store.js.map +1 -1
- package/dist/batch-processor.d.ts +32 -161
- package/dist/batch-processor.d.ts.map +1 -1
- package/dist/batch-processor.js +107 -333
- package/dist/batch-processor.js.map +1 -1
- package/dist/batch-progress.d.ts +94 -0
- package/dist/batch-progress.d.ts.map +1 -0
- package/dist/batch-progress.js +128 -0
- package/dist/batch-progress.js.map +1 -0
- package/dist/batch-scheduler.d.ts +70 -0
- package/dist/batch-scheduler.d.ts.map +1 -0
- package/dist/batch-scheduler.js +147 -0
- package/dist/batch-scheduler.js.map +1 -0
- package/dist/browser-detect.d.ts +115 -0
- package/dist/browser-detect.d.ts.map +1 -0
- package/dist/browser-detect.js +160 -0
- package/dist/browser-detect.js.map +1 -0
- package/dist/concurrency-controller.d.ts +31 -0
- package/dist/concurrency-controller.d.ts.map +1 -0
- package/dist/concurrency-controller.js +33 -0
- package/dist/concurrency-controller.js.map +1 -0
- package/dist/errors.d.ts +51 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +74 -0
- package/dist/errors.js.map +1 -0
- package/dist/executor.d.ts +9 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +102 -48
- package/dist/executor.js.map +1 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +3 -2
- package/dist/history.js.map +1 -1
- package/dist/idb-asset-store.d.ts.map +1 -1
- package/dist/idb-asset-store.js +6 -1
- package/dist/idb-asset-store.js.map +1 -1
- package/dist/index.d.ts +17 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -15
- package/dist/index.js.map +1 -1
- package/dist/managers/asset-manager.d.ts +114 -0
- package/dist/managers/asset-manager.d.ts.map +1 -0
- package/dist/managers/asset-manager.js +238 -0
- package/dist/managers/asset-manager.js.map +1 -0
- package/dist/managers/history-manager.d.ts +100 -0
- package/dist/managers/history-manager.d.ts.map +1 -0
- package/dist/managers/history-manager.js +296 -0
- package/dist/managers/history-manager.js.map +1 -0
- package/dist/managers/mcp-manifest-builder.d.ts +18 -0
- package/dist/managers/mcp-manifest-builder.d.ts.map +1 -0
- package/dist/managers/mcp-manifest-builder.js +99 -0
- package/dist/managers/mcp-manifest-builder.js.map +1 -0
- package/dist/managers/quota-manager.d.ts +42 -0
- package/dist/managers/quota-manager.d.ts.map +1 -0
- package/dist/managers/quota-manager.js +126 -0
- package/dist/managers/quota-manager.js.map +1 -0
- package/dist/managers/workflow-coordinator.d.ts +62 -0
- package/dist/managers/workflow-coordinator.d.ts.map +1 -0
- package/dist/managers/workflow-coordinator.js +125 -0
- package/dist/managers/workflow-coordinator.js.map +1 -0
- package/dist/opfs-asset-store.d.ts.map +1 -1
- package/dist/opfs-asset-store.js +12 -2
- package/dist/opfs-asset-store.js.map +1 -1
- package/dist/plugin-context.d.ts +35 -0
- package/dist/plugin-context.d.ts.map +1 -0
- package/dist/plugin-context.js +62 -0
- package/dist/plugin-context.js.map +1 -0
- package/dist/plugin-permissions.d.ts +99 -0
- package/dist/plugin-permissions.d.ts.map +1 -0
- package/dist/plugin-permissions.js +205 -0
- package/dist/plugin-permissions.js.map +1 -0
- package/dist/runtime-impl.d.ts +116 -0
- package/dist/runtime-impl.d.ts.map +1 -0
- package/dist/runtime-impl.js +270 -0
- package/dist/runtime-impl.js.map +1 -0
- package/dist/runtime.d.ts +10 -233
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +12 -978
- package/dist/runtime.js.map +1 -1
- package/dist/test-utils/fakes.d.ts +35 -0
- package/dist/test-utils/fakes.d.ts.map +1 -0
- package/dist/test-utils/fakes.js +60 -0
- package/dist/test-utils/fakes.js.map +1 -0
- package/dist/types.d.ts +113 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/worker-host.d.ts.map +1 -1
- package/dist/worker-host.js +32 -9
- package/dist/worker-host.js.map +1 -1
- package/dist/worker-protocol.d.ts +30 -0
- package/dist/worker-protocol.d.ts.map +1 -1
- package/dist/worker-protocol.js +24 -0
- package/dist/worker-protocol.js.map +1 -1
- package/package.json +2 -2
- package/src/asset-store.ts +32 -2
- package/src/batch-processor.ts +133 -510
- package/src/batch-progress.ts +228 -0
- package/src/batch-scheduler.ts +209 -0
- package/src/browser-detect.ts +221 -0
- package/src/concurrency-controller.ts +52 -0
- package/src/errors.ts +82 -0
- package/src/executor.ts +122 -55
- package/src/history.ts +3 -2
- package/src/idb-asset-store.ts +7 -1
- package/src/index.ts +50 -15
- package/src/managers/asset-manager.ts +259 -0
- package/src/managers/history-manager.ts +327 -0
- package/src/managers/mcp-manifest-builder.ts +108 -0
- package/src/managers/quota-manager.ts +149 -0
- package/src/managers/workflow-coordinator.ts +158 -0
- package/src/opfs-asset-store.ts +16 -2
- package/src/plugin-context.ts +87 -0
- package/src/plugin-permissions.ts +228 -0
- package/src/runtime-impl.ts +298 -0
- package/src/runtime.ts +16 -1116
- package/src/test-utils/fakes.ts +72 -0
- package/src/types.ts +116 -0
- package/src/worker-host.ts +35 -7
- package/src/worker-protocol.ts +47 -0
- package/dist/workflow-builder.d.ts +0 -124
- package/dist/workflow-builder.d.ts.map +0 -1
- package/dist/workflow-builder.js +0 -240
- package/dist/workflow-builder.js.map +0 -1
- package/src/workflow-builder.ts +0 -303
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset Manager
|
|
3
|
+
*
|
|
4
|
+
* 从 runtime.ts 抽取的资产操作逻辑,包装为独立类。
|
|
5
|
+
*
|
|
6
|
+
* 职责:
|
|
7
|
+
* - importAsset:URL fetch + 超时保护 / OPFS 不支持错误 / blob 导入 + 事件发射
|
|
8
|
+
* - getAsset:资产查找(不存在抛 "Asset not found")
|
|
9
|
+
* - exportAsset:OPFS fallback MIME 补全 + arrayBuffer 显式拉取(避免悬空引用)
|
|
10
|
+
* - readAssetExif:MetadataReader 依赖反转查找与降级
|
|
11
|
+
* - removeAsset:删除 + 事件发射
|
|
12
|
+
* - listAssets:列表
|
|
13
|
+
* - getStorageUsage:O(1) 缓存路径 + O(n) fallback
|
|
14
|
+
*
|
|
15
|
+
* 设计:
|
|
16
|
+
* - 通过 deps 注入 assetStore / eventBus / metadataReaders / storageQuota,
|
|
17
|
+
* 不持有 Runtime 实例,避免循环依赖。
|
|
18
|
+
* - metadataReaders 由 Runtime 持有并通过 `_registerMetadataReader` 写入,
|
|
19
|
+
* 这里按引用读取(Map 突变对 AssetManager 立即可见),保持 Plugin 注册语义不变。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
Asset,
|
|
24
|
+
AssetId,
|
|
25
|
+
AssetSource,
|
|
26
|
+
EventBus,
|
|
27
|
+
ExifData,
|
|
28
|
+
ImageMetadata,
|
|
29
|
+
MetadataReader,
|
|
30
|
+
MetadataReaderContext,
|
|
31
|
+
PdfInfo,
|
|
32
|
+
} from '@lokvis/schema';
|
|
33
|
+
import type { QuotaAwareAssetStore } from './quota-manager.js';
|
|
34
|
+
import { AssetNotFoundError } from '../errors.js';
|
|
35
|
+
|
|
36
|
+
/** AssetManager 依赖 */
|
|
37
|
+
export interface AssetManagerDeps {
|
|
38
|
+
assetStore: QuotaAwareAssetStore;
|
|
39
|
+
eventBus: EventBus;
|
|
40
|
+
metadataReaders: Map<string, MetadataReader>;
|
|
41
|
+
/** 配额上限,用于 getStorageUsage 返回值(不参与校验,校验在 quota-manager 内) */
|
|
42
|
+
storageQuota: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** OPFS 后端用 .bin 存储时,File.type 退化的兜底 MIME */
|
|
46
|
+
const OPFS_FALLBACK_MIME = 'application/octet-stream';
|
|
47
|
+
|
|
48
|
+
/** URL 导入超时(覆盖绝大多数正常图片下载) */
|
|
49
|
+
const URL_FETCH_TIMEOUT_MS = 30_000;
|
|
50
|
+
|
|
51
|
+
/** AssetManager:资产操作封装,由 RuntimeImpl 持有并委托 */
|
|
52
|
+
export class AssetManager {
|
|
53
|
+
constructor(private readonly deps: AssetManagerDeps) {}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 导入资产。
|
|
57
|
+
*
|
|
58
|
+
* - file/blob 源直接透传给 assetStore.import
|
|
59
|
+
* - url 源在 runtime 层 fetch → blob(含 30s 超时保护),再走 blob 导入
|
|
60
|
+
* - opfs 源暂不支持(OPFS 路径访问需要 filesystem access permission,未来单独实现)
|
|
61
|
+
*/
|
|
62
|
+
async importAsset(source: AssetSource): Promise<AssetId> {
|
|
63
|
+
// 修复 review 报告:原实现直接透传 source 给 assetStore.import,
|
|
64
|
+
// 但 MemoryAssetStore/OpfsAssetStore/IdbAssetStore 的 extractBlobFromSource
|
|
65
|
+
// 仅支持 file/blob 两种 kind,url/opfs 会抛 "not supported"。
|
|
66
|
+
// 这里在 runtime 层兜底处理 url(fetch → blob),opfs 暂不支持(OPFS
|
|
67
|
+
// 路径访问需要 filesystem access permission,未来单独实现)
|
|
68
|
+
let effectiveSource = source;
|
|
69
|
+
if (source.kind === 'url') {
|
|
70
|
+
// 超时保护:防止慢响应或挂起的 URL 无限期阻塞 import。
|
|
71
|
+
// 30s 覆盖绝大多数正常图片下载;超时后 abort 并抛明确错误。
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timeoutId = setTimeout(() => controller.abort(), URL_FETCH_TIMEOUT_MS);
|
|
74
|
+
try {
|
|
75
|
+
const resp = await fetch(source.url, { signal: controller.signal });
|
|
76
|
+
if (!resp.ok) {
|
|
77
|
+
throw new Error(`Failed to fetch asset from ${source.url}: ${resp.status} ${resp.statusText}`);
|
|
78
|
+
}
|
|
79
|
+
const blob = await resp.blob();
|
|
80
|
+
const name = source.url.split('/').pop()?.split('?')[0] || 'asset';
|
|
81
|
+
effectiveSource = { kind: 'blob', blob, name };
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
84
|
+
throw new Error(`Failed to fetch asset from ${source.url}: timed out after 30s`);
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timeoutId);
|
|
89
|
+
}
|
|
90
|
+
} else if (source.kind === 'opfs') {
|
|
91
|
+
throw new Error(
|
|
92
|
+
"AssetSource kind 'opfs' is not yet supported by importAsset; " +
|
|
93
|
+
'use the OPFS-aware AssetStore directly or convert to blob first'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const asset = await this.deps.assetStore.import(effectiveSource);
|
|
98
|
+
this.deps.eventBus.emit({
|
|
99
|
+
type: 'asset:imported',
|
|
100
|
+
assetId: asset.id,
|
|
101
|
+
metadata: asset.metadata,
|
|
102
|
+
});
|
|
103
|
+
return asset.id;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 获取资产(不存在抛 AssetNotFoundError,SDK 经 instanceof 转换)。
|
|
108
|
+
*/
|
|
109
|
+
async getAsset(id: AssetId): Promise<Asset> {
|
|
110
|
+
const asset = await this.deps.assetStore.get(id);
|
|
111
|
+
if (!asset) throw new AssetNotFoundError(id);
|
|
112
|
+
return asset;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 导出资产为 Blob。
|
|
117
|
+
*
|
|
118
|
+
* OPFS 存储后端用 .bin 扩展名存储,读取时 fileHandle.getFile() 返回的
|
|
119
|
+
* File.type 可能为空字符串或 'application/octet-stream'(浏览器对未知扩展名
|
|
120
|
+
* 的默认兜底 MIME)。这两种情况都会导致 Object URL 的 Content-Type 退化,
|
|
121
|
+
* 下载时文件扩展名变成 .octet-stream。
|
|
122
|
+
* 用 asset metadata 的 mimeType 补全 Blob type(IDB/Memory 后端不受影响)。
|
|
123
|
+
*
|
|
124
|
+
* 关键:用 arrayBuffer() 显式读取数据到内存,再构造新 Blob。
|
|
125
|
+
* 不能用 new Blob([blob]) —— 浏览器实现中它可能延迟引用底层 OPFS 文件,
|
|
126
|
+
* WatermarkBatchTool 在 export 后立即 removeAsset 删除 OPFS 文件,
|
|
127
|
+
* 导致后续 downloadBlob 读取悬空引用失败("check internet connection")。
|
|
128
|
+
* arrayBuffer() 立即拉取数据,确保返回的 Blob 完全独立于底层存储。
|
|
129
|
+
*/
|
|
130
|
+
async exportAsset(id: AssetId, format?: string): Promise<Blob> {
|
|
131
|
+
const asset = await this.getAsset(id);
|
|
132
|
+
const blob = await this.deps.assetStore.getBlob(asset.blob);
|
|
133
|
+
const mimeType =
|
|
134
|
+
!blob.type || blob.type === OPFS_FALLBACK_MIME
|
|
135
|
+
? asset.metadata.mimeType
|
|
136
|
+
: blob.type;
|
|
137
|
+
const buffer = await blob.arrayBuffer();
|
|
138
|
+
const exported = new Blob([buffer], { type: mimeType });
|
|
139
|
+
this.deps.eventBus.emit({
|
|
140
|
+
type: 'export:completed',
|
|
141
|
+
assetId: id,
|
|
142
|
+
format: format ?? asset.metadata.format,
|
|
143
|
+
size: blob.size,
|
|
144
|
+
});
|
|
145
|
+
return exported;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 读取 image 资产的 EXIF 元数据(W7.3/7.4 长期方案:MetadataReader 依赖反转)。
|
|
150
|
+
*
|
|
151
|
+
* Runtime 持有 plugin-image 通过 ctx.registerMetadataReader('image.read-exif', fn)
|
|
152
|
+
* 注册的 reader 引用,按名调用。reader 内部调 readExifFromBlob(exifr)。
|
|
153
|
+
* Plugin 未安装时优雅降级返回 null(不抛错)。
|
|
154
|
+
*
|
|
155
|
+
* 架构决策:readExif 是 Blob→ExifData 查询,不符合 Engine 层 Blob↔Blob 纯函数
|
|
156
|
+
* 约束,也不符合 Capability Asset[]→Asset[] 契约,故走 MetadataReader 机制,
|
|
157
|
+
* 不进 engine-image、不走 Capability execute。
|
|
158
|
+
*/
|
|
159
|
+
async readAssetExif(id: AssetId): Promise<ExifData | null> {
|
|
160
|
+
const asset = await this.getAsset(id);
|
|
161
|
+
if (asset.type !== 'image') return null;
|
|
162
|
+
const reader = this.deps.metadataReaders.get('image.read-exif');
|
|
163
|
+
if (!reader) return null; // Plugin 未安装,优雅降级
|
|
164
|
+
|
|
165
|
+
// TD-3.4 长期方案:构造 MetadataReaderContext,为 reader 提供可观测信号。
|
|
166
|
+
// log 前缀含 assetId,便于 Sentry 上报与调试时关联具体资产。
|
|
167
|
+
// info 级别不输出(避免噪音),warn/error 走 console。
|
|
168
|
+
const readerCtx: MetadataReaderContext = {
|
|
169
|
+
log: (level, message) => {
|
|
170
|
+
const prefix = `[lokvis:read-asset-exif:${id}]`;
|
|
171
|
+
if (level === 'error') console.error(`${prefix} ${message}`);
|
|
172
|
+
else if (level === 'warn') console.warn(`${prefix} ${message}`);
|
|
173
|
+
// info 不输出(避免噪音)
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
return reader(asset, readerCtx) as Promise<ExifData | null>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 读取 image 资产的 dimensions/format 元数据(MetadataReader 依赖反转)。
|
|
181
|
+
*
|
|
182
|
+
* Runtime 持有 plugin-image 通过 ctx.registerMetadataReader(
|
|
183
|
+
* 'image.read-metadata', fn) 注册的 reader 引用,按名调用。reader 内部调
|
|
184
|
+
* engine-image/node 的 getMetadata(sharp .metadata())。
|
|
185
|
+
* Plugin 未安装时优雅降级返回 null(不抛错)。
|
|
186
|
+
*
|
|
187
|
+
* 架构意义:mcp-server 经此方法读取处理后输出图像的精确尺寸,不再直接
|
|
188
|
+
* import @lokvis/engine-image(五层架构单向依赖,见 A1 修复)。
|
|
189
|
+
*/
|
|
190
|
+
async readAssetImageMetadata(id: AssetId): Promise<ImageMetadata | null> {
|
|
191
|
+
const asset = await this.getAsset(id);
|
|
192
|
+
if (asset.type !== 'image') return null;
|
|
193
|
+
const reader = this.deps.metadataReaders.get('image.read-metadata');
|
|
194
|
+
if (!reader) return null; // Plugin 未安装,优雅降级
|
|
195
|
+
|
|
196
|
+
const readerCtx: MetadataReaderContext = {
|
|
197
|
+
log: (level, message) => {
|
|
198
|
+
const prefix = `[lokvis:read-asset-image-metadata:${id}]`;
|
|
199
|
+
if (level === 'error') console.error(`${prefix} ${message}`);
|
|
200
|
+
else if (level === 'warn') console.warn(`${prefix} ${message}`);
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
return reader(asset, readerCtx) as Promise<ImageMetadata | null>;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 读取 pdf 资产的页数(MetadataReader 依赖反转)。
|
|
208
|
+
*
|
|
209
|
+
* Runtime 持有 plugin-pdf 通过 ctx.registerMetadataReader(
|
|
210
|
+
* 'pdf.read-info', fn) 注册的 reader 引用,按名调用。reader 内部调
|
|
211
|
+
* engine-pdf 的 getPdfInfo(pdf-lib getPageCount)。
|
|
212
|
+
* Plugin 未安装时优雅降级返回 null(不抛错)。
|
|
213
|
+
*
|
|
214
|
+
* 架构意义:mcp-server 经此方法读取处理后输出 PDF 的页数,不再直接
|
|
215
|
+
* import @lokvis/engine-pdf(五层架构单向依赖,见 A1 修复)。
|
|
216
|
+
*/
|
|
217
|
+
async readAssetPdfInfo(id: AssetId): Promise<PdfInfo | null> {
|
|
218
|
+
const asset = await this.getAsset(id);
|
|
219
|
+
if (asset.type !== 'pdf') return null;
|
|
220
|
+
const reader = this.deps.metadataReaders.get('pdf.read-info');
|
|
221
|
+
if (!reader) return null; // Plugin 未安装,优雅降级
|
|
222
|
+
|
|
223
|
+
const readerCtx: MetadataReaderContext = {
|
|
224
|
+
log: (level, message) => {
|
|
225
|
+
const prefix = `[lokvis:read-asset-pdf-info:${id}]`;
|
|
226
|
+
if (level === 'error') console.error(`${prefix} ${message}`);
|
|
227
|
+
else if (level === 'warn') console.warn(`${prefix} ${message}`);
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
return reader(asset, readerCtx) as Promise<PdfInfo | null>;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async removeAsset(id: AssetId): Promise<void> {
|
|
234
|
+
await this.deps.assetStore.remove(id);
|
|
235
|
+
this.deps.eventBus.emit({ type: 'asset:removed', assetId: id });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async listAssets(): Promise<Asset[]> {
|
|
239
|
+
return this.deps.assetStore.list();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 获取存储使用情况。
|
|
244
|
+
*
|
|
245
|
+
* m6 优化:优先用配额包装器内部维护的 usage(O(1),import/create/remove
|
|
246
|
+
* 时增量更新),避免每次 O(n) 全量 listAssets 影响 StatusBar 刷新。
|
|
247
|
+
* 包装器未就绪(ensureInit 未完成)返回 -1 时,fallback 到 listAssets
|
|
248
|
+
* 实时计算(source of truth,与 W6.4 富元数据一致)。
|
|
249
|
+
*/
|
|
250
|
+
async getStorageUsage(): Promise<{ usage: number; quota: number }> {
|
|
251
|
+
const cached = this.deps.assetStore._getQuotaUsage();
|
|
252
|
+
if (cached >= 0) {
|
|
253
|
+
return { usage: cached, quota: this.deps.storageQuota };
|
|
254
|
+
}
|
|
255
|
+
const all = await this.deps.assetStore.list();
|
|
256
|
+
const usage = all.reduce((sum, a) => sum + a.metadata.size, 0);
|
|
257
|
+
return { usage, quota: this.deps.storageQuota };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History Manager(W1.3 从 runtime.ts 抽取)
|
|
3
|
+
*
|
|
4
|
+
* 持有每个工作流的 HistoryStack + initialInputs / currentOutputs 映射,
|
|
5
|
+
* 提供 history / undo / redo / jumpTo,监听 node:finished 自动 append,
|
|
6
|
+
* LRU 上限清理,持久化快照到 historyStore(IDB)支持跨会话恢复。
|
|
7
|
+
* deps 注入 eventBus / assetStore / historyStore?,不持有 Runtime 实例避免
|
|
8
|
+
* 循环依赖。prepareForRun / recordRunResult 供 WorkflowCoordinator 调用。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
AssetId,
|
|
13
|
+
EventBus,
|
|
14
|
+
HistoryEntry,
|
|
15
|
+
LokvisEvent,
|
|
16
|
+
} from '@lokvis/schema';
|
|
17
|
+
import { generateId } from '../asset-store.js';
|
|
18
|
+
import { HistoryStack, type HistoryStackConfig } from '../history.js';
|
|
19
|
+
import type { HistoryStore } from '../history-store.js';
|
|
20
|
+
import type { QuotaAwareAssetStore } from './quota-manager.js';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MAX_HISTORY = 10;
|
|
23
|
+
|
|
24
|
+
/** 同时持有的工作流历史栈上限(W2.8);超限按 FIFO 清理最旧 stack。 */
|
|
25
|
+
const MAX_CONCURRENT_WORKFLOW_STACKS = 32;
|
|
26
|
+
|
|
27
|
+
export interface HistoryManagerDeps {
|
|
28
|
+
eventBus: EventBus;
|
|
29
|
+
assetStore: QuotaAwareAssetStore;
|
|
30
|
+
/** undefined 时退化为仅内存历史(刷新后丢失) */
|
|
31
|
+
historyStore?: HistoryStore;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 历史栈管理封装,由 RuntimeImpl 持有并委托 */
|
|
35
|
+
export class HistoryManager {
|
|
36
|
+
private historyStacks = new Map<string, HistoryStack>();
|
|
37
|
+
private initialInputsMap = new Map<string, AssetId[]>();
|
|
38
|
+
private currentOutputsMap = new Map<string, AssetId[]>();
|
|
39
|
+
/** 加载快照期间守卫:restore() 触发 onChanged → persistHistory 时跳过冗余写回 */
|
|
40
|
+
private isLoadingHistory = false;
|
|
41
|
+
/**
|
|
42
|
+
* 加载期间被跳过的 persistHistory 加入此 Set,loadPersistedHistory 结束后
|
|
43
|
+
* 逐个补 persist,避免加载期间变更被永久丢弃(W7.2 review 修复)。
|
|
44
|
+
*/
|
|
45
|
+
private dirtyDuringLoad = new Set<string>();
|
|
46
|
+
|
|
47
|
+
constructor(private readonly deps: HistoryManagerDeps) {
|
|
48
|
+
this.deps.eventBus.on('node:finished', (event) => {
|
|
49
|
+
this.recordFromNodeEvent(
|
|
50
|
+
event as Extract<LokvisEvent, { type: 'node:finished' }>
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async history(workflowId: string): Promise<HistoryEntry[]> {
|
|
56
|
+
return this.historyStacks.get(workflowId)?.list() ?? [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async getHistoryState(
|
|
60
|
+
workflowId: string
|
|
61
|
+
): Promise<{ entries: HistoryEntry[]; cursor: number }> {
|
|
62
|
+
const stack = this.historyStacks.get(workflowId);
|
|
63
|
+
if (!stack) return { entries: [], cursor: -1 };
|
|
64
|
+
const snap = stack.snapshot();
|
|
65
|
+
return { entries: snap.entries, cursor: snap.cursor };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 工作流当前输出 AssetId(undo/redo 后的"当前"状态) */
|
|
69
|
+
getCurrentOutputs(workflowId: string): AssetId[] {
|
|
70
|
+
return this.currentOutputsMap.get(workflowId) ?? [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// history:changed 事件由 stack.onChanged 统一发射,避免双发
|
|
74
|
+
async undo(workflowId: string): Promise<void> {
|
|
75
|
+
const stack = this.getOrCreateStack(workflowId);
|
|
76
|
+
const result = stack.undo();
|
|
77
|
+
if (result === undefined) return;
|
|
78
|
+
// null 表示回到初始状态 → 用 initialInputs;entry → 用其 outputs
|
|
79
|
+
const newCurrent = result === null
|
|
80
|
+
? (this.initialInputsMap.get(workflowId) ?? [])
|
|
81
|
+
: result.outputs;
|
|
82
|
+
this.currentOutputsMap.set(workflowId, newCurrent);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async redo(workflowId: string): Promise<void> {
|
|
86
|
+
const stack = this.getOrCreateStack(workflowId);
|
|
87
|
+
const entry = stack.redo();
|
|
88
|
+
if (entry === undefined) return;
|
|
89
|
+
this.currentOutputsMap.set(workflowId, entry.outputs);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async jumpTo(workflowId: string, index: number): Promise<void> {
|
|
93
|
+
const stack = this.getOrCreateStack(workflowId);
|
|
94
|
+
const result = stack.jumpTo(index);
|
|
95
|
+
if (result === undefined) return;
|
|
96
|
+
const newCurrent = result === null
|
|
97
|
+
? (this.initialInputsMap.get(workflowId) ?? [])
|
|
98
|
+
: result.outputs;
|
|
99
|
+
this.currentOutputsMap.set(workflowId, newCurrent);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* run() 前的历史栈准备:
|
|
104
|
+
* - appendHistory=true 且已有栈:保留栈与 currentOutputs,仅确保初始输入已记录
|
|
105
|
+
* - 否则(默认重置):丢弃旧历史,重新初始化
|
|
106
|
+
* 然后执行 LRU 上限清理。
|
|
107
|
+
*/
|
|
108
|
+
prepareForRun(
|
|
109
|
+
workflowId: string,
|
|
110
|
+
inputIds: AssetId[],
|
|
111
|
+
appendHistory: boolean
|
|
112
|
+
): void {
|
|
113
|
+
if (appendHistory && this.historyStacks.has(workflowId)) {
|
|
114
|
+
if (!this.initialInputsMap.has(workflowId)) {
|
|
115
|
+
this.initialInputsMap.set(workflowId, inputIds);
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
const existingStack = this.historyStacks.get(workflowId);
|
|
119
|
+
if (existingStack) {
|
|
120
|
+
existingStack.reset();
|
|
121
|
+
}
|
|
122
|
+
this.initialInputsMap.set(workflowId, inputIds);
|
|
123
|
+
this.currentOutputsMap.set(workflowId, inputIds);
|
|
124
|
+
}
|
|
125
|
+
this.enforceStacksLimit(workflowId);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** run() 成功完成后,记录最终输出为当前(outputs 非空时)。 */
|
|
129
|
+
recordRunResult(workflowId: string, outputs: AssetId[]): void {
|
|
130
|
+
if (outputs.length > 0) {
|
|
131
|
+
this.currentOutputsMap.set(workflowId, outputs);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** node:finished 事件处理:append 到对应工作流栈,inputs 取上一步 outputs。 */
|
|
136
|
+
recordFromNodeEvent(
|
|
137
|
+
event: Extract<LokvisEvent, { type: 'node:finished' }>
|
|
138
|
+
): void {
|
|
139
|
+
if (!event.outputs || event.outputs.length === 0) return;
|
|
140
|
+
|
|
141
|
+
const stack = this.getOrCreateStack(event.workflowId);
|
|
142
|
+
const outputs = event.outputs.map((a) => a.id);
|
|
143
|
+
const inputs = this.currentOutputsMap.get(event.workflowId) ?? [];
|
|
144
|
+
|
|
145
|
+
const entry: HistoryEntry = {
|
|
146
|
+
id: generateId(),
|
|
147
|
+
workflowId: event.workflowId,
|
|
148
|
+
nodeId: event.nodeId,
|
|
149
|
+
capability: event.capability,
|
|
150
|
+
params: event.params,
|
|
151
|
+
inputs,
|
|
152
|
+
outputs,
|
|
153
|
+
timestamp: Date.now(),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
stack.append(entry);
|
|
157
|
+
this.currentOutputsMap.set(event.workflowId, outputs);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* LRU 上限清理:超 MAX_CONCURRENT_WORKFLOW_STACKS 时按 FIFO 删最旧 stack
|
|
162
|
+
* (reset 触发 onEvict → assetStore.remove 回收资产)。
|
|
163
|
+
* appendHistory 模式下当前工作流栈必须跳过,否则 FIFO 首位时被误删。
|
|
164
|
+
*/
|
|
165
|
+
enforceStacksLimit(currentWorkflowId: string): void {
|
|
166
|
+
while (this.historyStacks.size >= MAX_CONCURRENT_WORKFLOW_STACKS) {
|
|
167
|
+
let oldestId = this.historyStacks.keys().next().value;
|
|
168
|
+
if (oldestId === undefined) break;
|
|
169
|
+
if (oldestId === currentWorkflowId) {
|
|
170
|
+
const iter = this.historyStacks.keys();
|
|
171
|
+
iter.next();
|
|
172
|
+
oldestId = iter.next().value;
|
|
173
|
+
if (oldestId === undefined) break;
|
|
174
|
+
}
|
|
175
|
+
const stack = this.historyStacks.get(oldestId);
|
|
176
|
+
if (stack) {
|
|
177
|
+
stack.reset();
|
|
178
|
+
}
|
|
179
|
+
this.historyStacks.delete(oldestId);
|
|
180
|
+
this.initialInputsMap.delete(oldestId);
|
|
181
|
+
this.currentOutputsMap.delete(oldestId);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 获取或创建工作流对应的 HistoryStack */
|
|
186
|
+
getOrCreateStack(workflowId: string): HistoryStack {
|
|
187
|
+
let stack = this.historyStacks.get(workflowId);
|
|
188
|
+
if (!stack) {
|
|
189
|
+
const stackConfig: Partial<HistoryStackConfig> = {
|
|
190
|
+
maxEntries: DEFAULT_MAX_HISTORY,
|
|
191
|
+
onEvict: (entry) => {
|
|
192
|
+
// 淘汰条目时清理其 outputs 资产(避免 OPFS 泄漏)
|
|
193
|
+
// TD-3.2: opfs/idb asset-store 的 remove 已对 NotFoundError 静默忽略
|
|
194
|
+
// (幂等删除),因此此处 catch 到的都是真实错误(权限/IO/quota 等),
|
|
195
|
+
// 用 error 级别记录,便于 Sentry 上报与孤儿资产排查
|
|
196
|
+
for (const assetId of entry.outputs) {
|
|
197
|
+
this.deps.assetStore.remove(assetId).catch((err) => {
|
|
198
|
+
console.error(`[lokvis] onEvict: remove(${assetId}) failed:`, err);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
onChanged: (wfId, entries, currentIndex) => {
|
|
203
|
+
this.deps.eventBus.emit({
|
|
204
|
+
type: 'history:changed',
|
|
205
|
+
workflowId: wfId,
|
|
206
|
+
entries,
|
|
207
|
+
currentIndex,
|
|
208
|
+
});
|
|
209
|
+
void this.persistHistory(wfId);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
stack = new HistoryStack(workflowId, stackConfig);
|
|
213
|
+
this.historyStacks.set(workflowId, stack);
|
|
214
|
+
}
|
|
215
|
+
return stack;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 清理指定工作流的历史栈(reset 触发 onEvict 回收资产),不取消运行中执行。
|
|
220
|
+
*
|
|
221
|
+
* TD-2.1 长期方案:返回 Promise 并在末尾 `await persistHistory`,使调用方
|
|
222
|
+
* (disposeWorkflow / disposeAll)能在持久化完成后才返回,消除测试轮询。
|
|
223
|
+
* stack.reset() 触发 onChanged → fire-and-forget persistHistory(删除空记录),
|
|
224
|
+
* 此处显式 await 保证 IDB 删除已落地;两次 delete 幂等,无副作用。
|
|
225
|
+
*/
|
|
226
|
+
async disposeHistory(workflowId: string): Promise<void> {
|
|
227
|
+
const stack = this.historyStacks.get(workflowId);
|
|
228
|
+
if (stack) {
|
|
229
|
+
stack.reset();
|
|
230
|
+
this.historyStacks.delete(workflowId);
|
|
231
|
+
}
|
|
232
|
+
this.initialInputsMap.delete(workflowId);
|
|
233
|
+
this.currentOutputsMap.delete(workflowId);
|
|
234
|
+
await this.persistHistory(workflowId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 清理所有工作流的历史栈(W21.6 runtime.dispose 用)。
|
|
239
|
+
*
|
|
240
|
+
* 复制 keys 后逐个 await disposeHistory,触发 onEvict 回收 outputs 资产。
|
|
241
|
+
* TD-2.1:改为 async 顺序 await,确保 runtime.dispose() 返回前所有持久化
|
|
242
|
+
* 删除已落地(避免 dispose 后 IDB 仍有残留记录的竞态)。
|
|
243
|
+
*/
|
|
244
|
+
async disposeAll(): Promise<void> {
|
|
245
|
+
// 复制一份:disposeHistory 内 stack.reset() → onEvict → assetStore.remove
|
|
246
|
+
// 可能间接触发其他 listener 改动 historyStacks(理论上不会,但防御性写法)
|
|
247
|
+
for (const workflowId of [...this.historyStacks.keys()]) {
|
|
248
|
+
await this.disposeHistory(workflowId);
|
|
249
|
+
}
|
|
250
|
+
this.dirtyDuringLoad.clear();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 把工作流当前历史快照写入 historyStore(W7.2)。
|
|
255
|
+
*
|
|
256
|
+
* 时序:currentOutputs 从 stack snapshot 派生(cursor === -1 用 initialInputs,
|
|
257
|
+
* 否则用 entries[cursor].outputs),不读 currentOutputsMap —— onChanged 在
|
|
258
|
+
* undo/redo/jumpTo/run 内部同步触发时 map 尚未更新会读到旧值。
|
|
259
|
+
* 竞态:多个 fire-and-forget save 的 IDB readwrite 事务由 IndexedDB 引擎按发起
|
|
260
|
+
* 顺序串行化,无需应用层加链。
|
|
261
|
+
* entries 为空 → delete;加载期间跳过写回,记入 dirtyDuringLoad 后续补 persist。
|
|
262
|
+
*/
|
|
263
|
+
async persistHistory(workflowId: string): Promise<void> {
|
|
264
|
+
if (!this.deps.historyStore) return;
|
|
265
|
+
if (this.isLoadingHistory) {
|
|
266
|
+
this.dirtyDuringLoad.add(workflowId);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// fire-and-forget 调用方用 `void this.persistHistory(...)`,故内部必须 try/catch,
|
|
270
|
+
// 否则 IDB 故障会变成 unhandled promise rejection。
|
|
271
|
+
try {
|
|
272
|
+
const stack = this.historyStacks.get(workflowId);
|
|
273
|
+
// 栈已从内存移除(disposeHistory / enforceStacksLimit 后异步到达)→ 删除持久化记录
|
|
274
|
+
if (!stack) {
|
|
275
|
+
await this.deps.historyStore.delete(workflowId);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const { entries, cursor } = stack.snapshot();
|
|
279
|
+
if (entries.length === 0) {
|
|
280
|
+
await this.deps.historyStore.delete(workflowId);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const initialInputs = this.initialInputsMap.get(workflowId) ?? [];
|
|
284
|
+
const currentOutputs = cursor === -1
|
|
285
|
+
? initialInputs
|
|
286
|
+
: (entries[cursor]?.outputs ?? initialInputs);
|
|
287
|
+
await this.deps.historyStore.save({
|
|
288
|
+
workflowId,
|
|
289
|
+
entries,
|
|
290
|
+
cursor,
|
|
291
|
+
initialInputs,
|
|
292
|
+
currentOutputs,
|
|
293
|
+
updatedAt: Date.now(),
|
|
294
|
+
});
|
|
295
|
+
} catch (err) {
|
|
296
|
+
console.warn(`[lokvis] persistHistory failed for workflow ${workflowId}:`, err);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 从 historyStore 预加载所有持久化的历史快照(由 createRuntime 工厂调用)。
|
|
302
|
+
* 加载期间置 isLoadingHistory 守卫;被跳过的 persist 记入 dirtyDuringLoad,
|
|
303
|
+
* 加载结束后补 persist,避免加载期间其他来源变更被永久丢弃。
|
|
304
|
+
*/
|
|
305
|
+
async loadPersistedHistory(): Promise<void> {
|
|
306
|
+
if (!this.deps.historyStore) return;
|
|
307
|
+
let records: Awaited<ReturnType<HistoryStore['loadAll']>> = [];
|
|
308
|
+
this.isLoadingHistory = true;
|
|
309
|
+
try {
|
|
310
|
+
records = await this.deps.historyStore.loadAll();
|
|
311
|
+
for (const record of records) {
|
|
312
|
+
if (record.entries.length === 0) continue; // 跳过空记录(双重防御)
|
|
313
|
+
const stack = this.getOrCreateStack(record.workflowId);
|
|
314
|
+
stack.restore({ entries: record.entries, cursor: record.cursor });
|
|
315
|
+
this.initialInputsMap.set(record.workflowId, record.initialInputs);
|
|
316
|
+
this.currentOutputsMap.set(record.workflowId, record.currentOutputs);
|
|
317
|
+
}
|
|
318
|
+
} finally {
|
|
319
|
+
this.isLoadingHistory = false;
|
|
320
|
+
// 补 persist:逐个 await 保证顺序;复制一份避免补 persist 过程中
|
|
321
|
+
// 新触发 onChanged → dirtyDuringLoad 死循环
|
|
322
|
+
const pending = [...this.dirtyDuringLoad];
|
|
323
|
+
this.dirtyDuringLoad.clear();
|
|
324
|
+
for (const wfId of pending) await this.persistHistory(wfId);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Manifest Builder(W1.5 从 runtime.ts 抽取)
|
|
3
|
+
*
|
|
4
|
+
* 把已注册 Capability 列表转换为 MCP server manifest(纯函数,无 `this`,
|
|
5
|
+
* 不依赖 CapabilityRegistry)。Runtime.toMcpManifest() 委托本模块。
|
|
6
|
+
*
|
|
7
|
+
* 暴露规则(见 docs/AI生态冲击调整方案.md §6):
|
|
8
|
+
* - private:任何模式都不暴露
|
|
9
|
+
* - batch-only:仅在 options.batchMode=true 时暴露(避免单文件误用)
|
|
10
|
+
* - 其余(默认 public):总是暴露
|
|
11
|
+
* tool 名取 capability.mcpToolName 或 `lokvis_${name.replace(/\./g, '_')}`;
|
|
12
|
+
* resource 固定为 capabilities 与 workflows 两个清单。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
Capability, CapabilityParam, CapabilityParamType,
|
|
17
|
+
McpManifest, McpToolManifest,
|
|
18
|
+
} from '@lokvis/schema';
|
|
19
|
+
import type { ToMcpManifestOptions } from '../types.js';
|
|
20
|
+
|
|
21
|
+
const SERVER_NAME = 'lokvis';
|
|
22
|
+
const CAPABILITIES_URI = 'lokvis://capabilities';
|
|
23
|
+
const WORKFLOWS_URI = 'lokvis://workflows';
|
|
24
|
+
|
|
25
|
+
/** 生成 MCP server manifest(不启动 server,仅描述可被 MCP 暴露的能力)。 */
|
|
26
|
+
export function toMcpManifest(
|
|
27
|
+
capabilities: Capability[],
|
|
28
|
+
options: ToMcpManifestOptions = {},
|
|
29
|
+
version: string
|
|
30
|
+
): McpManifest {
|
|
31
|
+
const { batchMode = false } = options;
|
|
32
|
+
const tools: McpToolManifest[] = [];
|
|
33
|
+
for (const cap of capabilities) {
|
|
34
|
+
if (cap.mcpExposure === 'private') continue;
|
|
35
|
+
if (cap.mcpExposure === 'batch-only' && !batchMode) continue;
|
|
36
|
+
const toolName = cap.mcpToolName ?? `lokvis_${cap.name.replace(/\./g, '_')}`;
|
|
37
|
+
tools.push({
|
|
38
|
+
name: toolName,
|
|
39
|
+
description: cap.description,
|
|
40
|
+
inputSchema: capabilityParamsToJsonSchema(cap.params),
|
|
41
|
+
capabilities: [cap.name],
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
serverName: SERVER_NAME, version, tools,
|
|
46
|
+
resources: [
|
|
47
|
+
{ uri: CAPABILITIES_URI, name: 'Capabilities',
|
|
48
|
+
description: 'List all available Lokvis capabilities',
|
|
49
|
+
mimeType: 'application/json' },
|
|
50
|
+
{ uri: WORKFLOWS_URI, name: 'Workflows',
|
|
51
|
+
description: 'List saved workflows', mimeType: 'application/json' },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Capability.params(CapabilityParam[])→ JSON Schema 对象(手写,非 zod-to-json-schema)。 */
|
|
57
|
+
function capabilityParamsToJsonSchema(params: CapabilityParam[]): object {
|
|
58
|
+
const properties: Record<string, unknown> = {};
|
|
59
|
+
const required: string[] = [];
|
|
60
|
+
for (const p of params) {
|
|
61
|
+
properties[p.name] = capabilityParamToJsonSchemaProperty(p);
|
|
62
|
+
if (p.required) required.push(p.name);
|
|
63
|
+
}
|
|
64
|
+
return { type: 'object', properties,
|
|
65
|
+
...(required.length > 0 ? { required } : {}) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* CapabilityParamType → 合法 JSON Schema 类型片段。
|
|
70
|
+
* color/file/enum 非 JSON Schema 标准类型,color→string+format,
|
|
71
|
+
* file/enum→string(enum 值由外层 enum 字段追加);其余同名 JSON Schema 类型。
|
|
72
|
+
*/
|
|
73
|
+
function capabilityParamTypeToJsonType(
|
|
74
|
+
type: CapabilityParamType
|
|
75
|
+
): { type: string; format?: string } {
|
|
76
|
+
switch (type) {
|
|
77
|
+
case 'color': return { type: 'string', format: 'color' };
|
|
78
|
+
case 'file':
|
|
79
|
+
case 'enum': return { type: 'string' };
|
|
80
|
+
case 'number':
|
|
81
|
+
case 'string':
|
|
82
|
+
case 'boolean':
|
|
83
|
+
case 'object':
|
|
84
|
+
case 'array': return { type };
|
|
85
|
+
default: return { type: 'string' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 单个 CapabilityParam → JSON Schema property */
|
|
90
|
+
function capabilityParamToJsonSchemaProperty(
|
|
91
|
+
p: CapabilityParam
|
|
92
|
+
): Record<string, unknown> {
|
|
93
|
+
const { type: jsonType, format } = capabilityParamTypeToJsonType(p.type);
|
|
94
|
+
const prop: Record<string, unknown> = { type: jsonType };
|
|
95
|
+
if (format) prop.format = format;
|
|
96
|
+
if (p.description) prop.description = p.description;
|
|
97
|
+
if (p.default !== undefined) prop.default = p.default;
|
|
98
|
+
// min/max 仅对 number 合法(string 应为 minLength/maxLength)
|
|
99
|
+
if (p.type === 'number') {
|
|
100
|
+
if (typeof p.min === 'number') prop.minimum = p.min;
|
|
101
|
+
if (typeof p.max === 'number') prop.maximum = p.max;
|
|
102
|
+
}
|
|
103
|
+
if (p.type === 'enum' && p.values) prop.enum = p.values;
|
|
104
|
+
if (p.type === 'array' && p.items) {
|
|
105
|
+
prop.items = capabilityParamTypeToJsonType(p.items);
|
|
106
|
+
}
|
|
107
|
+
return prop;
|
|
108
|
+
}
|