@lokvis/runtime 0.2.0-beta.0 → 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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/dist/asset-store.d.ts +5 -2
  3. package/dist/asset-store.d.ts.map +1 -1
  4. package/dist/asset-store.js +93 -3
  5. package/dist/asset-store.js.map +1 -1
  6. package/dist/batch-processor.d.ts +200 -0
  7. package/dist/batch-processor.d.ts.map +1 -0
  8. package/dist/batch-processor.js +505 -0
  9. package/dist/batch-processor.js.map +1 -0
  10. package/dist/degradation.d.ts +109 -0
  11. package/dist/degradation.d.ts.map +1 -0
  12. package/dist/degradation.js +184 -0
  13. package/dist/degradation.js.map +1 -0
  14. package/dist/history-store.d.ts +76 -0
  15. package/dist/history-store.d.ts.map +1 -0
  16. package/dist/history-store.js +109 -0
  17. package/dist/history-store.js.map +1 -0
  18. package/dist/idb-asset-store.js +1 -1
  19. package/dist/idb-asset-store.js.map +1 -1
  20. package/dist/index.d.ts +5 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +5 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/memory-guard.d.ts +109 -0
  25. package/dist/memory-guard.d.ts.map +1 -0
  26. package/dist/memory-guard.js +161 -0
  27. package/dist/memory-guard.js.map +1 -0
  28. package/dist/opfs-asset-store.d.ts +35 -4
  29. package/dist/opfs-asset-store.d.ts.map +1 -1
  30. package/dist/opfs-asset-store.js +94 -10
  31. package/dist/opfs-asset-store.js.map +1 -1
  32. package/dist/runtime.d.ts +130 -6
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +404 -16
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/types.d.ts +106 -1
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/worker-host.d.ts +13 -0
  39. package/dist/worker-host.d.ts.map +1 -1
  40. package/dist/worker-host.js +47 -15
  41. package/dist/worker-host.js.map +1 -1
  42. package/dist/worker-protocol.d.ts +10 -1
  43. package/dist/worker-protocol.d.ts.map +1 -1
  44. package/dist/worker-protocol.js.map +1 -1
  45. package/dist/workflow-builder.d.ts +124 -0
  46. package/dist/workflow-builder.d.ts.map +1 -0
  47. package/dist/workflow-builder.js +240 -0
  48. package/dist/workflow-builder.js.map +1 -0
  49. package/package.json +3 -2
  50. package/src/asset-store.ts +103 -3
  51. package/src/batch-processor.ts +699 -0
  52. package/src/degradation.ts +241 -0
  53. package/src/history-store.ts +159 -0
  54. package/src/idb-asset-store.ts +1 -1
  55. package/src/index.ts +5 -0
  56. package/src/memory-guard.ts +202 -0
  57. package/src/opfs-asset-store.ts +118 -10
  58. package/src/runtime.ts +445 -12
  59. package/src/types.ts +107 -0
  60. package/src/worker-host.ts +56 -16
  61. package/src/worker-protocol.ts +11 -1
  62. package/src/workflow-builder.ts +303 -0
package/src/types.ts CHANGED
@@ -11,12 +11,33 @@ import type {
11
11
  AssetSource,
12
12
  Capability,
13
13
  EngineSelectionStrategy,
14
+ ExifData,
14
15
  HistoryEntry,
15
16
  McpManifest,
17
+ PluginConfig,
18
+ PluginInstaller,
16
19
  } from '@lokvis/schema';
17
20
  import type { Workflow, WorkflowResult } from '@lokvis/schema';
18
21
  import type { EventBus } from '@lokvis/schema';
19
22
  import type { AssetStore } from './asset-store.js';
23
+ import type { BatchProcessor } from './batch-processor.js';
24
+ import type { HistoryStore, HistoryStoreOptions } from './history-store.js';
25
+
26
+ /**
27
+ * 待安装的插件条目。
28
+ *
29
+ * 由 SDK 的 `createLokvis({ plugins })` 与 `loadPlugin()` 构造,
30
+ * 交给 `LokvisRuntime.installPlugin()` 在 runtime 内部完成注册
31
+ * (能力声明 + 调用 installer + 发射 plugin:loaded 事件)。
32
+ *
33
+ * 把"插件安装"提升为 Runtime 公共接口,避免 SDK 通过
34
+ * `instanceof LokvisRuntimeImpl` + 私有 `_getAssetStore()` /
35
+ * `_getCapabilityRegistry()` 反向耦合具体实现类。
36
+ */
37
+ export interface PluginInstallEntry {
38
+ config: PluginConfig;
39
+ install: PluginInstaller;
40
+ }
20
41
 
21
42
  /** Runtime 配置 */
22
43
  export interface RuntimeConfig {
@@ -40,6 +61,29 @@ export interface RuntimeConfig {
40
61
  * 按 OPFS → IndexedDB → Memory 降级。
41
62
  */
42
63
  assetStore?: AssetStore;
64
+ /**
65
+ * 注入自定义 HistoryStore(W7.2 历史持久化)。
66
+ * 默认由 createRuntime 在 enableIndexedDB 时通过 createHistoryStore 自动创建;
67
+ * IndexedDB 不可用时为 undefined,历史退化为仅内存模式。
68
+ */
69
+ historyStore?: HistoryStore;
70
+ /**
71
+ * HistoryStore 工厂选项(W7.2,仅 historyStore 未注入时生效)。
72
+ * 测试可注入 dbInstance 或自定义 dbName。
73
+ */
74
+ historyStoreOptions?: HistoryStoreOptions;
75
+ /**
76
+ * 是否启用 Pro 模式(W6.2 / PROJECT_PLAN 17.4)。
77
+ * - false(默认):批量上限 10 文件、并发 4、workflow 槽位 5
78
+ * - true:批量无上限、并发 16、workflow 槽位无限
79
+ * 由 cloud 侧 createLokvis({ auth }) 注入 session 后置为 true。
80
+ */
81
+ isPro?: boolean;
82
+ /**
83
+ * 内存预算(字节,W3.3 MemoryGuard)。
84
+ * 默认 512MB。BatchProcessor 据此在内存压力高时收缩并发槽位。
85
+ */
86
+ memoryBudget?: number;
43
87
  }
44
88
 
45
89
  /** Runtime 状态 */
@@ -74,6 +118,10 @@ export interface LokvisRuntime {
74
118
  readonly status: RuntimeStatus;
75
119
  /** 事件总线 */
76
120
  readonly eventBus: EventBus;
121
+ /** 是否为 Pro 模式(影响批量上限/并发槽位/workflow 数,W6.2) */
122
+ readonly isPro: boolean;
123
+ /** 批量处理器(W6.1:并发控制 + 进度 + 失败重试) */
124
+ readonly batch: BatchProcessor;
77
125
 
78
126
  // ─── 工作流执行 ──────────────────────────────────────
79
127
  /** 运行工作流 */
@@ -104,10 +152,24 @@ export interface LokvisRuntime {
104
152
  // ─── 历史与撤销 ──────────────────────────────────────
105
153
  /** 获取工作流的执行历史 */
106
154
  history(workflowId: string): Promise<HistoryEntry[]>;
155
+ /**
156
+ * 获取工作流历史状态(条目 + 当前游标)。
157
+ * 游标 -1 表示无已应用条目(初始状态);i 表示第 i 条已应用。
158
+ * 比 history() 多返回 cursor,UI 据此高亮当前步骤。
159
+ */
160
+ getHistoryState(
161
+ workflowId: string
162
+ ): Promise<{ entries: HistoryEntry[]; cursor: number }>;
107
163
  /** 撤销一步 */
108
164
  undo(workflowId: string): Promise<void>;
109
165
  /** 重做一步 */
110
166
  redo(workflowId: string): Promise<void>;
167
+ /**
168
+ * 跳转到指定历史条目(按时间顺序的索引,-1 表示回到初始)。
169
+ * 用于 HistoryPanel 点击条目直接跳转,等价于连续 undo/redo 到目标位置。
170
+ * 越界或游标未变时为 no-op。
171
+ */
172
+ jumpTo(workflowId: string, index: number): Promise<void>;
111
173
 
112
174
  // ─── Asset 管理 ──────────────────────────────────────
113
175
  /** 导入资产 */
@@ -116,10 +178,36 @@ export interface LokvisRuntime {
116
178
  getAsset(id: AssetId): Promise<Asset>;
117
179
  /** 导出资产为 Blob */
118
180
  exportAsset(id: AssetId, format?: string): Promise<Blob>;
181
+ /**
182
+ * 读取 image 资产的 EXIF 元数据(W7.3/7.4)。
183
+ *
184
+ * 长期方案(MetadataReader 依赖反转):Runtime 持有 plugin-image 通过
185
+ * `ctx.registerMetadataReader('image.read-exif', fn)` 注册的读取器引用,
186
+ * 按名调用。Plugin 未安装时优雅降级返回 null。
187
+ * readExif 实现位于 plugin-image(Capability 层),不进 engine-image
188
+ * (不符合 Engine 层 Blob↔Blob 纯函数约束)。
189
+ * UI 通过此方法访问 EXIF,不直接依赖 Engine/Plugin 包(五层架构单向依赖)。
190
+ *
191
+ * @param id 资产 ID(须为 image 类型)
192
+ * @returns ExifData;非 image / 无 EXIF / 解析失败 / reader 未注册返回 null
193
+ */
194
+ readAssetExif(id: AssetId): Promise<ExifData | null>;
119
195
  /** 删除资产 */
120
196
  removeAsset(id: AssetId): Promise<void>;
121
197
  /** 列出所有资产 */
122
198
  listAssets(): Promise<Asset[]>;
199
+ /**
200
+ * 查询存储配额使用情况(W6.7)。
201
+ *
202
+ * 返回 `{ usage, quota }`:
203
+ * - `usage`:当前已用字节数(所有资产 metadata.size 之和)
204
+ * - `quota`:配置的存储配额上限(RuntimeConfig.storageQuota,默认 1GB)
205
+ *
206
+ * UI 据此展示"已用/总额"进度条,接近上限(>=80%)时警告。
207
+ * 注意:usage 基于 listAssets 实时计算,反映 runtime 实际占用,
208
+ * 与浏览器 `navigator.storage.estimate()`(origin 整体 OPFS)不同。
209
+ */
210
+ getStorageUsage(): Promise<{ usage: number; quota: number }>;
123
211
 
124
212
  // ─── 能力查询 ────────────────────────────────────────
125
213
  /** 列出所有已注册能力 */
@@ -145,4 +233,23 @@ export interface LokvisRuntime {
145
233
  * 仅为接口对称性(未来可能涉及异步加载)。
146
234
  */
147
235
  toMcpManifest(options?: ToMcpManifestOptions): McpManifest;
236
+
237
+ // ─── 插件安装 ────────────────────────────────────────
238
+ /**
239
+ * 在 Runtime 上安装一个插件。
240
+ *
241
+ * 步骤:
242
+ * 1. 把 plugin.config.capabilities 注册到 CapabilityRegistry(声明能力)
243
+ * 2. 构造受限 PluginContext(只暴露 getAsset/importAsset/getAssetBlob/
244
+ * createAsset/listCapabilities + eventBus + registerCapability +
245
+ * registerMetadataReader + registerPanel + log)
246
+ * 3. 调用 plugin.install(ctx),让插件注册 CapabilityImplementation
247
+ * 4. 发射 `plugin:loaded` 事件
248
+ *
249
+ * SDK 的 `createLokvis({ plugins })` 与 `loadPlugin()` 都委托到这里,
250
+ * 不再需要 `instanceof LokvisRuntimeImpl` + `_getAssetStore()` 等内部 API。
251
+ *
252
+ * @throws plugin.install 抛出的任何错误(runtime 不吞错,由 SDK 包成 PluginLoadError)
253
+ */
254
+ installPlugin(plugin: PluginInstallEntry): Promise<void>;
148
255
  }
@@ -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.offMessage?.();
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?: { transfer?: Transferable[]; timeoutMs?: number }
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) => resolve(v as T),
271
- reject,
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.offMessage?.();
319
- this.offError?.();
320
- this.offMessage = null;
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
 
@@ -48,7 +48,17 @@ export interface WorkerPing {
48
48
  ts: number;
49
49
  }
50
50
 
51
- export type WorkerMessageToWorker = WorkerRequest | WorkerPing;
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 };