@ryanfw/prompt-orchestration-pipeline 0.4.0 → 0.6.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/package.json +1 -1
- package/src/components/JobCard.jsx +1 -1
- package/src/components/JobDetail.jsx +45 -12
- package/src/components/JobTable.jsx +40 -1
- package/src/components/Layout.jsx +146 -22
- package/src/components/PageSubheader.jsx +75 -0
- package/src/components/UploadSeed.jsx +0 -70
- package/src/components/ui/Logo.jsx +16 -0
- package/src/core/config.js +145 -13
- package/src/core/file-io.js +12 -27
- package/src/core/orchestrator.js +92 -78
- package/src/core/pipeline-runner.js +13 -6
- package/src/core/status-writer.js +63 -52
- package/src/core/task-runner.js +61 -1
- package/src/llm/index.js +97 -40
- package/src/pages/Code.jsx +297 -0
- package/src/pages/PipelineDetail.jsx +47 -8
- package/src/pages/PromptPipelineDashboard.jsx +6 -53
- package/src/providers/deepseek.js +17 -1
- package/src/providers/openai.js +1 -1
- package/src/ui/client/adapters/job-adapter.js +26 -2
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +0 -1
- package/src/ui/client/index.css +6 -0
- package/src/ui/client/index.html +1 -1
- package/src/ui/client/main.jsx +2 -0
- package/src/ui/dist/assets/{index-CxcrauYR.js → index-WgJUlSmE.js} +716 -307
- package/src/ui/dist/assets/style-x0V-5m8e.css +62 -0
- package/src/ui/dist/index.html +3 -3
- package/src/ui/job-reader.js +0 -108
- package/src/ui/server.js +54 -0
- package/src/ui/sse-enhancer.js +0 -1
- package/src/ui/transformers/list-transformer.js +32 -12
- package/src/ui/transformers/status-transformer.js +11 -11
- package/src/utils/token-cost-calculator.js +297 -0
- package/src/utils/ui.jsx +4 -4
- package/src/ui/dist/assets/style-D6K_oQ12.css +0 -62
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryanfw/prompt-orchestration-pipeline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A Prompt-orchestration pipeline (POP) is a framework for building, running, and experimenting with complex chains of LLM tasks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/ui/server.js",
|
|
@@ -21,7 +21,7 @@ export default function JobCard({
|
|
|
21
21
|
: 0;
|
|
22
22
|
const totalCompleted = countCompleted(job);
|
|
23
23
|
const hasValidId = Boolean(job.id);
|
|
24
|
-
const jobTitle = job.title
|
|
24
|
+
const jobTitle = job.title;
|
|
25
25
|
|
|
26
26
|
return (
|
|
27
27
|
<Card
|
|
@@ -6,6 +6,25 @@ import DAGGrid from "./DAGGrid.jsx";
|
|
|
6
6
|
import { computeDagItems, computeActiveIndex } from "../utils/dag.js";
|
|
7
7
|
import { getTaskFilesForTask } from "../utils/task-files.js";
|
|
8
8
|
|
|
9
|
+
// Local helpers for formatting costs and tokens
|
|
10
|
+
function formatCurrency4(x) {
|
|
11
|
+
if (typeof x !== "number" || x === 0) return "$0.0000";
|
|
12
|
+
const formatted = x.toFixed(4);
|
|
13
|
+
// Trim trailing zeros and unnecessary decimal point
|
|
14
|
+
return `$${formatted.replace(/\.?0+$/, "")}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatTokensCompact(n) {
|
|
18
|
+
if (typeof n !== "number" || n === 0) return "0 tok";
|
|
19
|
+
|
|
20
|
+
if (n >= 1000000) {
|
|
21
|
+
return `${(n / 1000000).toFixed(1).replace(/\.0$/, "")}M tokens`;
|
|
22
|
+
} else if (n >= 1000) {
|
|
23
|
+
return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k tokens`;
|
|
24
|
+
}
|
|
25
|
+
return `${n} tokens`;
|
|
26
|
+
}
|
|
27
|
+
|
|
9
28
|
export default function JobDetail({ job, pipeline, onClose, onResume }) {
|
|
10
29
|
const now = useTicker(1000);
|
|
11
30
|
const [resumeFrom, setResumeFrom] = useState(
|
|
@@ -16,16 +35,6 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
|
|
|
16
35
|
: ""
|
|
17
36
|
);
|
|
18
37
|
|
|
19
|
-
useEffect(() => {
|
|
20
|
-
setResumeFrom(
|
|
21
|
-
pipeline?.tasks?.[0]
|
|
22
|
-
? typeof pipeline.tasks[0] === "string"
|
|
23
|
-
? pipeline.tasks[0]
|
|
24
|
-
: (pipeline.tasks[0].id ?? pipeline.tasks[0].name ?? "")
|
|
25
|
-
: ""
|
|
26
|
-
);
|
|
27
|
-
}, [job.id, pipeline?.tasks?.length]);
|
|
28
|
-
|
|
29
38
|
// job.tasks is expected to be an object keyed by task name; normalize from array if needed
|
|
30
39
|
const taskById = React.useMemo(() => {
|
|
31
40
|
const tasks = job?.tasks;
|
|
@@ -49,6 +58,16 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
|
|
|
49
58
|
return result;
|
|
50
59
|
}, [job?.tasks]);
|
|
51
60
|
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
setResumeFrom(
|
|
63
|
+
pipeline?.tasks?.[0]
|
|
64
|
+
? typeof pipeline.tasks[0] === "string"
|
|
65
|
+
? pipeline.tasks[0]
|
|
66
|
+
: (pipeline.tasks[0].id ?? pipeline.tasks[0].name ?? "")
|
|
67
|
+
: ""
|
|
68
|
+
);
|
|
69
|
+
}, [job.id, pipeline?.tasks?.length]);
|
|
70
|
+
|
|
52
71
|
// Compute pipeline tasks from pipeline or derive from job tasks
|
|
53
72
|
const computedPipeline = React.useMemo(() => {
|
|
54
73
|
let result;
|
|
@@ -112,9 +131,21 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
|
|
|
112
131
|
}
|
|
113
132
|
}
|
|
114
133
|
|
|
115
|
-
//
|
|
134
|
+
// Prefer taskBreakdown totals for consistency with backend
|
|
135
|
+
const taskBreakdown = job?.costs?.taskBreakdown?.[item.id]?.summary || {};
|
|
136
|
+
if (taskBreakdown.totalTokens > 0) {
|
|
137
|
+
subtitleParts.push(formatTokensCompact(taskBreakdown.totalTokens));
|
|
138
|
+
}
|
|
139
|
+
if (taskBreakdown.totalCost > 0) {
|
|
140
|
+
subtitleParts.push(formatCurrency4(taskBreakdown.totalCost));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Include error message in body when task status is error or failed
|
|
116
144
|
const errorMsg = task?.error?.message;
|
|
117
|
-
const body =
|
|
145
|
+
const body =
|
|
146
|
+
(item.status === "failed" || item.status === "error") && errorMsg
|
|
147
|
+
? errorMsg
|
|
148
|
+
: null;
|
|
118
149
|
|
|
119
150
|
const resultItem = {
|
|
120
151
|
...item,
|
|
@@ -146,6 +177,8 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
|
|
|
146
177
|
[job]
|
|
147
178
|
);
|
|
148
179
|
|
|
180
|
+
console.log("dagItems", dagItems);
|
|
181
|
+
|
|
149
182
|
return (
|
|
150
183
|
<div className="flex h-full flex-col">
|
|
151
184
|
<DAGGrid
|
|
@@ -7,6 +7,25 @@ import { taskDisplayDurationMs } from "../utils/duration.js";
|
|
|
7
7
|
import { countCompleted } from "../utils/jobs";
|
|
8
8
|
import { progressClasses, statusBadge } from "../utils/ui";
|
|
9
9
|
|
|
10
|
+
// Local helpers for formatting costs and tokens
|
|
11
|
+
function formatCurrency4(x) {
|
|
12
|
+
if (typeof x !== "number" || x === 0) return "$0.0000";
|
|
13
|
+
const formatted = x.toFixed(4);
|
|
14
|
+
// Trim trailing zeros and unnecessary decimal point
|
|
15
|
+
return `$${formatted.replace(/\.?0+$/, "")}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatTokensCompact(n) {
|
|
19
|
+
if (typeof n !== "number" || n === 0) return "0 tok";
|
|
20
|
+
|
|
21
|
+
if (n >= 1000000) {
|
|
22
|
+
return `${(n / 1000000).toFixed(1).replace(/\.0$/, "")}M tok`;
|
|
23
|
+
} else if (n >= 1000) {
|
|
24
|
+
return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k tok`;
|
|
25
|
+
}
|
|
26
|
+
return `${n} tok`;
|
|
27
|
+
}
|
|
28
|
+
|
|
10
29
|
export default function JobTable({
|
|
11
30
|
jobs,
|
|
12
31
|
pipeline,
|
|
@@ -35,6 +54,7 @@ export default function JobTable({
|
|
|
35
54
|
<Table.ColumnHeaderCell>Current Task</Table.ColumnHeaderCell>
|
|
36
55
|
<Table.ColumnHeaderCell>Progress</Table.ColumnHeaderCell>
|
|
37
56
|
<Table.ColumnHeaderCell>Tasks</Table.ColumnHeaderCell>
|
|
57
|
+
<Table.ColumnHeaderCell>Cost</Table.ColumnHeaderCell>
|
|
38
58
|
<Table.ColumnHeaderCell>Duration</Table.ColumnHeaderCell>
|
|
39
59
|
<Table.ColumnHeaderCell className="w-12"></Table.ColumnHeaderCell>
|
|
40
60
|
</Table.Row>
|
|
@@ -42,7 +62,7 @@ export default function JobTable({
|
|
|
42
62
|
|
|
43
63
|
<Table.Body>
|
|
44
64
|
{jobs.map((job) => {
|
|
45
|
-
const jobTitle = job.
|
|
65
|
+
const jobTitle = job.name;
|
|
46
66
|
const taskById = Array.isArray(job.tasks)
|
|
47
67
|
? Object.fromEntries(
|
|
48
68
|
(job.tasks || []).map((t) => {
|
|
@@ -73,6 +93,12 @@ export default function JobTable({
|
|
|
73
93
|
(currentTask?.config || pipeline?.taskConfig?.[job.current])) ||
|
|
74
94
|
{};
|
|
75
95
|
|
|
96
|
+
// Cost and token data
|
|
97
|
+
const costsSummary = job.costsSummary || {};
|
|
98
|
+
const totalCost = job.totalCost || costsSummary.totalCost || 0;
|
|
99
|
+
const totalTokens =
|
|
100
|
+
job.totalTokens || costsSummary.totalTokens || 0;
|
|
101
|
+
|
|
76
102
|
const hasValidId = Boolean(job.id);
|
|
77
103
|
return (
|
|
78
104
|
<Table.Row
|
|
@@ -173,6 +199,19 @@ export default function JobTable({
|
|
|
173
199
|
</Text>
|
|
174
200
|
</Table.Cell>
|
|
175
201
|
|
|
202
|
+
<Table.Cell>
|
|
203
|
+
<Flex direction="column" gap="1">
|
|
204
|
+
<Text size="2" className="text-slate-700">
|
|
205
|
+
{totalCost > 0 ? formatCurrency4(totalCost) : "—"}
|
|
206
|
+
</Text>
|
|
207
|
+
{totalTokens > 0 && (
|
|
208
|
+
<Text size="1" className="text-slate-500">
|
|
209
|
+
{formatTokensCompact(totalTokens)}
|
|
210
|
+
</Text>
|
|
211
|
+
)}
|
|
212
|
+
</Flex>
|
|
213
|
+
</Table.Cell>
|
|
214
|
+
|
|
176
215
|
<Table.Cell>
|
|
177
216
|
<Flex align="center" gap="1">
|
|
178
217
|
<TimerReset className="h-3 w-3 text-slate-500" />
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { useNavigate, useLocation } from "react-router-dom";
|
|
1
|
+
import React, { useState, useRef, useEffect } from "react";
|
|
2
|
+
import { useNavigate, useLocation, Link } from "react-router-dom";
|
|
3
3
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
4
4
|
import { Box, Flex, Text, Heading, Link as RadixLink } from "@radix-ui/themes";
|
|
5
5
|
import { Button } from "./ui/button.jsx";
|
|
6
|
-
import
|
|
6
|
+
import Logo from "./ui/Logo.jsx";
|
|
7
|
+
import PageSubheader from "./PageSubheader.jsx";
|
|
8
|
+
import UploadSeed from "./UploadSeed.jsx";
|
|
9
|
+
import { ArrowLeft, Code2, Upload } from "lucide-react";
|
|
7
10
|
import "./ui/focus-styles.css";
|
|
8
11
|
|
|
9
12
|
/**
|
|
@@ -13,6 +16,8 @@ import "./ui/focus-styles.css";
|
|
|
13
16
|
export default function Layout({
|
|
14
17
|
children,
|
|
15
18
|
title,
|
|
19
|
+
pageTitle,
|
|
20
|
+
breadcrumbs,
|
|
16
21
|
actions,
|
|
17
22
|
showBackButton = false,
|
|
18
23
|
backTo = "/",
|
|
@@ -20,6 +25,10 @@ export default function Layout({
|
|
|
20
25
|
}) {
|
|
21
26
|
const navigate = useNavigate();
|
|
22
27
|
const location = useLocation();
|
|
28
|
+
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
|
29
|
+
const [seedUploadSuccess, setSeedUploadSuccess] = useState(null);
|
|
30
|
+
const [seedUploadTimer, setSeedUploadTimer] = useState(null);
|
|
31
|
+
const uploadPanelRef = useRef(null);
|
|
23
32
|
|
|
24
33
|
// Determine active navigation based on current path
|
|
25
34
|
const isActivePath = (path) => {
|
|
@@ -32,6 +41,50 @@ export default function Layout({
|
|
|
32
41
|
navigate(backTo);
|
|
33
42
|
};
|
|
34
43
|
|
|
44
|
+
const toggleUploadPanel = () => {
|
|
45
|
+
setIsUploadOpen(!isUploadOpen);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Handle seed upload success
|
|
49
|
+
const handleSeedUploadSuccess = ({ jobName }) => {
|
|
50
|
+
// Clear any existing timer
|
|
51
|
+
if (seedUploadTimer) {
|
|
52
|
+
clearTimeout(seedUploadTimer);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Set success message
|
|
56
|
+
setSeedUploadSuccess(jobName);
|
|
57
|
+
|
|
58
|
+
// Auto-clear after exactly 5000 ms
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
setSeedUploadSuccess(null);
|
|
61
|
+
setSeedUploadTimer(null);
|
|
62
|
+
}, 5000);
|
|
63
|
+
|
|
64
|
+
setSeedUploadTimer(timer);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Cleanup timer on unmount
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
return () => {
|
|
70
|
+
if (seedUploadTimer) {
|
|
71
|
+
clearTimeout(seedUploadTimer);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}, [seedUploadTimer]);
|
|
75
|
+
|
|
76
|
+
// Focus upload panel when opened
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (isUploadOpen && uploadPanelRef.current) {
|
|
79
|
+
const uploadArea = uploadPanelRef.current.querySelector(
|
|
80
|
+
'[data-testid="upload-area"]'
|
|
81
|
+
);
|
|
82
|
+
if (uploadArea) {
|
|
83
|
+
uploadArea.focus();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}, [isUploadOpen]);
|
|
87
|
+
|
|
35
88
|
return (
|
|
36
89
|
<Tooltip.Provider delayDuration={200}>
|
|
37
90
|
<Box className="min-h-screen bg-gray-1">
|
|
@@ -56,7 +109,7 @@ export default function Layout({
|
|
|
56
109
|
gap="4"
|
|
57
110
|
>
|
|
58
111
|
{/* Left side: Navigation and title */}
|
|
59
|
-
<Flex align="center"
|
|
112
|
+
<Flex align="center" className="min-w-0 flex-1">
|
|
60
113
|
{/* Back button (conditional) */}
|
|
61
114
|
{showBackButton && (
|
|
62
115
|
<Tooltip.Root delayDuration={200}>
|
|
@@ -77,14 +130,39 @@ export default function Layout({
|
|
|
77
130
|
</Tooltip.Root>
|
|
78
131
|
)}
|
|
79
132
|
|
|
80
|
-
{/*
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
133
|
+
{/* Logo */}
|
|
134
|
+
<Box
|
|
135
|
+
asChild
|
|
136
|
+
className="shrink-0"
|
|
137
|
+
style={{ width: "80px", height: "60px" }}
|
|
85
138
|
>
|
|
86
|
-
|
|
87
|
-
|
|
139
|
+
<Link
|
|
140
|
+
to="/"
|
|
141
|
+
aria-label="Go to homepage"
|
|
142
|
+
className="rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
|
143
|
+
>
|
|
144
|
+
<Logo />
|
|
145
|
+
</Link>
|
|
146
|
+
</Box>
|
|
147
|
+
|
|
148
|
+
{/* App title - clickable to navigate to dashboard */}
|
|
149
|
+
<Box
|
|
150
|
+
asChild
|
|
151
|
+
className="shrink-0 cursor-pointer hover:bg-gray-3 rounded p-1 -m-1 transition-colors"
|
|
152
|
+
onClick={() => navigate("/")}
|
|
153
|
+
>
|
|
154
|
+
<Heading
|
|
155
|
+
size="6"
|
|
156
|
+
weight="medium"
|
|
157
|
+
className="text-gray-12 truncate"
|
|
158
|
+
>
|
|
159
|
+
<>
|
|
160
|
+
Prompt
|
|
161
|
+
<br />
|
|
162
|
+
Pipeline
|
|
163
|
+
</>
|
|
164
|
+
</Heading>
|
|
165
|
+
</Box>
|
|
88
166
|
</Flex>
|
|
89
167
|
|
|
90
168
|
{/* Center: Navigation */}
|
|
@@ -95,36 +173,82 @@ export default function Layout({
|
|
|
95
173
|
>
|
|
96
174
|
<Flex align="center" gap="6">
|
|
97
175
|
<RadixLink
|
|
98
|
-
href="/"
|
|
176
|
+
href="/code"
|
|
99
177
|
className={`text-sm font-medium transition-colors hover:text-blue-600 ${
|
|
100
|
-
isActivePath("/")
|
|
178
|
+
isActivePath("/code")
|
|
101
179
|
? "text-blue-600"
|
|
102
180
|
: "text-gray-11 hover:text-gray-12"
|
|
103
181
|
}`}
|
|
104
|
-
aria-current={isActivePath("/") ? "page" : undefined}
|
|
182
|
+
aria-current={isActivePath("/code") ? "page" : undefined}
|
|
105
183
|
>
|
|
106
184
|
<Flex align="center" gap="2">
|
|
107
|
-
<
|
|
108
|
-
|
|
185
|
+
<Code2 className="h-4 w-4" />
|
|
186
|
+
Help
|
|
109
187
|
</Flex>
|
|
110
188
|
</RadixLink>
|
|
111
189
|
</Flex>
|
|
112
190
|
</nav>
|
|
113
191
|
|
|
114
192
|
{/* Right side: Actions */}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
193
|
+
<Flex align="center" gap="3" className="shrink-0">
|
|
194
|
+
{actions}
|
|
195
|
+
<Tooltip.Root delayDuration={200}>
|
|
196
|
+
<Tooltip.Trigger asChild>
|
|
197
|
+
<Button
|
|
198
|
+
size="sm"
|
|
199
|
+
variant="default"
|
|
200
|
+
onClick={toggleUploadPanel}
|
|
201
|
+
aria-controls="layout-upload-panel"
|
|
202
|
+
aria-expanded={isUploadOpen}
|
|
203
|
+
>
|
|
204
|
+
<Upload className="h-4 w-4" />
|
|
205
|
+
<Text size="2" className="ml-2">
|
|
206
|
+
Upload Seed
|
|
207
|
+
</Text>
|
|
208
|
+
</Button>
|
|
209
|
+
</Tooltip.Trigger>
|
|
210
|
+
<Tooltip.Content side="bottom" sideOffset={5}>
|
|
211
|
+
<Text size="2">Upload seed file</Text>
|
|
212
|
+
</Tooltip.Content>
|
|
213
|
+
</Tooltip.Root>
|
|
214
|
+
</Flex>
|
|
120
215
|
</Flex>
|
|
121
216
|
</Box>
|
|
122
217
|
|
|
218
|
+
{/* Upload Panel */}
|
|
219
|
+
{isUploadOpen && (
|
|
220
|
+
<Box
|
|
221
|
+
id="layout-upload-panel"
|
|
222
|
+
ref={uploadPanelRef}
|
|
223
|
+
role="region"
|
|
224
|
+
aria-label="Upload seed file"
|
|
225
|
+
className="bg-blue-50"
|
|
226
|
+
>
|
|
227
|
+
<Flex
|
|
228
|
+
direction="column"
|
|
229
|
+
gap="3"
|
|
230
|
+
className={`mx-auto w-full ${maxWidth} px-4 sm:px-6 lg:px-8 py-4`}
|
|
231
|
+
>
|
|
232
|
+
{/* Success Message */}
|
|
233
|
+
{seedUploadSuccess && (
|
|
234
|
+
<Box className="rounded-md bg-green-50 p-3 border border-green-200">
|
|
235
|
+
<Text size="2" className="text-green-800">
|
|
236
|
+
Job <strong>{seedUploadSuccess}</strong> created
|
|
237
|
+
successfully
|
|
238
|
+
</Text>
|
|
239
|
+
</Box>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
<UploadSeed onUploadSuccess={handleSeedUploadSuccess} />
|
|
243
|
+
</Flex>
|
|
244
|
+
</Box>
|
|
245
|
+
)}
|
|
246
|
+
|
|
123
247
|
{/* Main content */}
|
|
124
248
|
<main
|
|
125
249
|
id="main-content"
|
|
126
250
|
role="main"
|
|
127
|
-
className={`mx-auto w-full ${maxWidth} px-4 sm:px-6 lg:px-8
|
|
251
|
+
className={`mx-auto w-full ${maxWidth} px-4 sm:px-6 lg:px-8`}
|
|
128
252
|
>
|
|
129
253
|
{children}
|
|
130
254
|
</main>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { Box, Flex, Text } from "@radix-ui/themes";
|
|
4
|
+
import { ChevronRight } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PageSubheader renders a secondary header with breadcrumbs and optional right-side content.
|
|
8
|
+
* Intended to be placed directly below main navigation header.
|
|
9
|
+
*/
|
|
10
|
+
export default function PageSubheader({
|
|
11
|
+
breadcrumbs = [],
|
|
12
|
+
children,
|
|
13
|
+
maxWidth = "max-w-7xl",
|
|
14
|
+
}) {
|
|
15
|
+
return (
|
|
16
|
+
<Box
|
|
17
|
+
role="region"
|
|
18
|
+
aria-label="Page header"
|
|
19
|
+
className="border-b border-gray-300 bg-gray-1/60 backdrop-blur supports-[backdrop-filter]:bg-gray-1/40 mb-4"
|
|
20
|
+
>
|
|
21
|
+
<Flex
|
|
22
|
+
align="center"
|
|
23
|
+
justify="between"
|
|
24
|
+
className={`mx-auto w-full ${maxWidth} px-1.5 py-3`}
|
|
25
|
+
gap="4"
|
|
26
|
+
wrap="wrap"
|
|
27
|
+
>
|
|
28
|
+
<Flex align="center" gap="3" className="min-w-0 flex-1">
|
|
29
|
+
{breadcrumbs.length > 0 && (
|
|
30
|
+
<nav aria-label="Breadcrumb" className="shrink-0">
|
|
31
|
+
<ol className="flex items-center gap-2 text-sm text-gray-11">
|
|
32
|
+
{breadcrumbs.map((crumb, index) => {
|
|
33
|
+
const isLast = index === breadcrumbs.length - 1;
|
|
34
|
+
return (
|
|
35
|
+
<React.Fragment key={index}>
|
|
36
|
+
{index > 0 && (
|
|
37
|
+
<ChevronRight
|
|
38
|
+
className="h-4 w-4 text-gray-9"
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
/>
|
|
41
|
+
)}
|
|
42
|
+
{crumb.href ? (
|
|
43
|
+
<Link
|
|
44
|
+
to={crumb.href}
|
|
45
|
+
className="hover:text-gray-12 transition-colors underline-offset-4 hover:underline"
|
|
46
|
+
>
|
|
47
|
+
{crumb.label}
|
|
48
|
+
</Link>
|
|
49
|
+
) : (
|
|
50
|
+
<Text
|
|
51
|
+
as="span"
|
|
52
|
+
aria-current={isLast ? "page" : undefined}
|
|
53
|
+
className={isLast ? "text-gray-12 font-medium" : ""}
|
|
54
|
+
>
|
|
55
|
+
{crumb.label}
|
|
56
|
+
</Text>
|
|
57
|
+
)}
|
|
58
|
+
</React.Fragment>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</ol>
|
|
62
|
+
</nav>
|
|
63
|
+
)}
|
|
64
|
+
</Flex>
|
|
65
|
+
|
|
66
|
+
{/* Right side content */}
|
|
67
|
+
{children && (
|
|
68
|
+
<Flex align="center" gap="3" className="shrink-0">
|
|
69
|
+
{children}
|
|
70
|
+
</Flex>
|
|
71
|
+
)}
|
|
72
|
+
</Flex>
|
|
73
|
+
</Box>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -24,23 +24,8 @@ export const normalizeUploadError = (err) => {
|
|
|
24
24
|
*/
|
|
25
25
|
export default function UploadSeed({ onUploadSuccess }) {
|
|
26
26
|
const fileInputRef = React.useRef(null);
|
|
27
|
-
const [showSample, setShowSample] = useState(false);
|
|
28
27
|
const [error, setError] = useState(null);
|
|
29
28
|
|
|
30
|
-
// Sample seed file structure for reference
|
|
31
|
-
const sampleSeed = {
|
|
32
|
-
name: "some-name",
|
|
33
|
-
pipeline: "content-generation",
|
|
34
|
-
data: {
|
|
35
|
-
type: "some-type",
|
|
36
|
-
contentType: "blog-post",
|
|
37
|
-
targetAudience: "software-developers",
|
|
38
|
-
tone: "professional-yet-accessible",
|
|
39
|
-
length: "1500-2000 words",
|
|
40
|
-
outputFormat: "blog-post",
|
|
41
|
-
},
|
|
42
|
-
};
|
|
43
|
-
|
|
44
29
|
const handleFileChange = async (event) => {
|
|
45
30
|
const files = event.target.files;
|
|
46
31
|
if (!files || files.length === 0) return;
|
|
@@ -179,61 +164,6 @@ export default function UploadSeed({ onUploadSuccess }) {
|
|
|
179
164
|
onChange={handleFileChange}
|
|
180
165
|
data-testid="file-input"
|
|
181
166
|
/>
|
|
182
|
-
|
|
183
|
-
{/* Sample seed file section */}
|
|
184
|
-
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
|
185
|
-
<button
|
|
186
|
-
type="button"
|
|
187
|
-
onClick={() => setShowSample(!showSample)}
|
|
188
|
-
className="w-full px-4 py-3 text-left bg-gray-50 hover:bg-gray-100 transition-colors flex items-center justify-between"
|
|
189
|
-
aria-expanded={showSample}
|
|
190
|
-
data-testid="sample-toggle"
|
|
191
|
-
>
|
|
192
|
-
<span className="text-sm font-medium text-gray-700">
|
|
193
|
-
Need help? View sample seed file structure
|
|
194
|
-
</span>
|
|
195
|
-
<svg
|
|
196
|
-
className={`w-4 h-4 text-gray-500 transition-transform ${
|
|
197
|
-
showSample ? "rotate-180" : ""
|
|
198
|
-
}`}
|
|
199
|
-
fill="none"
|
|
200
|
-
viewBox="0 0 24 24"
|
|
201
|
-
stroke="currentColor"
|
|
202
|
-
>
|
|
203
|
-
<path
|
|
204
|
-
strokeLinecap="round"
|
|
205
|
-
strokeLinejoin="round"
|
|
206
|
-
strokeWidth={2}
|
|
207
|
-
d="M19 9l-7 7-7-7"
|
|
208
|
-
/>
|
|
209
|
-
</svg>
|
|
210
|
-
</button>
|
|
211
|
-
|
|
212
|
-
{showSample && (
|
|
213
|
-
<div className="p-4 bg-white border-t border-gray-200">
|
|
214
|
-
<div className="flex items-center justify-between mb-2">
|
|
215
|
-
<p className="text-xs text-gray-600">
|
|
216
|
-
Use this structure as a reference for your seed file:
|
|
217
|
-
</p>
|
|
218
|
-
<button
|
|
219
|
-
type="button"
|
|
220
|
-
onClick={() =>
|
|
221
|
-
navigator.clipboard.writeText(
|
|
222
|
-
JSON.stringify(sampleSeed, null, 2)
|
|
223
|
-
)
|
|
224
|
-
}
|
|
225
|
-
className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded hover:bg-blue-200 transition-colors"
|
|
226
|
-
data-testid="copy-sample"
|
|
227
|
-
>
|
|
228
|
-
Copy
|
|
229
|
-
</button>
|
|
230
|
-
</div>
|
|
231
|
-
<pre className="text-xs bg-gray-50 p-3 rounded overflow-auto max-h-60">
|
|
232
|
-
{JSON.stringify(sampleSeed, null, 2)}
|
|
233
|
-
</pre>
|
|
234
|
-
</div>
|
|
235
|
-
)}
|
|
236
|
-
</div>
|
|
237
167
|
</div>
|
|
238
168
|
);
|
|
239
169
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const Logo = () => (
|
|
2
|
+
<svg
|
|
3
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
4
|
+
width="100%"
|
|
5
|
+
height="100%"
|
|
6
|
+
viewBox="0 0 1200 1200"
|
|
7
|
+
>
|
|
8
|
+
<path
|
|
9
|
+
fill="#009966"
|
|
10
|
+
d="M406.13 988.31c-17.297 75.047-84.562 131.11-164.86 131.11-93.375 0-169.18-75.797-169.18-169.18s75.797-169.18 169.18-169.18 169.18 75.797 169.18 169.18v1.266h447.74v-167.9H671.63c-14.859 0-29.062-5.906-39.562-16.406s-16.406-24.703-16.406-39.562v-37.312h-317.16c-10.312 0-18.656-8.344-18.656-18.656V355.78h-147.94c-14.859 0-29.062-5.906-39.562-16.406s-16.406-24.75-16.406-39.562v-111.94c0-14.859 5.906-29.109 16.406-39.562 10.5-10.5 24.75-16.406 39.562-16.406h391.78c14.859 0 29.062 5.906 39.562 16.406s16.406 24.75 16.406 39.562v37.312h202.4c9.281-84.609 81.094-150.52 168.14-150.52 93.375 0 169.18 75.797 169.18 169.18s-75.797 169.18-169.18 169.18c-87.047 0-158.86-65.906-168.14-150.52h-202.4v37.312c0 14.859-5.906 29.062-16.406 39.562s-24.75 16.406-39.562 16.406h-206.53v297.24h298.5v-37.312c0-14.859 5.906-29.062 16.406-39.562s24.703-16.406 39.562-16.406h392.63c14.859 0 29.062 5.906 39.562 16.406s16.406 24.703 16.406 39.562v111.94c0 14.859-5.906 29.062-16.406 39.562s-24.75 16.406-39.562 16.406h-168.74v186.56c0 10.312-8.344 18.656-18.656 18.656h-466.4c-1.5 0-2.906-.187-4.312-.516zM225.19 262.45h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656H225.19c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm186.56 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656H411.75c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm-93.281 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-18.656c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm616.18 0h85.5c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-85.5l29.062-22.594c8.109-6.328 9.609-18.047 3.281-26.156s-18.047-9.609-26.156-3.281l-71.953 55.969a18.61 18.61 0 0 0 0 29.438l71.953 55.969c8.109 6.328 19.875 4.875 26.156-3.281 6.328-8.109 4.875-19.875-3.281-26.203l-29.062-22.594zm-779.95 696.66l50.391 50.391c7.266 7.313 19.078 7.313 26.391 0l100.73-100.73c7.266-7.266 7.266-19.078 0-26.391-7.266-7.266-19.078-7.266-26.391 0l-87.562 87.562-37.172-37.172c-7.266-7.266-19.078-7.266-26.391 0-7.266 7.266-7.266 19.078 0 26.391zm797.21-268.78h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-18.656c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm-186.56 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-18.656c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm93.281 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656H858.63c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656z"
|
|
11
|
+
fillRule="evenodd"
|
|
12
|
+
/>
|
|
13
|
+
</svg>
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
export default Logo;
|