@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.
- package/dist/components/AssetPanel.d.ts +12 -0
- package/dist/components/AssetPanel.d.ts.map +1 -0
- package/dist/components/AssetPanel.js +69 -0
- package/dist/components/AssetPanel.js.map +1 -0
- package/dist/components/Canvas.d.ts +12 -0
- package/dist/components/Canvas.d.ts.map +1 -0
- package/dist/components/Canvas.js +35 -0
- package/dist/components/Canvas.js.map +1 -0
- package/dist/components/HistoryPanel.d.ts +14 -0
- package/dist/components/HistoryPanel.d.ts.map +1 -0
- package/dist/components/HistoryPanel.js +68 -0
- package/dist/components/HistoryPanel.js.map +1 -0
- package/dist/components/Inspector.d.ts +12 -0
- package/dist/components/Inspector.d.ts.map +1 -0
- package/dist/components/Inspector.js +46 -0
- package/dist/components/Inspector.js.map +1 -0
- package/dist/components/ParamForm.d.ts +14 -0
- package/dist/components/ParamForm.d.ts.map +1 -0
- package/dist/components/ParamForm.js +28 -0
- package/dist/components/ParamForm.js.map +1 -0
- package/dist/components/PipelineBar.d.ts +12 -0
- package/dist/components/PipelineBar.d.ts.map +1 -0
- package/dist/components/PipelineBar.js +44 -0
- package/dist/components/PipelineBar.js.map +1 -0
- package/dist/components/StatusBar.d.ts +10 -0
- package/dist/components/StatusBar.d.ts.map +1 -0
- package/dist/components/StatusBar.js +22 -0
- package/dist/components/StatusBar.js.map +1 -0
- package/dist/components/Toolbar.d.ts +14 -0
- package/dist/components/Toolbar.d.ts.map +1 -0
- package/dist/components/Toolbar.js +36 -0
- package/dist/components/Toolbar.js.map +1 -0
- package/dist/components/Workspace.d.ts +24 -0
- package/dist/components/Workspace.d.ts.map +1 -0
- package/dist/components/Workspace.js +33 -0
- package/dist/components/Workspace.js.map +1 -0
- package/dist/hooks/useLokvis.d.ts +32 -0
- package/dist/hooks/useLokvis.d.ts.map +1 -0
- package/dist/hooks/useLokvis.js +62 -0
- package/dist/hooks/useLokvis.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/store/assets-slice.d.ts +11 -0
- package/dist/store/assets-slice.d.ts.map +1 -0
- package/dist/store/assets-slice.js +39 -0
- package/dist/store/assets-slice.js.map +1 -0
- package/dist/store/capabilities-slice.d.ts +11 -0
- package/dist/store/capabilities-slice.d.ts.map +1 -0
- package/dist/store/capabilities-slice.js +15 -0
- package/dist/store/capabilities-slice.js.map +1 -0
- package/dist/store/index.d.ts +4 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +24 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/runtime-slice.d.ts +13 -0
- package/dist/store/runtime-slice.d.ts.map +1 -0
- package/dist/store/runtime-slice.js +29 -0
- package/dist/store/runtime-slice.js.map +1 -0
- package/dist/store/types.d.ts +77 -0
- package/dist/store/types.d.ts.map +1 -0
- package/dist/store/types.js +5 -0
- package/dist/store/types.js.map +1 -0
- package/dist/store/workflow-slice.d.ts +18 -0
- package/dist/store/workflow-slice.d.ts.map +1 -0
- package/dist/store/workflow-slice.js +133 -0
- package/dist/store/workflow-slice.js.map +1 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +57 -0
- package/src/components/AssetPanel.tsx +151 -0
- package/src/components/Canvas.tsx +123 -0
- package/src/components/HistoryPanel.tsx +127 -0
- package/src/components/Inspector.tsx +158 -0
- package/src/components/ParamForm.tsx +163 -0
- package/src/components/PipelineBar.tsx +125 -0
- package/src/components/StatusBar.tsx +63 -0
- package/src/components/Toolbar.tsx +87 -0
- package/src/components/Workspace.tsx +90 -0
- package/src/hooks/useLokvis.ts +78 -0
- package/src/index.ts +32 -0
- package/src/store/assets-slice.ts +57 -0
- package/src/store/capabilities-slice.ts +29 -0
- package/src/store/index.ts +27 -0
- package/src/store/runtime-slice.ts +53 -0
- package/src/store/types.ts +88 -0
- package/src/store/workflow-slice.ts +178 -0
- 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 — 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
|
+
}
|