@sakki_chin/dsh-codex-orchestrate 1.0.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/LICENSE +21 -0
- package/README.md +133 -0
- package/client/api.js +50 -0
- package/client/components/Composer.jsx +179 -0
- package/client/components/ConversationThread.jsx +396 -0
- package/client/components/Icons.jsx +47 -0
- package/client/components/OrchestrateWorkbench.jsx +68 -0
- package/client/components/Toast.jsx +26 -0
- package/client/components/WorkflowRail.jsx +414 -0
- package/client/hooks/useOrchestrateData.jsx +122 -0
- package/client/index.jsx +103 -0
- package/client/styles.js +296 -0
- package/cordis.patch.yml +11 -0
- package/esbuild.client.mjs +43 -0
- package/lib/client.js +8974 -0
- package/package.json +63 -0
- package/schema/workflow.example.yaml +32 -0
- package/schema/workflow.schema.json +75 -0
- package/schema/workflow.schema.md +112 -0
- package/scripts/verify-edge-routing.mjs +214 -0
- package/server/codex-runner.js +129 -0
- package/server/defaults.js +83 -0
- package/server/orchestrator.js +570 -0
- package/server/orchestrator.test.cjs +423 -0
- package/server/persistence.js +124 -0
- package/server/persistence.test.cjs +175 -0
- package/server/plugin.mjs +312 -0
- package/server/plugin.test.cjs +181 -0
- package/server/turn-reduce.js +77 -0
- package/server/turn-reduce.test.cjs +96 -0
- package/server/validate.js +263 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { styles } from '../styles.js'
|
|
3
|
+
|
|
4
|
+
const CARD_WIDTH = 190
|
|
5
|
+
const CARD_HEIGHT = 76
|
|
6
|
+
const COLUMN_GAP = 58
|
|
7
|
+
/* 行距同时决定「行间通道」的高度:够宽才能在一个通道带里放两条横向段而不贴卡片 */
|
|
8
|
+
const ROW_GAP = 28
|
|
9
|
+
const ROW_PITCH = CARD_HEIGHT + ROW_GAP
|
|
10
|
+
/* 竖直段不放在列间隙正中,而是各自贴近自己那一侧:
|
|
11
|
+
出口贴着源列、入口贴着目标列。否则「第 N 列的右侧间隙」与「第 N+1 列的左侧间隙」
|
|
12
|
+
是同一个间隙,两条无关的边会在同一 x 上叠出一条假的长连线。 */
|
|
13
|
+
const EXIT_INSET = COLUMN_GAP * 0.3
|
|
14
|
+
const ENTRY_INSET = COLUMN_GAP * 0.3
|
|
15
|
+
/* 通道 c 位于第 c-1 行与第 c 行之间横贯全图的空隙里(列顶部对齐后各行 y 一致,
|
|
16
|
+
所以这个空隙在每一列都是无卡片的)。长边的横向段只允许落在通道里。 */
|
|
17
|
+
const channelYOf = c => c * ROW_PITCH - ROW_GAP / 2
|
|
18
|
+
|
|
19
|
+
const STATUS_TEXT = {
|
|
20
|
+
pending: '待调度',
|
|
21
|
+
blocked: '等待前置',
|
|
22
|
+
queued: '排队中',
|
|
23
|
+
running: '运行中',
|
|
24
|
+
completed: '已完成',
|
|
25
|
+
failed: '失败',
|
|
26
|
+
cancelled: '已取消',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STATE_TEXT = { running: '运行中', completed: '已完成', failed: '有失败' }
|
|
30
|
+
|
|
31
|
+
/* 导出纯几何函数,供几何自证脚本直接调用真实实现(见 scripts/verify-edge-routing.mjs) */
|
|
32
|
+
export function layoutWorkflow(nodes) {
|
|
33
|
+
const byId = new Map(nodes.map(node => [node.id, node]))
|
|
34
|
+
const depths = new Map()
|
|
35
|
+
const depthOf = (node, trail = new Set()) => {
|
|
36
|
+
if (depths.has(node.id)) return depths.get(node.id)
|
|
37
|
+
if (trail.has(node.id)) return 0
|
|
38
|
+
const nextTrail = new Set(trail).add(node.id)
|
|
39
|
+
const parents = (node.dependsOn || []).map(id => byId.get(id)).filter(Boolean)
|
|
40
|
+
const depth = parents.length
|
|
41
|
+
? Math.max(...parents.map(parent => depthOf(parent, nextTrail) + 1))
|
|
42
|
+
: 0
|
|
43
|
+
depths.set(node.id, depth)
|
|
44
|
+
return depth
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const layers = []
|
|
48
|
+
for (const node of nodes) (layers[depthOf(node)] ||= []).push(node)
|
|
49
|
+
|
|
50
|
+
const order = new Map()
|
|
51
|
+
layers.forEach((layer, depth) => {
|
|
52
|
+
if (depth) {
|
|
53
|
+
layer.sort((a, b) => {
|
|
54
|
+
const score = item => {
|
|
55
|
+
const parentRows = (item.dependsOn || []).map(id => order.get(id)).filter(Number.isFinite)
|
|
56
|
+
return parentRows.length
|
|
57
|
+
? parentRows.reduce((sum, row) => sum + row, 0) / parentRows.length
|
|
58
|
+
: Number.MAX_SAFE_INTEGER
|
|
59
|
+
}
|
|
60
|
+
return score(a) - score(b)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
layer.forEach((node, row) => order.set(node.id, row))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const maxRows = Math.max(1, ...layers.map(layer => layer.length))
|
|
67
|
+
const contentBottom = (maxRows - 1) * ROW_PITCH + CARD_HEIGHT
|
|
68
|
+
const width = Math.max(CARD_WIDTH, layers.length * CARD_WIDTH + Math.max(0, layers.length - 1) * COLUMN_GAP)
|
|
69
|
+
|
|
70
|
+
/* 顶部对齐而非逐列居中:行 r 在所有列都落在同一个 y,
|
|
71
|
+
「行间空隙」才成为横贯全图的无卡片通道——这是长边能就近横移的前提。 */
|
|
72
|
+
const positions = new Map()
|
|
73
|
+
layers.forEach((layer, column) => {
|
|
74
|
+
layer.forEach((node, row) => positions.set(node.id, {
|
|
75
|
+
x: column * (CARD_WIDTH + COLUMN_GAP),
|
|
76
|
+
y: row * ROW_PITCH,
|
|
77
|
+
width: CARD_WIDTH,
|
|
78
|
+
height: CARD_HEIGHT,
|
|
79
|
+
}))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/* 先按固定顺序枚举边(节点序 → dependsOn 序),保证同一份 nodes 每次结果一致。 */
|
|
83
|
+
const spans = []
|
|
84
|
+
for (const target of nodes) {
|
|
85
|
+
for (const dependencyId of target.dependsOn || []) {
|
|
86
|
+
const from = positions.get(dependencyId)
|
|
87
|
+
const to = positions.get(target.id)
|
|
88
|
+
if (!from || !to || to.x <= from.x) continue /* 同列/逆向(环形依赖)不画 */
|
|
89
|
+
const long = to.x - from.x > CARD_WIDTH + COLUMN_GAP + 1
|
|
90
|
+
spans.push({
|
|
91
|
+
key: `${dependencyId}->${target.id}`,
|
|
92
|
+
sourceId: dependencyId,
|
|
93
|
+
targetId: target.id,
|
|
94
|
+
long,
|
|
95
|
+
fromRow: Math.round(from.y / ROW_PITCH),
|
|
96
|
+
toRow: Math.round(to.y / ROW_PITCH),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* 长边走 min(源行,目标行)+1 号通道——离两端最近、且一定夹在两端之间的空隙,落差最小。
|
|
102
|
+
同通道的长边**共用同一高度、允许重叠**,做成总线(bus)而不是逐条避让:
|
|
103
|
+
逐条避让会把冲突边一路推到更远的通道带,图上既稀疏又空出一大片。
|
|
104
|
+
只要方向相同就压在一起,视觉上读作一条干线段,而不是两三条平行线。 */
|
|
105
|
+
const usedChannels = new Set()
|
|
106
|
+
let maxChannelY = -Infinity
|
|
107
|
+
for (const span of spans) {
|
|
108
|
+
if (!span.long) continue
|
|
109
|
+
const c = Math.min(span.fromRow, span.toRow) + 1
|
|
110
|
+
const y = channelYOf(c)
|
|
111
|
+
span.channelY = y
|
|
112
|
+
usedChannels.add(c)
|
|
113
|
+
if (y > maxChannelY) maxChannelY = y
|
|
114
|
+
}
|
|
115
|
+
const channelCount = usedChannels.size
|
|
116
|
+
const height = Number.isFinite(maxChannelY)
|
|
117
|
+
? Math.max(contentBottom, maxChannelY + ROW_GAP / 2)
|
|
118
|
+
: contentBottom
|
|
119
|
+
|
|
120
|
+
const edges = []
|
|
121
|
+
for (const span of spans) {
|
|
122
|
+
const from = positions.get(span.sourceId)
|
|
123
|
+
const to = positions.get(span.targetId)
|
|
124
|
+
const x1 = from.x + from.width
|
|
125
|
+
const y1 = from.y + from.height / 2
|
|
126
|
+
const x2 = to.x - 2 /* 留 2px 贴边间隙,让箭头尖端停在卡片左边框上 */
|
|
127
|
+
const y2 = to.y + to.height / 2
|
|
128
|
+
edges.push({
|
|
129
|
+
key: span.key,
|
|
130
|
+
sourceId: span.sourceId,
|
|
131
|
+
targetId: span.targetId,
|
|
132
|
+
long: span.long,
|
|
133
|
+
d: span.long
|
|
134
|
+
? channelPath(x1, y1, x2, y2, span.channelY)
|
|
135
|
+
: orthogonalPath(x1, y1, x2, y2),
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
return { positions, width, height, edges, channelCount }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function useStableGraph(nodes) {
|
|
142
|
+
const signature = JSON.stringify(nodes.map(node => [
|
|
143
|
+
node.id,
|
|
144
|
+
node.title,
|
|
145
|
+
node.prompt,
|
|
146
|
+
node.status,
|
|
147
|
+
node.dependsOn,
|
|
148
|
+
]))
|
|
149
|
+
const ref = React.useRef(null)
|
|
150
|
+
if (!ref.current || ref.current.signature !== signature) {
|
|
151
|
+
ref.current = { signature, layout: layoutWorkflow(nodes) }
|
|
152
|
+
}
|
|
153
|
+
return ref.current
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function edgeColor(state) {
|
|
157
|
+
if (state === 'active') return 'var(--co-running)'
|
|
158
|
+
if (state === 'complete') return 'var(--co-ok)'
|
|
159
|
+
return 'var(--co-ink-faint)'
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* 圆角正交走线:右出 → 到中点竖直转折 → 左进,两个转角各用一段 1/4 圆弧。
|
|
163
|
+
* 同排(y 基本相等)时退化成一条直线。
|
|
164
|
+
* 半径同时受三段可用距离约束,短边不会画出回折;列间距 COLUMN_GAP(58) 下
|
|
165
|
+
* 两段水平线各留 ~17px,视觉上足够圆润。 */
|
|
166
|
+
export function orthogonalPath(x1, y1, x2, y2, radius = 12) {
|
|
167
|
+
if (Math.abs(y2 - y1) < 0.75) return `M${x1} ${y1}H${x2}`
|
|
168
|
+
const midX = x1 + (x2 - x1) / 2
|
|
169
|
+
const down = y2 > y1 ? 1 : -1
|
|
170
|
+
const r = Math.max(2, Math.min(
|
|
171
|
+
radius,
|
|
172
|
+
Math.abs(y2 - y1) / 2,
|
|
173
|
+
Math.abs(midX - x1),
|
|
174
|
+
Math.abs(x2 - midX),
|
|
175
|
+
))
|
|
176
|
+
return [
|
|
177
|
+
`M${x1} ${y1}`,
|
|
178
|
+
`H${midX - r}`,
|
|
179
|
+
`Q${midX} ${y1} ${midX} ${y1 + down * r}`,
|
|
180
|
+
`V${y2 - down * r}`,
|
|
181
|
+
`Q${midX} ${y2} ${midX + r} ${y2}`,
|
|
182
|
+
`H${x2}`,
|
|
183
|
+
].join(' ')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* 跨列长边:右出 → 源列右侧空隙上下行 → 行间通道横移 → 目标列左侧空隙上下行 → 左进。
|
|
187
|
+
* 竖直段只落在「列与列之间的空隙」(贯穿全高、无卡片),
|
|
188
|
+
* 横向段只落在「行与行之间的通道」(横贯全图、无卡片),
|
|
189
|
+
* 两者叠加即保证连线不覆盖任何卡片,也不从卡片底下穿过。
|
|
190
|
+
* 转弯用 1/4 圆弧,半径受通道高度与可用空隙共同约束。 */
|
|
191
|
+
export function channelPath(x1, y1, x2, y2, channelY, radius = 12) {
|
|
192
|
+
const leftGap = x1 + EXIT_INSET /* 出口竖直段:贴近源列右侧 */
|
|
193
|
+
const rightGap = x2 - ENTRY_INSET /* 入口竖直段:贴近目标列左侧 */
|
|
194
|
+
/* 通道可能位于任一端点的上方或下方(冲突下移到图底时就在下方)。
|
|
195
|
+
dir 一律取「从当前点朝向目标点」的方向,取反会让线先越过目标再折返。 */
|
|
196
|
+
const dir1 = channelY > y1 ? 1 : -1 /* y1 → channelY */
|
|
197
|
+
const dir2 = y2 > channelY ? 1 : -1 /* channelY → y2 */
|
|
198
|
+
const r = Math.max(2, Math.min(
|
|
199
|
+
radius,
|
|
200
|
+
Math.abs(y1 - channelY) / 2,
|
|
201
|
+
Math.abs(y2 - channelY) / 2,
|
|
202
|
+
Math.abs(rightGap - leftGap) / 2,
|
|
203
|
+
EXIT_INSET,
|
|
204
|
+
ENTRY_INSET,
|
|
205
|
+
))
|
|
206
|
+
return [
|
|
207
|
+
`M${x1} ${y1}`,
|
|
208
|
+
`H${leftGap - r}`,
|
|
209
|
+
`Q${leftGap} ${y1} ${leftGap} ${y1 + dir1 * r}`,
|
|
210
|
+
`V${channelY - dir1 * r}`,
|
|
211
|
+
`Q${leftGap} ${channelY} ${leftGap + r} ${channelY}`,
|
|
212
|
+
`H${rightGap - r}`,
|
|
213
|
+
`Q${rightGap} ${channelY} ${rightGap} ${channelY + dir2 * r}`,
|
|
214
|
+
`V${y2 - dir2 * r}`,
|
|
215
|
+
`Q${rightGap} ${y2} ${rightGap + r} ${y2}`,
|
|
216
|
+
`H${x2}`,
|
|
217
|
+
].join(' ')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const DependencyEdges = React.memo(function DependencyEdges({ nodes, layout, markerPrefix, signature }) {
|
|
221
|
+
const byId = new Map(nodes.map(node => [node.id, node]))
|
|
222
|
+
/* 走线几何全部由 layoutWorkflow 统一产出(含跨列长边的走廊车道分配),
|
|
223
|
+
这里只负责按节点状态着色——两处各算一套会让车道数与实际线数对不上。 */
|
|
224
|
+
const edges = []
|
|
225
|
+
for (const edge of layout.edges) {
|
|
226
|
+
const source = byId.get(edge.sourceId)
|
|
227
|
+
const target = byId.get(edge.targetId)
|
|
228
|
+
if (!source || !target) continue
|
|
229
|
+
edges.push({
|
|
230
|
+
key: edge.key,
|
|
231
|
+
d: edge.d,
|
|
232
|
+
state: target.status === 'running'
|
|
233
|
+
? 'active'
|
|
234
|
+
: source.status === 'completed' ? 'complete' : 'waiting',
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
const reducedMotion = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<svg
|
|
241
|
+
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
|
242
|
+
width={layout.width}
|
|
243
|
+
height={layout.height}
|
|
244
|
+
aria-hidden="true"
|
|
245
|
+
style={styles.edges}
|
|
246
|
+
data-graph-signature={signature}
|
|
247
|
+
>
|
|
248
|
+
{/* 箭头:默认 markerUnits=strokeWidth 会让箭头随线宽等比放大,
|
|
249
|
+
线加粗后会变成巨块。改用 userSpaceOnUse 把箭头固定成 11px。 */}
|
|
250
|
+
<defs>
|
|
251
|
+
{['waiting', 'complete', 'active'].map(state => (
|
|
252
|
+
<marker
|
|
253
|
+
key={state}
|
|
254
|
+
id={`${markerPrefix}-${state}`}
|
|
255
|
+
viewBox="0 0 10 10"
|
|
256
|
+
refX="9.5"
|
|
257
|
+
refY="5"
|
|
258
|
+
markerWidth="9"
|
|
259
|
+
markerHeight="9"
|
|
260
|
+
markerUnits="userSpaceOnUse"
|
|
261
|
+
orient="auto-start-reverse"
|
|
262
|
+
>
|
|
263
|
+
<path d="M0 0.6L10 5L0 9.4Z" fill={edgeColor(state)} />
|
|
264
|
+
</marker>
|
|
265
|
+
))}
|
|
266
|
+
</defs>
|
|
267
|
+
{edges.map(edge => {
|
|
268
|
+
const active = edge.state === 'active'
|
|
269
|
+
const color = edgeColor(edge.state)
|
|
270
|
+
return (
|
|
271
|
+
<g key={edge.key}>
|
|
272
|
+
{/* 曾经在活动边下垫过 10px 柔光底:并排两道时会让线糊成一坨,已去掉。
|
|
273
|
+
「正在流动」改由更亮的颜色 + 更粗的线 + 跑动虚线 + 沿线光点共同表达。 */}
|
|
274
|
+
<path
|
|
275
|
+
d={edge.d}
|
|
276
|
+
fill="none"
|
|
277
|
+
stroke={edge.state === 'waiting' ? 'var(--co-border-strong)' : color}
|
|
278
|
+
strokeWidth={active ? 2.6 : 1.9}
|
|
279
|
+
strokeLinecap="round"
|
|
280
|
+
strokeLinejoin="round"
|
|
281
|
+
markerEnd={`url(#${markerPrefix}-${edge.state})`}
|
|
282
|
+
opacity={edge.state === 'waiting' ? 0.8 : 0.78}
|
|
283
|
+
/>
|
|
284
|
+
<path
|
|
285
|
+
d={edge.d}
|
|
286
|
+
fill="none"
|
|
287
|
+
stroke={color}
|
|
288
|
+
strokeWidth={active ? 3.2 : 2.4}
|
|
289
|
+
strokeLinecap="round"
|
|
290
|
+
strokeLinejoin="round"
|
|
291
|
+
strokeDasharray="7 11"
|
|
292
|
+
opacity={active ? 1 : edge.state === 'complete' ? 0.7 : 0.5}
|
|
293
|
+
>
|
|
294
|
+
{!reducedMotion && (
|
|
295
|
+
<animate
|
|
296
|
+
attributeName="stroke-dashoffset"
|
|
297
|
+
from="0"
|
|
298
|
+
to="-36"
|
|
299
|
+
dur={active ? '1.1s' : edge.state === 'complete' ? '2.4s' : '1.8s'}
|
|
300
|
+
repeatCount="indefinite"
|
|
301
|
+
/>
|
|
302
|
+
)}
|
|
303
|
+
</path>
|
|
304
|
+
{/* 活动边上再跑一颗光点:比虚线流动更抓眼 */}
|
|
305
|
+
{active && !reducedMotion && (
|
|
306
|
+
<circle r="2.8" fill={color}>
|
|
307
|
+
<animateMotion dur="1.4s" repeatCount="indefinite" path={edge.d} />
|
|
308
|
+
</circle>
|
|
309
|
+
)}
|
|
310
|
+
</g>
|
|
311
|
+
)
|
|
312
|
+
})}
|
|
313
|
+
</svg>
|
|
314
|
+
)
|
|
315
|
+
}, (previous, next) => previous.signature === next.signature && previous.markerPrefix === next.markerPrefix)
|
|
316
|
+
|
|
317
|
+
function WorkflowNodeCard({ node, position, selected, onSelect }) {
|
|
318
|
+
const statusColor = node.status === 'running'
|
|
319
|
+
? 'var(--co-running)'
|
|
320
|
+
: node.status === 'completed' ? 'var(--co-ok)' : node.status === 'failed' ? 'var(--co-error)' : 'var(--co-ink-faint)'
|
|
321
|
+
return (
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
aria-current={selected}
|
|
325
|
+
aria-label={`任务 ${node.title || node.id},状态 ${STATUS_TEXT[node.status] || node.status}`}
|
|
326
|
+
onClick={() => onSelect(node.id)}
|
|
327
|
+
style={{
|
|
328
|
+
...styles.nodeCard,
|
|
329
|
+
transform: `translate(${position.x}px, ${position.y}px)`,
|
|
330
|
+
borderColor: selected ? 'var(--co-ink-secondary)' : 'var(--co-border)',
|
|
331
|
+
background: selected ? 'var(--co-sunken)' : 'var(--co-raised)',
|
|
332
|
+
}}
|
|
333
|
+
>
|
|
334
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
|
335
|
+
<span aria-hidden="true" style={{ width: 6, height: 6, flex: 'none', borderRadius: '50%', background: statusColor }} />
|
|
336
|
+
<span style={styles.nodeTitle}>{node.title || node.id}</span>
|
|
337
|
+
</span>
|
|
338
|
+
<span style={styles.nodePrompt}>{node.prompt || STATUS_TEXT[node.status] || node.status}</span>
|
|
339
|
+
</button>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function WorkflowStats({ nodes }) {
|
|
344
|
+
const stats = { completed: 0, running: 0, failed: 0 }
|
|
345
|
+
for (const node of nodes) if (Object.hasOwn(stats, node.status)) stats[node.status] += 1
|
|
346
|
+
return (
|
|
347
|
+
<div style={styles.stats} aria-label="工作流统计">
|
|
348
|
+
<span>{nodes.length} 节点</span>
|
|
349
|
+
<span>{stats.completed} 完成</span>
|
|
350
|
+
{stats.running > 0 && <span>{stats.running} 运行</span>}
|
|
351
|
+
{stats.failed > 0 && <span>{stats.failed} 失败</span>}
|
|
352
|
+
</div>
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function WorkflowRail({ workflow, workflows, selectedWorkflowId, currentNodeId, listError, onSelectWorkflow, onSelectNode }) {
|
|
357
|
+
const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : []
|
|
358
|
+
const graph = useStableGraph(nodes)
|
|
359
|
+
const markerPrefix = `co-arrow-${React.useId().replace(/[^a-zA-Z0-9_-]/g, '')}`
|
|
360
|
+
const options = workflow && !workflows.some(item => item.workflowId === workflow.workflowId)
|
|
361
|
+
? [{ workflowId: workflow.workflowId, title: workflow.title, state: workflow.state }, ...workflows]
|
|
362
|
+
: workflows
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<section style={styles.rail} aria-label="动态工作流">
|
|
366
|
+
<div style={styles.railMeta}>
|
|
367
|
+
<div style={styles.railTitleGroup}>
|
|
368
|
+
<h2 style={styles.railTitle}>{workflow?.title || '还没有工作流'}</h2>
|
|
369
|
+
<span style={styles.railSub}>
|
|
370
|
+
{workflow?.goal || (listError ? '插件服务暂时不可用,等待下轮重试' : '派发后会自动显示最新工作流')}
|
|
371
|
+
</span>
|
|
372
|
+
</div>
|
|
373
|
+
<div style={styles.railControls}>
|
|
374
|
+
{options.length > 1 && (
|
|
375
|
+
<select
|
|
376
|
+
value={selectedWorkflowId}
|
|
377
|
+
onChange={event => onSelectWorkflow(event.target.value)}
|
|
378
|
+
aria-label="选择工作流"
|
|
379
|
+
style={styles.picker}
|
|
380
|
+
>
|
|
381
|
+
{options.map(item => (
|
|
382
|
+
<option key={item.workflowId} value={item.workflowId}>
|
|
383
|
+
{item.title || item.workflowId} · {STATE_TEXT[item.state] || item.state || ''}
|
|
384
|
+
</option>
|
|
385
|
+
))}
|
|
386
|
+
</select>
|
|
387
|
+
)}
|
|
388
|
+
{workflow && <WorkflowStats nodes={nodes} />}
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
<div style={styles.railLanes} role="group" aria-label="任务节点">
|
|
392
|
+
{!workflow ? (
|
|
393
|
+
<div style={styles.empty}>{listError ? '无法读取工作流清单' : '等待工作流派发…'}</div>
|
|
394
|
+
) : nodes.length === 0 ? (
|
|
395
|
+
<div style={styles.empty}>这个工作流还没有任务节点。</div>
|
|
396
|
+
) : (
|
|
397
|
+
<div style={{ ...styles.graph, width: graph.layout.width, height: graph.layout.height }}>
|
|
398
|
+
<DependencyEdges nodes={nodes} layout={graph.layout} markerPrefix={markerPrefix} signature={graph.signature} />
|
|
399
|
+
{nodes.map(node => (
|
|
400
|
+
<WorkflowNodeCard
|
|
401
|
+
key={node.id}
|
|
402
|
+
node={node}
|
|
403
|
+
position={graph.layout.positions.get(node.id)}
|
|
404
|
+
selected={node.id === currentNodeId}
|
|
405
|
+
onSelect={onSelectNode}
|
|
406
|
+
/>
|
|
407
|
+
))}
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
</div>
|
|
411
|
+
</section>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { orchestrateApi } from '../api.js'
|
|
3
|
+
|
|
4
|
+
const POLL_INTERVAL_MS = 1500
|
|
5
|
+
|
|
6
|
+
export function useOrchestrateData(initialWorkflowId = '') {
|
|
7
|
+
const [workflows, setWorkflows] = React.useState([])
|
|
8
|
+
const [workflow, setWorkflow] = React.useState(null)
|
|
9
|
+
const [selectedWorkflowId, setSelectedWorkflowId] = React.useState(initialWorkflowId)
|
|
10
|
+
const [pinned, setPinned] = React.useState(Boolean(initialWorkflowId))
|
|
11
|
+
const [loading, setLoading] = React.useState(true)
|
|
12
|
+
const [listError, setListError] = React.useState(null)
|
|
13
|
+
const [stateError, setStateError] = React.useState(null)
|
|
14
|
+
const selectedRef = React.useRef(selectedWorkflowId)
|
|
15
|
+
const pinnedRef = React.useRef(pinned)
|
|
16
|
+
const mountedRef = React.useRef(false)
|
|
17
|
+
const activeControllerRef = React.useRef(null)
|
|
18
|
+
const refreshQueuedRef = React.useRef(false)
|
|
19
|
+
|
|
20
|
+
React.useEffect(() => { selectedRef.current = selectedWorkflowId }, [selectedWorkflowId])
|
|
21
|
+
React.useEffect(() => { pinnedRef.current = pinned }, [pinned])
|
|
22
|
+
|
|
23
|
+
const refresh = React.useCallback(async () => {
|
|
24
|
+
if (!mountedRef.current || refreshQueuedRef.current) return
|
|
25
|
+
refreshQueuedRef.current = true
|
|
26
|
+
activeControllerRef.current?.abort()
|
|
27
|
+
const controller = new AbortController()
|
|
28
|
+
activeControllerRef.current = controller
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
let list = null
|
|
32
|
+
try {
|
|
33
|
+
const result = await orchestrateApi.listWorkflows(controller.signal)
|
|
34
|
+
list = Array.isArray(result?.workflows) ? result.workflows : []
|
|
35
|
+
if (!mountedRef.current) return
|
|
36
|
+
setWorkflows(list)
|
|
37
|
+
setListError(null)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error?.name === 'AbortError' || !mountedRef.current) return
|
|
40
|
+
setListError(error.message)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let targetId = selectedRef.current
|
|
44
|
+
if (!pinnedRef.current && list) targetId = list[0]?.workflowId || ''
|
|
45
|
+
if (!targetId) {
|
|
46
|
+
if (mountedRef.current) {
|
|
47
|
+
selectedRef.current = ''
|
|
48
|
+
setSelectedWorkflowId('')
|
|
49
|
+
setWorkflow(null)
|
|
50
|
+
setStateError(null)
|
|
51
|
+
}
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const snapshot = await orchestrateApi.getState(targetId, controller.signal)
|
|
56
|
+
if (!mountedRef.current) return
|
|
57
|
+
selectedRef.current = targetId
|
|
58
|
+
setSelectedWorkflowId(targetId)
|
|
59
|
+
setWorkflow(snapshot)
|
|
60
|
+
setStateError(null)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.name !== 'AbortError' && mountedRef.current) setStateError(error.message)
|
|
63
|
+
} finally {
|
|
64
|
+
if (activeControllerRef.current === controller) activeControllerRef.current = null
|
|
65
|
+
refreshQueuedRef.current = false
|
|
66
|
+
if (mountedRef.current) setLoading(false)
|
|
67
|
+
}
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
React.useEffect(() => {
|
|
71
|
+
mountedRef.current = true
|
|
72
|
+
refresh()
|
|
73
|
+
const timer = window.setInterval(refresh, POLL_INTERVAL_MS)
|
|
74
|
+
return () => {
|
|
75
|
+
mountedRef.current = false
|
|
76
|
+
window.clearInterval(timer)
|
|
77
|
+
activeControllerRef.current?.abort()
|
|
78
|
+
}
|
|
79
|
+
}, [refresh])
|
|
80
|
+
|
|
81
|
+
React.useEffect(() => {
|
|
82
|
+
if (!selectedWorkflowId || typeof EventSource === 'undefined') return undefined
|
|
83
|
+
const source = new EventSource(orchestrateApi.eventsUrl(selectedWorkflowId))
|
|
84
|
+
let timer = null
|
|
85
|
+
source.onmessage = () => {
|
|
86
|
+
if (timer !== null) return
|
|
87
|
+
timer = window.setTimeout(() => {
|
|
88
|
+
timer = null
|
|
89
|
+
refresh()
|
|
90
|
+
}, 80)
|
|
91
|
+
}
|
|
92
|
+
return () => {
|
|
93
|
+
if (timer !== null) window.clearTimeout(timer)
|
|
94
|
+
source.close()
|
|
95
|
+
}
|
|
96
|
+
}, [selectedWorkflowId, refresh])
|
|
97
|
+
|
|
98
|
+
const selectWorkflow = React.useCallback(workflowId => {
|
|
99
|
+
if (!workflowId) return
|
|
100
|
+
pinnedRef.current = true
|
|
101
|
+
selectedRef.current = workflowId
|
|
102
|
+
setPinned(true)
|
|
103
|
+
setSelectedWorkflowId(workflowId)
|
|
104
|
+
setWorkflow(current => current?.workflowId === workflowId ? current : null)
|
|
105
|
+
setLoading(true)
|
|
106
|
+
activeControllerRef.current?.abort()
|
|
107
|
+
refreshQueuedRef.current = false
|
|
108
|
+
window.setTimeout(refresh, 0)
|
|
109
|
+
}, [refresh])
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
workflows,
|
|
113
|
+
workflow,
|
|
114
|
+
selectedWorkflowId,
|
|
115
|
+
pinned,
|
|
116
|
+
loading,
|
|
117
|
+
listError,
|
|
118
|
+
stateError,
|
|
119
|
+
refresh,
|
|
120
|
+
selectWorkflow,
|
|
121
|
+
}
|
|
122
|
+
}
|
package/client/index.jsx
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { OrchestrateWorkbench } from './components/OrchestrateWorkbench.jsx'
|
|
3
|
+
import { Icon } from './components/Icons.jsx'
|
|
4
|
+
import { styles } from './styles.js'
|
|
5
|
+
|
|
6
|
+
/* tab 系统内的身份:右侧栏类型 id + keyed slot 的 key + 菜单项 id。
|
|
7
|
+
* 与「模块加载器 id」是两回事——后者必须等于 package.json 的 name,
|
|
8
|
+
* 由 esbuild.client.mjs 的 banner 写进 lib/client.js,两者不要混用。 */
|
|
9
|
+
const TAB_ID = 'codex-orchestrate'
|
|
10
|
+
const TAB_KIND = 'codex-orchestrate'
|
|
11
|
+
|
|
12
|
+
const h = React.createElement
|
|
13
|
+
|
|
14
|
+
function OrchestrateBody({ useTabInfo }) {
|
|
15
|
+
const { tab } = useTabInfo()
|
|
16
|
+
const urlWorkflowId = typeof location === 'undefined'
|
|
17
|
+
? ''
|
|
18
|
+
: new URLSearchParams(location.search).get('workflowId') || ''
|
|
19
|
+
const initialWorkflowId = tab?.params?.workflowId || urlWorkflowId
|
|
20
|
+
return <OrchestrateWorkbench tabId={tab.id} initialWorkflowId={initialWorkflowId} />
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function OrchestrateTitle() {
|
|
24
|
+
return h('span', null, 'Codex Orchestrate')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* 引导页入口图标:宿主以 <Icon size={22 或 26} className={…} /> 调用。 */
|
|
28
|
+
function GuideIcon({ size = 22 }) {
|
|
29
|
+
return <Icon kind="command_execution" size={size} />
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const inject = ['slots', 'sidebarRight', 'sidebarRightTabs']
|
|
33
|
+
|
|
34
|
+
export function apply(ctx) {
|
|
35
|
+
ctx.effect(
|
|
36
|
+
() => ctx.sidebarRightTabs.register({
|
|
37
|
+
id: TAB_ID,
|
|
38
|
+
kind: TAB_KIND,
|
|
39
|
+
title: () => 'Codex Orchestrate',
|
|
40
|
+
/* 必须是 `guide` 数组,本类型才会出现在右侧栏「开始」引导页的入口列表里。
|
|
41
|
+
依据宿主 SidebarRightTabRegistry.refresh():
|
|
42
|
+
definition.guide ?? [] → 逐条 map 后按 order 排序
|
|
43
|
+
只注册 { id, kind, title } 时引导页不会有任何入口,用户无从打开本页。
|
|
44
|
+
title / description 都被宿主按函数调用;description 仅在入口数 ≤4 时显示。 */
|
|
45
|
+
guide: [{
|
|
46
|
+
order: 100,
|
|
47
|
+
icon: GuideIcon,
|
|
48
|
+
title: () => 'Codex Orchestrate',
|
|
49
|
+
description: () => '查看 codex 工作流的 DAG 与各节点会话',
|
|
50
|
+
}],
|
|
51
|
+
}),
|
|
52
|
+
'codex-orchestrate: right tab type',
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
ctx.effect(
|
|
56
|
+
() => ctx.slots.inject('sidebar.right.pane.tab', () =>
|
|
57
|
+
ctx.slots.register(
|
|
58
|
+
{ name: 'sidebar.right.pane.tab', key: TAB_ID },
|
|
59
|
+
OrchestrateBody,
|
|
60
|
+
),
|
|
61
|
+
),
|
|
62
|
+
'codex-orchestrate: right tab body',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
ctx.effect(
|
|
66
|
+
() => ctx.slots.inject('sidebar.right.pane.tab.title', () =>
|
|
67
|
+
ctx.slots.register(
|
|
68
|
+
{ name: 'sidebar.right.pane.tab.title', key: TAB_ID },
|
|
69
|
+
OrchestrateTitle,
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
'codex-orchestrate: right tab title',
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
ctx.effect(
|
|
76
|
+
() => ctx.slots.inject('sidebar.right.tab.menu.item', () =>
|
|
77
|
+
ctx.slots.register(
|
|
78
|
+
{
|
|
79
|
+
name: 'sidebar.right.tab.menu.item',
|
|
80
|
+
id: 'open-codex-orchestrate',
|
|
81
|
+
order: 100,
|
|
82
|
+
label: '打开 Codex Orchestrate',
|
|
83
|
+
},
|
|
84
|
+
function OpenOrchestrateMenuItem({ dismiss }) {
|
|
85
|
+
return h(
|
|
86
|
+
'button',
|
|
87
|
+
{
|
|
88
|
+
type: 'button',
|
|
89
|
+
role: 'menuitem',
|
|
90
|
+
style: styles.menuButton,
|
|
91
|
+
onClick() {
|
|
92
|
+
dismiss()
|
|
93
|
+
ctx.sidebarRight.openTab(TAB_KIND)
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
'打开 Codex Orchestrate',
|
|
97
|
+
)
|
|
98
|
+
},
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
'codex-orchestrate: right tab menu entry',
|
|
102
|
+
)
|
|
103
|
+
}
|