@lokvis/ui-react 0.1.1-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 (152) hide show
  1. package/LICENSE +21 -0
  2. package/dist/components/AssetPanel.d.ts +2 -2
  3. package/dist/components/AssetPanel.d.ts.map +1 -1
  4. package/dist/components/AssetPanel.js +79 -7
  5. package/dist/components/AssetPanel.js.map +1 -1
  6. package/dist/components/Canvas.d.ts +9 -1
  7. package/dist/components/Canvas.d.ts.map +1 -1
  8. package/dist/components/Canvas.js +39 -2
  9. package/dist/components/Canvas.js.map +1 -1
  10. package/dist/components/CommandPalette.d.ts +48 -0
  11. package/dist/components/CommandPalette.d.ts.map +1 -0
  12. package/dist/components/CommandPalette.js +154 -0
  13. package/dist/components/CommandPalette.js.map +1 -0
  14. package/dist/components/CompareSlider.d.ts +25 -0
  15. package/dist/components/CompareSlider.d.ts.map +1 -0
  16. package/dist/components/CompareSlider.js +106 -0
  17. package/dist/components/CompareSlider.js.map +1 -0
  18. package/dist/components/DownloadPanel.d.ts +20 -0
  19. package/dist/components/DownloadPanel.d.ts.map +1 -0
  20. package/dist/components/DownloadPanel.js +169 -0
  21. package/dist/components/DownloadPanel.js.map +1 -0
  22. package/dist/components/ErrorBanner.d.ts +22 -0
  23. package/dist/components/ErrorBanner.d.ts.map +1 -0
  24. package/dist/components/ErrorBanner.js +47 -0
  25. package/dist/components/ErrorBanner.js.map +1 -0
  26. package/dist/components/ExifPanel.d.ts +26 -0
  27. package/dist/components/ExifPanel.d.ts.map +1 -0
  28. package/dist/components/ExifPanel.js +104 -0
  29. package/dist/components/ExifPanel.js.map +1 -0
  30. package/dist/components/GlobalDropzone.d.ts +37 -0
  31. package/dist/components/GlobalDropzone.d.ts.map +1 -0
  32. package/dist/components/GlobalDropzone.js +154 -0
  33. package/dist/components/GlobalDropzone.js.map +1 -0
  34. package/dist/components/HistoryPanel.d.ts +17 -7
  35. package/dist/components/HistoryPanel.d.ts.map +1 -1
  36. package/dist/components/HistoryPanel.js +74 -62
  37. package/dist/components/HistoryPanel.js.map +1 -1
  38. package/dist/components/Inspector.d.ts.map +1 -1
  39. package/dist/components/Inspector.js +2 -1
  40. package/dist/components/Inspector.js.map +1 -1
  41. package/dist/components/ProgressBar.d.ts +18 -0
  42. package/dist/components/ProgressBar.d.ts.map +1 -0
  43. package/dist/components/ProgressBar.js +35 -0
  44. package/dist/components/ProgressBar.js.map +1 -0
  45. package/dist/components/StatusBar.d.ts +8 -2
  46. package/dist/components/StatusBar.d.ts.map +1 -1
  47. package/dist/components/StatusBar.js +59 -4
  48. package/dist/components/StatusBar.js.map +1 -1
  49. package/dist/components/ThemeToggle.d.ts +20 -0
  50. package/dist/components/ThemeToggle.d.ts.map +1 -0
  51. package/dist/components/ThemeToggle.js +60 -0
  52. package/dist/components/ThemeToggle.js.map +1 -0
  53. package/dist/components/WorkflowEditor.d.ts +24 -0
  54. package/dist/components/WorkflowEditor.d.ts.map +1 -0
  55. package/dist/components/WorkflowEditor.js +160 -0
  56. package/dist/components/WorkflowEditor.js.map +1 -0
  57. package/dist/components/WorkflowTemplates.d.ts +23 -0
  58. package/dist/components/WorkflowTemplates.d.ts.map +1 -0
  59. package/dist/components/WorkflowTemplates.js +32 -0
  60. package/dist/components/WorkflowTemplates.js.map +1 -0
  61. package/dist/components/Workspace.d.ts +48 -2
  62. package/dist/components/Workspace.d.ts.map +1 -1
  63. package/dist/components/Workspace.js +78 -4
  64. package/dist/components/Workspace.js.map +1 -1
  65. package/dist/data/workflow-templates.d.ts +42 -0
  66. package/dist/data/workflow-templates.d.ts.map +1 -0
  67. package/dist/data/workflow-templates.js +112 -0
  68. package/dist/data/workflow-templates.js.map +1 -0
  69. package/dist/hooks/useDebouncedRun.d.ts +49 -0
  70. package/dist/hooks/useDebouncedRun.d.ts.map +1 -0
  71. package/dist/hooks/useDebouncedRun.js +117 -0
  72. package/dist/hooks/useDebouncedRun.js.map +1 -0
  73. package/dist/hooks/useLokvis.js +1 -1
  74. package/dist/hooks/useMediaQuery.d.ts +27 -0
  75. package/dist/hooks/useMediaQuery.d.ts.map +1 -0
  76. package/dist/hooks/useMediaQuery.js +41 -0
  77. package/dist/hooks/useMediaQuery.js.map +1 -0
  78. package/dist/hooks/useShareLink.d.ts +58 -0
  79. package/dist/hooks/useShareLink.d.ts.map +1 -0
  80. package/dist/hooks/useShareLink.js +120 -0
  81. package/dist/hooks/useShareLink.js.map +1 -0
  82. package/dist/hooks/useTheme.d.ts +30 -0
  83. package/dist/hooks/useTheme.d.ts.map +1 -0
  84. package/dist/hooks/useTheme.js +105 -0
  85. package/dist/hooks/useTheme.js.map +1 -0
  86. package/dist/hooks/useWorkflowProgress.d.ts +28 -0
  87. package/dist/hooks/useWorkflowProgress.d.ts.map +1 -0
  88. package/dist/hooks/useWorkflowProgress.js +25 -0
  89. package/dist/hooks/useWorkflowProgress.js.map +1 -0
  90. package/dist/hooks/useWorkflows.d.ts +86 -0
  91. package/dist/hooks/useWorkflows.d.ts.map +1 -0
  92. package/dist/hooks/useWorkflows.js +247 -0
  93. package/dist/hooks/useWorkflows.js.map +1 -0
  94. package/dist/index.d.ts +26 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +26 -0
  97. package/dist/index.js.map +1 -1
  98. package/dist/store/assets-slice.d.ts.map +1 -1
  99. package/dist/store/assets-slice.js +2 -0
  100. package/dist/store/assets-slice.js.map +1 -1
  101. package/dist/store/history-slice.d.ts +18 -0
  102. package/dist/store/history-slice.d.ts.map +1 -0
  103. package/dist/store/history-slice.js +42 -0
  104. package/dist/store/history-slice.js.map +1 -0
  105. package/dist/store/index.d.ts +1 -0
  106. package/dist/store/index.d.ts.map +1 -1
  107. package/dist/store/index.js +3 -0
  108. package/dist/store/index.js.map +1 -1
  109. package/dist/store/runtime-slice.d.ts +10 -2
  110. package/dist/store/runtime-slice.d.ts.map +1 -1
  111. package/dist/store/runtime-slice.js +77 -28
  112. package/dist/store/runtime-slice.js.map +1 -1
  113. package/dist/store/types.d.ts +70 -1
  114. package/dist/store/types.d.ts.map +1 -1
  115. package/dist/store/types.js +10 -0
  116. package/dist/store/types.js.map +1 -1
  117. package/dist/store/workflow-slice.d.ts +1 -1
  118. package/dist/store/workflow-slice.d.ts.map +1 -1
  119. package/dist/store/workflow-slice.js +128 -9
  120. package/dist/store/workflow-slice.js.map +1 -1
  121. package/package.json +9 -6
  122. package/src/components/AssetPanel.tsx +149 -8
  123. package/src/components/Canvas.tsx +79 -2
  124. package/src/components/CommandPalette.tsx +300 -0
  125. package/src/components/CompareSlider.tsx +186 -0
  126. package/src/components/DownloadPanel.tsx +252 -0
  127. package/src/components/ErrorBanner.tsx +95 -0
  128. package/src/components/ExifPanel.tsx +187 -0
  129. package/src/components/GlobalDropzone.tsx +220 -0
  130. package/src/components/HistoryPanel.tsx +231 -104
  131. package/src/components/Inspector.tsx +4 -0
  132. package/src/components/ProgressBar.tsx +104 -0
  133. package/src/components/StatusBar.tsx +124 -5
  134. package/src/components/ThemeToggle.tsx +122 -0
  135. package/src/components/WorkflowEditor.tsx +382 -0
  136. package/src/components/WorkflowTemplates.tsx +101 -0
  137. package/src/components/Workspace.tsx +205 -8
  138. package/src/data/workflow-templates.ts +137 -0
  139. package/src/hooks/useDebouncedRun.ts +151 -0
  140. package/src/hooks/useLokvis.ts +1 -1
  141. package/src/hooks/useMediaQuery.ts +53 -0
  142. package/src/hooks/useShareLink.ts +158 -0
  143. package/src/hooks/useTheme.ts +116 -0
  144. package/src/hooks/useWorkflowProgress.ts +41 -0
  145. package/src/hooks/useWorkflows.ts +326 -0
  146. package/src/index.ts +32 -0
  147. package/src/store/assets-slice.ts +2 -0
  148. package/src/store/history-slice.ts +65 -0
  149. package/src/store/index.ts +3 -0
  150. package/src/store/runtime-slice.ts +89 -32
  151. package/src/store/types.ts +71 -1
  152. package/src/store/workflow-slice.ts +139 -10
@@ -36,6 +36,7 @@ export const createAssetsSlice: StateCreator<
36
36
  await runtime.importAsset({ kind: 'file', file });
37
37
  }
38
38
  await get().refreshAssets();
39
+ await get().refreshStorageUsage();
39
40
  set({ statusMessage: `Imported ${files.length} file(s)` });
40
41
  },
41
42
 
@@ -52,6 +53,7 @@ export const createAssetsSlice: StateCreator<
52
53
  if (!runtime) return;
53
54
  await runtime.removeAsset(id);
54
55
  await get().refreshAssets();
56
+ await get().refreshStorageUsage();
55
57
  if (get().selectedAssetId === id) set({ selectedAssetId: null });
56
58
 
57
59
  // 修复 review 报告:原实现删除 asset 后未 revoke 缩略图 ObjectURL,导致
@@ -0,0 +1,65 @@
1
+ /**
2
+ * History slice —— 历史栈状态与撤销/重做/跳转(W7.1)
3
+ *
4
+ * 负责:
5
+ * - 跟踪当前活跃工作流的历史条目与游标(供 HistoryPanel 高亮当前步骤)
6
+ * - undo / redo / jumpTo 委托给 runtime,history:changed 事件回流自动更新 state
7
+ *
8
+ * 事件桥接:runtime-slice.init 订阅 history:changed,回调里 set 本 slice 的
9
+ * state,保证 store 是历史状态的单一来源,组件无需各自订阅。
10
+ */
11
+ import type { StateCreator } from 'zustand';
12
+ import type { WorkspaceStore, WorkspaceState, WorkspaceActions } from './types.js';
13
+
14
+ export interface HistorySlice
15
+ extends Pick<
16
+ WorkspaceState,
17
+ 'historyEntries' | 'historyCursor' | 'historyWorkflowId'
18
+ >,
19
+ Pick<WorkspaceActions, 'refreshHistory' | 'undo' | 'redo' | 'jumpToHistory'> {}
20
+
21
+ export const createHistorySlice: StateCreator<
22
+ WorkspaceStore,
23
+ [],
24
+ [],
25
+ HistorySlice
26
+ > = (set, get) => ({
27
+ historyEntries: [],
28
+ historyCursor: -1,
29
+ historyWorkflowId: null,
30
+
31
+ async refreshHistory(workflowId) {
32
+ const { runtime } = get();
33
+ if (!runtime) return;
34
+ try {
35
+ const { entries, cursor } = await runtime.getHistoryState(workflowId);
36
+ set({
37
+ historyEntries: entries,
38
+ historyCursor: cursor,
39
+ historyWorkflowId: workflowId,
40
+ });
41
+ } catch {
42
+ // 查询失败保持上次值,不阻断
43
+ }
44
+ },
45
+
46
+ async undo() {
47
+ const { runtime, historyWorkflowId } = get();
48
+ if (!runtime || !historyWorkflowId) return;
49
+ await runtime.undo(historyWorkflowId);
50
+ // history:changed 事件会触发 runtime-slice 的订阅回调更新 state,
51
+ // 此处无需手动 refresh,避免双发
52
+ },
53
+
54
+ async redo() {
55
+ const { runtime, historyWorkflowId } = get();
56
+ if (!runtime || !historyWorkflowId) return;
57
+ await runtime.redo(historyWorkflowId);
58
+ },
59
+
60
+ async jumpToHistory(index) {
61
+ const { runtime, historyWorkflowId } = get();
62
+ if (!runtime || !historyWorkflowId) return;
63
+ await runtime.jumpTo(historyWorkflowId, index);
64
+ },
65
+ });
@@ -16,12 +16,15 @@ import { createRuntimeSlice } from './runtime-slice.js';
16
16
  import { createAssetsSlice } from './assets-slice.js';
17
17
  import { createCapabilitiesSlice } from './capabilities-slice.js';
18
18
  import { createWorkflowSlice } from './workflow-slice.js';
19
+ import { createHistorySlice } from './history-slice.js';
19
20
 
20
21
  export const useWorkspaceStore = create<WorkspaceStore>()((...a) => ({
21
22
  ...createRuntimeSlice(...a),
22
23
  ...createAssetsSlice(...a),
23
24
  ...createCapabilitiesSlice(...a),
24
25
  ...createWorkflowSlice(...a),
26
+ ...createHistorySlice(...a),
25
27
  }));
26
28
 
27
29
  export type { WorkspaceState, WorkspaceActions, WorkspaceStore } from './types.js';
30
+ export { MAX_WORKFLOW_STEPS } from './types.js';
@@ -1,53 +1,110 @@
1
1
  /**
2
2
  * Runtime slice —— Runtime 初始化与状态消息
3
3
  *
4
- * 负责 runtime 实例注入、初始化、全局状态消息与错误。
4
+ * 负责 runtime 实例注入、初始化、全局状态消息与错误、存储配额(W6.7)、
5
+ * 历史栈事件桥接(W7.1)。
5
6
  */
6
7
  import type { StateCreator } from 'zustand';
8
+ import type { LokvisEvent } from '@lokvis/schema';
7
9
  import type { WorkspaceStore, WorkspaceState, WorkspaceActions } from './types.js';
8
10
 
9
11
  export interface RuntimeSlice
10
12
  extends Pick<
11
- WorkspaceState,
12
- 'runtime' | 'initializing' | 'initError' | 'running' | 'statusMessage' | 'error'
13
- >,
13
+ WorkspaceState,
14
+ | 'runtime'
15
+ | 'initializing'
16
+ | 'initError'
17
+ | 'running'
18
+ | 'statusMessage'
19
+ | 'error'
20
+ | 'errorSeq'
21
+ | 'storageUsage'
22
+ >,
14
23
  Pick<
15
24
  WorkspaceActions,
16
- 'init' | 'setStatus' | 'setError'
25
+ 'init' | 'setStatus' | 'setError' | 'refreshStorageUsage'
17
26
  > {}
18
27
 
28
+ /**
29
+ * 历史事件订阅解除函数。
30
+ *
31
+ * 修复 review 报告:原为模块级 let,多个 store 实例(测试 / 多 Workspace)
32
+ * 共用同一变量,后创建的 store 会覆盖前者,先前的订阅无法被正确清理 → 泄漏。
33
+ * 现迁到 createRuntime slice 工厂闭包内,每个 store 实例持有独立 offFn。
34
+ */
19
35
  export const createRuntimeSlice: StateCreator<
20
36
  WorkspaceStore,
21
37
  [],
22
38
  [],
23
39
  RuntimeSlice
24
- > = (set, get) => ({
25
- runtime: null,
26
- initializing: false,
27
- initError: null,
28
- running: false,
29
- statusMessage: 'Idle',
30
- error: null,
40
+ > = (set, get) => {
41
+ /** 当前 store 实例的历史订阅解除函数(闭包私有,实例间隔离) */
42
+ let offHistoryChanged: (() => void) | null = null;
43
+
44
+ return {
45
+ runtime: null,
46
+ initializing: false,
47
+ initError: null,
48
+ running: false,
49
+ statusMessage: 'Idle',
50
+ error: null,
51
+ errorSeq: 0,
52
+ storageUsage: null,
53
+
54
+ async init(runtime) {
55
+ // 重入 init 时先清理上一次的历史订阅(避免重复订阅泄漏)
56
+ if (offHistoryChanged) {
57
+ offHistoryChanged();
58
+ offHistoryChanged = null;
59
+ }
60
+ set({ runtime, initializing: true, initError: null });
61
+ try {
62
+ // W7.1: 订阅 history:changed,把 runtime 历史事件桥接到 store,
63
+ // 使 HistoryPanel 无需各自订阅,store 成为历史状态单一来源。
64
+ // workspace 单工作流场景下直接采用事件携带的 workflowId/entries/cursor
65
+ // 覆盖 store,无需额外过滤。
66
+ offHistoryChanged = runtime.eventBus.on('history:changed', (e: LokvisEvent) => {
67
+ if (e.type !== 'history:changed') return;
68
+ set({
69
+ historyEntries: e.entries,
70
+ historyCursor: e.currentIndex,
71
+ historyWorkflowId: e.workflowId,
72
+ });
73
+ });
74
+ await get().refreshAssets();
75
+ await get().refreshCapabilities();
76
+ await get().refreshStorageUsage();
77
+ set({ initializing: false, statusMessage: 'Ready' });
78
+ } catch (err) {
79
+ set({
80
+ initializing: false,
81
+ initError: err instanceof Error ? err.message : String(err),
82
+ });
83
+ }
84
+ },
31
85
 
32
- async init(runtime) {
33
- set({ runtime, initializing: true, initError: null });
34
- try {
35
- await get().refreshAssets();
36
- await get().refreshCapabilities();
37
- set({ initializing: false, statusMessage: 'Ready' });
38
- } catch (err) {
39
- set({
40
- initializing: false,
41
- initError: err instanceof Error ? err.message : String(err),
42
- });
43
- }
44
- },
86
+ setStatus(message) {
87
+ set({ statusMessage: message });
88
+ },
45
89
 
46
- setStatus(message) {
47
- set({ statusMessage: message });
48
- },
90
+ setError(error) {
91
+ // errorSeq 在设置非 null error 时递增,使 ErrorBanner 能感知新错误事件
92
+ // 重新弹出(避免相同错误消息重复出现时 banner 因引用相等不触发 useEffect)
93
+ set((state) => ({
94
+ error,
95
+ errorSeq: error !== null ? state.errorSeq + 1 : state.errorSeq,
96
+ }));
97
+ },
49
98
 
50
- setError(error) {
51
- set({ error });
52
- },
53
- });
99
+ async refreshStorageUsage() {
100
+ const { runtime } = get();
101
+ if (!runtime) return;
102
+ try {
103
+ const usage = await runtime.getStorageUsage();
104
+ set({ storageUsage: usage });
105
+ } catch {
106
+ // 查询失败不阻断,保持上次值或 null
107
+ }
108
+ },
109
+ };
110
+ };
@@ -4,7 +4,7 @@
4
4
  * 把原 store.ts 顶部的 WorkspaceState / WorkspaceActions 抽出,
5
5
  * 供各 slice 引用,避免循环依赖。
6
6
  */
7
- import type { Asset, Capability } from '@lokvis/schema';
7
+ import type { Asset, Capability, HistoryEntry } from '@lokvis/schema';
8
8
  import type { LokvisRuntime } from '@lokvis/runtime';
9
9
  import type { CapabilityMap, NodeStatus, WorkspaceNode } from '../types.js';
10
10
 
@@ -40,6 +40,30 @@ export interface WorkspaceState {
40
40
  statusMessage: string;
41
41
  /** 错误信息 */
42
42
  error: string | null;
43
+ /**
44
+ * 错误事件序号(单调递增)。每次 setError(非 null) 都递增,使
45
+ * ErrorBanner 能在相同错误消息重复出现时仍感知到"新错误事件"重新弹出。
46
+ * null error 不递增。0 表示初始无错误状态。
47
+ */
48
+ errorSeq: number;
49
+
50
+ /** 存储配额使用情况(W6.7):{ usage, quota } 字节,null 表示未查询 */
51
+ storageUsage: { usage: number; quota: number } | null;
52
+
53
+ /** 历史栈条目(W7.1):当前活跃工作流的执行历史 */
54
+ historyEntries: HistoryEntry[];
55
+ /** 历史栈游标(W7.1):-1 表示无已应用条目(初始);i 表示第 i 条已应用 */
56
+ historyCursor: number;
57
+ /** 当前历史所属的工作流 ID(W7.1) */
58
+ historyWorkflowId: string | null;
59
+
60
+ /** 上次工作流执行的输出 Asset ID 列表(W9.4/W9.5):用于 before/after 对比与下载管理 */
61
+ lastOutputIds: string[];
62
+ /** 当前选中的输出 Asset ID(W9.4):用于多输出场景选择 */
63
+ selectedOutputId: string | null;
64
+
65
+ /** 当前正在运行的工作流 ID(W11.6:用于 cancel) */
66
+ currentRunId: string | null;
43
67
  }
44
68
 
45
69
  /** 工作台操作 */
@@ -69,19 +93,65 @@ export interface WorkspaceActions {
69
93
  selectNode(id: string | null): void;
70
94
  /** 设置节点状态 */
71
95
  setNodeStatus(id: string, status: NodeStatus, error?: string, duration?: number): void;
96
+ /**
97
+ * 移动节点到新位置(线性链重排,W10.4)。
98
+ * @param from 源索引(0-based)
99
+ * @param to 目标索引(0-based,移动后该节点的新位置)
100
+ */
101
+ moveNode(from: number, to: number): void;
102
+ /**
103
+ * 在指定位置插入节点(W11.1)。
104
+ * @param index 目标位置(0-based;越界时自动 clamp 到 [0, length])
105
+ * @param capability 能力名
106
+ */
107
+ insertNodeAt(index: number, capability: string): void;
72
108
 
73
109
  /** 执行工作流 */
74
110
  run(): Promise<Asset[]>;
111
+ /** 取消当前运行(W11.6) */
112
+ cancelRun(): Promise<void>;
113
+ /**
114
+ * 应用工作流模板(W11.4):替换当前 nodes 为模板节点序列。
115
+ * @param templateNodes 模板节点(capability + 默认 params)
116
+ */
117
+ loadWorkflowTemplate(
118
+ templateNodes: Array<{ capability: string; params: Record<string, unknown> }>
119
+ ): void;
75
120
  /** 设置状态消息 */
76
121
  setStatus(message: string): void;
77
122
  /** 设置错误 */
78
123
  setError(error: string | null): void;
124
+ /** 刷新存储配额使用情况(W6.7) */
125
+ refreshStorageUsage(): Promise<void>;
126
+ /** 刷新历史栈(W7.1) */
127
+ refreshHistory(workflowId: string): Promise<void>;
128
+ /** 撤销一步(W7.1) */
129
+ undo(): Promise<void>;
130
+ /** 重做一步(W7.1) */
131
+ redo(): Promise<void>;
132
+ /** 跳转到指定历史条目(W7.1) */
133
+ jumpToHistory(index: number): Promise<void>;
79
134
  /** 清空工作流 */
80
135
  clearWorkflow(): void;
136
+ /** 选择输出资产(W9.4) */
137
+ selectOutput(id: string | null): void;
138
+ /** 清空上次输出列表(W9.4/W9.5) */
139
+ clearOutputs(): void;
81
140
  }
82
141
 
83
142
  export type WorkspaceStore = WorkspaceState & WorkspaceActions;
84
143
 
144
+ /**
145
+ * 工作流最大节点数(M1 MVP 约束)。
146
+ *
147
+ * UI 层独立定义此常量而非 import runtime 包的 MAX_WORKFLOW_STEPS,
148
+ * 因为 ui-react → runtime 的方向依赖违反五层架构约束(UI → Workflow → Runtime)。
149
+ * 两层各自维护同值常量,通过测试 + 文档约定保持同步
150
+ * (runtime 测试断言 MAX_WORKFLOW_STEPS === 5,UI 层此常量也为 5)。
151
+ * 调整上限时需同步修改两处 + 相关注释。
152
+ */
153
+ export const MAX_WORKFLOW_STEPS = 5;
154
+
85
155
  /** 生成节点 ID */
86
156
  export function genNodeId(): string {
87
157
  return `node_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
@@ -12,10 +12,10 @@ import type { StateCreator } from 'zustand';
12
12
  import type { Asset, Workflow, WorkflowEdge, WorkflowNode } from '@lokvis/schema';
13
13
  import type { WorkspaceNode } from '../types.js';
14
14
  import type { WorkspaceStore, WorkspaceState, WorkspaceActions } from './types.js';
15
- import { genNodeId } from './types.js';
15
+ import { genNodeId, MAX_WORKFLOW_STEPS } from './types.js';
16
16
 
17
17
  export interface WorkflowSlice
18
- extends Pick<WorkspaceState, 'nodes' | 'selectedNodeId'>,
18
+ extends Pick<WorkspaceState, 'nodes' | 'selectedNodeId' | 'lastOutputIds' | 'selectedOutputId' | 'currentRunId'>,
19
19
  Pick<
20
20
  WorkspaceActions,
21
21
  | 'addNode'
@@ -23,8 +23,14 @@ export interface WorkflowSlice
23
23
  | 'removeNode'
24
24
  | 'selectNode'
25
25
  | 'setNodeStatus'
26
+ | 'moveNode'
27
+ | 'insertNodeAt'
26
28
  | 'run'
29
+ | 'cancelRun'
30
+ | 'loadWorkflowTemplate'
27
31
  | 'clearWorkflow'
32
+ | 'selectOutput'
33
+ | 'clearOutputs'
28
34
  > {}
29
35
 
30
36
  export const createWorkflowSlice: StateCreator<
@@ -35,8 +41,16 @@ export const createWorkflowSlice: StateCreator<
35
41
  > = (set, get) => ({
36
42
  nodes: [],
37
43
  selectedNodeId: null,
44
+ lastOutputIds: [],
45
+ selectedOutputId: null,
46
+ currentRunId: null,
38
47
 
39
48
  addNode(capability) {
49
+ // W10.1/W10.3: 节点数上限(与 runtime MAX_WORKFLOW_STEPS 对齐,UI 层独立常量)
50
+ if (get().nodes.length >= MAX_WORKFLOW_STEPS) {
51
+ get().setError(`工作流最多 ${MAX_WORKFLOW_STEPS} 个节点(M1 MVP 限制),请先删除不需要的节点`);
52
+ return;
53
+ }
40
54
  const node: WorkspaceNode = {
41
55
  id: genNodeId(),
42
56
  capability,
@@ -74,13 +88,96 @@ export const createWorkflowSlice: StateCreator<
74
88
  }));
75
89
  },
76
90
 
91
+ moveNode(from, to) {
92
+ set((state) => {
93
+ if (
94
+ from < 0 || from >= state.nodes.length ||
95
+ to < 0 || to >= state.nodes.length ||
96
+ from === to
97
+ ) {
98
+ return {};
99
+ }
100
+ const next = [...state.nodes];
101
+ const [moved] = next.splice(from, 1);
102
+ if (!moved) return {};
103
+ next.splice(to, 0, moved);
104
+ return { nodes: next };
105
+ });
106
+ },
107
+
108
+ insertNodeAt(index, capability) {
109
+ // W11.1: 在指定位置插入节点(与节点数上限对齐)
110
+ if (get().nodes.length >= MAX_WORKFLOW_STEPS) {
111
+ get().setError(`工作流最多 ${MAX_WORKFLOW_STEPS} 个节点(M1 MVP 限制),请先删除不需要的节点`);
112
+ return;
113
+ }
114
+ if (!capability) return;
115
+ const node: WorkspaceNode = {
116
+ id: genNodeId(),
117
+ capability,
118
+ params: {},
119
+ status: 'idle',
120
+ };
121
+ set((state) => {
122
+ const next = [...state.nodes];
123
+ const at = Math.max(0, Math.min(index, next.length));
124
+ next.splice(at, 0, node);
125
+ return { nodes: next, selectedNodeId: node.id };
126
+ });
127
+ },
128
+
129
+ loadWorkflowTemplate(templateNodes) {
130
+ // W11.4: 用模板节点序列替换当前工作流,清空上次输出
131
+ const nodes: WorkspaceNode[] = templateNodes.map(({ capability, params }) => ({
132
+ id: genNodeId(),
133
+ capability,
134
+ params,
135
+ status: 'idle' as const,
136
+ }));
137
+ set({
138
+ nodes,
139
+ selectedNodeId: nodes[0]?.id ?? null,
140
+ lastOutputIds: [],
141
+ selectedOutputId: null,
142
+ error: null,
143
+ });
144
+ },
145
+
146
+ async cancelRun() {
147
+ // W11.6: 取消当前运行。本函数仅负责向 runtime 发起 cancel 信号;
148
+ // running / currentRunId / 节点状态的归位由 run() 的 finally 统一处理,
149
+ // 避免 cancel 与 run() 的 finally 同时写状态造成竞态
150
+ // (review 反馈:若 cancel() 抛错,旧实现仍会 set running: false 伪造
151
+ // "已停止",但底层工作流仍在运行,UI 状态与实际不符)。
152
+ //
153
+ // 时序:cancel() 成功 → runtime.run() 返回 status='cancelled'
154
+ // → run() 兜底把 pending/running 节点标 cancelled + finally 归位。
155
+ // cancel() 抛错:不伪造停止状态,仅记录错误让用户感知"取消失败,仍在运行"。
156
+ const { runtime, currentRunId } = get();
157
+ if (!runtime || !currentRunId) return;
158
+ set({ statusMessage: 'Cancelling...' });
159
+ try {
160
+ await runtime.cancel(currentRunId);
161
+ } catch (err) {
162
+ const message = err instanceof Error ? err.message : String(err);
163
+ console.warn('[lokvis] cancelRun failed:', message);
164
+ get().setError(`取消失败,工作流仍在运行:${message}`);
165
+ }
166
+ // 不在此设置 running / currentRunId / 节点状态;
167
+ // 等 run() 的 await runtime.run() 返回后,由 run() 统一归位。
168
+ },
169
+
77
170
  async run() {
78
- const { runtime, nodes, selectedAssetId } = get();
171
+ const { runtime, nodes, selectedAssetId, running } = get();
79
172
  if (!runtime) throw new Error('Runtime not initialized');
80
173
  if (nodes.length === 0) throw new Error('Workflow is empty');
81
174
  if (!selectedAssetId) throw new Error('No asset selected');
175
+ // 并发保护:正在运行时再次调用直接拒绝(避免事件订阅泄漏 + currentRunId 覆盖)
176
+ if (running) {
177
+ throw new Error('Workflow is already running');
178
+ }
82
179
 
83
- set({ running: true, error: null, statusMessage: 'Running...' });
180
+ set({ running: true, error: null, statusMessage: 'Running...', currentRunId: null });
84
181
 
85
182
  // 重置所有节点状态
86
183
  set((state) => ({
@@ -110,6 +207,8 @@ export const createWorkflowSlice: StateCreator<
110
207
  try {
111
208
  // 构建 Workflow
112
209
  const workflow = buildLinearWorkflow(nodes);
210
+ // W11.6: 记录当前 workflowId 供 cancelRun() 使用
211
+ set({ currentRunId: workflow.id });
113
212
  const input = await runtime.getAsset(selectedAssetId);
114
213
 
115
214
  set({ statusMessage: 'Running workflow...' });
@@ -127,15 +226,30 @@ export const createWorkflowSlice: StateCreator<
127
226
  }
128
227
  }
129
228
 
130
- // 加载输出资产
229
+ // 加载输出资产。失败时收集失败 id 并设置 error 让用户感知(不静默丢弃)
131
230
  const outputs: Asset[] = [];
231
+ const failedOutputIds: string[] = [];
132
232
  for (const id of result.outputs) {
133
233
  try {
134
234
  outputs.push(await runtime.getAsset(id));
135
- } catch {
136
- // skip
235
+ } catch (err) {
236
+ console.warn(`[lokvis] getAsset(${id}) failed:`, err);
237
+ failedOutputIds.push(id);
137
238
  }
138
239
  }
240
+ if (failedOutputIds.length > 0) {
241
+ get().setError(`${failedOutputIds.length} 个输出资产加载失败,可能存储损坏`);
242
+ }
243
+
244
+ // W9.4/W9.5: 记录输出 Asset ID,供 before/after 对比与下载管理使用。
245
+ // 仅在 status === 'completed' 时记录,失败/取消的输出无意义。
246
+ if (result.status === 'completed' && outputs.length > 0) {
247
+ const firstOutput = outputs[0];
248
+ set({
249
+ lastOutputIds: outputs.map((o) => o.id),
250
+ selectedOutputId: firstOutput ? firstOutput.id : null,
251
+ });
252
+ }
139
253
 
140
254
  set({
141
255
  running: false,
@@ -143,18 +257,20 @@ export const createWorkflowSlice: StateCreator<
143
257
  });
144
258
 
145
259
  if (result.status === 'failed') {
146
- set({ error: result.error ?? 'Workflow failed' });
260
+ get().setError(result.error ?? 'Workflow failed');
147
261
  }
148
262
 
149
263
  await get().refreshAssets();
264
+ await get().refreshStorageUsage();
150
265
  return outputs;
151
266
  } catch (err) {
152
267
  const message = err instanceof Error ? err.message : String(err);
153
268
  set({
154
269
  running: false,
155
- error: message,
270
+ currentRunId: null,
156
271
  statusMessage: 'Failed',
157
272
  });
273
+ get().setError(message);
158
274
  // 异常退出时把仍在 pending/running 的节点标 failed
159
275
  for (const node of get().nodes) {
160
276
  if (node.status === 'pending' || node.status === 'running') {
@@ -163,6 +279,8 @@ export const createWorkflowSlice: StateCreator<
163
279
  }
164
280
  throw err;
165
281
  } finally {
282
+ // W11.6: 清除 currentRunId(成功/失败/取消都应清除)
283
+ set({ currentRunId: null });
166
284
  offStarted();
167
285
  offFinished();
168
286
  offNodeFailed();
@@ -172,6 +290,14 @@ export const createWorkflowSlice: StateCreator<
172
290
  clearWorkflow() {
173
291
  set({ nodes: [], selectedNodeId: null });
174
292
  },
293
+
294
+ selectOutput(id) {
295
+ set({ selectedOutputId: id });
296
+ },
297
+
298
+ clearOutputs() {
299
+ set({ lastOutputIds: [], selectedOutputId: null });
300
+ },
175
301
  });
176
302
 
177
303
  /** 把工作台节点序列构建为线性 Workflow */
@@ -187,7 +313,10 @@ function buildLinearWorkflow(nodes: WorkspaceNode[]): Workflow {
187
313
  }));
188
314
  const edges: WorkflowEdge[] = [];
189
315
  for (let i = 0; i < workflowNodes.length - 1; i++) {
190
- edges.push({ from: workflowNodes[i]!.id, to: workflowNodes[i + 1]!.id });
316
+ const fromNode = workflowNodes[i];
317
+ const toNode = workflowNodes[i + 1];
318
+ if (!fromNode || !toNode) continue;
319
+ edges.push({ from: fromNode.id, to: toNode.id });
191
320
  }
192
321
  return {
193
322
  id: `wf_${Date.now().toString(36)}`,