Skip to content

Commit 2b3f140

Browse files
committed
Wire workflow spine into frontend
(cherry picked from commit fa97e5e)
1 parent b2b6ca2 commit 2b3f140

17 files changed

Lines changed: 863 additions & 11 deletions

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
},
2929
"scripts": {
3030
"start": "react-scripts start",
31+
"test": "react-scripts test",
3132
"build": "cross-env CI=false react-scripts build",
3233
"electron": "electron ."
3334
},

client/src/App.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import "./App.css";
33
import Views from "./views/Views";
44
import { AppContext, ContextWrapper } from "./contexts/GlobalContext";
55
import { YamlContextWrapper } from "./contexts/YamlContext";
6+
import { WorkflowProvider } from "./contexts/WorkflowContext";
67

78
function CacheBootstrapper({ children }) {
89
const { resetFileState } = useContext(AppContext);
@@ -38,11 +39,13 @@ function App() {
3839
return (
3940
<ContextWrapper>
4041
<YamlContextWrapper>
41-
<CacheBootstrapper>
42-
<div className="App">
43-
<MainContent />
44-
</div>
45-
</CacheBootstrapper>
42+
<WorkflowProvider>
43+
<CacheBootstrapper>
44+
<div className="App">
45+
<MainContent />
46+
</div>
47+
</CacheBootstrapper>
48+
</WorkflowProvider>
4649
</YamlContextWrapper>
4750
</ContextWrapper>
4851
);

client/src/api.js

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const getErrorDetailMessage = (detail) => {
5252
return String(detail);
5353
};
5454

55-
export async function getNeuroglancerViewer(image, label, scales) {
55+
export async function getNeuroglancerViewer(image, label, scales, workflowId = null) {
5656
try {
5757
const url = `${BASE_URL}/neuroglancer`;
5858
if (hasBrowserFile(image)) {
@@ -70,6 +70,9 @@ export async function getNeuroglancerViewer(image, label, scales) {
7070
);
7171
}
7272
formData.append("scales", JSON.stringify(scales));
73+
if (workflowId) {
74+
formData.append("workflow_id", String(workflowId));
75+
}
7376
const res = await axios.post(url, formData);
7477
return res.data;
7578
}
@@ -78,6 +81,7 @@ export async function getNeuroglancerViewer(image, label, scales) {
7881
image: buildFilePath(image),
7982
label: buildFilePath(label),
8083
scales,
84+
workflow_id: workflowId,
8185
});
8286
const res = await axios.post(url, data);
8387
return res.data;
@@ -141,6 +145,7 @@ export async function startModelTraining(
141145
logPath,
142146
outputPath,
143147
configOriginPath = "",
148+
workflowId = null,
144149
) {
145150
try {
146151
console.log("[API] ===== Starting Training Configuration =====");
@@ -178,6 +183,7 @@ export async function startModelTraining(
178183
outputPath, // TensorBoard will use this instead
179184
trainingConfig: configToSend,
180185
configOriginPath,
186+
workflow_id: workflowId,
181187
});
182188

183189
console.log("[API] Request payload size:", data.length, "bytes");
@@ -232,6 +238,7 @@ export async function startModelInference(
232238
outputPath,
233239
checkpointPath,
234240
configOriginPath = "",
241+
workflowId = null,
235242
) {
236243
console.log("\n========== API.JS: START_MODEL_INFERENCE CALLED ==========");
237244
console.log("[API] Function arguments:");
@@ -293,6 +300,7 @@ export async function startModelInference(
293300
outputPath,
294301
inferenceConfig: configToSend,
295302
configOriginPath,
303+
workflow_id: workflowId,
296304
};
297305

298306
console.log("[API] Payload structure:");
@@ -462,3 +470,86 @@ export async function getConfigPresetContent(path) {
462470
export async function getModelArchitectures() {
463471
return makeApiRequest("pytc/architectures", "get");
464472
}
473+
474+
// ── Workflow spine ───────────────────────────────────────────────────────────
475+
476+
export async function getCurrentWorkflow() {
477+
try {
478+
const res = await apiClient.get("/api/workflows/current");
479+
return res.data;
480+
} catch (error) {
481+
handleError(error);
482+
}
483+
}
484+
485+
export async function updateWorkflow(workflowId, patch) {
486+
try {
487+
const res = await apiClient.patch(`/api/workflows/${workflowId}`, patch);
488+
return res.data;
489+
} catch (error) {
490+
handleError(error);
491+
}
492+
}
493+
494+
export async function listWorkflowEvents(workflowId) {
495+
try {
496+
const res = await apiClient.get(`/api/workflows/${workflowId}/events`);
497+
return res.data;
498+
} catch (error) {
499+
handleError(error);
500+
}
501+
}
502+
503+
export async function appendWorkflowEvent(workflowId, event) {
504+
try {
505+
const res = await apiClient.post(`/api/workflows/${workflowId}/events`, event);
506+
return res.data;
507+
} catch (error) {
508+
handleError(error);
509+
}
510+
}
511+
512+
export async function createAgentAction(workflowId, action) {
513+
try {
514+
const res = await apiClient.post(
515+
`/api/workflows/${workflowId}/agent-actions`,
516+
action,
517+
);
518+
return res.data;
519+
} catch (error) {
520+
handleError(error);
521+
}
522+
}
523+
524+
export async function approveAgentAction(workflowId, eventId) {
525+
try {
526+
const res = await apiClient.post(
527+
`/api/workflows/${workflowId}/agent-actions/${eventId}/approve`,
528+
);
529+
return res.data;
530+
} catch (error) {
531+
handleError(error);
532+
}
533+
}
534+
535+
export async function rejectAgentAction(workflowId, eventId) {
536+
try {
537+
const res = await apiClient.post(
538+
`/api/workflows/${workflowId}/agent-actions/${eventId}/reject`,
539+
);
540+
return res.data;
541+
} catch (error) {
542+
handleError(error);
543+
}
544+
}
545+
546+
export async function queryWorkflowAgent(workflowId, query) {
547+
try {
548+
const res = await apiClient.post(`/api/workflows/${workflowId}/agent/query`, {
549+
query,
550+
});
551+
return res.data;
552+
} catch (error) {
553+
handleError(error);
554+
}
555+
}

client/src/components/Chatbot.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
} from "../api";
2828
import ReactMarkdown from "react-markdown";
2929
import remarkGfm from "remark-gfm";
30+
import WorkflowTimeline from "./WorkflowTimeline";
31+
import { useWorkflow } from "../contexts/WorkflowContext";
3032

3133
const { TextArea } = Input;
3234
const { Text } = Typography;
@@ -41,6 +43,20 @@ const GREETING = {
4143
const truncate = (str, n = 50) =>
4244
str.length > n ? str.slice(0, n).trimEnd() + "…" : str;
4345

46+
const WORKFLOW_QUERY_TERMS = [
47+
"workflow",
48+
"next",
49+
"stage",
50+
"retrain",
51+
"training",
52+
"corrected",
53+
"proofread",
54+
"mask",
55+
"inference",
56+
"visualize",
57+
"evaluate",
58+
];
59+
4460
/* ═══════════════════════════════════════════════════════════════════════════ */
4561

4662
function Chatbot({ onClose }) {
@@ -54,6 +70,15 @@ function Chatbot({ onClose }) {
5470
const [isLoadingConvo, setIsLoadingConvo] = useState(false);
5571

5672
const lastMessageRef = useRef(null);
73+
const workflowContext = useWorkflow();
74+
75+
const shouldUseWorkflowAgent = (query) => {
76+
if (!workflowContext?.workflow?.id || !workflowContext?.queryAgent) {
77+
return false;
78+
}
79+
const lower = query.toLowerCase();
80+
return WORKFLOW_QUERY_TERMS.some((term) => lower.includes(term));
81+
};
5782

5883
/* ── scroll ────────────────────────────────────────────────────────────── */
5984
const scrollToBottom = useCallback(() => {
@@ -119,6 +144,18 @@ function Chatbot({ onClose }) {
119144
setMessages((prev) => [...prev, { role: "user", content: query }]);
120145
setIsSending(true);
121146
try {
147+
if (shouldUseWorkflowAgent(query)) {
148+
const data = await workflowContext.queryAgent(query);
149+
const response =
150+
data?.response ||
151+
"I could not inspect the workflow state for that request.";
152+
setMessages((prev) => [
153+
...prev,
154+
{ role: "assistant", content: response },
155+
]);
156+
return;
157+
}
158+
122159
const data = await queryChatBot(query, activeConvoId);
123160
const response =
124161
data?.response || "Sorry, I could not generate a response.";
@@ -418,6 +455,8 @@ function Chatbot({ onClose }) {
418455
</Space>
419456
</div>
420457

458+
<WorkflowTimeline />
459+
421460
{/* messages */}
422461
<div style={{ flex: 1, overflow: "auto", padding: "0 16px 16px" }}>
423462
{isLoadingConvo ? (
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import React from "react";
2+
import { Button, Empty, List, Space, Tag, Typography } from "antd";
3+
import { CheckOutlined, CloseOutlined } from "@ant-design/icons";
4+
import { useWorkflow } from "../contexts/WorkflowContext";
5+
6+
const { Text } = Typography;
7+
8+
const STAGE_LABELS = {
9+
setup: "Setup",
10+
visualization: "Visualization",
11+
inference: "Inference",
12+
proofreading: "Proofreading",
13+
retraining_staged: "Retraining staged",
14+
evaluation: "Evaluation",
15+
};
16+
17+
function formatEventTime(value) {
18+
if (!value) return "";
19+
const date = new Date(value);
20+
if (Number.isNaN(date.getTime())) return "";
21+
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
22+
}
23+
24+
function WorkflowTimeline({ limit = 8 }) {
25+
const workflowContext = useWorkflow();
26+
const workflow = workflowContext?.workflow;
27+
const events = workflowContext?.events || [];
28+
const approveAgentAction = workflowContext?.approveAgentAction;
29+
const rejectAgentAction = workflowContext?.rejectAgentAction;
30+
31+
if (!workflowContext) return null;
32+
33+
const visibleEvents = events.slice(-limit).reverse();
34+
const stageLabel = STAGE_LABELS[workflow?.stage] || workflow?.stage || "Loading";
35+
36+
return (
37+
<div
38+
data-testid="workflow-timeline"
39+
style={{
40+
borderTop: "1px solid #f0f0f0",
41+
borderBottom: "1px solid #f0f0f0",
42+
padding: "10px 16px",
43+
background: "#fbfbfb",
44+
}}
45+
>
46+
<Space
47+
size="small"
48+
style={{ display: "flex", justifyContent: "space-between" }}
49+
>
50+
<Space size="small">
51+
<Text strong style={{ fontSize: 13 }}>
52+
Workflow
53+
</Text>
54+
<Tag color="blue" style={{ margin: 0 }}>
55+
{stageLabel}
56+
</Tag>
57+
</Space>
58+
<Text type="secondary" style={{ fontSize: 12 }}>
59+
{workflow?.title || "Segmentation Workflow"}
60+
</Text>
61+
</Space>
62+
63+
{visibleEvents.length === 0 ? (
64+
<Empty
65+
image={Empty.PRESENTED_IMAGE_SIMPLE}
66+
description="No workflow events yet"
67+
style={{ margin: "8px 0 0" }}
68+
/>
69+
) : (
70+
<List
71+
size="small"
72+
dataSource={visibleEvents}
73+
style={{ marginTop: 8 }}
74+
renderItem={(event) => {
75+
const isPendingProposal =
76+
event.event_type === "agent.proposal_created" &&
77+
event.approval_status === "pending";
78+
return (
79+
<List.Item
80+
style={{
81+
padding: "6px 0",
82+
alignItems: "flex-start",
83+
borderBlockEnd: "none",
84+
}}
85+
actions={
86+
isPendingProposal
87+
? [
88+
<Button
89+
key="approve"
90+
size="small"
91+
icon={<CheckOutlined />}
92+
onClick={() => approveAgentAction?.(event.id)}
93+
>
94+
Approve
95+
</Button>,
96+
<Button
97+
key="reject"
98+
size="small"
99+
icon={<CloseOutlined />}
100+
onClick={() => rejectAgentAction?.(event.id)}
101+
>
102+
Reject
103+
</Button>,
104+
]
105+
: []
106+
}
107+
>
108+
<List.Item.Meta
109+
title={
110+
<Space size="small" wrap>
111+
<Text style={{ fontSize: 12 }}>{event.summary}</Text>
112+
{event.approval_status !== "not_required" && (
113+
<Tag style={{ margin: 0 }}>
114+
{event.approval_status}
115+
</Tag>
116+
)}
117+
</Space>
118+
}
119+
description={
120+
<Space size="small" wrap>
121+
<Text type="secondary" style={{ fontSize: 11 }}>
122+
{event.actor}
123+
</Text>
124+
<Text type="secondary" style={{ fontSize: 11 }}>
125+
{event.event_type}
126+
</Text>
127+
<Text type="secondary" style={{ fontSize: 11 }}>
128+
{formatEventTime(event.created_at)}
129+
</Text>
130+
</Space>
131+
}
132+
/>
133+
</List.Item>
134+
);
135+
}}
136+
/>
137+
)}
138+
</div>
139+
);
140+
}
141+
142+
export default WorkflowTimeline;

0 commit comments

Comments
 (0)