@lokvis/ui-react 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.
Files changed (133) hide show
  1. package/dist/components/AssetPanel.d.ts.map +1 -1
  2. package/dist/components/AssetPanel.js +10 -54
  3. package/dist/components/AssetPanel.js.map +1 -1
  4. package/dist/components/Canvas.d.ts.map +1 -1
  5. package/dist/components/Canvas.js +4 -4
  6. package/dist/components/Canvas.js.map +1 -1
  7. package/dist/components/CompareSlider.d.ts +4 -3
  8. package/dist/components/CompareSlider.d.ts.map +1 -1
  9. package/dist/components/CompareSlider.js +5 -4
  10. package/dist/components/CompareSlider.js.map +1 -1
  11. package/dist/components/DownloadPanel.d.ts.map +1 -1
  12. package/dist/components/DownloadPanel.js +1 -8
  13. package/dist/components/DownloadPanel.js.map +1 -1
  14. package/dist/components/ErrorBoundary.d.ts +40 -0
  15. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  16. package/dist/components/ErrorBoundary.js +52 -0
  17. package/dist/components/ErrorBoundary.js.map +1 -0
  18. package/dist/components/ExifPanel.js +1 -1
  19. package/dist/components/ExifPanel.js.map +1 -1
  20. package/dist/components/GlobalDropzone.d.ts.map +1 -1
  21. package/dist/components/GlobalDropzone.js +10 -3
  22. package/dist/components/GlobalDropzone.js.map +1 -1
  23. package/dist/components/Inspector.d.ts.map +1 -1
  24. package/dist/components/Inspector.js +10 -7
  25. package/dist/components/Inspector.js.map +1 -1
  26. package/dist/components/ParamForm.d.ts.map +1 -1
  27. package/dist/components/ParamForm.js +8 -5
  28. package/dist/components/ParamForm.js.map +1 -1
  29. package/dist/components/PipelineBar.d.ts.map +1 -1
  30. package/dist/components/PipelineBar.js +17 -26
  31. package/dist/components/PipelineBar.js.map +1 -1
  32. package/dist/components/StatusBar.d.ts.map +1 -1
  33. package/dist/components/StatusBar.js +3 -12
  34. package/dist/components/StatusBar.js.map +1 -1
  35. package/dist/components/StatusDot.d.ts +12 -0
  36. package/dist/components/StatusDot.d.ts.map +1 -0
  37. package/dist/components/StatusDot.js +15 -0
  38. package/dist/components/StatusDot.js.map +1 -0
  39. package/dist/components/ThemeToggle.js +1 -1
  40. package/dist/components/ThemeToggle.js.map +1 -1
  41. package/dist/components/ToolRunner.d.ts +53 -0
  42. package/dist/components/ToolRunner.d.ts.map +1 -0
  43. package/dist/components/ToolRunner.js +142 -0
  44. package/dist/components/ToolRunner.js.map +1 -0
  45. package/dist/components/Toolbar.js +2 -2
  46. package/dist/components/Toolbar.js.map +1 -1
  47. package/dist/components/WorkflowEditor.d.ts.map +1 -1
  48. package/dist/components/WorkflowEditor.js +31 -40
  49. package/dist/components/WorkflowEditor.js.map +1 -1
  50. package/dist/components/WorkflowTemplates.d.ts.map +1 -1
  51. package/dist/components/WorkflowTemplates.js +15 -5
  52. package/dist/components/WorkflowTemplates.js.map +1 -1
  53. package/dist/components/Workspace.d.ts +23 -1
  54. package/dist/components/Workspace.d.ts.map +1 -1
  55. package/dist/components/Workspace.js +60 -13
  56. package/dist/components/Workspace.js.map +1 -1
  57. package/dist/hooks/useCustomPresets.d.ts +98 -0
  58. package/dist/hooks/useCustomPresets.d.ts.map +1 -0
  59. package/dist/hooks/useCustomPresets.js +210 -0
  60. package/dist/hooks/useCustomPresets.js.map +1 -0
  61. package/dist/hooks/useLokvis.d.ts +19 -1
  62. package/dist/hooks/useLokvis.d.ts.map +1 -1
  63. package/dist/hooks/useLokvis.js +30 -9
  64. package/dist/hooks/useLokvis.js.map +1 -1
  65. package/dist/index.d.ts +3 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +3 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/store/assets-slice.d.ts +8 -1
  70. package/dist/store/assets-slice.d.ts.map +1 -1
  71. package/dist/store/assets-slice.js +101 -54
  72. package/dist/store/assets-slice.js.map +1 -1
  73. package/dist/store/capabilities-slice.d.ts +1 -1
  74. package/dist/store/capabilities-slice.d.ts.map +1 -1
  75. package/dist/store/capabilities-slice.js +8 -1
  76. package/dist/store/capabilities-slice.js.map +1 -1
  77. package/dist/store/index.d.ts +1 -1
  78. package/dist/store/index.d.ts.map +1 -1
  79. package/dist/store/index.js +1 -1
  80. package/dist/store/index.js.map +1 -1
  81. package/dist/store/runtime-slice.d.ts.map +1 -1
  82. package/dist/store/runtime-slice.js +22 -16
  83. package/dist/store/runtime-slice.js.map +1 -1
  84. package/dist/store/subscribe-utils.d.ts +45 -0
  85. package/dist/store/subscribe-utils.d.ts.map +1 -0
  86. package/dist/store/subscribe-utils.js +69 -0
  87. package/dist/store/subscribe-utils.js.map +1 -0
  88. package/dist/store/types.d.ts +19 -11
  89. package/dist/store/types.d.ts.map +1 -1
  90. package/dist/store/types.js +0 -10
  91. package/dist/store/types.js.map +1 -1
  92. package/dist/store/workflow-slice.d.ts +1 -1
  93. package/dist/store/workflow-slice.d.ts.map +1 -1
  94. package/dist/store/workflow-slice.js +19 -51
  95. package/dist/store/workflow-slice.js.map +1 -1
  96. package/dist/utils.d.ts +24 -0
  97. package/dist/utils.d.ts.map +1 -0
  98. package/dist/utils.js +31 -0
  99. package/dist/utils.js.map +1 -0
  100. package/package.json +7 -6
  101. package/src/components/AssetPanel.tsx +18 -58
  102. package/src/components/Canvas.tsx +11 -7
  103. package/src/components/CompareSlider.tsx +13 -10
  104. package/src/components/DownloadPanel.tsx +1 -7
  105. package/src/components/ErrorBoundary.tsx +101 -0
  106. package/src/components/ExifPanel.tsx +1 -1
  107. package/src/components/GlobalDropzone.tsx +8 -3
  108. package/src/components/Inspector.tsx +24 -16
  109. package/src/components/ParamForm.tsx +12 -9
  110. package/src/components/PipelineBar.tsx +20 -27
  111. package/src/components/StatusBar.tsx +3 -10
  112. package/src/components/StatusDot.tsx +27 -0
  113. package/src/components/ThemeToggle.tsx +1 -1
  114. package/src/components/ToolRunner.tsx +352 -0
  115. package/src/components/Toolbar.tsx +2 -2
  116. package/src/components/WorkflowEditor.tsx +50 -43
  117. package/src/components/WorkflowTemplates.tsx +27 -4
  118. package/src/components/Workspace.tsx +97 -10
  119. package/src/hooks/useCustomPresets.ts +284 -0
  120. package/src/hooks/useLokvis.ts +43 -10
  121. package/src/index.ts +3 -0
  122. package/src/store/assets-slice.ts +106 -52
  123. package/src/store/capabilities-slice.ts +10 -2
  124. package/src/store/index.ts +1 -1
  125. package/src/store/runtime-slice.ts +20 -15
  126. package/src/store/subscribe-utils.ts +82 -0
  127. package/src/store/types.ts +19 -12
  128. package/src/store/workflow-slice.ts +19 -53
  129. package/src/utils.ts +41 -0
  130. package/dist/store.d.ts +0 -76
  131. package/dist/store.d.ts.map +0 -1
  132. package/dist/store.js +0 -217
  133. package/dist/store.js.map +0 -1
@@ -0,0 +1,352 @@
1
+ /**
2
+ * ToolRunner - 单工具页组合组件
3
+ *
4
+ * 为单工具操作场景(压缩、裁剪、格式转换等)提供一站式 UI:
5
+ * 上传 → 调参 → 处理 → 对比 → 下载。
6
+ *
7
+ * 不依赖 Workspace Store(zustand),通过 engine prop 注入处理函数,
8
+ * 可在任何 React 应用中独立使用。
9
+ *
10
+ * 五层架构:UI 层不直接依赖 Engine 层,engine 函数由消费方传入。
11
+ * 消费方需自行实现按 capability.name 路由到对应 Blob↔Blob 操作的 wrapper
12
+ * (engine-image 已不再提供 runTool 统一入口,各 operation 需直接组合调用)。
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * import { ToolRunner } from '@lokvis/ui-react';
17
+ * import { IMAGE_COMPRESS, compress } from '@lokvis/engine-image';
18
+ *
19
+ * <ToolRunner
20
+ * capability={IMAGE_COMPRESS}
21
+ * engine={(name, input, params, opts) => compress(input, params, opts?.signal)}
22
+ * onOpenInWorkspace={(state) => { ... }}
23
+ * />
24
+ * ```
25
+ */
26
+
27
+ import * as React from 'react';
28
+ import type { Capability } from '@lokvis/schema';
29
+ import { Button, Card } from '@lokvis/ui-core';
30
+ import { ParamForm } from './ParamForm.js';
31
+
32
+ type EngineFn = (
33
+ toolName: string,
34
+ input: Blob,
35
+ params: Record<string, unknown>,
36
+ options?: { signal?: AbortSignal; onProgress?: (p: number) => void },
37
+ ) => Promise<Blob>;
38
+
39
+ export interface ToolRunnerProps {
40
+ /** 工具能力定义(决定参数表单和输入类型) */
41
+ capability: Capability;
42
+ /** 处理函数(按 capability.name 路由到对应 Blob↔Blob 操作的 wrapper) */
43
+ engine: EngineFn;
44
+ /** 在 Workspace 中打开的回调(携带当前状态) */
45
+ onOpenInWorkspace?: (state: {
46
+ inputBlob: Blob;
47
+ outputBlob: Blob | null;
48
+ params: Record<string, unknown>;
49
+ }) => void;
50
+ className?: string;
51
+ }
52
+
53
+ /**
54
+ * ToolRunner - 单工具操作组件。
55
+ *
56
+ * 内部组合:上传区 + 参数表单 + 预览/对比 + 进度 + 下载。
57
+ * 所有状态自管理,不依赖外部 store。
58
+ */
59
+ export function ToolRunner({
60
+ capability,
61
+ engine,
62
+ onOpenInWorkspace,
63
+ className = '',
64
+ }: ToolRunnerProps) {
65
+ const [inputBlob, setInputBlob] = React.useState<Blob | null>(null);
66
+ const [inputUrl, setInputUrl] = React.useState<string | null>(null);
67
+ const [outputBlob, setOutputBlob] = React.useState<Blob | null>(null);
68
+ const [outputUrl, setOutputUrl] = React.useState<string | null>(null);
69
+ const [params, setParams] = React.useState<Record<string, unknown>>(() => {
70
+ const defaults: Record<string, unknown> = {};
71
+ for (const p of capability.params) {
72
+ if (p.default !== undefined) defaults[p.name] = p.default;
73
+ }
74
+ return defaults;
75
+ });
76
+ const [progress, setProgress] = React.useState(0);
77
+ const [processing, setProcessing] = React.useState(false);
78
+ const [error, setError] = React.useState<string | null>(null);
79
+ const [dragging, setDragging] = React.useState(false);
80
+ const abortRef = React.useRef<AbortController | null>(null);
81
+
82
+ // 清理 ObjectURL
83
+ React.useEffect(() => {
84
+ return () => {
85
+ if (inputUrl) URL.revokeObjectURL(inputUrl);
86
+ if (outputUrl) URL.revokeObjectURL(outputUrl);
87
+ };
88
+ }, [inputUrl, outputUrl]);
89
+
90
+ // W21.6: unmount 时 abort 进行中的处理,防止后台泄漏
91
+ // (engine 函数持有 Worker/Canvas 资源,卸载后继续运行是浪费 + 潜在内存泄漏)
92
+ React.useEffect(() => {
93
+ return () => {
94
+ if (abortRef.current) {
95
+ abortRef.current.abort();
96
+ abortRef.current = null;
97
+ }
98
+ };
99
+ }, []);
100
+
101
+ const accept = capability.inputTypes.join(',');
102
+
103
+ function handleFiles(files: FileList | File[] | null) {
104
+ if (!files || files.length === 0) return;
105
+ const file = files[0];
106
+ if (!file) return;
107
+
108
+ setInputBlob(file);
109
+ if (inputUrl) URL.revokeObjectURL(inputUrl);
110
+ setInputUrl(URL.createObjectURL(file));
111
+
112
+ // 重置输出
113
+ setOutputBlob(null);
114
+ if (outputUrl) {
115
+ URL.revokeObjectURL(outputUrl);
116
+ setOutputUrl(null);
117
+ }
118
+ }
119
+
120
+ async function handleProcess() {
121
+ if (!inputBlob) return;
122
+ setProcessing(true);
123
+ setProgress(0);
124
+ setError(null);
125
+
126
+ abortRef.current = new AbortController();
127
+ try {
128
+ const result = await engine(capability.name, inputBlob, params, {
129
+ signal: abortRef.current.signal,
130
+ onProgress: setProgress,
131
+ });
132
+ setOutputBlob(result);
133
+ if (outputUrl) URL.revokeObjectURL(outputUrl);
134
+ setOutputUrl(URL.createObjectURL(result));
135
+ } catch (err) {
136
+ if (err instanceof Error && err.name === 'AbortError') return;
137
+ setError(err instanceof Error ? err.message : String(err));
138
+ } finally {
139
+ setProcessing(false);
140
+ abortRef.current = null;
141
+ }
142
+ }
143
+
144
+ function handleCancel() {
145
+ abortRef.current?.abort();
146
+ }
147
+
148
+ const downloadName = outputBlob
149
+ ? `output.${outputBlob.type.split('/')[1] ?? 'bin'}`
150
+ : 'output';
151
+
152
+ return (
153
+ <div className={`flex flex-col gap-6 ${className}`}>
154
+ {/* Upload zone */}
155
+ {!inputBlob && (
156
+ <label
157
+ className={`block cursor-pointer rounded-xl border-2 border-dashed p-10 text-center transition-all ${
158
+ dragging
159
+ ? 'border-[var(--lokvis-primary)] bg-[var(--lokvis-primary)]/10'
160
+ : 'border-[var(--lokvis-border)] hover:border-[var(--lokvis-primary)]/50 hover:bg-[var(--lokvis-surface-muted)]'
161
+ }`}
162
+ onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
163
+ onDragLeave={() => setDragging(false)}
164
+ onDrop={(e) => {
165
+ e.preventDefault();
166
+ setDragging(false);
167
+ handleFiles(e.dataTransfer.files);
168
+ }}
169
+ >
170
+ <input
171
+ type="file"
172
+ accept={accept}
173
+ className="hidden"
174
+ onChange={(e) => handleFiles(e.target.files)}
175
+ />
176
+ <div className="flex flex-col items-center gap-3">
177
+ <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--lokvis-surface-muted)]">
178
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--lokvis-fg-subtle)]">
179
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
180
+ <polyline points="17 8 12 3 7 8" />
181
+ <line x1="12" y1="3" x2="12" y2="15" />
182
+ </svg>
183
+ </div>
184
+ <div>
185
+ <p className="text-sm font-medium text-[var(--lokvis-fg)]">
186
+ Drop file here or click to upload
187
+ </p>
188
+ <p className="mt-1 text-xs text-[var(--lokvis-fg-subtle)]">
189
+ Accepted: {accept || 'any file'}
190
+ </p>
191
+ </div>
192
+ </div>
193
+ </label>
194
+ )}
195
+
196
+ {/* Processing area */}
197
+ {inputBlob && (
198
+ <div className="grid gap-6 md:grid-cols-2">
199
+ {/* Left: Parameters */}
200
+ <div className="space-y-4">
201
+ <Card>
202
+ <div className="mb-3 flex items-center justify-between">
203
+ <h3 className="text-sm font-semibold text-[var(--lokvis-fg)]">
204
+ Parameters
205
+ </h3>
206
+ <span className="rounded bg-[var(--lokvis-surface-muted)] px-2 py-0.5 font-mono text-xs text-[var(--lokvis-primary)]">
207
+ {capability.name}
208
+ </span>
209
+ </div>
210
+ <ParamForm
211
+ capability={capability}
212
+ values={params}
213
+ onChange={setParams}
214
+ />
215
+ </Card>
216
+
217
+ {/* Actions */}
218
+ <div className="flex gap-3">
219
+ <Button
220
+ variant="primary"
221
+ size="md"
222
+ onClick={handleProcess}
223
+ loading={processing}
224
+ disabled={!inputBlob}
225
+ >
226
+ {processing ? 'Processing...' : 'Process'}
227
+ </Button>
228
+ <Button
229
+ variant="secondary"
230
+ size="md"
231
+ onClick={() => {
232
+ setInputBlob(null);
233
+ if (inputUrl) URL.revokeObjectURL(inputUrl);
234
+ setInputUrl(null);
235
+ setOutputBlob(null);
236
+ if (outputUrl) {
237
+ URL.revokeObjectURL(outputUrl);
238
+ setOutputUrl(null);
239
+ }
240
+ }}
241
+ >
242
+ Change file
243
+ </Button>
244
+ {onOpenInWorkspace && (
245
+ <Button
246
+ variant="ghost"
247
+ size="md"
248
+ onClick={() => onOpenInWorkspace({ inputBlob, outputBlob, params })}
249
+ >
250
+ Open in Workspace →
251
+ </Button>
252
+ )}
253
+ </div>
254
+
255
+ {/* Progress */}
256
+ {processing && (
257
+ <div className="space-y-2">
258
+ <div className="flex items-center justify-between text-xs text-[var(--lokvis-fg-muted)]">
259
+ <span>Processing...</span>
260
+ <span className="tabular-nums">{Math.round(progress)}%</span>
261
+ </div>
262
+ <div className="h-1.5 overflow-hidden rounded-full bg-[var(--lokvis-border)]">
263
+ <div
264
+ className="h-full rounded-full bg-[var(--lokvis-primary)] transition-all duration-200"
265
+ style={{ width: `${progress}%` }}
266
+ />
267
+ </div>
268
+ <button
269
+ type="button"
270
+ onClick={handleCancel}
271
+ className="text-xs text-[var(--lokvis-danger)] hover:underline"
272
+ >
273
+ Cancel
274
+ </button>
275
+ </div>
276
+ )}
277
+
278
+ {/* Error */}
279
+ {error && (
280
+ <div className="rounded-lg border border-[var(--lokvis-danger)]/30 bg-[var(--lokvis-danger)]/10 px-4 py-3 text-sm text-[var(--lokvis-danger)]">
281
+ {error}
282
+ </div>
283
+ )}
284
+
285
+ {/* Download */}
286
+ {outputBlob && outputUrl && (
287
+ <Card>
288
+ <div className="flex items-center justify-between">
289
+ <div>
290
+ <p className="text-sm font-medium text-[var(--lokvis-fg)]">Ready to download</p>
291
+ <p className="mt-1 text-xs text-[var(--lokvis-fg-muted)]">
292
+ {(outputBlob.size / 1024).toFixed(1)} KB · {outputBlob.type.split('/')[1] ?? 'file'}
293
+ </p>
294
+ </div>
295
+ <a
296
+ href={outputUrl}
297
+ download={downloadName}
298
+ className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--lokvis-primary)] px-4 py-2 text-sm font-medium text-[var(--lokvis-primary-fg)] transition-colors hover:bg-[var(--lokvis-primary-hover)]"
299
+ >
300
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
301
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
302
+ <polyline points="7 10 12 15 17 10" />
303
+ <line x1="12" y1="15" x2="12" y2="3" />
304
+ </svg>
305
+ Download
306
+ </a>
307
+ </div>
308
+ </Card>
309
+ )}
310
+ </div>
311
+
312
+ {/* Right: Preview */}
313
+ <div className="space-y-4">
314
+ {/* Input preview */}
315
+ {inputUrl && (
316
+ <Card>
317
+ <h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--lokvis-fg-subtle)]">
318
+ Input
319
+ </h4>
320
+ <img
321
+ src={inputUrl}
322
+ alt="Input preview"
323
+ className="max-h-80 w-full rounded-lg object-contain"
324
+ />
325
+ <p className="mt-2 text-xs text-[var(--lokvis-fg-muted)]">
326
+ {(inputBlob.size / 1024).toFixed(1)} KB · {inputBlob.type.split('/')[1] ?? 'file'}
327
+ </p>
328
+ </Card>
329
+ )}
330
+
331
+ {/* Output preview */}
332
+ {outputUrl && (
333
+ <Card>
334
+ <h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--lokvis-fg-subtle)]">
335
+ Output
336
+ </h4>
337
+ <img
338
+ src={outputUrl}
339
+ alt="Output preview"
340
+ className="max-h-80 w-full rounded-lg object-contain"
341
+ />
342
+ <p className="mt-2 text-xs text-[var(--lokvis-fg-muted)]">
343
+ {(outputBlob!.size / 1024).toFixed(1)} KB · {outputBlob!.type.split('/')[1] ?? 'file'}
344
+ </p>
345
+ </Card>
346
+ )}
347
+ </div>
348
+ </div>
349
+ )}
350
+ </div>
351
+ );
352
+ }
@@ -36,13 +36,13 @@ export function Toolbar({ title = 'Lokvis Workspace', rightExtra }: ToolbarProps
36
36
  }
37
37
 
38
38
  return (
39
- <header className="flex h-12 shrink-0 items-center justify-between border-b border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-4">
39
+ <header className="flex h-[var(--lokvis-toolbar-h)] shrink-0 items-center justify-between border-b border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-4">
40
40
  {/* Left: Brand */}
41
41
  <div className="flex items-center gap-2.5 min-w-0">
42
42
  <span
43
43
  className="shrink-0 text-base font-bold leading-none"
44
44
  style={{
45
- background: 'linear-gradient(135deg, #6366f1, #a855f7)',
45
+ background: 'linear-gradient(135deg, var(--lokvis-primary), var(--lokvis-info))',
46
46
  WebkitBackgroundClip: 'text',
47
47
  WebkitTextFillColor: 'transparent',
48
48
  }}
@@ -18,9 +18,11 @@
18
18
  */
19
19
 
20
20
  import * as React from 'react';
21
- import { Icon } from '@lokvis/ui-core';
21
+ import { ConfirmDialog, Icon, Input } from '@lokvis/ui-core';
22
22
  import type { Capability } from '@lokvis/schema';
23
23
  import { useWorkspaceStore, MAX_WORKFLOW_STEPS } from '../store/index.js';
24
+ import { filterCapabilities } from '../utils.js';
25
+ import { StatusDot } from './StatusDot.js';
24
26
 
25
27
  export interface WorkflowEditorProps {
26
28
  className?: string;
@@ -34,11 +36,14 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
34
36
  const moveNode = useWorkspaceStore((s) => s.moveNode);
35
37
  const insertNodeAt = useWorkspaceStore((s) => s.insertNodeAt);
36
38
  const capabilities = useWorkspaceStore((s) => s.capabilities);
39
+ const stubCapabilities = useWorkspaceStore((s) => s.stubCapabilities);
37
40
  const clearWorkflow = useWorkspaceStore((s) => s.clearWorkflow);
38
41
 
39
42
  // 拖拽状态:被拖拽的节点索引 + 当前 hover 的插入位置
40
43
  const [dragIndex, setDragIndex] = React.useState<number | null>(null);
41
44
  const [hoverIndex, setHoverIndex] = React.useState<number | null>(null);
45
+ // 清空确认对话框
46
+ const [clearOpen, setClearOpen] = React.useState(false);
42
47
 
43
48
  const handleDragStart = (e: React.DragEvent, index: number) => {
44
49
  setDragIndex(index);
@@ -115,11 +120,7 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
115
120
  {nodes.length > 0 && (
116
121
  <button
117
122
  type="button"
118
- onClick={() => {
119
- if (window.confirm('清空当前工作流?所有节点将被移除。')) {
120
- clearWorkflow();
121
- }
122
- }}
123
+ onClick={() => setClearOpen(true)}
123
124
  className="text-[10px] text-[var(--lokvis-fg-subtle)] transition-colors hover:text-[var(--lokvis-danger)]"
124
125
  aria-label="清空工作流"
125
126
  >
@@ -148,6 +149,7 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
148
149
  <InsertConnector
149
150
  index={0}
150
151
  capabilities={capabilities}
152
+ stubCapabilities={stubCapabilities}
151
153
  onInsert={insertNodeAt}
152
154
  disabled={nodes.length >= MAX_WORKFLOW_STEPS}
153
155
  />
@@ -159,6 +161,8 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
159
161
  const isHoverTarget = hoverIndex === i && dragIndex !== null && dragIndex !== i;
160
162
  return (
161
163
  <React.Fragment key={node.id}>
164
+ {/* D7: remove button 作为 node button 的兄弟而非子元素(HTML 规范禁止 button 嵌套) */}
165
+ <div className="group relative flex shrink-0 items-center">
162
166
  <button
163
167
  type="button"
164
168
  draggable
@@ -170,7 +174,7 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
170
174
  onClick={() => selectNode(node.id)}
171
175
  onKeyDown={(e) => handleKeyDown(e, node.id, i)}
172
176
  aria-label={`节点 ${node.capability},位置 ${i + 1},拖拽或方向键重排,Delete 删除`}
173
- className={`group relative flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[11px] font-medium transition-all ${
177
+ className={`flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[11px] font-medium transition-all ${
174
178
  isDragging
175
179
  ? 'opacity-40'
176
180
  : isHoverTarget
@@ -192,27 +196,24 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
192
196
  </span>
193
197
  <span className="font-mono">{node.capability}</span>
194
198
  <StatusDot status={node.status} />
195
- {/* 删除按钮:作为兄弟 button 而非嵌套(HTML 规范禁止 button 嵌套)
196
- 使用 position absolute 浮在节点 button 之上,避免叠加在 capability 文字上;
197
- group-hover 显示;stopPropagation 避免触发 selectNode */}
199
+ </button>
200
+ {/* 删除按钮:兄弟 button(position absolute 浮在节点右上角),group-hover 显示 */}
198
201
  <button
199
202
  type="button"
200
- onClick={(e) => {
201
- e.stopPropagation();
202
- removeNode(node.id);
203
- }}
203
+ onClick={() => removeNode(node.id)}
204
204
  aria-label="删除节点"
205
205
  tabIndex={-1}
206
206
  className="absolute -right-1 -top-1 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-[var(--lokvis-danger)]/15 hover:text-[var(--lokvis-danger)]"
207
207
  >
208
208
  <Icon size={10} strokeWidth={3}><path d="M6 18L18 6M6 6l12 12" /></Icon>
209
209
  </button>
210
- </button>
210
+ </div>
211
211
 
212
212
  {/* W11.1: 每个节点后的插入连接器(最后一个用于追加到 Output 前) */}
213
213
  <InsertConnector
214
214
  index={i + 1}
215
215
  capabilities={capabilities}
216
+ stubCapabilities={stubCapabilities}
216
217
  onInsert={insertNodeAt}
217
218
  disabled={nodes.length >= MAX_WORKFLOW_STEPS}
218
219
  />
@@ -238,27 +239,24 @@ export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
238
239
  <span>拖拽重排 · 方向键移动 · Delete 删除 · hover 箭头处插入节点</span>
239
240
  </div>
240
241
  )}
242
+
243
+ {/* 清空确认对话框(替代 window.confirm) */}
244
+ <ConfirmDialog
245
+ open={clearOpen}
246
+ title="清空工作流"
247
+ message="清空当前工作流?所有节点将被移除。"
248
+ confirmText="清空"
249
+ variant="danger"
250
+ onConfirm={() => {
251
+ clearWorkflow();
252
+ setClearOpen(false);
253
+ }}
254
+ onClose={() => setClearOpen(false)}
255
+ />
241
256
  </div>
242
257
  );
243
258
  }
244
259
 
245
- function StatusDot({ status }: { status: string }) {
246
- const color =
247
- status === 'running'
248
- ? 'bg-[var(--lokvis-warning)]'
249
- : status === 'success'
250
- ? 'bg-[var(--lokvis-success)]'
251
- : status === 'failed'
252
- ? 'bg-[var(--lokvis-danger)]'
253
- : status === 'pending'
254
- ? 'bg-[var(--lokvis-fg-subtle)]'
255
- : 'bg-[var(--lokvis-fg-subtle)]';
256
-
257
- const animate = status === 'running' ? 'animate-pulse' : '';
258
-
259
- return <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${color} ${animate}`} />;
260
- }
261
-
262
260
  /**
263
261
  * InsertConnector - 节点之间的连接器 + 插入按钮(W11.1)
264
262
  *
@@ -268,11 +266,13 @@ function StatusDot({ status }: { status: string }) {
268
266
  function InsertConnector({
269
267
  index,
270
268
  capabilities,
269
+ stubCapabilities,
271
270
  onInsert,
272
271
  disabled,
273
272
  }: {
274
273
  index: number;
275
274
  capabilities: Capability[];
275
+ stubCapabilities: Set<string>;
276
276
  onInsert: (index: number, capability: string) => void;
277
277
  disabled?: boolean;
278
278
  }) {
@@ -296,12 +296,7 @@ function InsertConnector({
296
296
  }, [open]);
297
297
 
298
298
  const filtered = React.useMemo(
299
- () =>
300
- capabilities.filter(
301
- (c) =>
302
- c.name.toLowerCase().includes(filter.toLowerCase()) ||
303
- c.description.toLowerCase().includes(filter.toLowerCase())
304
- ),
299
+ () => filterCapabilities(capabilities, filter),
305
300
  [capabilities, filter]
306
301
  );
307
302
 
@@ -340,16 +335,16 @@ function InsertConnector({
340
335
 
341
336
  {/* 弹出菜单 */}
342
337
  {open && (
343
- <div className="absolute top-full left-1/2 z-50 mt-1 w-56 -translate-x-1/2 rounded-lg border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] shadow-lg">
338
+ <div className="absolute top-full left-1/2 z-50 mt-1 w-56 -translate-x-1/2 rounded-lg border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] shadow-[var(--lokvis-elevation-2)]">
344
339
  {/* 搜索 */}
345
340
  <div className="border-b border-[var(--lokvis-border)] p-1.5">
346
- <input
341
+ <Input
347
342
  type="text"
343
+ size="sm"
348
344
  autoFocus
349
345
  placeholder="搜索 capability..."
350
346
  value={filter}
351
347
  onChange={(e) => setFilter(e.target.value)}
352
- className="w-full rounded border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-2 py-1 text-[11px] placeholder-zinc-400 focus:border-[var(--lokvis-primary)] focus:outline-none"
353
348
  />
354
349
  </div>
355
350
  {/* 列表 */}
@@ -357,21 +352,33 @@ function InsertConnector({
357
352
  {filtered.length === 0 ? (
358
353
  <p className="px-2 py-2 text-center text-[10px] text-[var(--lokvis-fg-subtle)]">无匹配 capability</p>
359
354
  ) : (
360
- filtered.map((cap) => (
355
+ filtered.map((cap) => {
356
+ // A7: stub-only 能力显示 "Coming Soon" 标记
357
+ const isStubOnly = stubCapabilities.has(cap.name);
358
+ return (
361
359
  <button
362
360
  key={cap.name}
363
361
  type="button"
364
362
  onClick={() => handleSelect(cap.name)}
363
+ title={isStubOnly ? 'Coming soon — no engine installed yet' : undefined}
365
364
  className="block w-full rounded px-2 py-1 text-left transition-colors hover:bg-[var(--lokvis-primary)]/10"
366
365
  >
366
+ <span className="flex items-center justify-between gap-1.5">
367
367
  <span className="block font-mono text-[10px] font-medium text-[var(--lokvis-fg-muted)]">
368
368
  {cap.name}
369
369
  </span>
370
+ {isStubOnly && (
371
+ <span className="shrink-0 rounded px-1 py-0.5 text-[8px] font-medium uppercase bg-[var(--lokvis-warning)]/15 text-[var(--lokvis-warning)]">
372
+ Soon
373
+ </span>
374
+ )}
375
+ </span>
370
376
  <span className="block truncate text-[9px] text-[var(--lokvis-fg-muted)]">
371
377
  {cap.description}
372
378
  </span>
373
379
  </button>
374
- ))
380
+ );
381
+ })
375
382
  )}
376
383
  </div>
377
384
  </div>
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import * as React from 'react';
15
- import { Icon } from '@lokvis/ui-core';
15
+ import { ConfirmDialog, Icon } from '@lokvis/ui-core';
16
16
  import { useWorkspaceStore } from '../store/index.js';
17
17
  import { WORKFLOW_TEMPLATES, type WorkflowTemplate } from '../data/workflow-templates.js';
18
18
 
@@ -31,13 +31,18 @@ export function WorkflowTemplates({
31
31
  }: WorkflowTemplatesProps) {
32
32
  const loadWorkflowTemplate = useWorkspaceStore((s) => s.loadWorkflowTemplate);
33
33
  const nodes = useWorkspaceStore((s) => s.nodes);
34
+ // 待确认应用的模板(非空工作流替换前需用户确认)
35
+ const [pendingTpl, setPendingTpl] = React.useState<WorkflowTemplate | null>(null);
34
36
 
35
37
  const handleApply = (tpl: WorkflowTemplate) => {
36
38
  if (confirmIfNotEmpty && nodes.length > 0) {
37
- if (!window.confirm(`应用模板「${tpl.name}」将替换当前 ${nodes.length} 个节点,继续?`)) {
39
+ setPendingTpl(tpl);
38
40
  return;
39
41
  }
40
- }
42
+ applyTemplate(tpl);
43
+ };
44
+
45
+ const applyTemplate = (tpl: WorkflowTemplate) => {
41
46
  loadWorkflowTemplate(
42
47
  tpl.nodes.map((n) => ({ capability: n.capability, params: n.params }))
43
48
  );
@@ -62,7 +67,7 @@ export function WorkflowTemplates({
62
67
  key={tpl.id}
63
68
  type="button"
64
69
  onClick={() => handleApply(tpl)}
65
- className="group flex items-start gap-2 rounded-md border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-2.5 py-2 text-left transition-all hover:border-[var(--lokvis-primary)]/50 hover:bg-[var(--lokvis-primary)]/10/50"
70
+ className="group flex items-start gap-2 rounded-md border border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] px-2.5 py-2 text-left transition-all hover:border-[var(--lokvis-primary)]/50 hover:bg-[var(--lokvis-primary)]/10"
66
71
  >
67
72
  <span className="mt-0.5 text-base leading-none">{tpl.icon}</span>
68
73
  <div className="flex-1 min-w-0">
@@ -96,6 +101,24 @@ export function WorkflowTemplates({
96
101
  </button>
97
102
  ))}
98
103
  </div>
104
+
105
+ {/* 替换确认对话框(替代 window.confirm) */}
106
+ <ConfirmDialog
107
+ open={pendingTpl !== null}
108
+ title="应用模板"
109
+ message={
110
+ pendingTpl
111
+ ? `应用模板「${pendingTpl.name}」将替换当前 ${nodes.length} 个节点,继续?`
112
+ : ''
113
+ }
114
+ confirmText="替换"
115
+ variant="danger"
116
+ onConfirm={() => {
117
+ if (pendingTpl) applyTemplate(pendingTpl);
118
+ setPendingTpl(null);
119
+ }}
120
+ onClose={() => setPendingTpl(null)}
121
+ />
99
122
  </div>
100
123
  );
101
124
  }