@lokvis/ui-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/components/AssetPanel.d.ts +12 -0
  2. package/dist/components/AssetPanel.d.ts.map +1 -0
  3. package/dist/components/AssetPanel.js +69 -0
  4. package/dist/components/AssetPanel.js.map +1 -0
  5. package/dist/components/Canvas.d.ts +12 -0
  6. package/dist/components/Canvas.d.ts.map +1 -0
  7. package/dist/components/Canvas.js +35 -0
  8. package/dist/components/Canvas.js.map +1 -0
  9. package/dist/components/HistoryPanel.d.ts +14 -0
  10. package/dist/components/HistoryPanel.d.ts.map +1 -0
  11. package/dist/components/HistoryPanel.js +68 -0
  12. package/dist/components/HistoryPanel.js.map +1 -0
  13. package/dist/components/Inspector.d.ts +12 -0
  14. package/dist/components/Inspector.d.ts.map +1 -0
  15. package/dist/components/Inspector.js +46 -0
  16. package/dist/components/Inspector.js.map +1 -0
  17. package/dist/components/ParamForm.d.ts +14 -0
  18. package/dist/components/ParamForm.d.ts.map +1 -0
  19. package/dist/components/ParamForm.js +28 -0
  20. package/dist/components/ParamForm.js.map +1 -0
  21. package/dist/components/PipelineBar.d.ts +12 -0
  22. package/dist/components/PipelineBar.d.ts.map +1 -0
  23. package/dist/components/PipelineBar.js +44 -0
  24. package/dist/components/PipelineBar.js.map +1 -0
  25. package/dist/components/StatusBar.d.ts +10 -0
  26. package/dist/components/StatusBar.d.ts.map +1 -0
  27. package/dist/components/StatusBar.js +22 -0
  28. package/dist/components/StatusBar.js.map +1 -0
  29. package/dist/components/Toolbar.d.ts +14 -0
  30. package/dist/components/Toolbar.d.ts.map +1 -0
  31. package/dist/components/Toolbar.js +36 -0
  32. package/dist/components/Toolbar.js.map +1 -0
  33. package/dist/components/Workspace.d.ts +24 -0
  34. package/dist/components/Workspace.d.ts.map +1 -0
  35. package/dist/components/Workspace.js +33 -0
  36. package/dist/components/Workspace.js.map +1 -0
  37. package/dist/hooks/useLokvis.d.ts +32 -0
  38. package/dist/hooks/useLokvis.d.ts.map +1 -0
  39. package/dist/hooks/useLokvis.js +62 -0
  40. package/dist/hooks/useLokvis.js.map +1 -0
  41. package/dist/index.d.ts +27 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +26 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/store/assets-slice.d.ts +11 -0
  46. package/dist/store/assets-slice.d.ts.map +1 -0
  47. package/dist/store/assets-slice.js +39 -0
  48. package/dist/store/assets-slice.js.map +1 -0
  49. package/dist/store/capabilities-slice.d.ts +11 -0
  50. package/dist/store/capabilities-slice.d.ts.map +1 -0
  51. package/dist/store/capabilities-slice.js +15 -0
  52. package/dist/store/capabilities-slice.js.map +1 -0
  53. package/dist/store/index.d.ts +4 -0
  54. package/dist/store/index.d.ts.map +1 -0
  55. package/dist/store/index.js +24 -0
  56. package/dist/store/index.js.map +1 -0
  57. package/dist/store/runtime-slice.d.ts +13 -0
  58. package/dist/store/runtime-slice.d.ts.map +1 -0
  59. package/dist/store/runtime-slice.js +29 -0
  60. package/dist/store/runtime-slice.js.map +1 -0
  61. package/dist/store/types.d.ts +77 -0
  62. package/dist/store/types.d.ts.map +1 -0
  63. package/dist/store/types.js +5 -0
  64. package/dist/store/types.js.map +1 -0
  65. package/dist/store/workflow-slice.d.ts +18 -0
  66. package/dist/store/workflow-slice.d.ts.map +1 -0
  67. package/dist/store/workflow-slice.js +133 -0
  68. package/dist/store/workflow-slice.js.map +1 -0
  69. package/dist/types.d.ts +33 -0
  70. package/dist/types.d.ts.map +1 -0
  71. package/dist/types.js +5 -0
  72. package/dist/types.js.map +1 -0
  73. package/package.json +57 -0
  74. package/src/components/AssetPanel.tsx +151 -0
  75. package/src/components/Canvas.tsx +123 -0
  76. package/src/components/HistoryPanel.tsx +127 -0
  77. package/src/components/Inspector.tsx +158 -0
  78. package/src/components/ParamForm.tsx +163 -0
  79. package/src/components/PipelineBar.tsx +125 -0
  80. package/src/components/StatusBar.tsx +63 -0
  81. package/src/components/Toolbar.tsx +87 -0
  82. package/src/components/Workspace.tsx +90 -0
  83. package/src/hooks/useLokvis.ts +78 -0
  84. package/src/index.ts +32 -0
  85. package/src/store/assets-slice.ts +57 -0
  86. package/src/store/capabilities-slice.ts +29 -0
  87. package/src/store/index.ts +27 -0
  88. package/src/store/runtime-slice.ts +53 -0
  89. package/src/store/types.ts +88 -0
  90. package/src/store/workflow-slice.ts +178 -0
  91. package/src/types.ts +46 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Inspector - 右侧面板
3
+ *
4
+ * 上半部分:当前选中节点的参数配置(Configure)
5
+ * 下半部分:可用 Capabilities 列表(点击添加到工作流)
6
+ */
7
+
8
+ import * as React from 'react';
9
+ import { useWorkspaceStore } from '../store/index.js';
10
+ import { ParamForm } from './ParamForm.js';
11
+
12
+ export interface InspectorProps {
13
+ className?: string;
14
+ }
15
+
16
+ export function Inspector({ className = '' }: InspectorProps) {
17
+ const capabilities = useWorkspaceStore((s) => s.capabilities);
18
+ const nodes = useWorkspaceStore((s) => s.nodes);
19
+ const selectedNodeId = useWorkspaceStore((s) => s.selectedNodeId);
20
+ const capabilityMap = useWorkspaceStore((s) => s.capabilityMap);
21
+ const addNode = useWorkspaceStore((s) => s.addNode);
22
+ const updateNodeParams = useWorkspaceStore((s) => s.updateNodeParams);
23
+
24
+ const selectedNode = nodes.find((n) => n.id === selectedNodeId);
25
+ const [filter, setFilter] = React.useState('');
26
+ const [configureOpen, setConfigureOpen] = React.useState(true);
27
+
28
+ const filtered = capabilities.filter(
29
+ (c) =>
30
+ c.name.toLowerCase().includes(filter.toLowerCase()) ||
31
+ c.description.toLowerCase().includes(filter.toLowerCase())
32
+ );
33
+
34
+ // 按域分组
35
+ const grouped = React.useMemo(() => {
36
+ const map = new Map<string, typeof filtered>();
37
+ for (const cap of filtered) {
38
+ const domain = cap.name.split('.')[0] ?? 'other';
39
+ if (!map.has(domain)) map.set(domain, []);
40
+ map.get(domain)!.push(cap);
41
+ }
42
+ return map;
43
+ }, [filtered]);
44
+
45
+ const showConfigure = selectedNode && capabilityMap[selectedNode.capability];
46
+
47
+ return (
48
+ <aside
49
+ className={`flex w-72 shrink-0 flex-col border-l border-zinc-200 dark:border-zinc-800 ${className}`}
50
+ >
51
+ {/* Configure section (when a node is selected) */}
52
+ {showConfigure && (
53
+ <div className="shrink-0 border-b border-zinc-200 dark:border-zinc-800">
54
+ <button
55
+ type="button"
56
+ onClick={() => setConfigureOpen(!configureOpen)}
57
+ className="flex w-full items-center justify-between px-3 h-10 text-left transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
58
+ >
59
+ <div className="flex items-center gap-2">
60
+ <svg className={`h-3 w-3 text-zinc-400 transition-transform ${configureOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="m9 5 7 7-7 7"/></svg>
61
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">Configure</span>
62
+ </div>
63
+ <span className="font-mono text-[10px] text-indigo-500 bg-indigo-50 dark:bg-indigo-950/30 px-1.5 py-0.5 rounded">
64
+ {selectedNode.capability}
65
+ </span>
66
+ </button>
67
+ {configureOpen && (
68
+ <div className="px-3 pb-3 max-h-60 overflow-y-auto">
69
+ <ParamForm
70
+ capability={capabilityMap[selectedNode.capability]!}
71
+ values={selectedNode.params}
72
+ onChange={(params) => updateNodeParams(selectedNode.id, params)}
73
+ />
74
+ </div>
75
+ )}
76
+ </div>
77
+ )}
78
+
79
+ {/* Capabilities section */}
80
+ <div className="flex flex-1 flex-col overflow-hidden">
81
+ <div className="flex items-center justify-between px-3 h-10 shrink-0 border-b border-zinc-200 dark:border-zinc-800">
82
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
83
+ Capabilities
84
+ </span>
85
+ <span className="text-[11px] tabular-nums text-zinc-400">{filtered.length}</span>
86
+ </div>
87
+
88
+ {/* Search */}
89
+ <div className="px-2 py-2 border-b border-zinc-100 dark:border-zinc-800/50">
90
+ <div className="relative">
91
+ <svg className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
92
+ <input
93
+ type="text"
94
+ placeholder="Search capabilities..."
95
+ value={filter}
96
+ onChange={(e) => setFilter(e.target.value)}
97
+ className="w-full rounded-md border border-zinc-200 bg-white py-1 pl-7 pr-2 text-[11px] placeholder-zinc-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400 dark:border-zinc-700 dark:bg-zinc-900"
98
+ />
99
+ </div>
100
+ </div>
101
+
102
+ {/* Capability list */}
103
+ <div className="flex-1 overflow-y-auto p-2">
104
+ {capabilities.length === 0 ? (
105
+ <div className="px-2 py-8 text-center">
106
+ <p className="text-[11px] text-zinc-400">No capabilities loaded</p>
107
+ <p className="mt-1 text-[10px] text-zinc-500">Load a plugin to get started</p>
108
+ </div>
109
+ ) : filtered.length === 0 ? (
110
+ <p className="px-2 py-4 text-[11px] text-zinc-400">No matching capabilities</p>
111
+ ) : (
112
+ <div className="space-y-3">
113
+ {Array.from(grouped.entries()).map(([domain, caps]) => (
114
+ <div key={domain}>
115
+ <div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-zinc-400">
116
+ {domain}
117
+ </div>
118
+ <ul className="space-y-0.5">
119
+ {caps.map((cap) => {
120
+ const isInPipeline = nodes.some((n) => n.capability === cap.name);
121
+ return (
122
+ <li key={cap.name}>
123
+ <button
124
+ type="button"
125
+ onClick={() => addNode(cap.name)}
126
+ className={`group w-full rounded-md px-2 py-1.5 text-left transition-all ${
127
+ isInPipeline
128
+ ? 'bg-indigo-50/50 ring-1 ring-indigo-200 dark:bg-indigo-950/20 dark:ring-indigo-800'
129
+ : 'hover:bg-zinc-50 dark:hover:bg-zinc-800'
130
+ }`}
131
+ >
132
+ <div className="flex items-center justify-between gap-2">
133
+ <span className="truncate font-mono text-[11px] font-medium">{cap.name}</span>
134
+ <span className={`shrink-0 rounded px-1 py-0.5 text-[9px] font-medium uppercase ${
135
+ cap.performance === 'fast'
136
+ ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
137
+ : cap.performance === 'medium'
138
+ ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
139
+ : 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400'
140
+ }`}>
141
+ {cap.performance}
142
+ </span>
143
+ </div>
144
+ <p className="mt-0.5 truncate text-[10px] text-zinc-500 dark:text-zinc-400">{cap.description}</p>
145
+ </button>
146
+ </li>
147
+ );
148
+ })}
149
+ </ul>
150
+ </div>
151
+ ))}
152
+ </div>
153
+ )}
154
+ </div>
155
+ </div>
156
+ </aside>
157
+ );
158
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * ParamForm - 能力参数表单
3
+ *
4
+ * 根据 Capability 的 params Schema 自动生成表单。
5
+ * 紧凑行内布局,适配 Inspector 面板。
6
+ */
7
+
8
+ import type { Capability, CapabilityParam } from '@lokvis/schema';
9
+
10
+ export interface ParamFormProps {
11
+ capability: Capability;
12
+ values: Record<string, unknown>;
13
+ onChange: (values: Record<string, unknown>) => void;
14
+ }
15
+
16
+ export function ParamForm({ capability, values, onChange }: ParamFormProps) {
17
+ const set = (name: string, value: unknown) => {
18
+ onChange({ ...values, [name]: value });
19
+ };
20
+
21
+ if (capability.params.length === 0) {
22
+ return (
23
+ <p className="py-3 text-center text-[11px] text-zinc-400 italic">
24
+ This capability has no configurable parameters.
25
+ </p>
26
+ );
27
+ }
28
+
29
+ return (
30
+ <div className="space-y-2.5">
31
+ {capability.params.map((p) => (
32
+ <ParamField
33
+ key={p.name}
34
+ param={p}
35
+ value={values[p.name]}
36
+ onChange={(v) => set(p.name, v)}
37
+ />
38
+ ))}
39
+ </div>
40
+ );
41
+ }
42
+
43
+ const inputBase =
44
+ 'w-full rounded-md border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] transition-colors focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400 dark:border-zinc-700 dark:bg-zinc-900';
45
+
46
+ function ParamField({
47
+ param,
48
+ value,
49
+ onChange,
50
+ }: {
51
+ param: CapabilityParam;
52
+ value: unknown;
53
+ onChange: (value: unknown) => void;
54
+ }) {
55
+ if (param.type === 'boolean') {
56
+ return (
57
+ <label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
58
+ <input
59
+ type="checkbox"
60
+ checked={Boolean(value ?? param.default)}
61
+ onChange={(e) => onChange(e.target.checked)}
62
+ className="h-3.5 w-3.5 rounded border-zinc-300 text-indigo-600 focus:ring-indigo-500"
63
+ />
64
+ <span className="flex-1 text-[11px] text-zinc-700 dark:text-zinc-300">{param.name}</span>
65
+ {param.description && (
66
+ <span className="shrink-0 text-[10px] text-zinc-400">{param.description}</span>
67
+ )}
68
+ </label>
69
+ );
70
+ }
71
+
72
+ if (param.type === 'enum' && param.values) {
73
+ return (
74
+ <div>
75
+ <label className="mb-1 block text-[11px] font-medium text-zinc-600 dark:text-zinc-300">
76
+ {param.name}
77
+ {param.required && <span className="ml-0.5 text-red-500">*</span>}
78
+ </label>
79
+ <select
80
+ value={(value as string) ?? (param.default as string) ?? ''}
81
+ onChange={(e) => onChange(e.target.value)}
82
+ className={inputBase}
83
+ >
84
+ {param.values.map((v) => (
85
+ <option key={v} value={v}>{v}</option>
86
+ ))}
87
+ </select>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ if (param.type === 'color') {
93
+ return (
94
+ <div>
95
+ <label className="mb-1 block text-[11px] font-medium text-zinc-600 dark:text-zinc-300">
96
+ {param.name}
97
+ </label>
98
+ <div className="flex gap-2">
99
+ <input
100
+ type="color"
101
+ value={(value as string) ?? (param.default as string) ?? '#ffffff'}
102
+ onChange={(e) => onChange(e.target.value)}
103
+ className="h-7 w-7 shrink-0 cursor-pointer rounded border border-zinc-200 dark:border-zinc-700"
104
+ />
105
+ <input
106
+ type="text"
107
+ value={(value as string) ?? (param.default as string) ?? '#ffffff'}
108
+ onChange={(e) => onChange(e.target.value)}
109
+ className={inputBase}
110
+ />
111
+ </div>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ if (param.type === 'number') {
117
+ return (
118
+ <div>
119
+ <div className="mb-1 flex items-center justify-between">
120
+ <label className="text-[11px] font-medium text-zinc-600 dark:text-zinc-300">
121
+ {param.name}
122
+ {param.required && <span className="ml-0.5 text-red-500">*</span>}
123
+ </label>
124
+ {(param.min !== undefined || param.max !== undefined) && (
125
+ <span className="text-[10px] text-zinc-400">
126
+ {param.min !== undefined ? param.min : '-'}
127
+ {' — '}
128
+ {param.max !== undefined ? param.max : '∞'}
129
+ </span>
130
+ )}
131
+ </div>
132
+ <input
133
+ type="number"
134
+ value={value === undefined ? (param.default as number | undefined) ?? '' : (value as number)}
135
+ min={param.min}
136
+ max={param.max}
137
+ onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
138
+ className={inputBase}
139
+ />
140
+ </div>
141
+ );
142
+ }
143
+
144
+ // string / file / array / object → text input
145
+ return (
146
+ <div>
147
+ <label className="mb-1 block text-[11px] font-medium text-zinc-600 dark:text-zinc-300">
148
+ {param.name}
149
+ {param.required && <span className="ml-0.5 text-red-500">*</span>}
150
+ {param.description && (
151
+ <span className="ml-1 font-normal text-zinc-400">{param.description}</span>
152
+ )}
153
+ </label>
154
+ <input
155
+ type="text"
156
+ value={(value as string) ?? (param.default as string) ?? ''}
157
+ onChange={(e) => onChange(e.target.value)}
158
+ placeholder={param.type === 'file' ? 'data URL or path' : ''}
159
+ className={inputBase}
160
+ />
161
+ </div>
162
+ );
163
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * PipelineBar - 工作流管道条
3
+ *
4
+ * 显示当前工作流的节点序列,支持选中、删除节点。
5
+ * 位于 Canvas 下方、StatusBar 上方。
6
+ */
7
+
8
+ import * as React from 'react';
9
+ import { useWorkspaceStore } from '../store/index.js';
10
+
11
+ export interface PipelineBarProps {
12
+ className?: string;
13
+ }
14
+
15
+ export function PipelineBar({ className = '' }: PipelineBarProps) {
16
+ const nodes = useWorkspaceStore((s) => s.nodes);
17
+ const selectedNodeId = useWorkspaceStore((s) => s.selectedNodeId);
18
+ const selectNode = useWorkspaceStore((s) => s.selectNode);
19
+ const removeNode = useWorkspaceStore((s) => s.removeNode);
20
+
21
+ return (
22
+ <div className={`flex h-10 shrink-0 items-center gap-2 border-t border-zinc-200 bg-white px-3 dark:border-zinc-800 dark:bg-zinc-900 ${className}`}>
23
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-zinc-400 shrink-0">
24
+ Pipeline
25
+ </span>
26
+
27
+ {nodes.length === 0 ? (
28
+ <span className="text-[11px] text-zinc-400">
29
+ Empty &mdash; add capabilities from the right panel
30
+ </span>
31
+ ) : (
32
+ <div className="flex flex-1 items-center gap-1 overflow-x-auto min-w-0">
33
+ {/* Source indicator */}
34
+ <div className="flex 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 shrink-0">
35
+ <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
36
+ Source
37
+ </div>
38
+
39
+ {/* Arrow */}
40
+ <svg className="h-3 w-3 shrink-0 text-zinc-300 dark:text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
41
+ <path strokeLinecap="round" strokeLinejoin="round" d="m9 5 7 7-7 7" />
42
+ </svg>
43
+
44
+ {/* Nodes */}
45
+ {nodes.map((node, i) => {
46
+ const selected = node.id === selectedNodeId;
47
+ return (
48
+ <React.Fragment key={node.id}>
49
+ <button
50
+ type="button"
51
+ onClick={() => selectNode(node.id)}
52
+ className={`group flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-all ${
53
+ selected
54
+ ? 'bg-indigo-100 text-indigo-700 ring-1 ring-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:ring-indigo-700'
55
+ : node.status === 'running'
56
+ ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400'
57
+ : node.status === 'success'
58
+ ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400'
59
+ : node.status === 'failed'
60
+ ? 'bg-red-100 text-red-700 dark:bg-red-950/30 dark:text-red-400'
61
+ : 'bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700'
62
+ }`}
63
+ >
64
+ <span className="font-mono">{node.capability}</span>
65
+ <StatusDot status={node.status} />
66
+ <span
67
+ onClick={(e) => {
68
+ e.stopPropagation();
69
+ removeNode(node.id);
70
+ }}
71
+ className="ml-0.5 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"
72
+ role="button"
73
+ aria-label="Remove step"
74
+ >
75
+ <svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
76
+ </span>
77
+ </button>
78
+
79
+ {/* Arrow between nodes */}
80
+ {i < nodes.length - 1 && (
81
+ <svg className="h-3 w-3 shrink-0 text-zinc-300 dark:text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
82
+ <path strokeLinecap="round" strokeLinejoin="round" d="m9 5 7 7-7 7" />
83
+ </svg>
84
+ )}
85
+ </React.Fragment>
86
+ );
87
+ })}
88
+
89
+ {/* Output indicator */}
90
+ <svg className="h-3 w-3 shrink-0 text-zinc-300 dark:text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
91
+ <path strokeLinecap="round" strokeLinejoin="round" d="m9 5 7 7-7 7" />
92
+ </svg>
93
+ <div className="flex 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 shrink-0">
94
+ <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" 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"/></svg>
95
+ Output
96
+ </div>
97
+ </div>
98
+ )}
99
+
100
+ {/* Step count */}
101
+ {nodes.length > 0 && (
102
+ <span className="shrink-0 text-[10px] tabular-nums text-zinc-400">
103
+ {nodes.length} step{nodes.length !== 1 ? 's' : ''}
104
+ </span>
105
+ )}
106
+ </div>
107
+ );
108
+ }
109
+
110
+ function StatusDot({ status }: { status: string }) {
111
+ const color =
112
+ status === 'running'
113
+ ? 'bg-amber-500'
114
+ : status === 'success'
115
+ ? 'bg-emerald-500'
116
+ : status === 'failed'
117
+ ? 'bg-red-500'
118
+ : status === 'pending'
119
+ ? 'bg-zinc-400 dark:bg-zinc-500'
120
+ : 'bg-zinc-300 dark:bg-zinc-600';
121
+
122
+ const animate = status === 'running' ? 'animate-pulse' : '';
123
+
124
+ return <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${color} ${animate}`} />;
125
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * StatusBar - 底部状态栏
3
+ *
4
+ * 紧凑单行:左侧状态指示,右侧资源统计。
5
+ */
6
+
7
+ import { useWorkspaceStore } from '../store/index.js';
8
+
9
+ export interface StatusBarProps {
10
+ className?: string;
11
+ }
12
+
13
+ export function StatusBar({ className = '' }: StatusBarProps) {
14
+ const statusMessage = useWorkspaceStore((s) => s.statusMessage);
15
+ const error = useWorkspaceStore((s) => s.error);
16
+ const running = useWorkspaceStore((s) => s.running);
17
+ const assets = useWorkspaceStore((s) => s.assets);
18
+ const capabilities = useWorkspaceStore((s) => s.capabilities);
19
+ const nodes = useWorkspaceStore((s) => s.nodes);
20
+ const setError = useWorkspaceStore((s) => s.setError);
21
+
22
+ return (
23
+ <footer
24
+ className={`flex h-7 shrink-0 items-center justify-between border-t border-zinc-200 bg-zinc-50 px-3 dark:border-zinc-800 dark:bg-zinc-950 ${className}`}
25
+ >
26
+ {/* Left: Status */}
27
+ <div className="flex items-center gap-2 min-w-0">
28
+ <span className="flex items-center gap-1.5">
29
+ <span
30
+ className={`h-1.5 w-1.5 rounded-full shrink-0 ${
31
+ error
32
+ ? 'bg-red-500'
33
+ : running
34
+ ? 'bg-amber-500 animate-pulse'
35
+ : 'bg-emerald-500'
36
+ }`}
37
+ />
38
+ <span className={`truncate text-[10px] ${error ? 'text-red-500' : 'text-zinc-500'}`}>
39
+ {statusMessage}
40
+ </span>
41
+ </span>
42
+ {error && (
43
+ <button
44
+ type="button"
45
+ onClick={() => setError(null)}
46
+ className="shrink-0 text-[10px] text-red-500 underline decoration-red-300 hover:text-red-600 dark:decoration-red-800"
47
+ >
48
+ dismiss
49
+ </button>
50
+ )}
51
+ </div>
52
+
53
+ {/* Right: Stats */}
54
+ <div className="flex items-center gap-3 shrink-0 text-[10px] text-zinc-400 tabular-nums">
55
+ <span>{assets.length} asset{assets.length !== 1 ? 's' : ''}</span>
56
+ <span className="text-zinc-300 dark:text-zinc-700">|</span>
57
+ <span>{capabilities.length} cap{capabilities.length !== 1 ? 's' : ''}</span>
58
+ <span className="text-zinc-300 dark:text-zinc-700">|</span>
59
+ <span>{nodes.length} step{nodes.length !== 1 ? 's' : ''}</span>
60
+ </div>
61
+ </footer>
62
+ );
63
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Toolbar - 工作台顶部工具栏
3
+ *
4
+ * 左:Logo + 标题
5
+ * 中:状态消息
6
+ * 右:操作按钮组
7
+ */
8
+
9
+ import * as React from 'react';
10
+ import { Button } from '@lokvis/ui-core';
11
+ import { useWorkspaceStore } from '../store/index.js';
12
+
13
+ export interface ToolbarProps {
14
+ title?: string;
15
+ rightExtra?: React.ReactNode;
16
+ }
17
+
18
+ export function Toolbar({ title = 'Lokvis Workspace', rightExtra }: ToolbarProps) {
19
+ const running = useWorkspaceStore((s) => s.running);
20
+ const statusMessage = useWorkspaceStore((s) => s.statusMessage);
21
+ const error = useWorkspaceStore((s) => s.error);
22
+ const nodes = useWorkspaceStore((s) => s.nodes);
23
+ const selectedAssetId = useWorkspaceStore((s) => s.selectedAssetId);
24
+ const run = useWorkspaceStore((s) => s.run);
25
+ const clearWorkflow = useWorkspaceStore((s) => s.clearWorkflow);
26
+ const setError = useWorkspaceStore((s) => s.setError);
27
+
28
+ const canRun = !running && nodes.length > 0 && !!selectedAssetId;
29
+
30
+ async function handleRun() {
31
+ try {
32
+ await run();
33
+ } catch (err) {
34
+ setError(err instanceof Error ? err.message : String(err));
35
+ }
36
+ }
37
+
38
+ return (
39
+ <header className="flex h-12 shrink-0 items-center justify-between border-b border-zinc-200 bg-white px-4 dark:border-zinc-800 dark:bg-zinc-900">
40
+ {/* Left: Brand */}
41
+ <div className="flex items-center gap-2.5 min-w-0">
42
+ <span
43
+ className="shrink-0 text-base font-bold leading-none"
44
+ style={{
45
+ background: 'linear-gradient(135deg, #6366f1, #a855f7)',
46
+ WebkitBackgroundClip: 'text',
47
+ WebkitTextFillColor: 'transparent',
48
+ }}
49
+ >
50
+
51
+ </span>
52
+ <span className="truncate text-sm font-semibold tracking-tight">{title}</span>
53
+ </div>
54
+
55
+ {/* Center: Status */}
56
+ <div className="flex flex-1 items-center justify-center px-4 min-w-0">
57
+ <span className={`truncate text-xs ${error ? 'text-red-500' : 'text-zinc-400'}`}>
58
+ {statusMessage}
59
+ </span>
60
+ </div>
61
+
62
+ {/* Right: Actions */}
63
+ <div className="flex items-center gap-1.5 shrink-0">
64
+ {rightExtra}
65
+ {nodes.length > 0 && (
66
+ <Button
67
+ variant="ghost"
68
+ size="sm"
69
+ onClick={clearWorkflow}
70
+ disabled={running}
71
+ >
72
+ Clear
73
+ </Button>
74
+ )}
75
+ <Button
76
+ variant="primary"
77
+ size="sm"
78
+ onClick={handleRun}
79
+ loading={running}
80
+ disabled={!canRun}
81
+ >
82
+ Run
83
+ </Button>
84
+ </div>
85
+ </header>
86
+ );
87
+ }