@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
@@ -0,0 +1,214 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { Box, Flex, Text, Heading, Table } from "@radix-ui/themes";
4
+ import { ChevronRight, Plus } from "lucide-react";
5
+ import Layout from "../components/Layout.jsx";
6
+ import PageSubheader from "../components/PageSubheader.jsx";
7
+ import AddPipelineSidebar from "../components/AddPipelineSidebar.jsx";
8
+ import { Button } from "../components/ui/button.jsx";
9
+
10
+ /**
11
+ * PipelineList component displays available pipelines in a table layout
12
+ *
13
+ * Fetches pipeline data from /api/pipelines endpoint and handles:
14
+ * - Loading state during fetch
15
+ * - Error state for failed requests
16
+ * - Empty state when no pipelines are available
17
+ * - Table layout using Radix UI components
18
+ * - Add pipeline type functionality via sidebar
19
+ */
20
+ export default function PipelineList() {
21
+ const [pipelines, setPipelines] = useState([]);
22
+ const [loading, setLoading] = useState(true);
23
+ const [error, setError] = useState(null);
24
+ const [sidebarOpen, setSidebarOpen] = useState(false);
25
+ const navigate = useNavigate();
26
+
27
+ useEffect(() => {
28
+ const fetchPipelines = async () => {
29
+ try {
30
+ setLoading(true);
31
+ setError(null);
32
+
33
+ const response = await fetch("/api/pipelines");
34
+
35
+ if (!response.ok) {
36
+ const errorData = await response.json().catch(() => ({}));
37
+ throw new Error(errorData.message || `HTTP ${response.status}`);
38
+ }
39
+
40
+ const result = await response.json();
41
+
42
+ if (!result.ok) {
43
+ throw new Error(result.message || "Failed to load pipelines");
44
+ }
45
+
46
+ setPipelines(result.data?.pipelines || []);
47
+ } catch (err) {
48
+ setError(err.message || "Failed to load pipelines");
49
+ setPipelines([]);
50
+ } finally {
51
+ setLoading(false);
52
+ }
53
+ };
54
+
55
+ fetchPipelines();
56
+ }, []);
57
+
58
+ const breadcrumbs = [{ label: "Home", href: "/" }, { label: "Pipelines" }];
59
+
60
+ const openPipeline = (slug) => {
61
+ navigate(`/pipelines/${slug}`);
62
+ };
63
+
64
+ // Loading state
65
+ if (loading) {
66
+ return (
67
+ <Layout>
68
+ <PageSubheader breadcrumbs={breadcrumbs} />
69
+ <Box>
70
+ <Box mb="8">
71
+ <Heading size="6" mb="4">
72
+ Loading pipeline types...
73
+ </Heading>
74
+ </Box>
75
+ </Box>
76
+ </Layout>
77
+ );
78
+ }
79
+
80
+ // Error state
81
+ if (error) {
82
+ return (
83
+ <Layout>
84
+ <PageSubheader breadcrumbs={breadcrumbs} />
85
+ <Box>
86
+ <Box mb="8">
87
+ <Heading size="6" mb="4">
88
+ Failed
89
+ </Heading>
90
+ <Flex align="center" justify="center" className="min-h-64">
91
+ <Box className="text-center">
92
+ <Text size="2" color="gray" className="mt-2">
93
+ {error}
94
+ </Text>
95
+ </Box>
96
+ </Flex>
97
+ </Box>
98
+ </Box>
99
+ </Layout>
100
+ );
101
+ }
102
+
103
+ // Empty state
104
+ if (pipelines.length === 0) {
105
+ return (
106
+ <Layout>
107
+ <PageSubheader breadcrumbs={breadcrumbs} />
108
+ <Box>
109
+ <Box mb="8">
110
+ <Heading size="6" mb="4">
111
+ No pipelines available
112
+ </Heading>
113
+ <Flex align="center" justify="center" className="min-h-64">
114
+ <Box className="text-center">
115
+ <Text size="2" color="gray" className="mt-2">
116
+ Check back later for available pipelines.
117
+ </Text>
118
+ </Box>
119
+ </Flex>
120
+ </Box>
121
+ </Box>
122
+ </Layout>
123
+ );
124
+ }
125
+
126
+ // Main content with pipeline table
127
+ return (
128
+ <Layout>
129
+ <PageSubheader breadcrumbs={breadcrumbs}>
130
+ <Button size="md" variant="solid" onClick={() => setSidebarOpen(true)}>
131
+ <Plus className="h-4 w-4 mr-2" />
132
+ Add a Pipeline Type
133
+ </Button>
134
+ </PageSubheader>
135
+ <Box>
136
+ <Box mb="8">
137
+ <Heading size="6" mb="4">
138
+ Pipeline Types
139
+ </Heading>
140
+
141
+ <Table.Root radius="none">
142
+ <Table.Header>
143
+ <Table.Row>
144
+ <Table.ColumnHeaderCell>Pipeline Name</Table.ColumnHeaderCell>
145
+ <Table.ColumnHeaderCell>Description</Table.ColumnHeaderCell>
146
+ <Table.ColumnHeaderCell className="w-12"></Table.ColumnHeaderCell>
147
+ </Table.Row>
148
+ </Table.Header>
149
+
150
+ <Table.Body>
151
+ {pipelines.map((pipeline) => {
152
+ const pipelineName = pipeline.name;
153
+ const pipelineSlug = pipeline.slug;
154
+ const description = pipeline.description || "—";
155
+
156
+ return (
157
+ <Table.Row
158
+ key={pipelineSlug}
159
+ className="group cursor-pointer hover:bg-slate-50/50 transition-colors"
160
+ onClick={() => openPipeline(pipelineSlug)}
161
+ onKeyDown={(e) => {
162
+ if (e.key === " ") {
163
+ e.preventDefault();
164
+ openPipeline(pipelineSlug);
165
+ } else if (e.key === "Enter") {
166
+ openPipeline(pipelineSlug);
167
+ }
168
+ }}
169
+ tabIndex={0}
170
+ aria-label={`Open ${pipelineName} pipeline`}
171
+ >
172
+ <Table.Cell>
173
+ <Flex direction="column" gap="1">
174
+ <Text
175
+ size="2"
176
+ weight="medium"
177
+ className="text-slate-900"
178
+ >
179
+ {pipelineName}
180
+ </Text>
181
+ <Text size="1" className="text-slate-500">
182
+ {pipelineSlug}
183
+ </Text>
184
+ </Flex>
185
+ </Table.Cell>
186
+
187
+ <Table.Cell>
188
+ <Text size="2" className="text-slate-700">
189
+ {description}
190
+ </Text>
191
+ </Table.Cell>
192
+
193
+ <Table.Cell>
194
+ <Button
195
+ variant="ghost"
196
+ size="sm"
197
+ className="opacity-0 group-hover:opacity-100 transition-opacity"
198
+ aria-label={`View ${pipelineName} pipeline`}
199
+ >
200
+ <ChevronRight className="h-4 w-4" />
201
+ </Button>
202
+ </Table.Cell>
203
+ </Table.Row>
204
+ );
205
+ })}
206
+ </Table.Body>
207
+ </Table.Root>
208
+ </Box>
209
+ </Box>
210
+
211
+ <AddPipelineSidebar open={sidebarOpen} onOpenChange={setSidebarOpen} />
212
+ </Layout>
213
+ );
214
+ }
@@ -0,0 +1,234 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { useParams } from "react-router-dom";
3
+ import { Box, Flex, Text } from "@radix-ui/themes";
4
+ import Layout from "../components/Layout.jsx";
5
+ import PageSubheader from "../components/PageSubheader.jsx";
6
+ import { Button } from "../components/ui/button.jsx";
7
+
8
+ import PipelineDAGGrid from "../components/PipelineDAGGrid.jsx";
9
+ import TaskCreationSidebar from "../components/TaskCreationSidebar.jsx";
10
+ import { AnalysisProgressTray } from "../components/AnalysisProgressTray.jsx";
11
+ import { useAnalysisProgress } from "../ui/client/hooks/useAnalysisProgress.js";
12
+
13
+ export default function PipelineTypeDetail() {
14
+ const { slug } = useParams();
15
+ const [pipeline, setPipeline] = useState(null);
16
+ const [loading, setLoading] = useState(true);
17
+ const [error, setError] = useState(null);
18
+ const [sidebarOpen, setSidebarOpen] = useState(false);
19
+ const [trayDismissed, setTrayDismissed] = useState(false);
20
+
21
+ const { status, startAnalysis, reset, ...progressState } =
22
+ useAnalysisProgress();
23
+
24
+ const handleAnalyze = () => {
25
+ setTrayDismissed(false);
26
+ startAnalysis(slug);
27
+ };
28
+
29
+ useEffect(() => {
30
+ const fetchPipeline = async () => {
31
+ if (!slug) {
32
+ setError("No pipeline slug provided");
33
+ setLoading(false);
34
+ return;
35
+ }
36
+
37
+ try {
38
+ setLoading(true);
39
+ const response = await fetch(
40
+ `/api/pipelines/${encodeURIComponent(slug)}`
41
+ );
42
+
43
+ if (!response.ok) {
44
+ const errorData = await response.json().catch(() => ({}));
45
+ throw new Error(
46
+ errorData.message || `Failed to load pipeline: ${response.status}`
47
+ );
48
+ }
49
+
50
+ const data = await response.json();
51
+ if (!data.ok) {
52
+ throw new Error(data.message || "Failed to load pipeline");
53
+ }
54
+
55
+ setPipeline(data.data);
56
+ } catch (err) {
57
+ setError(err.message || "Failed to load pipeline");
58
+ } finally {
59
+ setLoading(false);
60
+ }
61
+ };
62
+
63
+ fetchPipeline();
64
+ }, [slug]);
65
+
66
+ // Handle missing slug
67
+ if (!slug) {
68
+ return (
69
+ <Layout
70
+ pageTitle="Pipeline Details"
71
+ breadcrumbs={[
72
+ { label: "Home", href: "/" },
73
+ { label: "Pipelines", href: "/pipelines" },
74
+ ]}
75
+ >
76
+ <Flex align="center" justify="center" className="min-h-64">
77
+ <Box className="text-center">
78
+ <Text size="5" weight="medium" color="red" className="mb-2">
79
+ No pipeline slug provided
80
+ </Text>
81
+ </Box>
82
+ </Flex>
83
+ </Layout>
84
+ );
85
+ }
86
+
87
+ // Loading state
88
+ if (loading) {
89
+ return (
90
+ <Layout
91
+ pageTitle="Pipeline Details"
92
+ breadcrumbs={[
93
+ { label: "Home", href: "/" },
94
+ { label: "Pipelines", href: "/pipelines" },
95
+ ]}
96
+ >
97
+ <Flex align="center" justify="center" className="min-h-64">
98
+ <Box className="text-center">
99
+ <Text size="5" weight="medium" className="mb-2">
100
+ Loading pipeline details...
101
+ </Text>
102
+ </Box>
103
+ </Flex>
104
+ </Layout>
105
+ );
106
+ }
107
+
108
+ // Error state
109
+ if (error) {
110
+ return (
111
+ <Layout
112
+ pageTitle="Pipeline Details"
113
+ breadcrumbs={[
114
+ { label: "Home", href: "/" },
115
+ { label: "Pipelines", href: "/pipelines" },
116
+ ]}
117
+ >
118
+ <Flex align="center" justify="center" className="min-h-64">
119
+ <Box className="text-center">
120
+ <Text size="5" weight="medium" color="red" className="mb-2">
121
+ Failed to load pipeline
122
+ </Text>
123
+ <Text size="2" color="gray" className="mt-2">
124
+ {error}
125
+ </Text>
126
+ </Box>
127
+ </Flex>
128
+ </Layout>
129
+ );
130
+ }
131
+
132
+ // No pipeline data
133
+ if (!pipeline) {
134
+ return (
135
+ <Layout
136
+ pageTitle="Pipeline Details"
137
+ breadcrumbs={[
138
+ { label: "Home", href: "/" },
139
+ { label: "Pipelines", href: "/pipelines" },
140
+ ]}
141
+ >
142
+ <Flex align="center" justify="center" className="min-h-64">
143
+ <Box className="text-center">
144
+ <Text size="5" weight="medium" className="mb-2">
145
+ Pipeline not found
146
+ </Text>
147
+ </Box>
148
+ </Flex>
149
+ </Layout>
150
+ );
151
+ }
152
+
153
+ const pageTitle = pipeline.name || "Pipeline Details";
154
+ const breadcrumbs = [
155
+ { label: "Home", href: "/" },
156
+ { label: "Pipelines", href: "/pipelines" },
157
+ { label: pipeline.name || slug },
158
+ ];
159
+
160
+ return (
161
+ <Layout pageTitle={pageTitle} breadcrumbs={breadcrumbs}>
162
+ <PageSubheader breadcrumbs={breadcrumbs} maxWidth="max-w-7xl">
163
+ <Flex gap="3" align="center">
164
+ <Text size="2" color="gray">
165
+ Slug: {slug}
166
+ </Text>
167
+ <Button
168
+ variant="solid"
169
+ size="md"
170
+ onClick={() => setSidebarOpen(true)}
171
+ >
172
+ Add Task
173
+ </Button>
174
+ <Button
175
+ variant="outline"
176
+ size="md"
177
+ onClick={handleAnalyze}
178
+ disabled={status === "connecting" || status === "running"}
179
+ >
180
+ Analyze Pipeline
181
+ </Button>
182
+ </Flex>
183
+ </PageSubheader>
184
+
185
+ {/* Pipeline description */}
186
+ {pipeline.description && (
187
+ <Box className="mb-6">
188
+ <Text size="2" color="gray" className="leading-relaxed">
189
+ {pipeline.description}
190
+ </Text>
191
+ </Box>
192
+ )}
193
+
194
+ {/* Pipeline DAG - will be implemented in step 5 */}
195
+ <Box className="bg-gray-50 rounded-lg p-4">
196
+ <Text size="3" weight="medium" className="mb-4">
197
+ Pipeline Tasks
198
+ </Text>
199
+
200
+ {pipeline.tasks && pipeline.tasks.length > 0 ? (
201
+ <Box>
202
+ <Text size="2" color="gray" className="mb-4">
203
+ {pipeline.tasks.length} task
204
+ {pipeline.tasks.length !== 1 ? "s" : ""} defined
205
+ </Text>
206
+
207
+ <PipelineDAGGrid items={pipeline.tasks} pipelineSlug={slug} />
208
+ </Box>
209
+ ) : (
210
+ <Box className="mb-4">
211
+ <Text size="2" color="gray">
212
+ No tasks defined for this pipeline
213
+ </Text>
214
+ </Box>
215
+ )}
216
+ </Box>
217
+
218
+ <TaskCreationSidebar
219
+ isOpen={sidebarOpen}
220
+ onClose={() => setSidebarOpen(false)}
221
+ pipelineSlug={slug}
222
+ />
223
+
224
+ {!trayDismissed && (
225
+ <AnalysisProgressTray
226
+ {...progressState}
227
+ status={status}
228
+ pipelineSlug={slug}
229
+ onDismiss={() => setTrayDismissed(true)}
230
+ />
231
+ )}
232
+ </Layout>
233
+ );
234
+ }
@@ -7,24 +7,23 @@ import { Box, Flex, Text, Tabs } from "@radix-ui/themes";
7
7
  import { Progress } from "../components/ui/progress";
8
8
  import { useJobListWithUpdates } from "../ui/client/hooks/useJobListWithUpdates";
9
9
  import { adaptJobSummary } from "../ui/client/adapters/job-adapter";
10
- import { TaskState, JobStatus } from "../config/statuses.js";
11
10
 
12
11
  // Referenced components — leave these alone
13
12
  import JobTable from "../components/JobTable";
14
13
  import Layout from "../components/Layout.jsx";
15
14
 
16
- export default function PromptPipelineDashboard({ isConnected }) {
15
+ export default function PromptPipelineDashboard() {
17
16
  const navigate = useNavigate();
18
17
  const hookResult = useJobListWithUpdates();
19
18
 
20
19
  if (
20
+ /* eslint-disable-next-line no-undef */
21
21
  process.env.NODE_ENV === "test" &&
22
22
  (hookResult === undefined ||
23
23
  hookResult === null ||
24
24
  typeof hookResult !== "object" ||
25
25
  Array.isArray(hookResult))
26
26
  ) {
27
- // eslint-disable-next-line no-console
28
27
  console.error(
29
28
  "[PromptPipelineDashboard] useJobListWithUpdates returned unexpected value",
30
29
  {
@@ -39,7 +38,7 @@ export default function PromptPipelineDashboard({ isConnected }) {
39
38
  );
40
39
  }
41
40
 
42
- const { data: apiJobs, loading, error, connectionStatus } = hookResult;
41
+ const { data: apiJobs, error } = hookResult;
43
42
 
44
43
  const jobs = useMemo(() => {
45
44
  const src = Array.isArray(apiJobs) ? apiJobs : [];
@@ -57,26 +56,26 @@ export default function PromptPipelineDashboard({ isConnected }) {
57
56
  // Shared ticker for live duration updates - removed useTicker
58
57
 
59
58
  const errorCount = useMemo(
60
- () => jobs.filter((j) => j.status === TaskState.FAILED).length,
59
+ () => jobs.filter((j) => j.displayCategory === "errors").length,
61
60
  [jobs]
62
61
  );
63
62
  const currentCount = useMemo(
64
- () => jobs.filter((j) => j.status === TaskState.RUNNING).length,
63
+ () => jobs.filter((j) => j.displayCategory === "current").length,
65
64
  [jobs]
66
65
  );
67
66
  const completedCount = useMemo(
68
- () => jobs.filter((j) => j.status === JobStatus.COMPLETE).length,
67
+ () => jobs.filter((j) => j.displayCategory === "complete").length,
69
68
  [jobs]
70
69
  );
71
70
 
72
71
  const filteredJobs = useMemo(() => {
73
72
  switch (activeTab) {
74
73
  case "current":
75
- return jobs.filter((j) => j.status === TaskState.RUNNING);
74
+ return jobs.filter((j) => j.displayCategory === "current");
76
75
  case "errors":
77
- return jobs.filter((j) => j.status === TaskState.FAILED);
76
+ return jobs.filter((j) => j.displayCategory === "errors");
78
77
  case "complete":
79
- return jobs.filter((j) => j.status === JobStatus.COMPLETE);
78
+ return jobs.filter((j) => j.displayCategory === "complete");
80
79
  default:
81
80
  return [];
82
81
  }
@@ -86,7 +85,7 @@ export default function PromptPipelineDashboard({ isConnected }) {
86
85
 
87
86
  // Aggregate progress for currently running jobs (for a subtle top progress bar)
88
87
  const runningJobs = useMemo(
89
- () => jobs.filter((j) => j.status === TaskState.RUNNING),
88
+ () => jobs.filter((j) => j.displayCategory === "current"),
90
89
  [jobs]
91
90
  );
92
91
  const aggregateProgress = useMemo(() => {
@@ -12,20 +12,24 @@ export async function deepseekChat({
12
12
  model = "deepseek-chat",
13
13
  temperature = 0.7,
14
14
  maxTokens,
15
- responseFormat,
15
+ responseFormat = "json_object",
16
16
  topP,
17
17
  frequencyPenalty,
18
18
  presencePenalty,
19
19
  stop,
20
+ stream = false,
20
21
  maxRetries = 3,
21
22
  }) {
22
- // Enforce JSON mode - reject calls without proper JSON responseFormat
23
- ensureJsonResponseFormat(responseFormat, "DeepSeek");
24
-
25
23
  if (!process.env.DEEPSEEK_API_KEY) {
26
24
  throw new Error("DeepSeek API key not configured");
27
25
  }
28
26
 
27
+ // Determine if JSON mode is requested
28
+ const isJsonMode =
29
+ responseFormat?.type === "json_object" ||
30
+ responseFormat?.type === "json_schema" ||
31
+ responseFormat === "json";
32
+
29
33
  const { systemMsg, userMsg } = extractMessages(messages);
30
34
 
31
35
  let lastError;
@@ -47,10 +51,11 @@ export async function deepseekChat({
47
51
  frequency_penalty: frequencyPenalty,
48
52
  presence_penalty: presencePenalty,
49
53
  stop,
54
+ stream,
50
55
  };
51
56
 
52
- // Add response format - this is now required for all calls
53
- if (responseFormat?.type === "json_object" || responseFormat === "json") {
57
+ // Add response format only for JSON mode (streaming uses text mode)
58
+ if (isJsonMode && !stream) {
54
59
  requestBody.response_format = { type: "json_object" };
55
60
  }
56
61
 
@@ -73,22 +78,35 @@ export async function deepseekChat({
73
78
  throw { status: response.status, ...error };
74
79
  }
75
80
 
81
+ // Streaming mode - return async generator for real-time chunks
82
+ if (stream) {
83
+ return createStreamGenerator(response.body);
84
+ }
85
+
76
86
  const data = await response.json();
77
87
  const content = data.choices[0].message.content;
78
88
 
79
- // Parse JSON - this is now required for all calls
80
- const parsed = tryParseJSON(content);
81
- if (!parsed) {
82
- throw new ProviderJsonParseError(
83
- "DeepSeek",
84
- model,
85
- content.substring(0, 200),
86
- "Failed to parse JSON response from DeepSeek API"
87
- );
89
+ // Parse JSON only in JSON mode; return raw string for text mode
90
+ if (isJsonMode) {
91
+ const parsed = tryParseJSON(content);
92
+ if (!parsed) {
93
+ throw new ProviderJsonParseError(
94
+ "DeepSeek",
95
+ model,
96
+ content.substring(0, 200),
97
+ "Failed to parse JSON response from DeepSeek API"
98
+ );
99
+ }
100
+ return {
101
+ content: parsed,
102
+ usage: data.usage,
103
+ raw: data,
104
+ };
88
105
  }
89
106
 
107
+ // Text mode - return raw string
90
108
  return {
91
- content: parsed,
109
+ content,
92
110
  usage: data.usage,
93
111
  raw: data,
94
112
  };
@@ -107,3 +125,45 @@ export async function deepseekChat({
107
125
 
108
126
  throw lastError || new Error(`Failed after ${maxRetries + 1} attempts`);
109
127
  }
128
+
129
+ /**
130
+ * Create async generator for streaming DeepSeek responses.
131
+ * DeepSeek uses Server-Sent Events format with "data:" prefix.
132
+ */
133
+ async function* createStreamGenerator(stream) {
134
+ const decoder = new TextDecoder();
135
+ const reader = stream.getReader();
136
+ let buffer = "";
137
+
138
+ try {
139
+ while (true) {
140
+ const { done, value } = await reader.read();
141
+ if (done) break;
142
+
143
+ buffer += decoder.decode(value, { stream: true });
144
+ const lines = buffer.split("\n");
145
+ buffer = lines.pop(); // Keep incomplete line
146
+
147
+ for (const line of lines) {
148
+ if (line.startsWith("data: ")) {
149
+ const data = line.slice(6);
150
+ if (data === "[DONE]") continue;
151
+
152
+ try {
153
+ const parsed = JSON.parse(data);
154
+ const content = parsed.choices?.[0]?.delta?.content;
155
+ // Skip only truly empty chunks; preserve whitespace-only content
156
+ if (content !== undefined && content !== null && content !== "") {
157
+ yield { content };
158
+ }
159
+ } catch (e) {
160
+ // Skip malformed JSON
161
+ console.warn("[deepseek] Failed to parse stream chunk:", e);
162
+ }
163
+ }
164
+ }
165
+ }
166
+ } finally {
167
+ reader.releaseLock();
168
+ }
169
+ }