@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,396 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { XMarkdown } from '@ant-design/x-markdown'
|
|
3
|
+
import { Icon, Spinner } from './Icons.jsx'
|
|
4
|
+
import { styles } from '../styles.js'
|
|
5
|
+
|
|
6
|
+
const markdownComponents = {
|
|
7
|
+
p: props => <p {...props} style={{ margin: '0 0 10px' }} />,
|
|
8
|
+
a: props => <a {...props} style={{ color: 'var(--co-running)' }} />,
|
|
9
|
+
pre: props => <pre {...props} style={{ ...styles.code, margin: '10px 0' }} />,
|
|
10
|
+
code: props => <code {...props} style={{ padding: '1px 4px', borderRadius: 4, background: 'var(--co-code)', fontFamily: 'var(--co-font-mono)', fontSize: '.92em' }} />,
|
|
11
|
+
blockquote: props => <blockquote {...props} style={{ margin: '10px 0', paddingLeft: 10, borderLeft: '2px solid var(--co-border-strong)', color: 'var(--co-ink-secondary)' }} />,
|
|
12
|
+
h1: props => <h1 {...props} style={{ margin: '16px 0 7px', fontSize: 19, lineHeight: 1.35 }} />,
|
|
13
|
+
h2: props => <h2 {...props} style={{ margin: '16px 0 7px', fontSize: 16, lineHeight: 1.35 }} />,
|
|
14
|
+
h3: props => <h3 {...props} style={{ margin: '14px 0 6px', fontSize: 14, lineHeight: 1.35 }} />,
|
|
15
|
+
ul: props => <ul {...props} style={{ margin: '0 0 10px', paddingLeft: 21 }} />,
|
|
16
|
+
ol: props => <ol {...props} style={{ margin: '0 0 10px', paddingLeft: 21 }} />,
|
|
17
|
+
table: props => <div style={{ overflowX: 'auto' }}><table {...props} style={{ width: '100%', margin: '10px 0', borderCollapse: 'collapse', fontSize: 12 }} /></div>,
|
|
18
|
+
th: props => <th {...props} style={{ padding: '6px 8px', border: '1px solid var(--co-border)', textAlign: 'left', background: 'var(--co-raised)' }} />,
|
|
19
|
+
td: props => <td {...props} style={{ padding: '6px 8px', border: '1px solid var(--co-border)', textAlign: 'left' }} />,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function legacyTurns(node) {
|
|
23
|
+
if (Array.isArray(node?.turns) && node.turns.length) return node.turns
|
|
24
|
+
if (!node) return []
|
|
25
|
+
return [{
|
|
26
|
+
id: 'legacy_turn',
|
|
27
|
+
kind: 'initial',
|
|
28
|
+
input: node.prompt,
|
|
29
|
+
status: node.status,
|
|
30
|
+
items: node.items || [],
|
|
31
|
+
finalMessage: node.finalMessage,
|
|
32
|
+
usage: node.usage,
|
|
33
|
+
error: node.error,
|
|
34
|
+
startedAt: node.startedAt,
|
|
35
|
+
finishedAt: node.finishedAt,
|
|
36
|
+
}]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function orderedTurnEntries(turn) {
|
|
40
|
+
const entries = Array.isArray(turn.items) ? [...turn.items] : []
|
|
41
|
+
if (turn.finalMessage && !entries.some(entry => entry.type === 'agent_message' && entry.text === turn.finalMessage)) {
|
|
42
|
+
entries.push({ id: `${turn.id || 'turn'}-final`, type: 'agent_message', text: turn.finalMessage, live: false })
|
|
43
|
+
}
|
|
44
|
+
return entries
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function timelineSegments(turn) {
|
|
48
|
+
const segments = []
|
|
49
|
+
let processItems = []
|
|
50
|
+
let processIndex = 0
|
|
51
|
+
const flush = () => {
|
|
52
|
+
if (!processItems.length) return
|
|
53
|
+
segments.push({
|
|
54
|
+
kind: 'process',
|
|
55
|
+
key: `process:${processItems[0]?.id || processIndex}`,
|
|
56
|
+
items: processItems,
|
|
57
|
+
index: processIndex,
|
|
58
|
+
})
|
|
59
|
+
processItems = []
|
|
60
|
+
processIndex += 1
|
|
61
|
+
}
|
|
62
|
+
for (const entry of orderedTurnEntries(turn)) {
|
|
63
|
+
if (!['agent_message', 'error'].includes(entry.type)) {
|
|
64
|
+
processItems.push(entry)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
flush()
|
|
68
|
+
segments.push({ kind: entry.type, key: `entry:${entry.id || segments.length}`, entry })
|
|
69
|
+
}
|
|
70
|
+
flush()
|
|
71
|
+
return segments
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function processSummary(entries, active) {
|
|
75
|
+
if (active) {
|
|
76
|
+
const current = [...entries].reverse().find(item => item.live || item.status === 'in_progress') || entries.at(-1)
|
|
77
|
+
if (current?.type === 'reasoning') return '正在思考…'
|
|
78
|
+
if (current?.type === 'command_execution') return '正在运行命令…'
|
|
79
|
+
if (current?.type === 'file_change') return '正在修改文件…'
|
|
80
|
+
if (['mcp_tool_call', 'web_search', 'todo_list'].includes(current?.type)) return '正在使用工具…'
|
|
81
|
+
return '正在执行任务…'
|
|
82
|
+
}
|
|
83
|
+
const counts = { reasoning: 0, command_execution: 0, tool: 0, file_change: 0 }
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (entry.type === 'reasoning') counts.reasoning += 1
|
|
86
|
+
else if (entry.type === 'command_execution') counts.command_execution += 1
|
|
87
|
+
else if (entry.type === 'file_change') counts.file_change += 1
|
|
88
|
+
else counts.tool += 1
|
|
89
|
+
}
|
|
90
|
+
return [
|
|
91
|
+
counts.reasoning ? '已思考' : '',
|
|
92
|
+
counts.command_execution ? `运行了${counts.command_execution > 1 ? ` ${counts.command_execution} 个` : ''}命令` : '',
|
|
93
|
+
counts.tool ? `使用了${counts.tool > 1 ? ` ${counts.tool} 个` : ''}工具` : '',
|
|
94
|
+
counts.file_change ? `修改了${counts.file_change > 1 ? ` ${counts.file_change} 个` : ''}文件` : '',
|
|
95
|
+
].filter(Boolean).join(' · ') || '查看执行过程'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function entryPresentation(entry) {
|
|
99
|
+
const live = Boolean(entry.live) || entry.status === 'in_progress'
|
|
100
|
+
const failed = entry.status === 'failed' || (entry.exit_code !== undefined && entry.exit_code !== 0) || Boolean(entry.error)
|
|
101
|
+
if (entry.type === 'reasoning') return { label: live ? '正在思考…' : '思考过程', body: entry.text || '(暂无思考摘要)', live, failed }
|
|
102
|
+
if (entry.type === 'command_execution') return { label: live ? '正在运行' : '已运行', meta: entry.command || '', body: entry.aggregated_output || '(无输出)', live, failed }
|
|
103
|
+
if (entry.type === 'file_change') {
|
|
104
|
+
const paths = (entry.changes || []).map(change => change.path)
|
|
105
|
+
return { label: failed ? '文件变更失败' : '已修改文件', meta: paths.length > 1 ? `${paths[0]} 等 ${paths.length} 个文件` : paths[0] || '', body: entry.changes || [], live, failed }
|
|
106
|
+
}
|
|
107
|
+
if (entry.type === 'mcp_tool_call') return { label: live ? '正在调用工具' : '已调用工具', meta: [entry.server, entry.tool].filter(Boolean).join(' · '), body: entry.error || entry.result || entry.arguments || {}, live, failed }
|
|
108
|
+
if (entry.type === 'web_search') return { label: '已搜索网页', meta: entry.query || '', live, failed }
|
|
109
|
+
if (entry.type === 'todo_list') return { label: '已更新计划', meta: `${(entry.items || []).filter(item => item.completed).length}/${(entry.items || []).length}`, body: entry.items || [], live, failed }
|
|
110
|
+
return { label: entry.type || '工具活动', body: entry, live, failed }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function Disclosure({ disclosureKey, expandedKeys, onToggle, summary, children, bodyStyle = styles.activityBody }) {
|
|
114
|
+
const open = expandedKeys.has(disclosureKey)
|
|
115
|
+
return (
|
|
116
|
+
<div>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
aria-expanded={open}
|
|
120
|
+
onClick={() => onToggle(disclosureKey)}
|
|
121
|
+
style={{ ...styles.buttonReset, ...styles.activitySummary }}
|
|
122
|
+
>
|
|
123
|
+
{summary(open)}
|
|
124
|
+
</button>
|
|
125
|
+
{open && <div style={bodyStyle}>{children}</div>}
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function ActivityBody({ entry, presentation }) {
|
|
131
|
+
if (entry.type === 'reasoning') return <div style={styles.prose}>{presentation.body}</div>
|
|
132
|
+
if (entry.type === 'command_execution') return <pre style={styles.code}>{presentation.body}</pre>
|
|
133
|
+
if (entry.type === 'file_change') {
|
|
134
|
+
return <div style={{ display: 'grid', gap: 3 }}>{presentation.body.map((change, index) => (
|
|
135
|
+
<div key={`${change.path}:${index}`} style={{ display: 'flex', justifyContent: 'space-between', gap: 10, color: 'var(--co-ink-muted)', fontSize: 11 }}>
|
|
136
|
+
<code style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{change.path}</code>
|
|
137
|
+
<span>{change.kind}</span>
|
|
138
|
+
</div>
|
|
139
|
+
))}</div>
|
|
140
|
+
}
|
|
141
|
+
if (entry.type === 'todo_list') {
|
|
142
|
+
return <div style={{ display: 'grid', gap: 3 }}>{presentation.body.map((item, index) => (
|
|
143
|
+
<div key={index} style={{ color: item.completed ? 'var(--co-ink-faint)' : 'var(--co-ink-muted)', fontSize: 11, textDecoration: item.completed ? 'line-through' : 'none' }}>
|
|
144
|
+
{item.completed ? '✓' : '○'} {item.text}
|
|
145
|
+
</div>
|
|
146
|
+
))}</div>
|
|
147
|
+
}
|
|
148
|
+
return <pre style={styles.code}>{JSON.stringify(presentation.body, null, 2)}</pre>
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ActivityRow({ entry, rowKey, expandedKeys, onToggle }) {
|
|
152
|
+
const presentation = entryPresentation(entry)
|
|
153
|
+
const hasBody = presentation.body !== undefined
|
|
154
|
+
const line = open => (
|
|
155
|
+
<>
|
|
156
|
+
{hasBody && <Icon kind="chevron" size={12} style={{ transform: open ? 'rotate(90deg)' : 'none' }} />}
|
|
157
|
+
<Icon kind={entry.type} size={14} />
|
|
158
|
+
<span style={{ color: presentation.failed ? 'var(--co-error)' : 'var(--co-ink-secondary)', flex: 'none' }}>{presentation.label}</span>
|
|
159
|
+
{presentation.meta && <span style={styles.activityMeta}>{presentation.meta}</span>}
|
|
160
|
+
{entry.type === 'command_execution' && entry.exit_code !== undefined && <span style={{ marginLeft: 'auto', color: presentation.failed ? 'var(--co-error)' : 'var(--co-ink-faint)', fontFamily: 'var(--co-font-mono)', fontSize: 10 }}>exit {entry.exit_code}</span>}
|
|
161
|
+
{presentation.live && <Spinner size={12} />}
|
|
162
|
+
</>
|
|
163
|
+
)
|
|
164
|
+
if (!hasBody) return <div style={styles.activityRow}>{line(false)}</div>
|
|
165
|
+
return (
|
|
166
|
+
<Disclosure disclosureKey={rowKey} expandedKeys={expandedKeys} onToggle={onToggle} summary={line}>
|
|
167
|
+
<ActivityBody entry={entry} presentation={presentation} />
|
|
168
|
+
</Disclosure>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function ActivityGroup({ segment, workflowId, nodeId, turnId, expandedKeys, onToggle }) {
|
|
173
|
+
const groupKey = `${workflowId}/${nodeId}/${turnId}/activity/${segment.key}`
|
|
174
|
+
const active = segment.items.some(entry => entry.live || entry.status === 'in_progress')
|
|
175
|
+
const types = [...new Set(segment.items.map(entry => entry.type))]
|
|
176
|
+
const groupIcon = types.length === 1 ? types[0] : 'tool'
|
|
177
|
+
return (
|
|
178
|
+
<div data-scroll-key={`${turnId}/${segment.key}`}>
|
|
179
|
+
<Disclosure
|
|
180
|
+
disclosureKey={groupKey}
|
|
181
|
+
expandedKeys={expandedKeys}
|
|
182
|
+
onToggle={onToggle}
|
|
183
|
+
summary={open => <>
|
|
184
|
+
<Icon kind={groupIcon} size={14} />
|
|
185
|
+
<span>{processSummary(segment.items, active)}</span>
|
|
186
|
+
{active && <Spinner size={12} />}
|
|
187
|
+
<Icon kind="chevron" size={12} style={{ marginLeft: 1, transform: open ? 'rotate(90deg)' : 'none' }} />
|
|
188
|
+
</>}
|
|
189
|
+
>
|
|
190
|
+
{segment.items.map((entry, index) => (
|
|
191
|
+
<ActivityRow
|
|
192
|
+
key={entry.id || `${entry.type}:${index}`}
|
|
193
|
+
entry={entry}
|
|
194
|
+
rowKey={`${groupKey}/${entry.id || `${entry.type}:${index}`}`}
|
|
195
|
+
expandedKeys={expandedKeys}
|
|
196
|
+
onToggle={onToggle}
|
|
197
|
+
/>
|
|
198
|
+
))}
|
|
199
|
+
</Disclosure>
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function MarkdownMessage({ entry, scrollKey }) {
|
|
205
|
+
return (
|
|
206
|
+
<div data-entry-id={entry.id} data-scroll-key={scrollKey} style={styles.agentMessage}>
|
|
207
|
+
<XMarkdown
|
|
208
|
+
content={String(entry.text || '')}
|
|
209
|
+
components={markdownComponents}
|
|
210
|
+
openLinksInNewTab
|
|
211
|
+
escapeRawHtml
|
|
212
|
+
disableDefaultStyles
|
|
213
|
+
style={{ color: 'var(--co-ink)', fontSize: 13, lineHeight: 1.75 }}
|
|
214
|
+
streaming={entry.live ? { hasNextChunk: true, enableAnimation: false, tail: false } : undefined}
|
|
215
|
+
/>
|
|
216
|
+
{entry.live && <span aria-label="输出中" style={{ color: 'var(--co-running)' }}> ▍</span>}
|
|
217
|
+
</div>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function formatTokens(value) {
|
|
222
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`
|
|
223
|
+
if (value >= 1000) return `${(value / 1000).toFixed(value >= 100_000 ? 0 : 1)}K`
|
|
224
|
+
return String(value)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function formatDuration(startedAt, finishedAt) {
|
|
228
|
+
const milliseconds = new Date(finishedAt) - new Date(startedAt)
|
|
229
|
+
if (!Number.isFinite(milliseconds) || milliseconds < 0) return ''
|
|
230
|
+
const seconds = Math.round(milliseconds / 1000)
|
|
231
|
+
if (seconds < 60) return `${seconds}秒`
|
|
232
|
+
return `${Math.floor(seconds / 60)}分 ${seconds % 60}秒`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function turnText(turn) {
|
|
236
|
+
const messages = (turn.items || []).filter(item => item.type === 'agent_message').map(item => item.text).filter(Boolean)
|
|
237
|
+
if (turn.finalMessage && !messages.includes(turn.finalMessage)) messages.push(turn.finalMessage)
|
|
238
|
+
return messages.join('\n\n')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function copyText(text) {
|
|
242
|
+
if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text)
|
|
243
|
+
const textarea = document.createElement('textarea')
|
|
244
|
+
textarea.value = text
|
|
245
|
+
textarea.style.position = 'fixed'
|
|
246
|
+
textarea.style.opacity = '0'
|
|
247
|
+
document.body.append(textarea)
|
|
248
|
+
textarea.select()
|
|
249
|
+
document.execCommand('copy')
|
|
250
|
+
textarea.remove()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function TurnActions({ turn, toast }) {
|
|
254
|
+
const text = turnText(turn)
|
|
255
|
+
const duration = turn.startedAt && turn.finishedAt ? formatDuration(turn.startedAt, turn.finishedAt) : ''
|
|
256
|
+
const input = turn.usage?.input_tokens || 0
|
|
257
|
+
const output = turn.usage?.output_tokens || 0
|
|
258
|
+
return (
|
|
259
|
+
<div style={styles.actions} aria-label="本轮操作与统计">
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
disabled={!text}
|
|
263
|
+
aria-label="复制本轮回复"
|
|
264
|
+
title="复制"
|
|
265
|
+
onClick={async () => { await copyText(text); toast('已复制本轮回复') }}
|
|
266
|
+
style={{ ...styles.buttonReset, ...styles.action, width: 26, justifyContent: 'center', opacity: text ? 1 : 0.35 }}
|
|
267
|
+
>
|
|
268
|
+
<Icon kind="copy" size={16} />
|
|
269
|
+
</button>
|
|
270
|
+
{turn.usage && <span style={styles.action} title={`输入 ${input} · 输出 ${output}`}><Icon kind="usage" size={15} />用量 {formatTokens(input + output)} tok</span>}
|
|
271
|
+
{duration && <span style={styles.action}><Icon kind="clock" size={15} />用时 {duration}</span>}
|
|
272
|
+
{turn.finishedAt && <time dateTime={turn.finishedAt} title={`完成于 ${new Date(turn.finishedAt).toLocaleString('zh-CN')}`}>{new Date(turn.finishedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })}</time>}
|
|
273
|
+
</div>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function ConversationTurn({ turn, workflowId, node, expandedKeys, onToggle, toast }) {
|
|
278
|
+
const segments = timelineSegments(turn)
|
|
279
|
+
const entries = orderedTurnEntries(turn)
|
|
280
|
+
const messageCount = entries.filter(entry => entry.type === 'agent_message').length
|
|
281
|
+
const errors = entries.filter(entry => entry.type === 'error')
|
|
282
|
+
return (
|
|
283
|
+
<section style={styles.turn} data-status={turn.status} data-scroll-key={`turn:${turn.id || 'turn'}`}>
|
|
284
|
+
<div style={styles.user}>{turn.input || node.prompt}</div>
|
|
285
|
+
<div style={styles.assistant}>
|
|
286
|
+
<div style={styles.stream} role="group" aria-label="Codex 输出时间线">
|
|
287
|
+
{segments.map(segment => {
|
|
288
|
+
if (segment.kind === 'process') return (
|
|
289
|
+
<ActivityGroup key={segment.key} segment={segment} workflowId={workflowId} nodeId={node.id} turnId={turn.id || 'turn'} expandedKeys={expandedKeys} onToggle={onToggle} />
|
|
290
|
+
)
|
|
291
|
+
if (segment.kind === 'agent_message') return <MarkdownMessage key={segment.key} entry={segment.entry} scrollKey={`${turn.id}/${segment.key}`} />
|
|
292
|
+
return <div key={segment.key} data-scroll-key={`${turn.id}/${segment.key}`} style={styles.agentError}>{segment.entry.message || ''}</div>
|
|
293
|
+
})}
|
|
294
|
+
</div>
|
|
295
|
+
{turn.error && !errors.some(entry => entry.message === turn.error) && <div style={styles.agentError}>{turn.error}</div>}
|
|
296
|
+
{!entries.length && ['pending', 'blocked', 'queued'].includes(turn.status) && <div style={styles.turnState}>{turn.status === 'blocked' ? '等待前置任务…' : '等待 Codex 开始…'}</div>}
|
|
297
|
+
{!messageCount && turn.status === 'running' && <div style={{ ...styles.turnState, display: 'flex', gap: 6, alignItems: 'center' }}><Spinner size={13} />Codex 正在处理…</div>}
|
|
298
|
+
{!['pending', 'blocked'].includes(turn.status) && <TurnActions turn={turn} toast={toast} />}
|
|
299
|
+
</div>
|
|
300
|
+
</section>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function captureViewport(container, nodeId) {
|
|
305
|
+
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
|
|
306
|
+
const snapshot = { nodeId, scrollTop: container.scrollTop, stickToBottom: distanceFromBottom < 80, anchorKey: null, anchorOffset: 0 }
|
|
307
|
+
if (snapshot.stickToBottom) return snapshot
|
|
308
|
+
const viewport = container.getBoundingClientRect()
|
|
309
|
+
const anchor = [...container.querySelectorAll('[data-scroll-key]')].find(candidate => {
|
|
310
|
+
const rect = candidate.getBoundingClientRect()
|
|
311
|
+
return rect.bottom > viewport.top + 1 && rect.top < viewport.bottom - 1
|
|
312
|
+
})
|
|
313
|
+
if (anchor) {
|
|
314
|
+
snapshot.anchorKey = anchor.dataset.scrollKey
|
|
315
|
+
snapshot.anchorOffset = anchor.getBoundingClientRect().top - viewport.top
|
|
316
|
+
}
|
|
317
|
+
return snapshot
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function ConversationThread({ workflow, node, expandedKeys, onToggleExpanded, toast }) {
|
|
321
|
+
const containerRef = React.useRef(null)
|
|
322
|
+
const threadRef = React.useRef(null)
|
|
323
|
+
const followOutputRef = React.useRef(true)
|
|
324
|
+
const pendingViewportRef = React.useRef(null)
|
|
325
|
+
const previousNodeIdRef = React.useRef(null)
|
|
326
|
+
const turns = legacyTurns(node)
|
|
327
|
+
const contentSignature = JSON.stringify(node ? {
|
|
328
|
+
id: node.id,
|
|
329
|
+
status: node.status,
|
|
330
|
+
activeTurnId: node.activeTurnId,
|
|
331
|
+
turns,
|
|
332
|
+
} : null)
|
|
333
|
+
|
|
334
|
+
React.useLayoutEffect(() => {
|
|
335
|
+
const container = containerRef.current
|
|
336
|
+
if (!container) return undefined
|
|
337
|
+
const nodeChanged = previousNodeIdRef.current !== node?.id
|
|
338
|
+
previousNodeIdRef.current = node?.id || null
|
|
339
|
+
const snapshot = pendingViewportRef.current
|
|
340
|
+
if (nodeChanged || followOutputRef.current || snapshot?.stickToBottom) {
|
|
341
|
+
container.scrollTop = container.scrollHeight
|
|
342
|
+
} else if (snapshot?.nodeId === node?.id) {
|
|
343
|
+
const anchor = snapshot.anchorKey
|
|
344
|
+
? [...container.querySelectorAll('[data-scroll-key]')].find(item => item.dataset.scrollKey === snapshot.anchorKey)
|
|
345
|
+
: null
|
|
346
|
+
if (anchor) {
|
|
347
|
+
const currentOffset = anchor.getBoundingClientRect().top - container.getBoundingClientRect().top
|
|
348
|
+
container.scrollTop += currentOffset - snapshot.anchorOffset
|
|
349
|
+
} else {
|
|
350
|
+
container.scrollTop = snapshot.scrollTop
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
pendingViewportRef.current = null
|
|
354
|
+
return () => {
|
|
355
|
+
if (containerRef.current) pendingViewportRef.current = captureViewport(containerRef.current, node?.id)
|
|
356
|
+
}
|
|
357
|
+
}, [contentSignature, node?.id])
|
|
358
|
+
|
|
359
|
+
React.useEffect(() => {
|
|
360
|
+
if (!globalThis.ResizeObserver || !threadRef.current) return undefined
|
|
361
|
+
const observer = new ResizeObserver(() => {
|
|
362
|
+
const container = containerRef.current
|
|
363
|
+
if (container && followOutputRef.current) container.scrollTop = container.scrollHeight
|
|
364
|
+
})
|
|
365
|
+
observer.observe(threadRef.current)
|
|
366
|
+
return () => observer.disconnect()
|
|
367
|
+
}, [])
|
|
368
|
+
|
|
369
|
+
const handleScroll = event => {
|
|
370
|
+
if (!event.nativeEvent?.isTrusted) return
|
|
371
|
+
const container = event.currentTarget
|
|
372
|
+
followOutputRef.current = container.scrollHeight - container.scrollTop - container.clientHeight < 80
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<main ref={containerRef} onScroll={handleScroll} style={styles.conversation} aria-live="polite">
|
|
377
|
+
<div ref={threadRef} style={styles.thread}>
|
|
378
|
+
{!workflow && <div style={styles.empty}>工作流派发后,这里显示每个任务的 Codex 对话。</div>}
|
|
379
|
+
{workflow && !node && <div style={styles.empty}>选择一个任务查看对话。</div>}
|
|
380
|
+
{workflow && node && turns.map(turn => (
|
|
381
|
+
<ConversationTurn
|
|
382
|
+
key={turn.id || 'legacy_turn'}
|
|
383
|
+
turn={turn}
|
|
384
|
+
workflowId={workflow.workflowId}
|
|
385
|
+
node={node}
|
|
386
|
+
expandedKeys={expandedKeys}
|
|
387
|
+
onToggle={onToggleExpanded}
|
|
388
|
+
toast={toast}
|
|
389
|
+
/>
|
|
390
|
+
))}
|
|
391
|
+
</div>
|
|
392
|
+
</main>
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export { legacyTurns, orderedTurnEntries, timelineSegments }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
const paths = {
|
|
4
|
+
chevron: ['M5.5 3.5 10 8l-4.5 4.5'],
|
|
5
|
+
reasoning: ['M8 1.5a5 5 0 0 0-3.2 8.8c.5.4.8 1 .8 1.7v.5h4.8V12c0-.7.3-1.3.8-1.7A5 5 0 0 0 8 1.5Z', 'M5.8 14.5h4.4'],
|
|
6
|
+
command_execution: ['M2 2.5h12v11H2z', 'm4 5 2 2-2 2', 'M8.5 9.5H11'],
|
|
7
|
+
file_change: ['M3 1.5h6l3 3v10H3z', 'M9 1.5v3h3', 'M5 8h5M5 10.5h5'],
|
|
8
|
+
tool: ['M8 2.2a2.9 2.9 0 0 0-3.4 3.7L1.9 8.6l2 2 2.7-2.7A2.9 2.9 0 0 0 10.3 4L14 7.7l-6.3 6.3-2-2L8.4 9.3'],
|
|
9
|
+
web_search: ['M7 2a5 5 0 1 0 0 10A5 5 0 0 0 7 2Z', 'm10.8 10.8 3 3'],
|
|
10
|
+
todo_list: ['M3 4h.01M6 4h7M3 8h.01M6 8h7M3 12h.01M6 12h7'],
|
|
11
|
+
copy: ['M5.5 5.5h8v8h-8z', 'M10.5 5.5v-3h-8v8h3'],
|
|
12
|
+
usage: ['M3 4.2c0 1 2.2 1.8 5 1.8s5-.8 5-1.8-2.2-1.8-5-1.8-5 .8-5 1.8Z', 'M3 4.2v3.6c0 1 2.2 1.8 5 1.8s5-.8 5-1.8V4.2M3 7.8v3.6c0 1 2.2 1.8 5 1.8s5-.8 5-1.8V7.8'],
|
|
13
|
+
clock: ['M8 1.8a6.2 6.2 0 1 0 0 12.4A6.2 6.2 0 0 0 8 1.8Z', 'M8 4.5v3.8l2.5 1.5'],
|
|
14
|
+
send: ['M2.5 8h11M9 3.5 13.5 8 9 12.5'],
|
|
15
|
+
stop: ['M4.5 4.5h7v7h-7z'],
|
|
16
|
+
plus: ['M8 2v12M2 8h12'],
|
|
17
|
+
shield: ['M8 1.5 13 3.6v3.8c0 3.1-2 5.3-5 6.6-3-1.3-5-3.5-5-6.6V3.6L8 1.5Z'],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Icon({ kind, size = 15, style }) {
|
|
21
|
+
return (
|
|
22
|
+
<svg
|
|
23
|
+
viewBox="0 0 16 16"
|
|
24
|
+
aria-hidden="true"
|
|
25
|
+
fill="none"
|
|
26
|
+
stroke="currentColor"
|
|
27
|
+
strokeWidth="1.35"
|
|
28
|
+
strokeLinecap="round"
|
|
29
|
+
strokeLinejoin="round"
|
|
30
|
+
style={{ width: size, height: size, flex: 'none', ...style }}
|
|
31
|
+
>
|
|
32
|
+
{(paths[kind] || paths.tool).map((d, index) => <path d={d} key={index} />)}
|
|
33
|
+
</svg>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function Spinner({ size = 16 }) {
|
|
38
|
+
return (
|
|
39
|
+
<svg viewBox="0 0 16 16" aria-hidden="true" style={{ width: size, height: size, flex: 'none' }}>
|
|
40
|
+
<circle cx="8" cy="8" r="5.5" fill="none" stroke="var(--co-border-strong)" strokeWidth="2.5" />
|
|
41
|
+
<path d="M8 2.5a5.5 5.5 0 0 1 5.5 5.5" fill="none" stroke="var(--co-ink-secondary)" strokeWidth="2.5" strokeLinecap="round">
|
|
42
|
+
<animateTransform attributeName="transform" type="rotate" from="0 8 8" to="360 8 8" dur=".9s" repeatCount="indefinite" />
|
|
43
|
+
</path>
|
|
44
|
+
</svg>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useOrchestrateData } from '../hooks/useOrchestrateData.jsx'
|
|
3
|
+
import { styles } from '../styles.js'
|
|
4
|
+
import { Composer } from './Composer.jsx'
|
|
5
|
+
import { ConversationThread } from './ConversationThread.jsx'
|
|
6
|
+
import { ToastProvider, useToast } from './Toast.jsx'
|
|
7
|
+
import { WorkflowRail } from './WorkflowRail.jsx'
|
|
8
|
+
|
|
9
|
+
function WorkbenchContent({ initialWorkflowId, tabId }) {
|
|
10
|
+
const data = useOrchestrateData(initialWorkflowId)
|
|
11
|
+
const toast = useToast()
|
|
12
|
+
const [currentNodeId, setCurrentNodeId] = React.useState(null)
|
|
13
|
+
const [expandedKeys, setExpandedKeys] = React.useState(() => new Set())
|
|
14
|
+
const workflowId = data.workflow?.workflowId || null
|
|
15
|
+
|
|
16
|
+
React.useEffect(() => {
|
|
17
|
+
setCurrentNodeId(data.workflow?.nodes?.[0]?.id || null)
|
|
18
|
+
}, [workflowId])
|
|
19
|
+
|
|
20
|
+
React.useEffect(() => {
|
|
21
|
+
if (!data.workflow) return
|
|
22
|
+
setCurrentNodeId(current => data.workflow.nodes.some(node => node.id === current)
|
|
23
|
+
? current
|
|
24
|
+
: data.workflow.nodes[0]?.id || null)
|
|
25
|
+
}, [data.workflow])
|
|
26
|
+
|
|
27
|
+
const currentNode = data.workflow?.nodes?.find(node => node.id === currentNodeId) || null
|
|
28
|
+
const toggleExpanded = React.useCallback(key => {
|
|
29
|
+
setExpandedKeys(current => {
|
|
30
|
+
const next = new Set(current)
|
|
31
|
+
if (next.has(key)) next.delete(key)
|
|
32
|
+
else next.add(key)
|
|
33
|
+
return next
|
|
34
|
+
})
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<section style={{ ...styles.workbench, position: 'relative' }} data-tab-id={tabId} aria-busy={data.loading}>
|
|
39
|
+
<WorkflowRail
|
|
40
|
+
workflow={data.workflow}
|
|
41
|
+
workflows={data.workflows}
|
|
42
|
+
selectedWorkflowId={data.selectedWorkflowId}
|
|
43
|
+
currentNodeId={currentNodeId}
|
|
44
|
+
listError={data.listError}
|
|
45
|
+
onSelectWorkflow={data.selectWorkflow}
|
|
46
|
+
onSelectNode={setCurrentNodeId}
|
|
47
|
+
/>
|
|
48
|
+
<ConversationThread
|
|
49
|
+
workflow={data.workflow}
|
|
50
|
+
node={currentNode}
|
|
51
|
+
expandedKeys={expandedKeys}
|
|
52
|
+
onToggleExpanded={toggleExpanded}
|
|
53
|
+
toast={toast}
|
|
54
|
+
/>
|
|
55
|
+
<Composer workflow={data.workflow} node={currentNode} refresh={data.refresh} toast={toast} />
|
|
56
|
+
{(data.listError || data.stateError) && (
|
|
57
|
+
<div role="alert" style={{ ...styles.alert, position: 'absolute', zIndex: 5, top: 68, left: 0, right: 0, background: 'var(--co-bg)' }}>
|
|
58
|
+
{data.stateError || data.listError}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
</section>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function OrchestrateWorkbench(props) {
|
|
66
|
+
return <ToastProvider><WorkbenchContent {...props} /></ToastProvider>
|
|
67
|
+
}
|
|
68
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { styles } from '../styles.js'
|
|
3
|
+
|
|
4
|
+
const ToastContext = React.createContext(() => {})
|
|
5
|
+
|
|
6
|
+
export function ToastProvider({ children }) {
|
|
7
|
+
const [message, setMessage] = React.useState('')
|
|
8
|
+
const timerRef = React.useRef(null)
|
|
9
|
+
const toast = React.useCallback(text => {
|
|
10
|
+
window.clearTimeout(timerRef.current)
|
|
11
|
+
setMessage(String(text || ''))
|
|
12
|
+
timerRef.current = window.setTimeout(() => setMessage(''), 2600)
|
|
13
|
+
}, [])
|
|
14
|
+
React.useEffect(() => () => window.clearTimeout(timerRef.current), [])
|
|
15
|
+
return (
|
|
16
|
+
<ToastContext.Provider value={toast}>
|
|
17
|
+
{children}
|
|
18
|
+
{message && <div role="status" aria-live="polite" style={styles.toast}>{message}</div>}
|
|
19
|
+
</ToastContext.Provider>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useToast() {
|
|
24
|
+
return React.useContext(ToastContext)
|
|
25
|
+
}
|
|
26
|
+
|