@lokvis/runtime 0.1.0 → 0.2.0
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/LICENSE +21 -0
- package/dist/asset-store.d.ts +5 -2
- package/dist/asset-store.d.ts.map +1 -1
- package/dist/asset-store.js +93 -3
- package/dist/asset-store.js.map +1 -1
- package/dist/batch-processor.d.ts +200 -0
- package/dist/batch-processor.d.ts.map +1 -0
- package/dist/batch-processor.js +505 -0
- package/dist/batch-processor.js.map +1 -0
- package/dist/capability-registry.d.ts +4 -0
- package/dist/capability-registry.d.ts.map +1 -1
- package/dist/capability-registry.js +19 -2
- package/dist/capability-registry.js.map +1 -1
- package/dist/degradation.d.ts +109 -0
- package/dist/degradation.d.ts.map +1 -0
- package/dist/degradation.js +184 -0
- package/dist/degradation.js.map +1 -0
- package/dist/event-bus.d.ts.map +1 -1
- package/dist/event-bus.js +7 -2
- package/dist/event-bus.js.map +1 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +37 -1
- package/dist/executor.js.map +1 -1
- package/dist/history-store.d.ts +76 -0
- package/dist/history-store.d.ts.map +1 -0
- package/dist/history-store.js +109 -0
- package/dist/history-store.js.map +1 -0
- package/dist/history.d.ts +1 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +1 -1
- package/dist/history.js.map +1 -1
- package/dist/idb-asset-store.js +1 -1
- package/dist/idb-asset-store.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/memory-guard.d.ts +109 -0
- package/dist/memory-guard.d.ts.map +1 -0
- package/dist/memory-guard.js +161 -0
- package/dist/memory-guard.js.map +1 -0
- package/dist/opfs-asset-store.d.ts +35 -4
- package/dist/opfs-asset-store.d.ts.map +1 -1
- package/dist/opfs-asset-store.js +94 -10
- package/dist/opfs-asset-store.js.map +1 -1
- package/dist/runtime.d.ts +139 -4
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +443 -25
- package/dist/runtime.js.map +1 -1
- package/dist/types.d.ts +101 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/worker-host.d.ts +13 -0
- package/dist/worker-host.d.ts.map +1 -1
- package/dist/worker-host.js +47 -15
- package/dist/worker-host.js.map +1 -1
- package/dist/worker-protocol.d.ts +10 -1
- package/dist/worker-protocol.d.ts.map +1 -1
- package/dist/worker-protocol.js.map +1 -1
- package/dist/workflow-builder.d.ts +124 -0
- package/dist/workflow-builder.d.ts.map +1 -0
- package/dist/workflow-builder.js +240 -0
- package/dist/workflow-builder.js.map +1 -0
- package/package.json +3 -2
- package/src/asset-store.ts +103 -3
- package/src/batch-processor.ts +699 -0
- package/src/capability-registry.ts +19 -2
- package/src/degradation.ts +241 -0
- package/src/event-bus.ts +6 -2
- package/src/executor.ts +46 -1
- package/src/history-store.ts +159 -0
- package/src/history.ts +2 -2
- package/src/idb-asset-store.ts +1 -1
- package/src/index.ts +5 -0
- package/src/memory-guard.ts +202 -0
- package/src/opfs-asset-store.ts +118 -10
- package/src/runtime.ts +482 -21
- package/src/types.ts +98 -1
- package/src/worker-host.ts +56 -16
- package/src/worker-protocol.ts +11 -1
- package/src/workflow-builder.ts +303 -0
package/src/types.ts
CHANGED
|
@@ -11,12 +11,15 @@ import type {
|
|
|
11
11
|
AssetSource,
|
|
12
12
|
Capability,
|
|
13
13
|
EngineSelectionStrategy,
|
|
14
|
+
ExifData,
|
|
14
15
|
HistoryEntry,
|
|
15
16
|
McpManifest,
|
|
16
17
|
} from '@lokvis/schema';
|
|
17
18
|
import type { Workflow, WorkflowResult } from '@lokvis/schema';
|
|
18
19
|
import type { EventBus } from '@lokvis/schema';
|
|
19
20
|
import type { AssetStore } from './asset-store.js';
|
|
21
|
+
import type { BatchProcessor } from './batch-processor.js';
|
|
22
|
+
import type { HistoryStore, HistoryStoreOptions } from './history-store.js';
|
|
20
23
|
|
|
21
24
|
/** Runtime 配置 */
|
|
22
25
|
export interface RuntimeConfig {
|
|
@@ -40,6 +43,29 @@ export interface RuntimeConfig {
|
|
|
40
43
|
* 按 OPFS → IndexedDB → Memory 降级。
|
|
41
44
|
*/
|
|
42
45
|
assetStore?: AssetStore;
|
|
46
|
+
/**
|
|
47
|
+
* 注入自定义 HistoryStore(W7.2 历史持久化)。
|
|
48
|
+
* 默认由 createRuntime 在 enableIndexedDB 时通过 createHistoryStore 自动创建;
|
|
49
|
+
* IndexedDB 不可用时为 undefined,历史退化为仅内存模式。
|
|
50
|
+
*/
|
|
51
|
+
historyStore?: HistoryStore;
|
|
52
|
+
/**
|
|
53
|
+
* HistoryStore 工厂选项(W7.2,仅 historyStore 未注入时生效)。
|
|
54
|
+
* 测试可注入 dbInstance 或自定义 dbName。
|
|
55
|
+
*/
|
|
56
|
+
historyStoreOptions?: HistoryStoreOptions;
|
|
57
|
+
/**
|
|
58
|
+
* 是否启用 Pro 模式(W6.2 / PROJECT_PLAN 17.4)。
|
|
59
|
+
* - false(默认):批量上限 10 文件、并发 4、workflow 槽位 5
|
|
60
|
+
* - true:批量无上限、并发 16、workflow 槽位无限
|
|
61
|
+
* 由 cloud 侧 createLokvis({ auth }) 注入 session 后置为 true。
|
|
62
|
+
*/
|
|
63
|
+
isPro?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* 内存预算(字节,W3.3 MemoryGuard)。
|
|
66
|
+
* 默认 512MB。BatchProcessor 据此在内存压力高时收缩并发槽位。
|
|
67
|
+
*/
|
|
68
|
+
memoryBudget?: number;
|
|
43
69
|
}
|
|
44
70
|
|
|
45
71
|
/** Runtime 状态 */
|
|
@@ -55,6 +81,17 @@ export interface ToMcpManifestOptions {
|
|
|
55
81
|
batchMode?: boolean;
|
|
56
82
|
}
|
|
57
83
|
|
|
84
|
+
/**
|
|
85
|
+
* `run()` 选项。
|
|
86
|
+
*
|
|
87
|
+
* - `appendHistory`:为 true 时,同一工作流 ID 的后续 run() 在已有历史栈上
|
|
88
|
+
* 追加条目(支持跨次 undo/redo 链,如 playground HistoryDemo 的连续滤镜)。
|
|
89
|
+
* 默认 false —— 每次 run() 重置历史栈,与"重新执行"语义一致。
|
|
90
|
+
*/
|
|
91
|
+
export interface RunOptions {
|
|
92
|
+
appendHistory?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
/** 核心 Runtime API(第一版,必须克制) */
|
|
59
96
|
export interface LokvisRuntime {
|
|
60
97
|
/** Runtime 版本 */
|
|
@@ -63,24 +100,58 @@ export interface LokvisRuntime {
|
|
|
63
100
|
readonly status: RuntimeStatus;
|
|
64
101
|
/** 事件总线 */
|
|
65
102
|
readonly eventBus: EventBus;
|
|
103
|
+
/** 是否为 Pro 模式(影响批量上限/并发槽位/workflow 数,W6.2) */
|
|
104
|
+
readonly isPro: boolean;
|
|
105
|
+
/** 批量处理器(W6.1:并发控制 + 进度 + 失败重试) */
|
|
106
|
+
readonly batch: BatchProcessor;
|
|
66
107
|
|
|
67
108
|
// ─── 工作流执行 ──────────────────────────────────────
|
|
68
109
|
/** 运行工作流 */
|
|
69
|
-
run(workflow: Workflow, inputs: AssetId[] | Asset[]): Promise<WorkflowResult>;
|
|
110
|
+
run(workflow: Workflow, inputs: AssetId[] | Asset[], options?: RunOptions): Promise<WorkflowResult>;
|
|
70
111
|
/** 取消运行 */
|
|
71
112
|
cancel(workflowId: string): Promise<void>;
|
|
72
113
|
/** 暂停运行 */
|
|
73
114
|
pause(workflowId: string): Promise<void>;
|
|
74
115
|
/** 恢复运行 */
|
|
75
116
|
resume(workflowId: string): Promise<void>;
|
|
117
|
+
/**
|
|
118
|
+
* 获取工作流当前输出 AssetId(undo/redo 后的"当前"状态)。
|
|
119
|
+
*
|
|
120
|
+
* 用途:
|
|
121
|
+
* - UI 实时展示工作流中间结果
|
|
122
|
+
* - MCP server 查询当前工作流产物
|
|
123
|
+
* - 暂停时检查中间输出
|
|
124
|
+
*/
|
|
125
|
+
getCurrentOutputs(workflowId: string): Promise<AssetId[]>;
|
|
126
|
+
/**
|
|
127
|
+
* 销毁工作流的运行时状态(取消运行 + 清空历史栈 + 回收历史 outputs 资产)。
|
|
128
|
+
*
|
|
129
|
+
* 修复 review 报告:原接口无清理入口,长会话累积导致 historyStacks Map
|
|
130
|
+
* 与 AssetStore 中孤儿资产泄漏。ui-react 应在 Workspace 卸载时调用。
|
|
131
|
+
*/
|
|
132
|
+
disposeWorkflow(workflowId: string): Promise<void>;
|
|
76
133
|
|
|
77
134
|
// ─── 历史与撤销 ──────────────────────────────────────
|
|
78
135
|
/** 获取工作流的执行历史 */
|
|
79
136
|
history(workflowId: string): Promise<HistoryEntry[]>;
|
|
137
|
+
/**
|
|
138
|
+
* 获取工作流历史状态(条目 + 当前游标)。
|
|
139
|
+
* 游标 -1 表示无已应用条目(初始状态);i 表示第 i 条已应用。
|
|
140
|
+
* 比 history() 多返回 cursor,UI 据此高亮当前步骤。
|
|
141
|
+
*/
|
|
142
|
+
getHistoryState(
|
|
143
|
+
workflowId: string
|
|
144
|
+
): Promise<{ entries: HistoryEntry[]; cursor: number }>;
|
|
80
145
|
/** 撤销一步 */
|
|
81
146
|
undo(workflowId: string): Promise<void>;
|
|
82
147
|
/** 重做一步 */
|
|
83
148
|
redo(workflowId: string): Promise<void>;
|
|
149
|
+
/**
|
|
150
|
+
* 跳转到指定历史条目(按时间顺序的索引,-1 表示回到初始)。
|
|
151
|
+
* 用于 HistoryPanel 点击条目直接跳转,等价于连续 undo/redo 到目标位置。
|
|
152
|
+
* 越界或游标未变时为 no-op。
|
|
153
|
+
*/
|
|
154
|
+
jumpTo(workflowId: string, index: number): Promise<void>;
|
|
84
155
|
|
|
85
156
|
// ─── Asset 管理 ──────────────────────────────────────
|
|
86
157
|
/** 导入资产 */
|
|
@@ -89,10 +160,36 @@ export interface LokvisRuntime {
|
|
|
89
160
|
getAsset(id: AssetId): Promise<Asset>;
|
|
90
161
|
/** 导出资产为 Blob */
|
|
91
162
|
exportAsset(id: AssetId, format?: string): Promise<Blob>;
|
|
163
|
+
/**
|
|
164
|
+
* 读取 image 资产的 EXIF 元数据(W7.3/7.4)。
|
|
165
|
+
*
|
|
166
|
+
* 长期方案(MetadataReader 依赖反转):Runtime 持有 plugin-image 通过
|
|
167
|
+
* `ctx.registerMetadataReader('image.read-exif', fn)` 注册的读取器引用,
|
|
168
|
+
* 按名调用。Plugin 未安装时优雅降级返回 null。
|
|
169
|
+
* readExif 实现位于 plugin-image(Capability 层),不进 engine-image
|
|
170
|
+
* (不符合 Engine 层 Blob↔Blob 纯函数约束)。
|
|
171
|
+
* UI 通过此方法访问 EXIF,不直接依赖 Engine/Plugin 包(五层架构单向依赖)。
|
|
172
|
+
*
|
|
173
|
+
* @param id 资产 ID(须为 image 类型)
|
|
174
|
+
* @returns ExifData;非 image / 无 EXIF / 解析失败 / reader 未注册返回 null
|
|
175
|
+
*/
|
|
176
|
+
readAssetExif(id: AssetId): Promise<ExifData | null>;
|
|
92
177
|
/** 删除资产 */
|
|
93
178
|
removeAsset(id: AssetId): Promise<void>;
|
|
94
179
|
/** 列出所有资产 */
|
|
95
180
|
listAssets(): Promise<Asset[]>;
|
|
181
|
+
/**
|
|
182
|
+
* 查询存储配额使用情况(W6.7)。
|
|
183
|
+
*
|
|
184
|
+
* 返回 `{ usage, quota }`:
|
|
185
|
+
* - `usage`:当前已用字节数(所有资产 metadata.size 之和)
|
|
186
|
+
* - `quota`:配置的存储配额上限(RuntimeConfig.storageQuota,默认 1GB)
|
|
187
|
+
*
|
|
188
|
+
* UI 据此展示"已用/总额"进度条,接近上限(>=80%)时警告。
|
|
189
|
+
* 注意:usage 基于 listAssets 实时计算,反映 runtime 实际占用,
|
|
190
|
+
* 与浏览器 `navigator.storage.estimate()`(origin 整体 OPFS)不同。
|
|
191
|
+
*/
|
|
192
|
+
getStorageUsage(): Promise<{ usage: number; quota: number }>;
|
|
96
193
|
|
|
97
194
|
// ─── 能力查询 ────────────────────────────────────────
|
|
98
195
|
/** 列出所有已注册能力 */
|
package/src/worker-host.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
isWorkerFatalError,
|
|
29
29
|
type WorkerRequest,
|
|
30
30
|
type WorkerPing,
|
|
31
|
+
type WorkerCancel,
|
|
31
32
|
} from './worker-protocol.js';
|
|
32
33
|
|
|
33
34
|
// ─── 传输层抽象 ─────────────────────────────────────────────────
|
|
@@ -105,6 +106,22 @@ export class WorkerHandshakeError extends Error {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/**
|
|
110
|
+
* 请求被 AbortSignal 取消(W3.5 cancel 贯穿)。
|
|
111
|
+
*
|
|
112
|
+
* 与超时/崩溃不同:cancel 是调用方主动发起的,Host 会向 Worker
|
|
113
|
+
* 发送 WorkerCancel 消息让在途操作尽快中止,同时本地立即 reject,
|
|
114
|
+
* 不等 Worker 回响应。
|
|
115
|
+
*/
|
|
116
|
+
export class WorkerRequestAbortedError extends Error {
|
|
117
|
+
readonly method: string;
|
|
118
|
+
constructor(method: string) {
|
|
119
|
+
super(`Request "${method}" was aborted via AbortSignal`);
|
|
120
|
+
this.name = 'WorkerRequestAbortedError';
|
|
121
|
+
this.method = method;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
108
125
|
// ─── Host 选项与事件 ────────────────────────────────────────────
|
|
109
126
|
|
|
110
127
|
export interface WorkerHostOptions {
|
|
@@ -230,14 +247,8 @@ export class WorkerHost {
|
|
|
230
247
|
async dispose(): Promise<void> {
|
|
231
248
|
if (this.status === 'disposed') return;
|
|
232
249
|
this.status = 'disposed';
|
|
233
|
-
this.clearHeartbeat();
|
|
234
250
|
this.rejectAllPending(new Error('WorkerHost disposed'));
|
|
235
|
-
this.
|
|
236
|
-
this.offError?.();
|
|
237
|
-
this.offMessage = null;
|
|
238
|
-
this.offError = null;
|
|
239
|
-
this.transport?.terminate();
|
|
240
|
-
this.transport = null;
|
|
251
|
+
this.teardownTransport();
|
|
241
252
|
this.listeners.clear();
|
|
242
253
|
}
|
|
243
254
|
|
|
@@ -247,7 +258,12 @@ export class WorkerHost {
|
|
|
247
258
|
async request<T = unknown>(
|
|
248
259
|
method: string,
|
|
249
260
|
params?: unknown,
|
|
250
|
-
options?: {
|
|
261
|
+
options?: {
|
|
262
|
+
transfer?: Transferable[];
|
|
263
|
+
timeoutMs?: number;
|
|
264
|
+
/** 取消信号:触发时向 Worker 发送 WorkerCancel 并立即 reject(W3.5) */
|
|
265
|
+
signal?: AbortSignal;
|
|
266
|
+
}
|
|
251
267
|
): Promise<T> {
|
|
252
268
|
if (this.status === 'disposed') throw new Error('WorkerHost is disposed');
|
|
253
269
|
if (this.status === 'dead') throw new WorkerDeadError(this.restartCount);
|
|
@@ -255,6 +271,12 @@ export class WorkerHost {
|
|
|
255
271
|
throw new WorkerRestartingError();
|
|
256
272
|
}
|
|
257
273
|
|
|
274
|
+
const signal = options?.signal;
|
|
275
|
+
// 已取消:不发送,直接 reject
|
|
276
|
+
if (signal?.aborted) {
|
|
277
|
+
throw new WorkerRequestAbortedError(method);
|
|
278
|
+
}
|
|
279
|
+
|
|
258
280
|
const id = createRequestId();
|
|
259
281
|
const timeoutMs = options?.timeoutMs ?? this.opts.requestTimeoutMs;
|
|
260
282
|
const msg: WorkerRequest = { id, type: 'request', method, params };
|
|
@@ -262,13 +284,34 @@ export class WorkerHost {
|
|
|
262
284
|
return new Promise<T>((resolve, reject) => {
|
|
263
285
|
const timer = setTimeout(() => {
|
|
264
286
|
if (this.pending.delete(id)) {
|
|
287
|
+
offAbort();
|
|
265
288
|
reject(new WorkerRequestTimeoutError(method, timeoutMs));
|
|
266
289
|
}
|
|
267
290
|
}, timeoutMs);
|
|
268
291
|
|
|
292
|
+
// W3.5:signal 触发 → 发 WorkerCancel + 立即 reject(不等 Worker 回响应)
|
|
293
|
+
const onAbort = () => {
|
|
294
|
+
if (this.pending.delete(id)) {
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
const cancelMsg: WorkerCancel = { type: 'cancel', id };
|
|
297
|
+
this.send(cancelMsg);
|
|
298
|
+
reject(new WorkerRequestAbortedError(method));
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
const offAbort = signal
|
|
302
|
+
? () => signal.removeEventListener('abort', onAbort)
|
|
303
|
+
: () => {};
|
|
304
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
|
305
|
+
|
|
269
306
|
this.pending.set(id, {
|
|
270
|
-
resolve: (v) =>
|
|
271
|
-
|
|
307
|
+
resolve: (v) => {
|
|
308
|
+
offAbort();
|
|
309
|
+
resolve(v as T);
|
|
310
|
+
},
|
|
311
|
+
reject: (e) => {
|
|
312
|
+
offAbort();
|
|
313
|
+
reject(e);
|
|
314
|
+
},
|
|
272
315
|
timer,
|
|
273
316
|
method,
|
|
274
317
|
});
|
|
@@ -315,12 +358,9 @@ export class WorkerHost {
|
|
|
315
358
|
});
|
|
316
359
|
} catch (err) {
|
|
317
360
|
clearTimeout(readyTimer!);
|
|
318
|
-
this.
|
|
319
|
-
|
|
320
|
-
this.
|
|
321
|
-
this.offError = null;
|
|
322
|
-
transport.terminate();
|
|
323
|
-
this.transport = null;
|
|
361
|
+
// this.transport 在 spawn 顶部已赋值,teardownTransport 可安全清理
|
|
362
|
+
// (clearHeartbeat 对未启动的心跳是无害 no-op)
|
|
363
|
+
this.teardownTransport();
|
|
324
364
|
throw err as Error;
|
|
325
365
|
}
|
|
326
366
|
|
package/src/worker-protocol.ts
CHANGED
|
@@ -48,7 +48,17 @@ export interface WorkerPing {
|
|
|
48
48
|
ts: number;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* 取消一个正在执行的请求(W3.5 cancel 贯穿)。
|
|
53
|
+
* Host 在 AbortSignal 触发时发送,Worker 据此中止当前计算。
|
|
54
|
+
*/
|
|
55
|
+
export interface WorkerCancel {
|
|
56
|
+
type: 'cancel';
|
|
57
|
+
/** 要取消的请求 id */
|
|
58
|
+
id: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type WorkerMessageToWorker = WorkerRequest | WorkerPing | WorkerCancel;
|
|
52
62
|
|
|
53
63
|
// ─── Worker → Host 消息 ─────────────────────────────────────────
|
|
54
64
|
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkflowBuilder - 线性工作流链式构造器(W10.1)
|
|
3
|
+
*
|
|
4
|
+
* 提供链式 API 构造线性 Workflow(第一年不支持 Branch/Loop/Condition/Parallel)。
|
|
5
|
+
*
|
|
6
|
+
* 限制:
|
|
7
|
+
* - 最多 5 步(M1 MVP 约束,免费用户定位,避免复杂度过高)
|
|
8
|
+
* - 节点必须为 transform 类型(load/export 由执行器隐式处理)
|
|
9
|
+
* - 自动生成线性 edges:node[i] → node[i+1]
|
|
10
|
+
*
|
|
11
|
+
* 使用示例:
|
|
12
|
+
* ```typescript
|
|
13
|
+
* const workflow = new WorkflowBuilder('my-workflow', 'Web 优化')
|
|
14
|
+
* .setInput({ type: 'image', multiple: true })
|
|
15
|
+
* .setOutput({ type: 'image', format: 'webp' })
|
|
16
|
+
* .add('image.resize', { width: 1920, fit: 'inside' })
|
|
17
|
+
* .add('image.compress', { format: 'webp', quality: 80 })
|
|
18
|
+
* .add('image.watermark', { text: '@lokvis' })
|
|
19
|
+
* .build();
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* 与 workflow-slice 的 buildLinearWorkflow 区别:
|
|
23
|
+
* - buildLinearWorkflow 是内部工具函数,从 WorkspaceNode[] 构造无校验
|
|
24
|
+
* - WorkflowBuilder 是公开 API,链式调用 + 5 步上限校验 + 元数据完善
|
|
25
|
+
*
|
|
26
|
+
* @module workflow-builder
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
AssetType,
|
|
31
|
+
Workflow,
|
|
32
|
+
WorkflowAuthor,
|
|
33
|
+
WorkflowCategory,
|
|
34
|
+
WorkflowEdge,
|
|
35
|
+
WorkflowInput,
|
|
36
|
+
WorkflowNode,
|
|
37
|
+
WorkflowOutput,
|
|
38
|
+
} from '@lokvis/schema';
|
|
39
|
+
|
|
40
|
+
/** 工作流最大节点数(M1 MVP 约束) */
|
|
41
|
+
export const MAX_WORKFLOW_STEPS = 5;
|
|
42
|
+
|
|
43
|
+
/** Builder 内部节点结构(构造中,与 WorkflowNode 略有差异:label 可独立设置) */
|
|
44
|
+
interface BuilderNode {
|
|
45
|
+
/** 内部生成的节点 id(builder 内部唯一) */
|
|
46
|
+
id: string;
|
|
47
|
+
capability: string;
|
|
48
|
+
params: Record<string, unknown>;
|
|
49
|
+
label?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** WorkflowBuilder 构造选项 */
|
|
53
|
+
export interface WorkflowBuilderOptions {
|
|
54
|
+
/** 工作流 ID(必填,需全局唯一) */
|
|
55
|
+
id: string;
|
|
56
|
+
/** 工作流名称 */
|
|
57
|
+
name: string;
|
|
58
|
+
/** 工作流描述(可选,默认为空字符串) */
|
|
59
|
+
description?: string;
|
|
60
|
+
/** 作者信息(可选,默认本地用户) */
|
|
61
|
+
author?: WorkflowAuthor;
|
|
62
|
+
/** 分类(可选,默认 'image') */
|
|
63
|
+
category?: WorkflowCategory;
|
|
64
|
+
/** 标签(可选) */
|
|
65
|
+
tags?: string[];
|
|
66
|
+
/** 是否为官方工作流(可选,默认 false) */
|
|
67
|
+
official?: boolean;
|
|
68
|
+
/** 最大节点数(可选,默认 MAX_WORKFLOW_STEPS;测试或特殊场景可放宽) */
|
|
69
|
+
maxSteps?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* WorkflowBuilder - 链式构造线性 Workflow。
|
|
74
|
+
*
|
|
75
|
+
* 抛错时机:
|
|
76
|
+
* - add() 超过 maxSteps 时立即抛错(避免构造完才发现步数超限)
|
|
77
|
+
* - build() 在节点为空 / 输入输出未设置时抛错
|
|
78
|
+
* - remove()/move() 在节点 id 不存在时静默(链式 API 容错)
|
|
79
|
+
*/
|
|
80
|
+
export class WorkflowBuilder {
|
|
81
|
+
private readonly options: WorkflowBuilderOptions;
|
|
82
|
+
private readonly maxSteps: number;
|
|
83
|
+
private nodes: BuilderNode[] = [];
|
|
84
|
+
private input: WorkflowInput | null = null;
|
|
85
|
+
private output: WorkflowOutput | null = null;
|
|
86
|
+
private nodeCounter = 0;
|
|
87
|
+
|
|
88
|
+
constructor(options: WorkflowBuilderOptions) {
|
|
89
|
+
if (!options.id) throw new Error('WorkflowBuilder: id is required');
|
|
90
|
+
if (!options.name) throw new Error('WorkflowBuilder: name is required');
|
|
91
|
+
this.options = options;
|
|
92
|
+
this.maxSteps = options.maxSteps ?? MAX_WORKFLOW_STEPS;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 设置工作流输入定义 */
|
|
96
|
+
setInput(input: WorkflowInput): this {
|
|
97
|
+
this.input = input;
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 设置工作流输出定义 */
|
|
102
|
+
setOutput(output: WorkflowOutput): this {
|
|
103
|
+
this.output = output;
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 添加一个节点到链尾。
|
|
109
|
+
*
|
|
110
|
+
* @param capability 能力名,如 `image.resize`
|
|
111
|
+
* @param params 能力参数(可选,默认空对象)
|
|
112
|
+
* @param label 节点标签(可选,UI 显示用)
|
|
113
|
+
* @throws Error 当节点数达到 maxSteps 时
|
|
114
|
+
*/
|
|
115
|
+
add(
|
|
116
|
+
capability: string,
|
|
117
|
+
params: Record<string, unknown> = {},
|
|
118
|
+
label?: string
|
|
119
|
+
): this {
|
|
120
|
+
if (!capability) throw new Error('WorkflowBuilder.add: capability is required');
|
|
121
|
+
if (this.nodes.length >= this.maxSteps) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`WorkflowBuilder.add: max steps (${this.maxSteps}) reached; ` +
|
|
124
|
+
`cannot add more nodes. Adjust maxSteps option if needed.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
this.nodeCounter += 1;
|
|
128
|
+
this.nodes.push({
|
|
129
|
+
id: `${this.options.id}-node-${this.nodeCounter}`,
|
|
130
|
+
capability,
|
|
131
|
+
params,
|
|
132
|
+
label,
|
|
133
|
+
});
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 按 capability 或节点 id 移除节点。
|
|
139
|
+
* 若有多个同 capability 节点,只移除第一个。
|
|
140
|
+
* 若不存在则静默(链式 API 容错)。
|
|
141
|
+
*
|
|
142
|
+
* @param capabilityOrId 能力名或节点 id
|
|
143
|
+
*/
|
|
144
|
+
remove(capabilityOrId: string): this {
|
|
145
|
+
const idx = this.nodes.findIndex(
|
|
146
|
+
(n) => n.id === capabilityOrId || n.capability === capabilityOrId
|
|
147
|
+
);
|
|
148
|
+
if (idx >= 0) {
|
|
149
|
+
this.nodes.splice(idx, 1);
|
|
150
|
+
}
|
|
151
|
+
return this;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 移动节点到新位置(线性链中重排)。
|
|
156
|
+
*
|
|
157
|
+
* @param from 源位置(0-based 索引)
|
|
158
|
+
* @param to 目标位置(0-based 索引,移动后该节点的新位置)
|
|
159
|
+
* @throws Error 当索引越界
|
|
160
|
+
*/
|
|
161
|
+
move(from: number, to: number): this {
|
|
162
|
+
if (from < 0 || from >= this.nodes.length) {
|
|
163
|
+
throw new Error(`WorkflowBuilder.move: from index ${from} out of range`);
|
|
164
|
+
}
|
|
165
|
+
if (to < 0 || to >= this.nodes.length) {
|
|
166
|
+
throw new Error(`WorkflowBuilder.move: to index ${to} out of range`);
|
|
167
|
+
}
|
|
168
|
+
if (from === to) return this;
|
|
169
|
+
const [node] = this.nodes.splice(from, 1);
|
|
170
|
+
// splice(from, 1) 在已校验的 in-range 索引上必返回 1 元素;
|
|
171
|
+
// 此处显式检查以满足 noUncheckedIndexedAccess,并在不变量被打破时报错
|
|
172
|
+
if (!node) {
|
|
173
|
+
throw new Error(`WorkflowBuilder.move: source node at index ${from} missing`);
|
|
174
|
+
}
|
|
175
|
+
this.nodes.splice(to, 0, node);
|
|
176
|
+
return this;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 交换两个节点位置 */
|
|
180
|
+
swap(i: number, j: number): this {
|
|
181
|
+
if (i < 0 || i >= this.nodes.length || j < 0 || j >= this.nodes.length) {
|
|
182
|
+
throw new Error('WorkflowBuilder.swap: index out of range');
|
|
183
|
+
}
|
|
184
|
+
if (i === j) return this;
|
|
185
|
+
const a = this.nodes[i];
|
|
186
|
+
const b = this.nodes[j];
|
|
187
|
+
// 上方范围校验保证 i / j 在界内;显式检查以满足 noUncheckedIndexedAccess
|
|
188
|
+
if (!a || !b) {
|
|
189
|
+
throw new Error('WorkflowBuilder.swap: node missing (invariant violated)');
|
|
190
|
+
}
|
|
191
|
+
this.nodes[i] = b;
|
|
192
|
+
this.nodes[j] = a;
|
|
193
|
+
return this;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 更新指定节点的参数 */
|
|
197
|
+
updateParams(nodeId: string, params: Record<string, unknown>): this {
|
|
198
|
+
const node = this.nodes.find((n) => n.id === nodeId);
|
|
199
|
+
if (node) {
|
|
200
|
+
node.params = { ...node.params, ...params };
|
|
201
|
+
}
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 当前节点数 */
|
|
206
|
+
get size(): number {
|
|
207
|
+
return this.nodes.length;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 是否已达最大节点数 */
|
|
211
|
+
get isFull(): boolean {
|
|
212
|
+
return this.nodes.length >= this.maxSteps;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 获取节点的只读副本(用于 UI 预览) */
|
|
216
|
+
getNodes(): ReadonlyArray<Readonly<BuilderNode>> {
|
|
217
|
+
return this.nodes.map((n) => ({ ...n }));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 构造 Workflow 实例。
|
|
222
|
+
*
|
|
223
|
+
* @throws Error 当节点为空 / 输入未设置 / 输出未设置
|
|
224
|
+
*/
|
|
225
|
+
build(): Workflow {
|
|
226
|
+
if (this.nodes.length === 0) {
|
|
227
|
+
throw new Error('WorkflowBuilder.build: no nodes added');
|
|
228
|
+
}
|
|
229
|
+
if (!this.input) {
|
|
230
|
+
throw new Error('WorkflowBuilder.build: input not set (call setInput first)');
|
|
231
|
+
}
|
|
232
|
+
if (!this.output) {
|
|
233
|
+
throw new Error('WorkflowBuilder.build: output not set (call setOutput first)');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const workflowNodes: WorkflowNode[] = this.nodes.map((n) => ({
|
|
237
|
+
id: n.id,
|
|
238
|
+
type: 'transform' as const,
|
|
239
|
+
capability: n.capability,
|
|
240
|
+
params: n.params,
|
|
241
|
+
label: n.label,
|
|
242
|
+
}));
|
|
243
|
+
|
|
244
|
+
const edges: WorkflowEdge[] = [];
|
|
245
|
+
for (let i = 0; i < workflowNodes.length - 1; i++) {
|
|
246
|
+
const fromNode = workflowNodes[i];
|
|
247
|
+
const toNode = workflowNodes[i + 1];
|
|
248
|
+
// 循环边界 i < length - 1 保证 i 与 i+1 均在界内;显式检查以满足 noUncheckedIndexedAccess
|
|
249
|
+
if (!fromNode || !toNode) {
|
|
250
|
+
throw new Error('WorkflowBuilder.build: edge node missing (invariant violated)');
|
|
251
|
+
}
|
|
252
|
+
edges.push({
|
|
253
|
+
from: fromNode.id,
|
|
254
|
+
to: toNode.id,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
return {
|
|
260
|
+
$schema: `https://lokvis.dev/schemas/workflow.json`,
|
|
261
|
+
id: this.options.id,
|
|
262
|
+
version: '1.0.0',
|
|
263
|
+
name: this.options.name,
|
|
264
|
+
description: this.options.description ?? '',
|
|
265
|
+
author: this.options.author ?? { id: 'local', name: 'Local User' },
|
|
266
|
+
category: this.options.category ?? 'image',
|
|
267
|
+
tags: this.options.tags ?? [],
|
|
268
|
+
nodes: workflowNodes,
|
|
269
|
+
edges,
|
|
270
|
+
inputs: this.input,
|
|
271
|
+
outputs: this.output,
|
|
272
|
+
official: this.options.official ?? false,
|
|
273
|
+
createdAt: now,
|
|
274
|
+
updatedAt: now,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 从 Workflow 反向构造 Builder(用于编辑已有工作流) */
|
|
280
|
+
export function workflowToBuilder(workflow: Workflow, maxSteps?: number): WorkflowBuilder {
|
|
281
|
+
const builder = new WorkflowBuilder({
|
|
282
|
+
id: workflow.id,
|
|
283
|
+
name: workflow.name,
|
|
284
|
+
description: workflow.description,
|
|
285
|
+
author: workflow.author,
|
|
286
|
+
category: workflow.category,
|
|
287
|
+
tags: workflow.tags,
|
|
288
|
+
official: workflow.official,
|
|
289
|
+
maxSteps,
|
|
290
|
+
});
|
|
291
|
+
builder.setInput(workflow.inputs);
|
|
292
|
+
builder.setOutput(workflow.outputs);
|
|
293
|
+
// 按拓扑顺序添加节点(线性链 nodes 数组即顺序)
|
|
294
|
+
for (const node of workflow.nodes) {
|
|
295
|
+
if (node.type === 'transform' && node.capability) {
|
|
296
|
+
builder.add(node.capability, node.params ?? {}, node.label);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return builder;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** AssetType 用于类型导出(便于消费方) */
|
|
303
|
+
export type { AssetType };
|