@ryanfw/prompt-orchestration-pipeline 0.15.0 → 0.16.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 +4 -1
- package/src/components/Layout.jsx +4 -0
- package/src/components/TaskCreationSidebar.jsx +198 -47
- package/src/components/ui/CopyableCode.jsx +110 -0
- package/src/core/batch-runner.js +277 -0
- package/src/core/file-io.js +37 -1
- package/src/core/orchestrator.js +0 -18
- package/src/core/pipeline-runner.js +0 -37
- package/src/core/status-writer.js +0 -18
- package/src/core/symlink-utils.js +0 -12
- package/src/core/task-runner.js +0 -23
- package/src/pages/Code.jsx +538 -272
- package/src/pages/PromptPipelineDashboard.jsx +28 -13
- package/src/task-analysis/enrichers/analysis-writer.js +32 -0
- package/src/task-analysis/enrichers/artifact-resolver.js +98 -0
- package/src/task-analysis/extractors/artifacts.js +70 -26
- package/src/task-analysis/index.js +4 -2
- package/src/ui/dist/assets/{index-B5HMRkR9.js → index-DI_nRqVI.js} +3271 -397
- package/src/ui/dist/assets/index-DI_nRqVI.js.map +1 -0
- package/src/ui/dist/assets/style-CVd3RRU2.css +180 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/endpoints/pipeline-analysis-endpoint.js +59 -0
- package/src/ui/endpoints/pipeline-artifacts-endpoint.js +109 -0
- package/src/ui/express-app.js +4 -0
- package/src/ui/watcher.js +20 -10
- package/src/ui/dist/assets/index-B5HMRkR9.js.map +0 -1
- package/src/ui/dist/assets/style-CoM9SoQF.css +0 -180
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryanfw/prompt-orchestration-pipeline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
46
46
|
"@radix-ui/themes": "^3.2.1",
|
|
47
47
|
"ajv": "^8.17.1",
|
|
48
|
+
"better-sqlite3": "^11.7.0",
|
|
48
49
|
"chokidar": "^3.5.3",
|
|
49
50
|
"commander": "^14.0.2",
|
|
50
51
|
"dotenv": "^17.2.3",
|
|
@@ -52,9 +53,11 @@
|
|
|
52
53
|
"fflate": "^0.8.2",
|
|
53
54
|
"lucide-react": "^0.544.0",
|
|
54
55
|
"openai": "^5.23.1",
|
|
56
|
+
"p-limit": "^6.1.0",
|
|
55
57
|
"react": "^19.2.0",
|
|
56
58
|
"react-dom": "^19.2.0",
|
|
57
59
|
"react-markdown": "^10.1.0",
|
|
60
|
+
"react-mentions": "^4.4.10",
|
|
58
61
|
"react-router-dom": "^7.9.4",
|
|
59
62
|
"react-syntax-highlighter": "^15.6.1",
|
|
60
63
|
"rehype-highlight": "^7.0.2",
|
|
@@ -18,6 +18,7 @@ export default function Layout({
|
|
|
18
18
|
pageTitle,
|
|
19
19
|
breadcrumbs,
|
|
20
20
|
actions,
|
|
21
|
+
subheader,
|
|
21
22
|
backTo = "/",
|
|
22
23
|
maxWidth = "max-w-7xl",
|
|
23
24
|
}) {
|
|
@@ -227,6 +228,9 @@ export default function Layout({
|
|
|
227
228
|
</Box>
|
|
228
229
|
)}
|
|
229
230
|
|
|
231
|
+
{/* Subheader slot */}
|
|
232
|
+
{subheader}
|
|
233
|
+
|
|
230
234
|
{/* Main content */}
|
|
231
235
|
<main
|
|
232
236
|
id="main-content"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { MentionsInput, Mention } from "react-mentions";
|
|
2
3
|
import { Button } from "./ui/button.jsx";
|
|
3
4
|
import { Sidebar, SidebarFooter } from "./ui/sidebar.jsx";
|
|
4
5
|
import { MarkdownRenderer } from "./MarkdownRenderer.jsx";
|
|
@@ -6,6 +7,59 @@ import { MarkdownRenderer } from "./MarkdownRenderer.jsx";
|
|
|
6
7
|
const TASK_PROPOSAL_REGEX =
|
|
7
8
|
/\[TASK_PROPOSAL\]\r?\nFILENAME:\s*(\S+)\r?\nTASKNAME:\s*(\S+)\r?\nCODE:\s*```javascript\s*([\s\S]*?)\s*```\s*\[\/TASK_PROPOSAL\]/;
|
|
8
9
|
|
|
10
|
+
const MENTION_REGEX = /@\[([^\]]+)\]\([^)]+\)/g;
|
|
11
|
+
|
|
12
|
+
function renderWithMentions(content) {
|
|
13
|
+
const parts = [];
|
|
14
|
+
let lastIndex = 0;
|
|
15
|
+
let match;
|
|
16
|
+
const regex = new RegExp(MENTION_REGEX.source, "g");
|
|
17
|
+
|
|
18
|
+
while ((match = regex.exec(content)) !== null) {
|
|
19
|
+
if (match.index > lastIndex) {
|
|
20
|
+
parts.push(content.slice(lastIndex, match.index));
|
|
21
|
+
}
|
|
22
|
+
parts.push(
|
|
23
|
+
<span
|
|
24
|
+
key={match.index}
|
|
25
|
+
className="bg-primary/20 text-primary rounded-md px-1"
|
|
26
|
+
>
|
|
27
|
+
@{match[1]}
|
|
28
|
+
</span>
|
|
29
|
+
);
|
|
30
|
+
lastIndex = regex.lastIndex;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (lastIndex < content.length) {
|
|
34
|
+
parts.push(content.slice(lastIndex));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return parts.length > 0 ? parts : content;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const mentionsInputStyle = {
|
|
41
|
+
control: {
|
|
42
|
+
backgroundColor: "var(--background)",
|
|
43
|
+
border: "1px solid var(--border)",
|
|
44
|
+
},
|
|
45
|
+
highlighter: {
|
|
46
|
+
overflow: "hidden",
|
|
47
|
+
},
|
|
48
|
+
input: {
|
|
49
|
+
padding: "0.5rem 0.75rem",
|
|
50
|
+
},
|
|
51
|
+
suggestions: {
|
|
52
|
+
marginTop: "1.5rem",
|
|
53
|
+
list: {
|
|
54
|
+
backgroundColor: "var(--card)",
|
|
55
|
+
border: "1px solid var(--border)",
|
|
56
|
+
},
|
|
57
|
+
item: {
|
|
58
|
+
padding: "0.5rem",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
9
63
|
function parseTaskProposal(content) {
|
|
10
64
|
const match = content.match(TASK_PROPOSAL_REGEX);
|
|
11
65
|
if (!match) return null;
|
|
@@ -77,8 +131,31 @@ export default function TaskCreationSidebar({ isOpen, onClose, pipelineSlug }) {
|
|
|
77
131
|
const [error, setError] = useState(null);
|
|
78
132
|
const [taskProposals, setTaskProposals] = useState({});
|
|
79
133
|
const [creatingTask, setCreatingTask] = useState({});
|
|
134
|
+
const [artifacts, setArtifacts] = useState([]);
|
|
135
|
+
const [activeTab, setActiveTab] = useState("conversation");
|
|
80
136
|
const messagesEndRef = useRef(null);
|
|
81
137
|
|
|
138
|
+
// Fetch artifacts on mount
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!pipelineSlug) return;
|
|
141
|
+
|
|
142
|
+
const fetchArtifacts = async () => {
|
|
143
|
+
try {
|
|
144
|
+
const response = await fetch(
|
|
145
|
+
`/api/pipelines/${pipelineSlug}/artifacts`
|
|
146
|
+
);
|
|
147
|
+
if (response.ok) {
|
|
148
|
+
const data = await response.json();
|
|
149
|
+
setArtifacts(data.artifacts || []);
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
console.error("[TaskCreationSidebar] Failed to fetch artifacts:", err);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
fetchArtifacts();
|
|
157
|
+
}, [pipelineSlug]);
|
|
158
|
+
|
|
82
159
|
// Auto-scroll to bottom when messages change
|
|
83
160
|
useEffect(() => {
|
|
84
161
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
@@ -125,6 +202,11 @@ export default function TaskCreationSidebar({ isOpen, onClose, pipelineSlug }) {
|
|
|
125
202
|
onClose();
|
|
126
203
|
};
|
|
127
204
|
|
|
205
|
+
const insertMention = (fileName) => {
|
|
206
|
+
setInput((prev) => prev + "@" + fileName + " ");
|
|
207
|
+
setActiveTab("conversation");
|
|
208
|
+
};
|
|
209
|
+
|
|
128
210
|
const handleSend = (e) => {
|
|
129
211
|
e.preventDefault();
|
|
130
212
|
|
|
@@ -333,55 +415,112 @@ export default function TaskCreationSidebar({ isOpen, onClose, pipelineSlug }) {
|
|
|
333
415
|
description="Describe the task you want to create"
|
|
334
416
|
contentClassName="flex flex-col max-h-screen"
|
|
335
417
|
>
|
|
336
|
-
{/*
|
|
418
|
+
{/* Tab Header */}
|
|
419
|
+
<div className="flex border-b border-border">
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
onClick={() => setActiveTab("conversation")}
|
|
423
|
+
className={`flex-1 px-4 py-2 text-sm font-medium ${
|
|
424
|
+
activeTab === "conversation"
|
|
425
|
+
? "bg-primary text-primary-foreground"
|
|
426
|
+
: "bg-muted text-muted-foreground hover:text-foreground"
|
|
427
|
+
}`}
|
|
428
|
+
>
|
|
429
|
+
Conversation
|
|
430
|
+
</button>
|
|
431
|
+
<button
|
|
432
|
+
type="button"
|
|
433
|
+
onClick={() => setActiveTab("files")}
|
|
434
|
+
className={`flex-1 px-4 py-2 text-sm font-medium ${
|
|
435
|
+
activeTab === "files"
|
|
436
|
+
? "bg-primary text-primary-foreground"
|
|
437
|
+
: "bg-muted text-muted-foreground hover:text-foreground"
|
|
438
|
+
}`}
|
|
439
|
+
>
|
|
440
|
+
Files ({artifacts.length})
|
|
441
|
+
</button>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
{/* Content area */}
|
|
337
445
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
|
338
|
-
{
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
446
|
+
{activeTab === "files" ? (
|
|
447
|
+
/* File List */
|
|
448
|
+
artifacts.length === 0 ? (
|
|
449
|
+
<p className="text-muted-foreground text-sm">
|
|
450
|
+
No artifact files available.
|
|
451
|
+
</p>
|
|
452
|
+
) : (
|
|
453
|
+
artifacts.map((artifact, i) => (
|
|
454
|
+
<button
|
|
455
|
+
key={i}
|
|
456
|
+
type="button"
|
|
457
|
+
onClick={() => insertMention(artifact.fileName)}
|
|
458
|
+
className="w-full text-left p-3 rounded-lg border border-border hover:bg-muted transition-colors"
|
|
349
459
|
>
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
{
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
460
|
+
<p className="font-medium text-foreground">
|
|
461
|
+
{artifact.fileName}
|
|
462
|
+
</p>
|
|
463
|
+
{artifact.sources && artifact.sources.length > 0 && (
|
|
464
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
465
|
+
{artifact.sources.map((s) => s.taskName).join(", ")}
|
|
466
|
+
</p>
|
|
467
|
+
)}
|
|
468
|
+
</button>
|
|
469
|
+
))
|
|
470
|
+
)
|
|
471
|
+
) : (
|
|
472
|
+
/* Conversation Messages */
|
|
473
|
+
<>
|
|
474
|
+
{messages.map((msg, i) => (
|
|
475
|
+
<div key={i}>
|
|
476
|
+
<div
|
|
477
|
+
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
|
478
|
+
>
|
|
479
|
+
<div
|
|
480
|
+
className={`rounded-lg p-3 max-w-full ${
|
|
481
|
+
msg.role === "user"
|
|
482
|
+
? "bg-primary text-primary-foreground"
|
|
483
|
+
: "bg-muted text-muted-foreground"
|
|
484
|
+
}`}
|
|
485
|
+
>
|
|
486
|
+
{msg.role === "assistant" ? (
|
|
487
|
+
<>
|
|
488
|
+
<MarkdownRenderer
|
|
489
|
+
content={msg.content.replace(TASK_PROPOSAL_REGEX, "")}
|
|
490
|
+
/>
|
|
491
|
+
{isWaiting && !msg.content && (
|
|
492
|
+
<div className="flex items-center gap-1 text-muted-foreground mt-2">
|
|
493
|
+
{Array.from({ length: 5 }).map((_, idx) => (
|
|
494
|
+
<span
|
|
495
|
+
key={idx}
|
|
496
|
+
className="animate-bounce-wave"
|
|
497
|
+
style={{ animationDelay: `${idx * 0.1}s` }}
|
|
498
|
+
>
|
|
499
|
+
•
|
|
500
|
+
</span>
|
|
501
|
+
))}
|
|
502
|
+
</div>
|
|
503
|
+
)}
|
|
504
|
+
</>
|
|
505
|
+
) : (
|
|
506
|
+
<p className="whitespace-pre-wrap">
|
|
507
|
+
{renderWithMentions(msg.content)}
|
|
508
|
+
</p>
|
|
367
509
|
)}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
{taskProposals[i] && (
|
|
513
|
+
<TaskProposalCard
|
|
514
|
+
proposal={taskProposals[i]}
|
|
515
|
+
isCreating={creatingTask[i]}
|
|
516
|
+
onCreate={() => handleCreateTask(i, taskProposals[i])}
|
|
517
|
+
/>
|
|
371
518
|
)}
|
|
372
519
|
</div>
|
|
373
|
-
|
|
374
|
-
{
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
isCreating={creatingTask[i]}
|
|
378
|
-
onCreate={() => handleCreateTask(i, taskProposals[i])}
|
|
379
|
-
/>
|
|
380
|
-
)}
|
|
381
|
-
</div>
|
|
382
|
-
))}
|
|
383
|
-
|
|
384
|
-
<div ref={messagesEndRef} />
|
|
520
|
+
))}
|
|
521
|
+
<div ref={messagesEndRef} />
|
|
522
|
+
</>
|
|
523
|
+
)}
|
|
385
524
|
|
|
386
525
|
{error && (
|
|
387
526
|
<div className="mt-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
|
@@ -418,15 +557,27 @@ export default function TaskCreationSidebar({ isOpen, onClose, pipelineSlug }) {
|
|
|
418
557
|
{/* Input area */}
|
|
419
558
|
<SidebarFooter className="bg-card">
|
|
420
559
|
<form onSubmit={handleSend} className="w-full">
|
|
421
|
-
<
|
|
560
|
+
<MentionsInput
|
|
422
561
|
value={input}
|
|
423
|
-
onChange={(e) => setInput(
|
|
562
|
+
onChange={(e, newValue) => setInput(newValue)}
|
|
424
563
|
disabled={isSending || isWaiting || isReceiving}
|
|
425
564
|
placeholder="Describe the task you want to create..."
|
|
426
565
|
rows={3}
|
|
427
566
|
className="w-full border rounded-md px-3 py-2 resize-none disabled:bg-muted disabled:cursor-not-allowed mb-3 focus:outline-none focus:ring-2 focus:ring-ring bg-background"
|
|
428
567
|
aria-label="Task description input"
|
|
429
|
-
|
|
568
|
+
style={mentionsInputStyle}
|
|
569
|
+
>
|
|
570
|
+
<Mention
|
|
571
|
+
trigger="@"
|
|
572
|
+
markup="@[__display__](__id__)"
|
|
573
|
+
data={artifacts.map((a) => ({
|
|
574
|
+
id: a.fileName,
|
|
575
|
+
display: a.fileName,
|
|
576
|
+
}))}
|
|
577
|
+
displayTransform={(id, display) => `@${display}`}
|
|
578
|
+
className="bg-primary/20 text-primary rounded-md px-1"
|
|
579
|
+
/>
|
|
580
|
+
</MentionsInput>
|
|
430
581
|
<div className="flex justify-end">
|
|
431
582
|
<Button
|
|
432
583
|
variant="solid"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Code } from "@radix-ui/themes";
|
|
3
|
+
import { Button } from "./button.jsx";
|
|
4
|
+
import { Copy, Check } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CopyableCode component - displays code with a copy button
|
|
8
|
+
* Follows Tufte principles: minimal chrome, high data-ink ratio
|
|
9
|
+
*/
|
|
10
|
+
export function CopyableCode({
|
|
11
|
+
children,
|
|
12
|
+
className = "",
|
|
13
|
+
block = false,
|
|
14
|
+
size = "2",
|
|
15
|
+
}) {
|
|
16
|
+
const [copied, setCopied] = useState(false);
|
|
17
|
+
|
|
18
|
+
const handleCopy = async () => {
|
|
19
|
+
const text = typeof children === "string" ? children : String(children);
|
|
20
|
+
await navigator.clipboard.writeText(text);
|
|
21
|
+
setCopied(true);
|
|
22
|
+
setTimeout(() => setCopied(false), 2000);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (block) {
|
|
26
|
+
return (
|
|
27
|
+
<Box className={`relative group ${className}`}>
|
|
28
|
+
<pre className="text-sm bg-gray-50 p-4 rounded-lg overflow-auto border border-gray-200 font-mono">
|
|
29
|
+
{children}
|
|
30
|
+
</pre>
|
|
31
|
+
<Button
|
|
32
|
+
size="sm"
|
|
33
|
+
variant="ghost"
|
|
34
|
+
onClick={handleCopy}
|
|
35
|
+
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0"
|
|
36
|
+
aria-label={copied ? "Copied" : "Copy code"}
|
|
37
|
+
>
|
|
38
|
+
{copied ? (
|
|
39
|
+
<Check className="h-3.5 w-3.5 text-green-600" />
|
|
40
|
+
) : (
|
|
41
|
+
<Copy className="h-3.5 w-3.5" />
|
|
42
|
+
)}
|
|
43
|
+
</Button>
|
|
44
|
+
</Box>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<span className={`inline-flex items-center gap-1 group ${className}`}>
|
|
50
|
+
<Code size={size}>{children}</Code>
|
|
51
|
+
<button
|
|
52
|
+
onClick={handleCopy}
|
|
53
|
+
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:bg-gray-100 rounded"
|
|
54
|
+
aria-label={copied ? "Copied" : "Copy code"}
|
|
55
|
+
>
|
|
56
|
+
{copied ? (
|
|
57
|
+
<Check className="h-3 w-3 text-green-600" />
|
|
58
|
+
) : (
|
|
59
|
+
<Copy className="h-3 w-3 text-gray-500" />
|
|
60
|
+
)}
|
|
61
|
+
</button>
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* CopyableCodeBlock - larger block display with syntax-like formatting
|
|
68
|
+
*/
|
|
69
|
+
export function CopyableCodeBlock({ children, className = "", maxHeight }) {
|
|
70
|
+
const [copied, setCopied] = useState(false);
|
|
71
|
+
|
|
72
|
+
const handleCopy = async () => {
|
|
73
|
+
const text = typeof children === "string" ? children : String(children);
|
|
74
|
+
await navigator.clipboard.writeText(text);
|
|
75
|
+
setCopied(true);
|
|
76
|
+
setTimeout(() => setCopied(false), 2000);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Box className={`relative group ${className}`}>
|
|
81
|
+
<pre
|
|
82
|
+
className="text-sm bg-gray-50 p-4 rounded-lg overflow-auto border border-gray-200 font-mono leading-relaxed"
|
|
83
|
+
style={maxHeight ? { maxHeight } : undefined}
|
|
84
|
+
>
|
|
85
|
+
{children}
|
|
86
|
+
</pre>
|
|
87
|
+
<Button
|
|
88
|
+
size="sm"
|
|
89
|
+
variant="ghost"
|
|
90
|
+
onClick={handleCopy}
|
|
91
|
+
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity h-8 px-2 bg-white/80 hover:bg-white border border-gray-200"
|
|
92
|
+
aria-label={copied ? "Copied" : "Copy code"}
|
|
93
|
+
>
|
|
94
|
+
{copied ? (
|
|
95
|
+
<>
|
|
96
|
+
<Check className="h-3.5 w-3.5 text-green-600 mr-1" />
|
|
97
|
+
<span className="text-xs text-green-600">Copied</span>
|
|
98
|
+
</>
|
|
99
|
+
) : (
|
|
100
|
+
<>
|
|
101
|
+
<Copy className="h-3.5 w-3.5 mr-1" />
|
|
102
|
+
<span className="text-xs">Copy</span>
|
|
103
|
+
</>
|
|
104
|
+
)}
|
|
105
|
+
</Button>
|
|
106
|
+
</Box>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default CopyableCode;
|