@lokvis/ui-react 0.2.1 → 0.4.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 (165) hide show
  1. package/dist/components/AssetPanel.d.ts.map +1 -1
  2. package/dist/components/AssetPanel.js +18 -62
  3. package/dist/components/AssetPanel.js.map +1 -1
  4. package/dist/components/Canvas.d.ts +4 -4
  5. package/dist/components/Canvas.d.ts.map +1 -1
  6. package/dist/components/Canvas.js +9 -9
  7. package/dist/components/Canvas.js.map +1 -1
  8. package/dist/components/CommandPalette.d.ts +7 -7
  9. package/dist/components/CommandPalette.d.ts.map +1 -1
  10. package/dist/components/CommandPalette.js +10 -10
  11. package/dist/components/CommandPalette.js.map +1 -1
  12. package/dist/components/CompareSlider.d.ts +9 -8
  13. package/dist/components/CompareSlider.d.ts.map +1 -1
  14. package/dist/components/CompareSlider.js +10 -9
  15. package/dist/components/CompareSlider.js.map +1 -1
  16. package/dist/components/DownloadPanel.d.ts +3 -3
  17. package/dist/components/DownloadPanel.d.ts.map +1 -1
  18. package/dist/components/DownloadPanel.js +10 -17
  19. package/dist/components/DownloadPanel.js.map +1 -1
  20. package/dist/components/ErrorBanner.d.ts +2 -2
  21. package/dist/components/ErrorBanner.d.ts.map +1 -1
  22. package/dist/components/ErrorBanner.js +3 -3
  23. package/dist/components/ErrorBanner.js.map +1 -1
  24. package/dist/components/ErrorBoundary.d.ts +40 -0
  25. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  26. package/dist/components/ErrorBoundary.js +52 -0
  27. package/dist/components/ErrorBoundary.js.map +1 -0
  28. package/dist/components/ExifPanel.d.ts +1 -1
  29. package/dist/components/ExifPanel.d.ts.map +1 -1
  30. package/dist/components/ExifPanel.js +2 -2
  31. package/dist/components/ExifPanel.js.map +1 -1
  32. package/dist/components/GlobalDropzone.d.ts +5 -5
  33. package/dist/components/GlobalDropzone.d.ts.map +1 -1
  34. package/dist/components/GlobalDropzone.js +15 -8
  35. package/dist/components/GlobalDropzone.js.map +1 -1
  36. package/dist/components/HistoryPanel.d.ts +2 -2
  37. package/dist/components/HistoryPanel.d.ts.map +1 -1
  38. package/dist/components/HistoryPanel.js +23 -23
  39. package/dist/components/HistoryPanel.js.map +1 -1
  40. package/dist/components/Inspector.d.ts.map +1 -1
  41. package/dist/components/Inspector.js +13 -10
  42. package/dist/components/Inspector.js.map +1 -1
  43. package/dist/components/ParamForm.d.ts.map +1 -1
  44. package/dist/components/ParamForm.js +10 -7
  45. package/dist/components/ParamForm.js.map +1 -1
  46. package/dist/components/PipelineBar.d.ts.map +1 -1
  47. package/dist/components/PipelineBar.js +19 -28
  48. package/dist/components/PipelineBar.js.map +1 -1
  49. package/dist/components/ProgressBar.d.ts +4 -4
  50. package/dist/components/ProgressBar.d.ts.map +1 -1
  51. package/dist/components/ProgressBar.js +8 -8
  52. package/dist/components/ProgressBar.js.map +1 -1
  53. package/dist/components/StatusBar.d.ts +3 -3
  54. package/dist/components/StatusBar.d.ts.map +1 -1
  55. package/dist/components/StatusBar.js +12 -21
  56. package/dist/components/StatusBar.js.map +1 -1
  57. package/dist/components/StatusDot.d.ts +12 -0
  58. package/dist/components/StatusDot.d.ts.map +1 -0
  59. package/dist/components/StatusDot.js +15 -0
  60. package/dist/components/StatusDot.js.map +1 -0
  61. package/dist/components/ThemeToggle.d.ts +4 -4
  62. package/dist/components/ThemeToggle.d.ts.map +1 -1
  63. package/dist/components/ThemeToggle.js +7 -7
  64. package/dist/components/ThemeToggle.js.map +1 -1
  65. package/dist/components/ToolRunner.d.ts +53 -0
  66. package/dist/components/ToolRunner.d.ts.map +1 -0
  67. package/dist/components/ToolRunner.js +142 -0
  68. package/dist/components/ToolRunner.js.map +1 -0
  69. package/dist/components/Toolbar.d.ts.map +1 -1
  70. package/dist/components/Toolbar.js +3 -3
  71. package/dist/components/Toolbar.js.map +1 -1
  72. package/dist/components/WorkflowEditor.d.ts +6 -6
  73. package/dist/components/WorkflowEditor.d.ts.map +1 -1
  74. package/dist/components/WorkflowEditor.js +40 -49
  75. package/dist/components/WorkflowEditor.js.map +1 -1
  76. package/dist/components/WorkflowTemplates.d.ts +2 -2
  77. package/dist/components/WorkflowTemplates.d.ts.map +1 -1
  78. package/dist/components/WorkflowTemplates.js +17 -7
  79. package/dist/components/WorkflowTemplates.js.map +1 -1
  80. package/dist/components/Workspace.d.ts +45 -23
  81. package/dist/components/Workspace.d.ts.map +1 -1
  82. package/dist/components/Workspace.js +87 -40
  83. package/dist/components/Workspace.js.map +1 -1
  84. package/dist/hooks/useCustomPresets.d.ts +98 -0
  85. package/dist/hooks/useCustomPresets.d.ts.map +1 -0
  86. package/dist/hooks/useCustomPresets.js +210 -0
  87. package/dist/hooks/useCustomPresets.js.map +1 -0
  88. package/dist/hooks/useLokvis.d.ts +19 -1
  89. package/dist/hooks/useLokvis.d.ts.map +1 -1
  90. package/dist/hooks/useLokvis.js +30 -9
  91. package/dist/hooks/useLokvis.js.map +1 -1
  92. package/dist/index.d.ts +4 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +6 -0
  95. package/dist/index.js.map +1 -1
  96. package/dist/store/assets-slice.d.ts +8 -1
  97. package/dist/store/assets-slice.d.ts.map +1 -1
  98. package/dist/store/assets-slice.js +101 -54
  99. package/dist/store/assets-slice.js.map +1 -1
  100. package/dist/store/capabilities-slice.d.ts +1 -1
  101. package/dist/store/capabilities-slice.d.ts.map +1 -1
  102. package/dist/store/capabilities-slice.js +8 -1
  103. package/dist/store/capabilities-slice.js.map +1 -1
  104. package/dist/store/index.d.ts +1 -1
  105. package/dist/store/index.d.ts.map +1 -1
  106. package/dist/store/index.js +1 -1
  107. package/dist/store/index.js.map +1 -1
  108. package/dist/store/runtime-slice.d.ts.map +1 -1
  109. package/dist/store/runtime-slice.js +22 -16
  110. package/dist/store/runtime-slice.js.map +1 -1
  111. package/dist/store/subscribe-utils.d.ts +45 -0
  112. package/dist/store/subscribe-utils.d.ts.map +1 -0
  113. package/dist/store/subscribe-utils.js +69 -0
  114. package/dist/store/subscribe-utils.js.map +1 -0
  115. package/dist/store/types.d.ts +19 -11
  116. package/dist/store/types.d.ts.map +1 -1
  117. package/dist/store/types.js +0 -10
  118. package/dist/store/types.js.map +1 -1
  119. package/dist/store/workflow-slice.d.ts +1 -1
  120. package/dist/store/workflow-slice.d.ts.map +1 -1
  121. package/dist/store/workflow-slice.js +19 -51
  122. package/dist/store/workflow-slice.js.map +1 -1
  123. package/dist/utils.d.ts +24 -0
  124. package/dist/utils.d.ts.map +1 -0
  125. package/dist/utils.js +31 -0
  126. package/dist/utils.js.map +1 -0
  127. package/package.json +7 -6
  128. package/src/components/AssetPanel.tsx +224 -264
  129. package/src/components/Canvas.tsx +178 -174
  130. package/src/components/CommandPalette.tsx +242 -242
  131. package/src/components/CompareSlider.tsx +168 -165
  132. package/src/components/DownloadPanel.tsx +201 -207
  133. package/src/components/ErrorBanner.tsx +64 -64
  134. package/src/components/ErrorBoundary.tsx +101 -0
  135. package/src/components/ExifPanel.tsx +154 -154
  136. package/src/components/GlobalDropzone.tsx +175 -170
  137. package/src/components/HistoryPanel.tsx +219 -219
  138. package/src/components/Inspector.tsx +145 -137
  139. package/src/components/ParamForm.tsx +140 -137
  140. package/src/components/PipelineBar.tsx +97 -104
  141. package/src/components/ProgressBar.tsx +78 -78
  142. package/src/components/StatusBar.tsx +145 -152
  143. package/src/components/StatusDot.tsx +27 -0
  144. package/src/components/ThemeToggle.tsx +98 -98
  145. package/src/components/ToolRunner.tsx +352 -0
  146. package/src/components/Toolbar.tsx +65 -65
  147. package/src/components/WorkflowEditor.tsx +333 -326
  148. package/src/components/WorkflowTemplates.tsx +101 -78
  149. package/src/components/Workspace.tsx +310 -223
  150. package/src/css-modules.d.ts +3 -0
  151. package/src/hooks/useCustomPresets.ts +284 -0
  152. package/src/hooks/useLokvis.ts +43 -10
  153. package/src/index.ts +7 -0
  154. package/src/store/assets-slice.ts +106 -52
  155. package/src/store/capabilities-slice.ts +10 -2
  156. package/src/store/index.ts +1 -1
  157. package/src/store/runtime-slice.ts +20 -15
  158. package/src/store/subscribe-utils.ts +82 -0
  159. package/src/store/types.ts +19 -12
  160. package/src/store/workflow-slice.ts +19 -53
  161. package/src/utils.ts +41 -0
  162. package/dist/store.d.ts +0 -76
  163. package/dist/store.d.ts.map +0 -1
  164. package/dist/store.js +0 -217
  165. package/dist/store.js.map +0 -1
@@ -2,14 +2,14 @@
2
2
  * WorkflowEditor - 拖拽式工作流编辑器(W10.4)
3
3
  *
4
4
  * 增强 PipelineBar,提供节点拖拽重排能力:
5
- * - 拖拽节点到任意位置重排线性链
6
- * - 拖拽时显示插入指示器
7
- * - 键盘支持:选中节点后 ← → 移动(无障碍)
8
- * - 节点数上限 MAX_WORKFLOW_STEPS=5(M1 MVP 约束,UI 层常量)
5
+ * - 拖拽节点到任意位置重排线性链
6
+ * - 拖拽时显示插入指示器
7
+ * - 键盘支持:选中节点后 ← → 移动(无障碍)
8
+ * - 节点数上限 MAX_WORKFLOW_STEPS=5(M1 MVP 约束,UI 层常量)
9
9
  *
10
10
  * 与 PipelineBar 区别:
11
- * - PipelineBar:只读 + 选中/删除(快速预览)
12
- * - WorkflowEditor:可编辑 + 拖拽重排(编辑模式)
11
+ * - PipelineBar:只读 + 选中/删除(快速预览)
12
+ * - WorkflowEditor:可编辑 + 拖拽重排(编辑模式)
13
13
  *
14
14
  * 使用原生 HTML5 Drag and Drop API,无额外依赖。
15
15
  * 触摸设备降级为按钮上下移动(HTML5 DnD 在移动端支持不佳)。
@@ -18,245 +18,243 @@
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
- className?: string;
28
+ className?: string;
27
29
  }
28
30
 
29
31
  export function WorkflowEditor({ className = '' }: WorkflowEditorProps) {
30
- const nodes = useWorkspaceStore((s) => s.nodes);
31
- const selectedNodeId = useWorkspaceStore((s) => s.selectedNodeId);
32
- const selectNode = useWorkspaceStore((s) => s.selectNode);
33
- const removeNode = useWorkspaceStore((s) => s.removeNode);
34
- const moveNode = useWorkspaceStore((s) => s.moveNode);
35
- const insertNodeAt = useWorkspaceStore((s) => s.insertNodeAt);
36
- const capabilities = useWorkspaceStore((s) => s.capabilities);
37
- const clearWorkflow = useWorkspaceStore((s) => s.clearWorkflow);
32
+ const nodes = useWorkspaceStore((s) => s.nodes);
33
+ const selectedNodeId = useWorkspaceStore((s) => s.selectedNodeId);
34
+ const selectNode = useWorkspaceStore((s) => s.selectNode);
35
+ const removeNode = useWorkspaceStore((s) => s.removeNode);
36
+ const moveNode = useWorkspaceStore((s) => s.moveNode);
37
+ const insertNodeAt = useWorkspaceStore((s) => s.insertNodeAt);
38
+ const capabilities = useWorkspaceStore((s) => s.capabilities);
39
+ const stubCapabilities = useWorkspaceStore((s) => s.stubCapabilities);
40
+ const clearWorkflow = useWorkspaceStore((s) => s.clearWorkflow);
38
41
 
39
- // 拖拽状态:被拖拽的节点索引 + 当前 hover 的插入位置
40
- const [dragIndex, setDragIndex] = React.useState<number | null>(null);
41
- const [hoverIndex, setHoverIndex] = React.useState<number | null>(null);
42
+ // 拖拽状态:被拖拽的节点索引 + 当前 hover 的插入位置
43
+ const [dragIndex, setDragIndex] = React.useState<number | null>(null);
44
+ const [hoverIndex, setHoverIndex] = React.useState<number | null>(null);
45
+ // 清空确认对话框
46
+ const [clearOpen, setClearOpen] = React.useState(false);
42
47
 
43
- const handleDragStart = (e: React.DragEvent, index: number) => {
44
- setDragIndex(index);
45
- e.dataTransfer.effectAllowed = 'move';
46
- // Firefox 需要设置 data 才能触发 dragstart
47
- e.dataTransfer.setData('text/plain', String(index));
48
- };
48
+ const handleDragStart = (e: React.DragEvent, index: number) => {
49
+ setDragIndex(index);
50
+ e.dataTransfer.effectAllowed = 'move';
51
+ // Firefox 需要设置 data 才能触发 dragstart
52
+ e.dataTransfer.setData('text/plain', String(index));
53
+ };
49
54
 
50
- const handleDragOver = (e: React.DragEvent, index: number) => {
51
- e.preventDefault();
52
- e.dataTransfer.dropEffect = 'move';
53
- if (dragIndex !== null && dragIndex !== index) {
54
- setHoverIndex(index);
55
- }
56
- };
55
+ const handleDragOver = (e: React.DragEvent, index: number) => {
56
+ e.preventDefault();
57
+ e.dataTransfer.dropEffect = 'move';
58
+ if (dragIndex !== null && dragIndex !== index) {
59
+ setHoverIndex(index);
60
+ }
61
+ };
57
62
 
58
- const handleDragLeave = () => {
59
- // 不立即清空 hoverIndex,因为 dragleave 在子元素间会误触发
60
- // 由 handleDrop / handleDragEnd 统一清空
61
- };
63
+ const handleDragLeave = () => {
64
+ // 不立即清空 hoverIndex,因为 dragleave 在子元素间会误触发
65
+ // 由 handleDrop / handleDragEnd 统一清空
66
+ };
62
67
 
63
- const handleDrop = (e: React.DragEvent, index: number) => {
64
- e.preventDefault();
65
- if (dragIndex !== null && dragIndex !== index) {
66
- moveNode(dragIndex, index);
67
- }
68
- setDragIndex(null);
69
- setHoverIndex(null);
70
- };
68
+ const handleDrop = (e: React.DragEvent, index: number) => {
69
+ e.preventDefault();
70
+ if (dragIndex !== null && dragIndex !== index) {
71
+ moveNode(dragIndex, index);
72
+ }
73
+ setDragIndex(null);
74
+ setHoverIndex(null);
75
+ };
71
76
 
72
- const handleDragEnd = () => {
73
- setDragIndex(null);
74
- setHoverIndex(null);
75
- };
77
+ const handleDragEnd = () => {
78
+ setDragIndex(null);
79
+ setHoverIndex(null);
80
+ };
76
81
 
77
- // 键盘支持:选中节点后用 ← → 移动。传 node.id 而非 index 避免异步操作后 stale index
78
- const handleKeyDown = (e: React.KeyboardEvent, nodeId: string, index: number) => {
79
- if (e.key === 'ArrowLeft' && index > 0) {
80
- e.preventDefault();
81
- moveNode(index, index - 1);
82
- } else if (e.key === 'ArrowRight' && index < nodes.length - 1) {
83
- e.preventDefault();
84
- moveNode(index, index + 1);
85
- } else if (e.key === 'Delete' || e.key === 'Backspace') {
86
- e.preventDefault();
87
- removeNode(nodeId);
88
- }
89
- };
82
+ // 键盘支持:选中节点后用 ← → 移动。传 node.id 而非 index 避免异步操作后 stale index
83
+ const handleKeyDown = (e: React.KeyboardEvent, nodeId: string, index: number) => {
84
+ if (e.key === 'ArrowLeft' && index > 0) {
85
+ e.preventDefault();
86
+ moveNode(index, index - 1);
87
+ } else if (e.key === 'ArrowRight' && index < nodes.length - 1) {
88
+ e.preventDefault();
89
+ moveNode(index, index + 1);
90
+ } else if (e.key === 'Delete' || e.key === 'Backspace') {
91
+ e.preventDefault();
92
+ removeNode(nodeId);
93
+ }
94
+ };
90
95
 
91
- const canAddMore = nodes.length < MAX_WORKFLOW_STEPS;
96
+ const canAddMore = nodes.length < MAX_WORKFLOW_STEPS;
92
97
 
93
- return (
94
- <div
95
- className={`flex flex-col border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900 ${className}`}
96
- role="region"
97
- aria-label="工作流编辑器"
98
- >
99
- {/* 顶部:标题 + 步数 + 清空 */}
100
- <div className="flex h-9 shrink-0 items-center justify-between border-b border-zinc-100 px-3 dark:border-zinc-800/50">
101
- <div className="flex items-center gap-2">
102
- <span className="text-[10px] font-semibold uppercase tracking-wider text-zinc-400">
103
- Workflow Editor
104
- </span>
105
- <span
106
- className={`rounded px-1.5 py-0.5 text-[10px] tabular-nums ${
107
- canAddMore
108
- ? 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400'
109
- : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
110
- }`}
111
- >
112
- {nodes.length}/{MAX_WORKFLOW_STEPS}
113
- </span>
114
- </div>
115
- {nodes.length > 0 && (
116
- <button
117
- type="button"
118
- onClick={() => {
119
- if (window.confirm('清空当前工作流?所有节点将被移除。')) {
120
- clearWorkflow();
121
- }
122
- }}
123
- className="text-[10px] text-zinc-400 transition-colors hover:text-red-500"
124
- aria-label="清空工作流"
125
- >
126
- Clear
127
- </button>
128
- )}
129
- </div>
98
+ return (
99
+ <div
100
+ className={`flex flex-col border-b border-[var(--lokvis-border)] bg-[var(--lokvis-surface)] ${className}`}
101
+ role="region"
102
+ aria-label="工作流编辑器"
103
+ >
104
+ {/* 顶部:标题 + 步数 + 清空 */}
105
+ <div className="flex h-9 shrink-0 items-center justify-between border-b border-[var(--lokvis-border)] px-3">
106
+ <div className="flex items-center gap-2">
107
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--lokvis-fg-subtle)]">
108
+ Workflow Editor
109
+ </span>
110
+ <span
111
+ className={`rounded px-1.5 py-0.5 text-[10px] tabular-nums ${
112
+ canAddMore
113
+ ? 'bg-[var(--lokvis-surface-muted)] text-[var(--lokvis-fg-muted)]'
114
+ : 'bg-[var(--lokvis-warning)]/15 text-[var(--lokvis-warning)]'
115
+ }`}
116
+ >
117
+ {nodes.length}/{MAX_WORKFLOW_STEPS}
118
+ </span>
119
+ </div>
120
+ {nodes.length > 0 && (
121
+ <button
122
+ type="button"
123
+ onClick={() => setClearOpen(true)}
124
+ className="text-[10px] text-[var(--lokvis-fg-subtle)] transition-colors hover:text-[var(--lokvis-danger)]"
125
+ aria-label="清空工作流"
126
+ >
127
+ Clear
128
+ </button>
129
+ )}
130
+ </div>
130
131
 
131
- {/* 节点链 */}
132
- <div className="flex min-h-[3rem] items-center gap-1 overflow-x-auto px-3 py-2">
133
- {nodes.length === 0 ? (
134
- <div className="flex flex-1 items-center justify-center py-2 text-center">
135
- <p className="text-[11px] text-zinc-400">
136
- 空工作流 &mdash; 从右侧 <span className="font-mono">Capabilities</span> 面板点击添加节点
137
- </p>
138
- </div>
139
- ) : (
140
- <>
141
- {/* Source indicator */}
142
- <div className="flex shrink-0 items-center gap-1 rounded-md bg-zinc-100 px-2 py-1 text-[10px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
143
- <Icon size={12}><path d="M12 4.5v15m7.5-7.5h-15" /></Icon>
144
- Source
145
- </div>
132
+ {/* 节点链 */}
133
+ <div className="flex min-h-[3rem] items-center gap-1 overflow-x-auto px-3 py-2">
134
+ {nodes.length === 0 ? (
135
+ <div className="flex flex-1 items-center justify-center py-2 text-center">
136
+ <p className="text-[11px] text-[var(--lokvis-fg-subtle)]">
137
+ 空工作流 &mdash; 从右侧 <span className="font-mono">Capabilities</span> 面板点击添加节点
138
+ </p>
139
+ </div>
140
+ ) : (
141
+ <>
142
+ {/* Source indicator */}
143
+ <div className="flex shrink-0 items-center gap-1 rounded-md bg-[var(--lokvis-surface-muted)] px-2 py-1 text-[10px] font-medium text-[var(--lokvis-fg-muted)]">
144
+ <Icon size={12}><path d="M12 4.5v15m7.5-7.5h-15" /></Icon>
145
+ Source
146
+ </div>
146
147
 
147
- {/* W11.1: 节点之间可插入的连接器(+ 按钮) */}
148
- <InsertConnector
149
- index={0}
150
- capabilities={capabilities}
151
- onInsert={insertNodeAt}
152
- disabled={nodes.length >= MAX_WORKFLOW_STEPS}
153
- />
148
+ {/* W11.1: 节点之间可插入的连接器(+ 按钮) */}
149
+ <InsertConnector
150
+ index={0}
151
+ capabilities={capabilities}
152
+ stubCapabilities={stubCapabilities}
153
+ onInsert={insertNodeAt}
154
+ disabled={nodes.length >= MAX_WORKFLOW_STEPS}
155
+ />
154
156
 
155
- {/* 节点 */}
156
- {nodes.map((node, i) => {
157
- const selected = node.id === selectedNodeId;
158
- const isDragging = dragIndex === i;
159
- const isHoverTarget = hoverIndex === i && dragIndex !== null && dragIndex !== i;
160
- return (
161
- <React.Fragment key={node.id}>
162
- <button
163
- type="button"
164
- draggable
165
- onDragStart={(e) => handleDragStart(e, i)}
166
- onDragOver={(e) => handleDragOver(e, i)}
167
- onDragLeave={handleDragLeave}
168
- onDrop={(e) => handleDrop(e, i)}
169
- onDragEnd={handleDragEnd}
170
- onClick={() => selectNode(node.id)}
171
- onKeyDown={(e) => handleKeyDown(e, node.id, i)}
172
- 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 ${
174
- isDragging
175
- ? 'opacity-40'
176
- : isHoverTarget
177
- ? 'ring-2 ring-indigo-400 ring-offset-1 dark:ring-offset-zinc-900'
178
- : selected
179
- ? 'bg-indigo-100 text-indigo-700 ring-1 ring-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:ring-indigo-700'
180
- : node.status === 'running'
181
- ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400'
182
- : node.status === 'success'
183
- ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400'
184
- : node.status === 'failed'
185
- ? 'bg-red-100 text-red-700 dark:bg-red-950/30 dark:text-red-400'
186
- : 'bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700'
187
- } cursor-grab active:cursor-grabbing`}
188
- >
189
- {/* 步骤序号 */}
190
- <span className="flex h-4 w-4 items-center justify-center rounded-full bg-black/5 text-[9px] tabular-nums dark:bg-white/10">
191
- {i + 1}
192
- </span>
193
- <span className="font-mono">{node.capability}</span>
194
- <StatusDot status={node.status} />
195
- {/* 删除按钮:作为兄弟 button 而非嵌套(HTML 规范禁止 button 嵌套)
196
- 使用 position absolute 浮在节点 button 之上,避免叠加在 capability 文字上;
197
- group-hover 显示;stopPropagation 避免触发 selectNode */}
198
- <button
199
- type="button"
200
- onClick={(e) => {
201
- e.stopPropagation();
202
- removeNode(node.id);
203
- }}
204
- aria-label="删除节点"
205
- tabIndex={-1}
206
- className="absolute -right-1 -top-1 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/40"
207
- >
208
- <Icon size={10} strokeWidth={3}><path d="M6 18L18 6M6 6l12 12" /></Icon>
209
- </button>
210
- </button>
157
+ {/* 节点 */}
158
+ {nodes.map((node, i) => {
159
+ const selected = node.id === selectedNodeId;
160
+ const isDragging = dragIndex === i;
161
+ const isHoverTarget = hoverIndex === i && dragIndex !== null && dragIndex !== i;
162
+ return (
163
+ <React.Fragment key={node.id}>
164
+ {/* D7: remove button 作为 node button 的兄弟而非子元素(HTML 规范禁止 button 嵌套) */}
165
+ <div className="group relative flex shrink-0 items-center">
166
+ <button
167
+ type="button"
168
+ draggable
169
+ onDragStart={(e) => handleDragStart(e, i)}
170
+ onDragOver={(e) => handleDragOver(e, i)}
171
+ onDragLeave={handleDragLeave}
172
+ onDrop={(e) => handleDrop(e, i)}
173
+ onDragEnd={handleDragEnd}
174
+ onClick={() => selectNode(node.id)}
175
+ onKeyDown={(e) => handleKeyDown(e, node.id, i)}
176
+ aria-label={`节点 ${node.capability},位置 ${i + 1},拖拽或方向键重排,Delete 删除`}
177
+ className={`flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[11px] font-medium transition-all ${
178
+ isDragging
179
+ ? 'opacity-40'
180
+ : isHoverTarget
181
+ ? 'ring-2 ring-[var(--lokvis-primary)] ring-offset-1'
182
+ : selected
183
+ ? 'bg-[var(--lokvis-primary)]/15 text-[var(--lokvis-primary)] ring-1 ring-[var(--lokvis-primary)]/50'
184
+ : node.status === 'running'
185
+ ? 'bg-[var(--lokvis-warning)]/15 text-[var(--lokvis-warning)]'
186
+ : node.status === 'success'
187
+ ? 'bg-[var(--lokvis-success)]/15 text-[var(--lokvis-success)]'
188
+ : node.status === 'failed'
189
+ ? 'bg-[var(--lokvis-danger)]/15 text-[var(--lokvis-danger)]'
190
+ : 'bg-[var(--lokvis-surface-muted)] text-[var(--lokvis-fg-muted)] hover:bg-[var(--lokvis-border)]'
191
+ } cursor-grab active:cursor-grabbing`}
192
+ >
193
+ {/* 步骤序号 */}
194
+ <span className="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--lokvis-fg)]/5 text-[9px] tabular-nums">
195
+ {i + 1}
196
+ </span>
197
+ <span className="font-mono">{node.capability}</span>
198
+ <StatusDot status={node.status} />
199
+ </button>
200
+ {/* 删除按钮:兄弟 button(position absolute 浮在节点右上角),group-hover 显示 */}
201
+ <button
202
+ type="button"
203
+ onClick={() => removeNode(node.id)}
204
+ aria-label="删除节点"
205
+ tabIndex={-1}
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
+ >
208
+ <Icon size={10} strokeWidth={3}><path d="M6 18L18 6M6 6l12 12" /></Icon>
209
+ </button>
210
+ </div>
211
211
 
212
- {/* W11.1: 每个节点后的插入连接器(最后一个用于追加到 Output 前) */}
213
- <InsertConnector
214
- index={i + 1}
215
- capabilities={capabilities}
216
- onInsert={insertNodeAt}
217
- disabled={nodes.length >= MAX_WORKFLOW_STEPS}
218
- />
219
- </React.Fragment>
220
- );
221
- })}
212
+ {/* W11.1: 每个节点后的插入连接器(最后一个用于追加到 Output 前) */}
213
+ <InsertConnector
214
+ index={i + 1}
215
+ capabilities={capabilities}
216
+ stubCapabilities={stubCapabilities}
217
+ onInsert={insertNodeAt}
218
+ disabled={nodes.length >= MAX_WORKFLOW_STEPS}
219
+ />
220
+ </React.Fragment>
221
+ );
222
+ })}
222
223
 
223
- {/* Output indicator */}
224
- {nodes.length > 0 && (
225
- <div className="flex shrink-0 items-center gap-1 rounded-md bg-zinc-100 px-2 py-1 text-[10px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
226
- <Icon size={12}><path d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></Icon>
227
- Output
228
- </div>
229
- )}
230
- </>
231
- )}
232
- </div>
224
+ {/* Output indicator */}
225
+ {nodes.length > 0 && (
226
+ <div className="flex shrink-0 items-center gap-1 rounded-md bg-[var(--lokvis-surface-muted)] px-2 py-1 text-[10px] font-medium text-[var(--lokvis-fg-muted)]">
227
+ <Icon size={12}><path d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></Icon>
228
+ Output
229
+ </div>
230
+ )}
231
+ </>
232
+ )}
233
+ </div>
233
234
 
234
- {/* 提示行 */}
235
- {nodes.length > 0 && (
236
- <div className="flex shrink-0 items-center gap-2 border-t border-zinc-100 px-3 py-1 text-[10px] text-zinc-400 dark:border-zinc-800/50">
237
- <Icon size={10}><path d="M13 5.5a1 1 0 1 1 2 0 1-1 0 0 0-.5.86L16.5 9l1.5-.5a1 1 0 1 1 0 2l-1.5-.5-1 1.5a1 1 0 1 1-2 0l1-1.5-1-1.5a1 1 0 0 1 0-2z" /></Icon>
238
- <span>拖拽重排 · 方向键移动 · Delete 删除 · hover 箭头处插入节点</span>
239
- </div>
240
- )}
241
- </div>
242
- );
243
- }
244
-
245
- function StatusDot({ status }: { status: string }) {
246
- const color =
247
- status === 'running'
248
- ? 'bg-amber-500'
249
- : status === 'success'
250
- ? 'bg-emerald-500'
251
- : status === 'failed'
252
- ? 'bg-red-500'
253
- : status === 'pending'
254
- ? 'bg-zinc-400 dark:bg-zinc-500'
255
- : 'bg-zinc-300 dark:bg-zinc-600';
256
-
257
- const animate = status === 'running' ? 'animate-pulse' : '';
235
+ {/* 提示行 */}
236
+ {nodes.length > 0 && (
237
+ <div className="flex shrink-0 items-center gap-2 border-t border-[var(--lokvis-border)] px-3 py-1 text-[10px] text-[var(--lokvis-fg-subtle)]">
238
+ <Icon size={10}><path d="M13 5.5a1 1 0 1 1 2 0 1-1 0 0 0-.5.86L16.5 9l1.5-.5a1 1 0 1 1 0 2l-1.5-.5-1 1.5a1 1 0 1 1-2 0l1-1.5-1-1.5a1 1 0 0 1 0-2z" /></Icon>
239
+ <span>拖拽重排 · 方向键移动 · Delete 删除 · hover 箭头处插入节点</span>
240
+ </div>
241
+ )}
258
242
 
259
- return <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${color} ${animate}`} />;
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
+ />
256
+ </div>
257
+ );
260
258
  }
261
259
 
262
260
  /**
@@ -266,117 +264,126 @@ function StatusDot({ status }: { status: string }) {
266
264
  * 选择后调用 onInsert(index, capability)。
267
265
  */
268
266
  function InsertConnector({
269
- index,
270
- capabilities,
271
- onInsert,
272
- disabled,
267
+ index,
268
+ capabilities,
269
+ stubCapabilities,
270
+ onInsert,
271
+ disabled,
273
272
  }: {
274
- index: number;
275
- capabilities: Capability[];
276
- onInsert: (index: number, capability: string) => void;
277
- disabled?: boolean;
273
+ index: number;
274
+ capabilities: Capability[];
275
+ stubCapabilities: Set<string>;
276
+ onInsert: (index: number, capability: string) => void;
277
+ disabled?: boolean;
278
278
  }) {
279
- const [open, setOpen] = React.useState(false);
280
- const [filter, setFilter] = React.useState('');
281
- const ref = React.useRef<HTMLDivElement>(null);
279
+ const [open, setOpen] = React.useState(false);
280
+ const [filter, setFilter] = React.useState('');
281
+ const ref = React.useRef<HTMLDivElement>(null);
282
282
 
283
- // 点击外部关闭菜单
284
- React.useEffect(() => {
285
- if (!open) return;
286
- const handler = (e: MouseEvent) => {
287
- // 用 instanceof guard 替代 as Node 断言:运行时安全 + 类型安全
288
- if (!(e.target instanceof Node)) return;
289
- if (ref.current && !ref.current.contains(e.target)) {
290
- setOpen(false);
291
- setFilter('');
292
- }
293
- };
294
- document.addEventListener('mousedown', handler);
295
- return () => document.removeEventListener('mousedown', handler);
296
- }, [open]);
283
+ // 点击外部关闭菜单
284
+ React.useEffect(() => {
285
+ if (!open) return;
286
+ const handler = (e: MouseEvent) => {
287
+ // 用 instanceof guard 替代 as Node 断言:运行时安全 + 类型安全
288
+ if (!(e.target instanceof Node)) return;
289
+ if (ref.current && !ref.current.contains(e.target)) {
290
+ setOpen(false);
291
+ setFilter('');
292
+ }
293
+ };
294
+ document.addEventListener('mousedown', handler);
295
+ return () => document.removeEventListener('mousedown', handler);
296
+ }, [open]);
297
297
 
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
- ),
305
- [capabilities, filter]
306
- );
298
+ const filtered = React.useMemo(
299
+ () => filterCapabilities(capabilities, filter),
300
+ [capabilities, filter]
301
+ );
307
302
 
308
- const handleSelect = (capName: string) => {
309
- onInsert(index, capName);
310
- setOpen(false);
311
- setFilter('');
312
- };
303
+ const handleSelect = (capName: string) => {
304
+ onInsert(index, capName);
305
+ setOpen(false);
306
+ setFilter('');
307
+ };
313
308
 
314
- if (disabled) {
315
- // 5 步上限时只显示箭头,不显示 +
316
- return (
317
- <Icon size={12} className="shrink-0 text-zinc-300 dark:text-zinc-600">
318
- <path d="m9 5 7 7-7 7" />
319
- </Icon>
320
- );
321
- }
309
+ if (disabled) {
310
+ // 5 步上限时只显示箭头,不显示 +
311
+ return (
312
+ <Icon size={12} className="shrink-0 text-[var(--lokvis-fg-subtle)]">
313
+ <path d="m9 5 7 7-7 7" />
314
+ </Icon>
315
+ );
316
+ }
322
317
 
323
- return (
324
- <div ref={ref} className="relative flex shrink-0 items-center">
325
- {/* 箭头 / + 按钮切换 */}
326
- <button
327
- type="button"
328
- onClick={() => setOpen(!open)}
329
- aria-label={`在位置 ${index + 1} 插入节点`}
330
- title="插入节点"
331
- className="group flex h-5 w-5 items-center justify-center rounded text-zinc-300 transition-colors hover:bg-indigo-100 hover:text-indigo-500 dark:text-zinc-600 dark:hover:bg-indigo-950/40"
332
- >
333
- <Icon size={12} className="group-hover:hidden">
334
- <path d="m9 5 7 7-7 7" />
335
- </Icon>
336
- <Icon size={12} className="hidden group-hover:block" strokeWidth={2.5}>
337
- <path d="M12 5v14M5 12h14" />
338
- </Icon>
339
- </button>
318
+ return (
319
+ <div ref={ref} className="relative flex shrink-0 items-center">
320
+ {/* 箭头 / + 按钮切换 */}
321
+ <button
322
+ type="button"
323
+ onClick={() => setOpen(!open)}
324
+ aria-label={`在位置 ${index + 1} 插入节点`}
325
+ title="插入节点"
326
+ className="group flex h-5 w-5 items-center justify-center rounded text-[var(--lokvis-fg-subtle)] transition-colors hover:bg-[var(--lokvis-primary)]/15 hover:text-[var(--lokvis-primary)]"
327
+ >
328
+ <Icon size={12} className="group-hover:hidden">
329
+ <path d="m9 5 7 7-7 7" />
330
+ </Icon>
331
+ <Icon size={12} className="hidden group-hover:block" strokeWidth={2.5}>
332
+ <path d="M12 5v14M5 12h14" />
333
+ </Icon>
334
+ </button>
340
335
 
341
- {/* 弹出菜单 */}
342
- {open && (
343
- <div className="absolute top-full left-1/2 z-50 mt-1 w-56 -translate-x-1/2 rounded-lg border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
344
- {/* 搜索 */}
345
- <div className="border-b border-zinc-100 p-1.5 dark:border-zinc-800">
346
- <input
347
- type="text"
348
- autoFocus
349
- placeholder="搜索 capability..."
350
- value={filter}
351
- onChange={(e) => setFilter(e.target.value)}
352
- className="w-full rounded border border-zinc-200 bg-white px-2 py-1 text-[11px] placeholder-zinc-400 focus:border-indigo-400 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900"
353
- />
354
- </div>
355
- {/* 列表 */}
356
- <div className="max-h-48 overflow-y-auto p-1">
357
- {filtered.length === 0 ? (
358
- <p className="px-2 py-2 text-center text-[10px] text-zinc-400">无匹配 capability</p>
359
- ) : (
360
- filtered.map((cap) => (
361
- <button
362
- key={cap.name}
363
- type="button"
364
- onClick={() => handleSelect(cap.name)}
365
- className="block w-full rounded px-2 py-1 text-left transition-colors hover:bg-indigo-50 dark:hover:bg-indigo-950/30"
366
- >
367
- <span className="block font-mono text-[10px] font-medium text-zinc-700 dark:text-zinc-300">
368
- {cap.name}
369
- </span>
370
- <span className="block truncate text-[9px] text-zinc-500 dark:text-zinc-400">
371
- {cap.description}
372
- </span>
373
- </button>
374
- ))
375
- )}
376
- </div>
377
- </div>
378
- )}
379
- </div>
380
- );
336
+ {/* 弹出菜单 */}
337
+ {open && (
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)]">
339
+ {/* 搜索 */}
340
+ <div className="border-b border-[var(--lokvis-border)] p-1.5">
341
+ <Input
342
+ type="text"
343
+ size="sm"
344
+ autoFocus
345
+ placeholder="搜索 capability..."
346
+ value={filter}
347
+ onChange={(e) => setFilter(e.target.value)}
348
+ />
349
+ </div>
350
+ {/* 列表 */}
351
+ <div className="max-h-48 overflow-y-auto p-1">
352
+ {filtered.length === 0 ? (
353
+ <p className="px-2 py-2 text-center text-[10px] text-[var(--lokvis-fg-subtle)]">无匹配 capability</p>
354
+ ) : (
355
+ filtered.map((cap) => {
356
+ // A7: stub-only 能力显示 "Coming Soon" 标记
357
+ const isStubOnly = stubCapabilities.has(cap.name);
358
+ return (
359
+ <button
360
+ key={cap.name}
361
+ type="button"
362
+ onClick={() => handleSelect(cap.name)}
363
+ title={isStubOnly ? 'Coming soon — no engine installed yet' : undefined}
364
+ className="block w-full rounded px-2 py-1 text-left transition-colors hover:bg-[var(--lokvis-primary)]/10"
365
+ >
366
+ <span className="flex items-center justify-between gap-1.5">
367
+ <span className="block font-mono text-[10px] font-medium text-[var(--lokvis-fg-muted)]">
368
+ {cap.name}
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>
376
+ <span className="block truncate text-[9px] text-[var(--lokvis-fg-muted)]">
377
+ {cap.description}
378
+ </span>
379
+ </button>
380
+ );
381
+ })
382
+ )}
383
+ </div>
384
+ </div>
385
+ )}
386
+ </div>
387
+ );
381
388
  }
382
389