@ryanfw/prompt-orchestration-pipeline 0.11.0 → 0.13.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.
Files changed (83) hide show
  1. package/package.json +11 -1
  2. package/src/cli/analyze-task.js +51 -0
  3. package/src/cli/index.js +8 -0
  4. package/src/components/AddPipelineSidebar.jsx +144 -0
  5. package/src/components/AnalysisProgressTray.jsx +87 -0
  6. package/src/components/DAGGrid.jsx +157 -47
  7. package/src/components/JobTable.jsx +4 -3
  8. package/src/components/Layout.jsx +142 -139
  9. package/src/components/MarkdownRenderer.jsx +149 -0
  10. package/src/components/PipelineDAGGrid.jsx +404 -0
  11. package/src/components/PipelineTypeTaskSidebar.jsx +96 -0
  12. package/src/components/SchemaPreviewPanel.jsx +97 -0
  13. package/src/components/StageTimeline.jsx +36 -0
  14. package/src/components/TaskAnalysisDisplay.jsx +227 -0
  15. package/src/components/TaskCreationSidebar.jsx +447 -0
  16. package/src/components/TaskDetailSidebar.jsx +119 -117
  17. package/src/components/TaskFilePane.jsx +94 -39
  18. package/src/components/ui/RestartJobModal.jsx +26 -6
  19. package/src/components/ui/StopJobModal.jsx +183 -0
  20. package/src/components/ui/button.jsx +59 -27
  21. package/src/components/ui/sidebar.jsx +118 -0
  22. package/src/config/models.js +99 -67
  23. package/src/core/config.js +11 -4
  24. package/src/core/lifecycle-policy.js +62 -0
  25. package/src/core/pipeline-runner.js +312 -217
  26. package/src/core/status-writer.js +84 -0
  27. package/src/llm/index.js +129 -9
  28. package/src/pages/Code.jsx +8 -1
  29. package/src/pages/PipelineDetail.jsx +84 -2
  30. package/src/pages/PipelineList.jsx +214 -0
  31. package/src/pages/PipelineTypeDetail.jsx +234 -0
  32. package/src/pages/PromptPipelineDashboard.jsx +10 -11
  33. package/src/providers/deepseek.js +76 -16
  34. package/src/providers/openai.js +61 -34
  35. package/src/task-analysis/enrichers/analysis-writer.js +62 -0
  36. package/src/task-analysis/enrichers/schema-deducer.js +145 -0
  37. package/src/task-analysis/enrichers/schema-writer.js +74 -0
  38. package/src/task-analysis/extractors/artifacts.js +137 -0
  39. package/src/task-analysis/extractors/llm-calls.js +176 -0
  40. package/src/task-analysis/extractors/stages.js +51 -0
  41. package/src/task-analysis/index.js +103 -0
  42. package/src/task-analysis/parser.js +28 -0
  43. package/src/task-analysis/utils/ast.js +43 -0
  44. package/src/ui/client/adapters/job-adapter.js +60 -0
  45. package/src/ui/client/api.js +233 -8
  46. package/src/ui/client/hooks/useAnalysisProgress.js +145 -0
  47. package/src/ui/client/hooks/useJobList.js +14 -1
  48. package/src/ui/client/index.css +64 -0
  49. package/src/ui/client/main.jsx +4 -0
  50. package/src/ui/client/sse-fetch.js +120 -0
  51. package/src/ui/dist/app.js +262 -0
  52. package/src/ui/dist/assets/index-cjHV9mYW.js +82578 -0
  53. package/src/ui/dist/assets/index-cjHV9mYW.js.map +1 -0
  54. package/src/ui/dist/assets/style-CoM9SoQF.css +180 -0
  55. package/src/ui/dist/favicon.svg +12 -0
  56. package/src/ui/dist/index.html +2 -2
  57. package/src/ui/endpoints/create-pipeline-endpoint.js +194 -0
  58. package/src/ui/endpoints/file-endpoints.js +330 -0
  59. package/src/ui/endpoints/job-control-endpoints.js +1001 -0
  60. package/src/ui/endpoints/job-endpoints.js +62 -0
  61. package/src/ui/endpoints/pipeline-analysis-endpoint.js +246 -0
  62. package/src/ui/endpoints/pipeline-type-detail-endpoint.js +181 -0
  63. package/src/ui/endpoints/pipelines-endpoint.js +133 -0
  64. package/src/ui/endpoints/schema-file-endpoint.js +105 -0
  65. package/src/ui/endpoints/sse-endpoints.js +223 -0
  66. package/src/ui/endpoints/state-endpoint.js +85 -0
  67. package/src/ui/endpoints/task-analysis-endpoint.js +104 -0
  68. package/src/ui/endpoints/task-creation-endpoint.js +114 -0
  69. package/src/ui/endpoints/task-save-endpoint.js +101 -0
  70. package/src/ui/endpoints/upload-endpoints.js +406 -0
  71. package/src/ui/express-app.js +227 -0
  72. package/src/ui/lib/analysis-lock.js +67 -0
  73. package/src/ui/lib/sse.js +30 -0
  74. package/src/ui/server.js +42 -1880
  75. package/src/ui/sse-broadcast.js +93 -0
  76. package/src/ui/utils/http-utils.js +139 -0
  77. package/src/ui/utils/mime-types.js +196 -0
  78. package/src/ui/utils/slug.js +31 -0
  79. package/src/ui/vite.config.js +22 -0
  80. package/src/ui/watcher.js +28 -2
  81. package/src/utils/jobs.js +39 -0
  82. package/src/ui/dist/assets/index-DeDzq-Kk.js +0 -23863
  83. package/src/ui/dist/assets/style-aBtD_Yrs.css +0 -62
@@ -3,12 +3,14 @@
3
3
  */
4
4
 
5
5
  /**
6
- * Restart a job with clean-slate mode
6
+ * Restart a job with clean-slate mode or from a specific task
7
7
  *
8
8
  * @param {string} jobId - The ID of the job to restart
9
9
  * @param {Object} opts - Options object
10
- * @param {Object} opts.options - Additional options for the restart
11
- * @param {boolean} opts.options.clearTokenUsage - Whether to clear token usage (default: true)
10
+ * @param {string} [opts.fromTask] - Task ID to restart from (inclusive)
11
+ * @param {boolean} [opts.singleTask] - Whether to run only the target task and then stop
12
+ * @param {Object} [opts.options] - Additional options for the restart
13
+ * @param {boolean} [opts.options.clearTokenUsage=true] - Whether to clear token usage
12
14
  * @returns {Promise<Object>} Parsed JSON response from the server
13
15
  * @throws {Object} Structured error object with { code, message } for non-2xx responses
14
16
  */
@@ -19,8 +21,16 @@ export async function restartJob(jobId, opts = {}) {
19
21
  };
20
22
 
21
23
  const requestBody = opts.fromTask
22
- ? { fromTask: opts.fromTask, options }
23
- : { mode: "clean-slate", options };
24
+ ? {
25
+ fromTask: opts.fromTask,
26
+ options,
27
+ ...(opts.singleTask !== undefined && { singleTask: opts.singleTask }),
28
+ }
29
+ : {
30
+ mode: "clean-slate",
31
+ options,
32
+ ...(opts.singleTask !== undefined && { singleTask: opts.singleTask }),
33
+ };
24
34
 
25
35
  try {
26
36
  const response = await fetch(
@@ -67,6 +77,164 @@ export async function restartJob(jobId, opts = {}) {
67
77
  }
68
78
  }
69
79
 
80
+ /**
81
+ * Rescan a job to synchronize tasks with the pipeline definition, detecting both added and removed tasks.
82
+ *
83
+ * @param {string} jobId - The ID of the job to rescan
84
+ * @returns {Promise<Object>} Parsed JSON response from the server
85
+ * @throws {Object} Structured error object with { code, message } for non-2xx responses
86
+ */
87
+ export async function rescanJob(jobId) {
88
+ try {
89
+ const response = await fetch(
90
+ `/api/jobs/${encodeURIComponent(jobId)}/rescan`,
91
+ {
92
+ method: "POST",
93
+ headers: {
94
+ "Content-Type": "application/json",
95
+ },
96
+ }
97
+ );
98
+
99
+ if (!response.ok) {
100
+ // Try to parse error response, fall back to status text if parsing fails
101
+ let errorData;
102
+ try {
103
+ errorData = await response.json();
104
+ } catch {
105
+ errorData = { message: response.statusText };
106
+ }
107
+
108
+ // Throw structured error with code and message
109
+ throw {
110
+ code: errorData.code || getErrorCodeFromStatus(response.status),
111
+ message:
112
+ errorData.message || getErrorMessageFromStatus(response.status),
113
+ status: response.status,
114
+ };
115
+ }
116
+
117
+ // Return parsed JSON for successful responses
118
+ return await response.json();
119
+ } catch (error) {
120
+ // Re-throw structured errors as-is
121
+ if (error.code && error.message) {
122
+ throw error;
123
+ }
124
+
125
+ // Handle network errors or other unexpected errors
126
+ throw {
127
+ code: "network_error",
128
+ message: error.message || "Failed to connect to server",
129
+ };
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Start a specific pending task for a job that is not actively running
135
+ *
136
+ * @param {string} jobId - The ID of the job
137
+ * @param {string} taskId - The ID of the task to start
138
+ * @returns {Promise<Object>} Parsed JSON response from the server
139
+ * @throws {Object} Structured error object with { code, message } for non-2xx responses
140
+ */
141
+ export async function startTask(jobId, taskId) {
142
+ try {
143
+ const response = await fetch(
144
+ `/api/jobs/${encodeURIComponent(jobId)}/tasks/${encodeURIComponent(taskId)}/start`,
145
+ {
146
+ method: "POST",
147
+ headers: {
148
+ "Content-Type": "application/json",
149
+ },
150
+ }
151
+ );
152
+
153
+ if (!response.ok) {
154
+ // Try to parse error response, fall back to status text if parsing fails
155
+ let errorData;
156
+ try {
157
+ errorData = await response.json();
158
+ } catch {
159
+ errorData = { message: response.statusText };
160
+ }
161
+
162
+ // Throw structured error with code and message
163
+ throw {
164
+ code: errorData.code || getErrorCodeFromStatus(response.status),
165
+ message: getStartTaskErrorMessage(errorData, response.status),
166
+ status: response.status,
167
+ };
168
+ }
169
+
170
+ // Return parsed JSON for successful responses
171
+ return await response.json();
172
+ } catch (error) {
173
+ // Re-throw structured errors as-is
174
+ if (error.code && error.message) {
175
+ throw error;
176
+ }
177
+
178
+ // Handle network errors or other unexpected errors
179
+ throw {
180
+ code: "network_error",
181
+ message: error.message || "Failed to connect to server",
182
+ };
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Stop a running job's pipeline
188
+ *
189
+ * @param {string} jobId - The ID of the job to stop
190
+ * @returns {Promise<Object>} Parsed JSON response from the server
191
+ * @throws {Object} Structured error object with { code, message } for non-2xx responses
192
+ */
193
+ export async function stopJob(jobId) {
194
+ try {
195
+ const response = await fetch(
196
+ `/api/jobs/${encodeURIComponent(jobId)}/stop`,
197
+ {
198
+ method: "POST",
199
+ headers: {
200
+ "Content-Type": "application/json",
201
+ },
202
+ }
203
+ );
204
+
205
+ if (!response.ok) {
206
+ // Try to parse error response, fall back to status text if parsing fails
207
+ let errorData;
208
+ try {
209
+ errorData = await response.json();
210
+ } catch {
211
+ errorData = { message: response.statusText };
212
+ }
213
+
214
+ // Throw structured error with code and message
215
+ throw {
216
+ code: errorData.code || getErrorCodeFromStatus(response.status),
217
+ message: getStopErrorMessage(errorData, response.status),
218
+ status: response.status,
219
+ };
220
+ }
221
+
222
+ // Return parsed JSON for successful responses
223
+ return await response.json();
224
+ } catch (error) {
225
+ // Re-throw structured errors as-is
226
+ if (error.code && error.message) {
227
+ throw error;
228
+ }
229
+
230
+ // Handle network errors or other unexpected errors
231
+ throw {
232
+ code: "network_error",
233
+ message: error.message || "Failed to connect to server",
234
+ };
235
+ }
236
+ }
237
+
70
238
  /**
71
239
  * Map HTTP status codes to error codes for structured error handling
72
240
  */
@@ -108,9 +276,6 @@ function getRestartErrorMessage(errorData, status) {
108
276
  if (errorData.code === "job_running") {
109
277
  return "Job is currently running; restart is unavailable.";
110
278
  }
111
- if (errorData.code === "unsupported_lifecycle") {
112
- return "Job must be in current to restart.";
113
- }
114
279
  if (errorData.message?.includes("job_running")) {
115
280
  return "Job is currently running; restart is unavailable.";
116
281
  }
@@ -132,3 +297,63 @@ function getRestartErrorMessage(errorData, status) {
132
297
  // Fall back to provided message or default
133
298
  return errorData.message || "Failed to restart job.";
134
299
  }
300
+
301
+ /**
302
+ * Get specific error message from error response for start task functionality
303
+ */
304
+ function getStartTaskErrorMessage(errorData, status) {
305
+ // Handle specific 409 conflict errors
306
+ if (status === 409) {
307
+ if (errorData.code === "job_running") {
308
+ return "Job is currently running; start is unavailable.";
309
+ }
310
+ if (errorData.code === "dependencies_not_satisfied") {
311
+ return "Dependencies not satisfied for task.";
312
+ }
313
+ if (errorData.code === "unsupported_lifecycle") {
314
+ return "Job must be in current to start a task.";
315
+ }
316
+ return "Request conflict.";
317
+ }
318
+
319
+ // Handle 404 errors
320
+ if (status === 404) {
321
+ return "Job not found.";
322
+ }
323
+
324
+ // Handle 400 errors
325
+ if (status === 400) {
326
+ return errorData.message || "Bad request";
327
+ }
328
+
329
+ // Handle 500 errors
330
+ if (status === 500) {
331
+ return "Internal server error";
332
+ }
333
+
334
+ // Fall back to provided message or default
335
+ return errorData.message || "Failed to start task.";
336
+ }
337
+
338
+ /**
339
+ * Get specific error message from error response for stop functionality
340
+ */
341
+ function getStopErrorMessage(errorData, status) {
342
+ // Handle 404 errors
343
+ if (status === 404) {
344
+ return "Job not found.";
345
+ }
346
+
347
+ // Handle 409 errors
348
+ if (status === 409) {
349
+ return errorData.message || "Job stop is already in progress.";
350
+ }
351
+
352
+ // Handle 500 errors
353
+ if (status === 500) {
354
+ return "Internal server error";
355
+ }
356
+
357
+ // Fall back to provided message or default
358
+ return errorData.message || `Request failed with status ${status}`;
359
+ }
@@ -0,0 +1,145 @@
1
+ import { useState, useRef, useCallback } from "react";
2
+ import { fetchSSE } from "../sse-fetch.js";
3
+
4
+ const initialState = {
5
+ status: "idle",
6
+ pipelineSlug: null,
7
+ totalTasks: 0,
8
+ completedTasks: 0,
9
+ totalArtifacts: 0,
10
+ completedArtifacts: 0,
11
+ currentTask: null,
12
+ currentArtifact: null,
13
+ error: null,
14
+ };
15
+
16
+ /**
17
+ * useAnalysisProgress - Hook for managing pipeline analysis progress via SSE
18
+ *
19
+ * Provides state and controls for triggering pipeline analysis with real-time
20
+ * progress updates via Server-Sent Events.
21
+ *
22
+ * @returns {Object} { ...state, startAnalysis, reset }
23
+ */
24
+ export function useAnalysisProgress() {
25
+ const [state, setState] = useState(initialState);
26
+ const cancelRef = useRef(null);
27
+
28
+ const reset = useCallback(() => {
29
+ if (cancelRef.current) {
30
+ cancelRef.current();
31
+ cancelRef.current = null;
32
+ }
33
+ setState(initialState);
34
+ }, []);
35
+
36
+ const handleEvent = useCallback((event, data) => {
37
+ switch (event) {
38
+ case "started":
39
+ setState((prev) => ({
40
+ ...prev,
41
+ status: "running",
42
+ pipelineSlug: data.pipelineSlug || prev.pipelineSlug,
43
+ totalTasks: data.totalTasks || 0,
44
+ totalArtifacts: data.totalArtifacts || 0,
45
+ }));
46
+ break;
47
+
48
+ case "task:start":
49
+ setState((prev) => ({
50
+ ...prev,
51
+ currentTask: data.taskId || null,
52
+ }));
53
+ break;
54
+
55
+ case "artifact:start":
56
+ setState((prev) => ({
57
+ ...prev,
58
+ currentArtifact: data.artifactName || null,
59
+ }));
60
+ break;
61
+
62
+ case "artifact:complete":
63
+ setState((prev) => ({
64
+ ...prev,
65
+ completedArtifacts: prev.completedArtifacts + 1,
66
+ }));
67
+ break;
68
+
69
+ case "task:complete":
70
+ setState((prev) => ({
71
+ ...prev,
72
+ completedTasks: prev.completedTasks + 1,
73
+ currentArtifact: null,
74
+ }));
75
+ break;
76
+
77
+ case "complete":
78
+ setState((prev) => ({
79
+ ...prev,
80
+ status: "complete",
81
+ currentTask: null,
82
+ currentArtifact: null,
83
+ }));
84
+ if (cancelRef.current) {
85
+ cancelRef.current = null;
86
+ }
87
+ break;
88
+
89
+ case "error":
90
+ setState((prev) => ({
91
+ ...prev,
92
+ status: "error",
93
+ error: data.message || "Unknown error",
94
+ }));
95
+ if (cancelRef.current) {
96
+ cancelRef.current = null;
97
+ }
98
+ break;
99
+
100
+ default:
101
+ console.warn("Unknown SSE event:", event);
102
+ }
103
+ }, []);
104
+
105
+ const startAnalysis = useCallback(
106
+ async (pipelineSlug) => {
107
+ // Reset previous state
108
+ if (cancelRef.current) {
109
+ cancelRef.current();
110
+ }
111
+
112
+ setState({
113
+ ...initialState,
114
+ status: "connecting",
115
+ pipelineSlug,
116
+ });
117
+
118
+ const url = `/api/pipelines/${encodeURIComponent(pipelineSlug)}/analyze`;
119
+
120
+ const sse = fetchSSE(
121
+ url,
122
+ {},
123
+ handleEvent,
124
+ // Error handler for HTTP errors
125
+ (errorData) => {
126
+ setState((prev) => ({
127
+ ...prev,
128
+ status: "error",
129
+ error:
130
+ errorData.message ||
131
+ `HTTP ${errorData.status || "error"}: ${errorData.statusText || "Unknown error"}`,
132
+ }));
133
+ }
134
+ );
135
+ cancelRef.current = sse.cancel;
136
+ },
137
+ [handleEvent]
138
+ );
139
+
140
+ return {
141
+ ...state,
142
+ startAnalysis,
143
+ reset,
144
+ };
145
+ }
@@ -20,7 +20,20 @@ export function useJobList() {
20
20
  setData(null);
21
21
  } else {
22
22
  const json = await res.json();
23
- setData(json);
23
+ if (json && typeof json === "object" && "ok" in json) {
24
+ if (json.ok && Array.isArray(json.data)) {
25
+ setData(json.data);
26
+ } else if (!json.ok) {
27
+ setError({ code: json.code, message: json.message });
28
+ setData(null);
29
+ } else {
30
+ // Fallback: data is not an array; treat as empty to avoid crashes
31
+ setData([]);
32
+ }
33
+ } else {
34
+ // Legacy path: response is already an array
35
+ setData(Array.isArray(json) ? json : []);
36
+ }
24
37
  }
25
38
  } catch (err) {
26
39
  if (err.name === "AbortError") {
@@ -2,6 +2,55 @@
2
2
  @import "@radix-ui/themes/styles.css" layer(radix);
3
3
  @import "tailwindcss";
4
4
 
5
+ /* Steel Terminal Theme - CSS Variables */
6
+ :root {
7
+ /* Base colors */
8
+ --background: 30 5% 97%; /* Soft warm gray */
9
+ --foreground: 210 15% 15%; /* Deep slate */
10
+ --card: 0 0% 100%; /* Pure white */
11
+ --card-foreground: 210 15% 15%; /* Deep slate */
12
+ --popover: 0 0% 100%; /* Pure white */
13
+ --popover-foreground: 210 15% 15%; /* Deep slate */
14
+
15
+ /* Primary colors - Indigo for primary actions with clear active state */
16
+ --primary: 243deg 75% 59%; /* #4f46e5 - indigo-600 */
17
+ --primary-foreground: 0deg 0% 100%; /* #ffffff */
18
+ --primary-hover: 243deg 77% 49%; /* #4338ca - indigo-700 */
19
+
20
+ /* Secondary colors - For outline buttons */
21
+ --secondary: 210deg 20% 98%; /* #f8fafc - slate-50 */
22
+ --secondary-foreground: 243deg 75% 30%; /* #312e81 - indigo-950 */
23
+
24
+ /* Destructive colors - For danger actions */
25
+ --destructive: 0deg 84% 60%; /* #dc2626 - red-600 */
26
+ --destructive-foreground: 0deg 0% 100%; /* #ffffff */
27
+
28
+ /* Muted colors - For disabled/subtle states */
29
+ --muted: 210deg 40% 96%; /* #f1f5f9 - slate-100 */
30
+ --muted-foreground: 243deg 50% 45%; /* #6366f1 - indigo-500 */
31
+
32
+ /* Accent colors - For highlights */
33
+ --accent: 210deg 40% 96%; /* #f1f5f9 - slate-100 */
34
+ --accent-foreground: 243deg 75% 59%; /* #4f46e5 - indigo-600 */
35
+
36
+ /* Border and input colors */
37
+ --border: 214deg 32% 91%; /* #e2e8f0 - slate-200 */
38
+ --input: 214deg 32% 91%; /* #e2e8f0 - slate-200 */
39
+
40
+ /* Ring - For focus states */
41
+ --ring: 243deg 75% 59%; /* #4f46e5 - indigo-600 */
42
+
43
+ /* Status colors */
44
+ --success: 142deg 76% 36%; /* #16a34a - green-600 */
45
+ --success-foreground: 0deg 0% 100%;
46
+ --warning: 38deg 92% 50%; /* #ca8a04 - yellow-600 */
47
+ --warning-foreground: 0deg 0% 100%;
48
+ --error: 0deg 84% 60%; /* #dc2626 - red-600 */
49
+ --error-foreground: 0deg 0% 100%;
50
+ --info: 215deg 90% 52%; /* #0284c7 - sky-600 */
51
+ --info-foreground: 0deg 0% 100%;
52
+ }
53
+
5
54
  .radix-themes {
6
55
  --heading-font-family: "Source Sans 3", sans-serif;
7
56
  --body-font-family: "Source Sans 3", sans-serif;
@@ -44,3 +93,18 @@ body {
44
93
  margin: 0 auto;
45
94
  padding: 2rem;
46
95
  }
96
+
97
+ /* Bouncing wave animation for typing indicators */
98
+ @keyframes bounce-wave {
99
+ 0%,
100
+ 100% {
101
+ transform: translateY(0);
102
+ }
103
+ 50% {
104
+ transform: translateY(-8px);
105
+ }
106
+ }
107
+
108
+ .animate-bounce-wave {
109
+ animation: bounce-wave 0.6s ease-in-out infinite;
110
+ }
@@ -17,6 +17,8 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
17
17
  import PromptPipelineDashboard from "@/pages/PromptPipelineDashboard.jsx";
18
18
  import PipelineDetail from "@/pages/PipelineDetail.jsx";
19
19
  import Code from "@/pages/Code.jsx";
20
+ import PipelineList from "@/pages/PipelineList.jsx";
21
+ import PipelineTypeDetail from "@/pages/PipelineTypeDetail.jsx";
20
22
  import { Theme } from "@radix-ui/themes";
21
23
  import { ToastProvider } from "@/components/ui/toast.jsx";
22
24
 
@@ -34,6 +36,8 @@ ReactDOM.createRoot(document.getElementById("root")).render(
34
36
  <Routes>
35
37
  <Route path="/" element={<PromptPipelineDashboard />} />
36
38
  <Route path="/pipeline/:jobId" element={<PipelineDetail />} />
39
+ <Route path="/pipelines" element={<PipelineList />} />
40
+ <Route path="/pipelines/:slug" element={<PipelineTypeDetail />} />
37
41
  <Route path="/code" element={<Code />} />
38
42
  </Routes>
39
43
  </BrowserRouter>
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Parse Server-Sent Events from a fetch response stream.
3
+ * Supports POST requests (unlike native EventSource).
4
+ *
5
+ * @param {string} url - The URL to fetch
6
+ * @param {RequestInit} options - Fetch options (method defaults to POST)
7
+ * @param {function} onEvent - Callback for each SSE event: (eventName, parsedData) => void
8
+ * @param {function} onError - Callback for HTTP errors: (errorData) => void
9
+ * @returns {{ cancel: () => void }} Object with cancel method to abort the fetch
10
+ */
11
+ export function fetchSSE(url, options = {}, onEvent, onError) {
12
+ if (typeof onEvent !== "function") {
13
+ throw new Error("onEvent callback is required");
14
+ }
15
+
16
+ const controller = new AbortController();
17
+ const requestOptions = {
18
+ method: "POST",
19
+ ...options,
20
+ signal: controller.signal,
21
+ };
22
+
23
+ fetch(url, requestOptions)
24
+ .then(async (response) => {
25
+ // Handle HTTP errors before attempting to read stream
26
+ if (!response.ok) {
27
+ let errorData = {};
28
+ try {
29
+ errorData = await response.json();
30
+ } catch {
31
+ // If response isn't JSON, just use status text
32
+ errorData = {
33
+ ok: false,
34
+ code: "http_error",
35
+ message: response.statusText,
36
+ status: response.status,
37
+ };
38
+ }
39
+ if (typeof onError === "function") {
40
+ onError(errorData);
41
+ } else {
42
+ console.error(`[sse-fetch] HTTP ${response.status}:`, errorData);
43
+ }
44
+ return;
45
+ }
46
+
47
+ const reader = response.body.getReader();
48
+ const decoder = new TextDecoder();
49
+ let buffer = "";
50
+
51
+ while (true) {
52
+ const { done, value } = await reader.read();
53
+ if (done) break;
54
+
55
+ buffer += decoder.decode(value, { stream: true });
56
+ const events = buffer.split("\n\n");
57
+
58
+ // Keep the last partial event in the buffer
59
+ buffer = events.pop();
60
+
61
+ for (const eventText of events) {
62
+ if (!eventText.trim()) continue;
63
+
64
+ const event = parseSSEEvent(eventText);
65
+ if (event) {
66
+ onEvent(event.type, event.data);
67
+ }
68
+ }
69
+ }
70
+
71
+ // Process any remaining content in buffer
72
+ if (buffer.trim()) {
73
+ const event = parseSSEEvent(buffer);
74
+ if (event) {
75
+ onEvent(event.type, event.data);
76
+ }
77
+ }
78
+ })
79
+ .catch((error) => {
80
+ if (error.name === "AbortError") {
81
+ return; // Expected when cancel() is called
82
+ }
83
+ console.error("[sse-fetch] Error:", error);
84
+ });
85
+
86
+ return {
87
+ cancel: () => controller.abort(),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Parse a single SSE event string.
93
+ *
94
+ * @param {string} eventText - The raw SSE event text
95
+ * @returns {{ type: string, data: object }|null} Parsed event or null if invalid
96
+ */
97
+ function parseSSEEvent(eventText) {
98
+ let type = null;
99
+ let data = null;
100
+
101
+ for (const line of eventText.split("\n")) {
102
+ const trimmedLine = line.trim();
103
+ if (trimmedLine.startsWith("event:")) {
104
+ type = trimmedLine.slice(6).trim();
105
+ } else if (trimmedLine.startsWith("data:")) {
106
+ const dataStr = trimmedLine.slice(5).trim();
107
+ try {
108
+ data = JSON.parse(dataStr);
109
+ } catch {
110
+ console.error("[sse-fetch] Failed to parse data:", dataStr);
111
+ }
112
+ }
113
+ }
114
+
115
+ if (!type || data === null) {
116
+ return null;
117
+ }
118
+
119
+ return { type, data };
120
+ }