@promptbook/cli 0.103.0-66 â 0.103.0-68
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/apps/agents-server/TODO.txt +1 -5
- package/apps/agents-server/config.ts +3 -1
- package/apps/agents-server/package-lock.json +8 -2317
- package/apps/agents-server/package.json +0 -9
- package/apps/agents-server/src/app/agents/[agentName]/AgentOptionsMenu.tsx +34 -2
- package/apps/agents-server/src/app/agents/[agentName]/AgentProfileChat.tsx +1 -1
- package/apps/agents-server/src/app/agents/[agentName]/AgentProfileWrapper.tsx +4 -0
- package/apps/agents-server/src/app/humans.txt/route.ts +15 -0
- package/apps/agents-server/src/app/layout.tsx +31 -0
- package/apps/agents-server/src/app/robots.txt/route.ts +15 -0
- package/apps/agents-server/src/app/security.txt/route.ts +15 -0
- package/apps/agents-server/src/app/sitemap.xml/route.ts +37 -0
- package/apps/agents-server/src/components/AgentProfile/AgentProfile.tsx +28 -23
- package/apps/agents-server/src/components/DocumentationContent/DocumentationContent.tsx +19 -18
- package/apps/agents-server/src/components/Footer/Footer.tsx +13 -13
- package/apps/agents-server/src/components/Header/Header.tsx +95 -20
- package/apps/agents-server/src/components/LayoutWrapper/LayoutWrapper.tsx +3 -0
- package/apps/agents-server/src/components/_utils/generateMetaTxt.ts +28 -0
- package/apps/agents-server/src/components/_utils/headlessParam.tsx +36 -0
- package/apps/agents-server/src/middleware.ts +6 -2
- package/apps/agents-server/src/tools/$provideCdnForServer.ts +13 -1
- package/apps/agents-server/src/utils/cdn/classes/DigitalOceanSpaces.ts +119 -0
- package/apps/agents-server/src/utils/cdn/classes/VercelBlobStorage.ts +2 -1
- package/esm/index.es.js +191 -14
- package/esm/index.es.js.map +1 -1
- package/esm/typings/src/_packages/components.index.d.ts +2 -0
- package/esm/typings/src/_packages/types.index.d.ts +6 -0
- package/esm/typings/src/book-components/BookEditor/BookEditor.d.ts +10 -0
- package/esm/typings/src/book-components/BookEditor/BookEditorActionbar.d.ts +4 -0
- package/esm/typings/src/book-components/icons/CameraIcon.d.ts +11 -0
- package/esm/typings/src/execution/LlmExecutionTools.d.ts +5 -1
- package/esm/typings/src/execution/PromptResult.d.ts +7 -1
- package/esm/typings/src/llm-providers/ollama/OllamaExecutionTools.d.ts +4 -0
- package/esm/typings/src/llm-providers/openai/OpenAiCompatibleExecutionTools.d.ts +13 -1
- package/esm/typings/src/llm-providers/openai/OpenAiExecutionTools.d.ts +4 -0
- package/esm/typings/src/llm-providers/openai/createOpenAiCompatibleExecutionTools.d.ts +6 -6
- package/esm/typings/src/types/ModelRequirements.d.ts +13 -1
- package/esm/typings/src/types/ModelVariant.d.ts +1 -1
- package/esm/typings/src/types/Prompt.d.ts +13 -1
- package/esm/typings/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/umd/index.umd.js +191 -14
- package/umd/index.umd.js.map +1 -1
|
@@ -4,7 +4,7 @@ import promptbookLogoBlueTransparent from '@/public/logo-blue-white-256.png';
|
|
|
4
4
|
import { $createAgentAction, logoutAction } from '@/src/app/actions';
|
|
5
5
|
import { ArrowRight, ChevronDown, Lock, LogIn, LogOut, User } from 'lucide-react';
|
|
6
6
|
import Image from 'next/image';
|
|
7
|
-
import
|
|
7
|
+
import { HeadlessLink, useIsHeadless, pushWithHeadless } from '../_utils/headlessParam';
|
|
8
8
|
import { useRouter } from 'next/navigation';
|
|
9
9
|
import { ReactNode, useState } from 'react';
|
|
10
10
|
import { AgentBasicInformation } from '../../../../../src/book-2.0/agent-source/AgentBasicInformation';
|
|
@@ -41,6 +41,11 @@ type HeaderProps = {
|
|
|
41
41
|
* List of agents
|
|
42
42
|
*/
|
|
43
43
|
agents: Array<AgentBasicInformation>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* List of federated servers for navigation dropdown
|
|
47
|
+
*/
|
|
48
|
+
federatedServers: Array<{ url: string; title: string; logoUrl?: string | null }>;
|
|
44
49
|
};
|
|
45
50
|
|
|
46
51
|
/* TODO: [đąâđ] Make this Agents server native */
|
|
@@ -70,7 +75,7 @@ type MenuItem =
|
|
|
70
75
|
};
|
|
71
76
|
|
|
72
77
|
export function Header(props: HeaderProps) {
|
|
73
|
-
const { isAdmin = false, currentUser = null, serverName, serverLogoUrl, agents } = props;
|
|
78
|
+
const { isAdmin = false, currentUser = null, serverName, serverLogoUrl, agents, federatedServers } = props;
|
|
74
79
|
|
|
75
80
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
76
81
|
const [isLoginOpen, setIsLoginOpen] = useState(false);
|
|
@@ -86,6 +91,7 @@ export function Header(props: HeaderProps) {
|
|
|
86
91
|
const [isMobileSystemOpen, setIsMobileSystemOpen] = useState(false);
|
|
87
92
|
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
|
88
93
|
const router = useRouter();
|
|
94
|
+
const isHeadless = useIsHeadless();
|
|
89
95
|
|
|
90
96
|
const { users: adminUsers } = useUsersAdmin();
|
|
91
97
|
|
|
@@ -98,7 +104,7 @@ export function Header(props: HeaderProps) {
|
|
|
98
104
|
const agentName = await $createAgentAction();
|
|
99
105
|
|
|
100
106
|
if (agentName) {
|
|
101
|
-
router
|
|
107
|
+
pushWithHeadless(router, `/agents/${agentName}`, isHeadless);
|
|
102
108
|
setIsAgentsOpen(false);
|
|
103
109
|
setIsMenuOpen(false);
|
|
104
110
|
} else {
|
|
@@ -109,6 +115,37 @@ export function Header(props: HeaderProps) {
|
|
|
109
115
|
}
|
|
110
116
|
};
|
|
111
117
|
|
|
118
|
+
// Federated servers dropdown items (respect logo, only current is not clickable)
|
|
119
|
+
const [isFederatedOpen, setIsFederatedOpen] = useState(false);
|
|
120
|
+
const [isMobileFederatedOpen, setIsMobileFederatedOpen] = useState(false);
|
|
121
|
+
|
|
122
|
+
const federatedDropdownItems: SubMenuItem[] = federatedServers.map(server => {
|
|
123
|
+
const isCurrent = server.url === (typeof window !== 'undefined' ? window.location.origin : '');
|
|
124
|
+
return isCurrent
|
|
125
|
+
? {
|
|
126
|
+
label: (
|
|
127
|
+
<span className="flex items-center gap-2">
|
|
128
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
129
|
+
<img src={server.logoUrl || serverLogoUrl || promptbookLogoBlueTransparent.src} alt={server.title} width={20} height={20} className="w-5 h-5 object-contain rounded-full" />
|
|
130
|
+
<span className="font-semibold">{server.title.replace(/^Federated: /, '')}</span>
|
|
131
|
+
<span className="ml-1 text-xs text-blue-600">(current)</span>
|
|
132
|
+
</span>
|
|
133
|
+
),
|
|
134
|
+
isBold: true,
|
|
135
|
+
isBordered: true,
|
|
136
|
+
}
|
|
137
|
+
: {
|
|
138
|
+
label: (
|
|
139
|
+
<span className="flex items-center gap-2">
|
|
140
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
141
|
+
<img src={server.logoUrl || promptbookLogoBlueTransparent.src} alt={server.title} width={20} height={20} className="w-5 h-5 object-contain rounded-full" />
|
|
142
|
+
<span>{server.title.replace(/^Federated: /, '')}</span>
|
|
143
|
+
</span>
|
|
144
|
+
),
|
|
145
|
+
href: server.url,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
|
|
112
149
|
// Menu items configuration (DRY principle)
|
|
113
150
|
const menuItems: MenuItem[] = [
|
|
114
151
|
{
|
|
@@ -257,8 +294,8 @@ export function Header(props: HeaderProps) {
|
|
|
257
294
|
<ChangePasswordDialog isOpen={isChangePasswordOpen} onClose={() => setIsChangePasswordOpen(false)} />
|
|
258
295
|
<div className="container mx-auto px-4 h-full">
|
|
259
296
|
<div className="flex items-center justify-between h-full">
|
|
260
|
-
{/* Logo */}
|
|
261
|
-
<
|
|
297
|
+
{/* Logo and heading */}
|
|
298
|
+
<HeadlessLink href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
|
262
299
|
{serverLogoUrl ? (
|
|
263
300
|
// Note: `next/image` does not load external images well without extra config
|
|
264
301
|
// eslint-disable-next-line @next/next/no-img-element
|
|
@@ -279,20 +316,58 @@ export function Header(props: HeaderProps) {
|
|
|
279
316
|
/>
|
|
280
317
|
)}
|
|
281
318
|
<h1 className="text-xl font-bold tracking-tight text-gray-900">{serverName}</h1>
|
|
282
|
-
</
|
|
319
|
+
</HeadlessLink>
|
|
283
320
|
|
|
284
321
|
{/* Desktop Navigation */}
|
|
285
322
|
<nav className="hidden lg:flex items-center gap-8">
|
|
323
|
+
{/* Federated servers dropdown */}
|
|
324
|
+
<div className="relative">
|
|
325
|
+
<button
|
|
326
|
+
className="flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors cursor-pointer"
|
|
327
|
+
onClick={() => setIsFederatedOpen(!isFederatedOpen)}
|
|
328
|
+
onBlur={() => setTimeout(() => setIsFederatedOpen(false), 200)}
|
|
329
|
+
>
|
|
330
|
+
<ChevronDown className="w-4 h-4" />
|
|
331
|
+
<span>Switch server</span>
|
|
332
|
+
</button>
|
|
333
|
+
{isFederatedOpen && (
|
|
334
|
+
<div className="absolute top-full left-0 mt-2 w-56 bg-white rounded-md shadow-lg border border-gray-100 py-1 z-50 animate-in fade-in zoom-in-95 duration-200 max-h-[80vh] overflow-y-auto">
|
|
335
|
+
{federatedDropdownItems.map((subItem, subIndex) => {
|
|
336
|
+
const className = `block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 hover:text-gray-900 ${
|
|
337
|
+
subItem.isBold ? 'font-medium' : ''
|
|
338
|
+
} ${subItem.isBordered ? 'border-b border-gray-100' : ''}`;
|
|
339
|
+
|
|
340
|
+
if (subItem.href) {
|
|
341
|
+
return (
|
|
342
|
+
<HeadlessLink
|
|
343
|
+
key={subIndex}
|
|
344
|
+
href={subItem.href}
|
|
345
|
+
className={className}
|
|
346
|
+
onClick={() => setIsFederatedOpen(false)}
|
|
347
|
+
>
|
|
348
|
+
{subItem.label}
|
|
349
|
+
</HeadlessLink>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return (
|
|
353
|
+
<span key={subIndex} className={className}>
|
|
354
|
+
{subItem.label}
|
|
355
|
+
</span>
|
|
356
|
+
);
|
|
357
|
+
})}
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
286
361
|
{menuItems.map((item, index) => {
|
|
287
362
|
if (item.type === 'link') {
|
|
288
363
|
return (
|
|
289
|
-
<
|
|
364
|
+
<HeadlessLink
|
|
290
365
|
key={index}
|
|
291
366
|
href={item.href}
|
|
292
367
|
className="text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors cursor-pointer"
|
|
293
368
|
>
|
|
294
369
|
{item.label}
|
|
295
|
-
</
|
|
370
|
+
</HeadlessLink>
|
|
296
371
|
);
|
|
297
372
|
}
|
|
298
373
|
|
|
@@ -328,14 +403,14 @@ export function Header(props: HeaderProps) {
|
|
|
328
403
|
}
|
|
329
404
|
|
|
330
405
|
return (
|
|
331
|
-
<
|
|
406
|
+
<HeadlessLink
|
|
332
407
|
key={subIndex}
|
|
333
408
|
href={subItem.href!}
|
|
334
409
|
className={className}
|
|
335
410
|
onClick={() => item.setIsOpen(false)}
|
|
336
411
|
>
|
|
337
412
|
{subItem.label}
|
|
338
|
-
</
|
|
413
|
+
</HeadlessLink>
|
|
339
414
|
);
|
|
340
415
|
})}
|
|
341
416
|
</div>
|
|
@@ -348,25 +423,25 @@ export function Header(props: HeaderProps) {
|
|
|
348
423
|
})}
|
|
349
424
|
|
|
350
425
|
{just(false /* TODO: [đ§ ] Figure out what to do with theese links */) && (
|
|
351
|
-
<
|
|
426
|
+
<a
|
|
352
427
|
href="https://ptbk.io/"
|
|
353
428
|
target="_blank"
|
|
354
429
|
className="text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors cursor-pointer"
|
|
355
430
|
>
|
|
356
431
|
Create your server
|
|
357
|
-
</
|
|
432
|
+
</a>
|
|
358
433
|
)}
|
|
359
434
|
</nav>
|
|
360
435
|
|
|
361
436
|
{/* CTA Button & Mobile Menu Toggle */}
|
|
362
437
|
<div className="flex items-center gap-4">
|
|
363
438
|
{just(false /* TODO: [đ§ ] Figure out what to do with call to action */) && (
|
|
364
|
-
<
|
|
439
|
+
<a href="https://ptbk.io/?modal=get-started" target="_blank" className="hidden md:block">
|
|
365
440
|
<button className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2 bg-promptbook-blue-dark text-white hover:bg-promptbook-blue-dark/90">
|
|
366
441
|
Get Started
|
|
367
442
|
<ArrowRight className="ml-2 w-4 h-4" />
|
|
368
443
|
</button>
|
|
369
|
-
</
|
|
444
|
+
</a>
|
|
370
445
|
)}
|
|
371
446
|
|
|
372
447
|
{!currentUser && !isAdmin && (
|
|
@@ -508,14 +583,14 @@ export function Header(props: HeaderProps) {
|
|
|
508
583
|
{menuItems.map((item, index) => {
|
|
509
584
|
if (item.type === 'link') {
|
|
510
585
|
return (
|
|
511
|
-
<
|
|
586
|
+
<HeadlessLink
|
|
512
587
|
key={index}
|
|
513
588
|
href={item.href}
|
|
514
589
|
className="block text-base font-medium text-gray-600 hover:text-gray-900 py-2"
|
|
515
590
|
onClick={() => setIsMenuOpen(false)}
|
|
516
591
|
>
|
|
517
592
|
{item.label}
|
|
518
|
-
</
|
|
593
|
+
</HeadlessLink>
|
|
519
594
|
);
|
|
520
595
|
}
|
|
521
596
|
|
|
@@ -555,14 +630,14 @@ export function Header(props: HeaderProps) {
|
|
|
555
630
|
}
|
|
556
631
|
|
|
557
632
|
return (
|
|
558
|
-
<
|
|
633
|
+
<HeadlessLink
|
|
559
634
|
key={subIndex}
|
|
560
635
|
href={subItem.href!}
|
|
561
636
|
className={className}
|
|
562
637
|
onClick={() => setIsMenuOpen(false)}
|
|
563
638
|
>
|
|
564
639
|
{subItem.label}
|
|
565
|
-
</
|
|
640
|
+
</HeadlessLink>
|
|
566
641
|
);
|
|
567
642
|
})}
|
|
568
643
|
</div>
|
|
@@ -575,14 +650,14 @@ export function Header(props: HeaderProps) {
|
|
|
575
650
|
})}
|
|
576
651
|
|
|
577
652
|
{just(false /* TODO: [đ§ ] Figure out what to do with these links */) && (
|
|
578
|
-
<
|
|
653
|
+
<a
|
|
579
654
|
href="https://ptbk.io/"
|
|
580
655
|
target="_blank"
|
|
581
656
|
className="text-base font-medium text-gray-600 hover:text-gray-900 py-2"
|
|
582
657
|
onClick={() => setIsMenuOpen(false)}
|
|
583
658
|
>
|
|
584
659
|
Create your server
|
|
585
|
-
</
|
|
660
|
+
</a>
|
|
586
661
|
)}
|
|
587
662
|
</nav>
|
|
588
663
|
</div>
|
|
@@ -15,6 +15,7 @@ type LayoutWrapperProps = {
|
|
|
15
15
|
agents: Array<AgentBasicInformation>;
|
|
16
16
|
isFooterShown: boolean;
|
|
17
17
|
footerLinks: Array<FooterLink>;
|
|
18
|
+
federatedServers: Array<{ url: string; title: string }>;
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export function LayoutWrapper({
|
|
@@ -26,6 +27,7 @@ export function LayoutWrapper({
|
|
|
26
27
|
agents,
|
|
27
28
|
isFooterShown,
|
|
28
29
|
footerLinks,
|
|
30
|
+
federatedServers,
|
|
29
31
|
}: LayoutWrapperProps) {
|
|
30
32
|
const pathname = usePathname();
|
|
31
33
|
const searchParams = useSearchParams();
|
|
@@ -46,6 +48,7 @@ export function LayoutWrapper({
|
|
|
46
48
|
serverName={serverName}
|
|
47
49
|
serverLogoUrl={serverLogoUrl}
|
|
48
50
|
agents={agents}
|
|
51
|
+
federatedServers={federatedServers}
|
|
49
52
|
/>
|
|
50
53
|
<main className={`pt-[60px]`}>{children}</main>
|
|
51
54
|
{isFooterShown && !isFooterHiddenOnPage && <Footer extraLinks={footerLinks} />}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Utility to generate content for robots.txt, security.txt, and humans.txt [DRY]
|
|
2
|
+
|
|
3
|
+
import { NEXT_PUBLIC_SITE_URL } from '@/config';
|
|
4
|
+
|
|
5
|
+
// Get base URL from environment or config
|
|
6
|
+
const baseUrl = NEXT_PUBLIC_SITE_URL?.href || process.env.PUBLIC_URL || 'https://ptbk.io';
|
|
7
|
+
|
|
8
|
+
export function generateRobotsTxt(): string {
|
|
9
|
+
return ['User-agent: *', `Sitemap: ${baseUrl}sitemap.xml`, ''].join('\n');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function generateSecurityTxt(): string {
|
|
13
|
+
// See https://securitytxt.org/ for more fields
|
|
14
|
+
return [
|
|
15
|
+
`Contact: mailto:security@ptbk.io`,
|
|
16
|
+
`Expires: ${new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}`,
|
|
17
|
+
`Canonical: ${baseUrl}security.txt`,
|
|
18
|
+
'',
|
|
19
|
+
].join('\n');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function generateHumansTxt(): string {
|
|
23
|
+
return ['/* TEAM */', 'Developer: Promptbook Team', `Site: https://ptbk.io`, `Instance: ${baseUrl}`].join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* TODO: Use `spaceTrim`
|
|
28
|
+
*/
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Utility to append ?headless param if present in current URL
|
|
2
|
+
import { usePathname, useSearchParams } from 'next/navigation';
|
|
3
|
+
import Link, { LinkProps } from 'next/link';
|
|
4
|
+
import { useMemo } from 'react';
|
|
5
|
+
|
|
6
|
+
// Returns true if ?headless is present in current search params
|
|
7
|
+
export function useIsHeadless() {
|
|
8
|
+
const searchParams = useSearchParams();
|
|
9
|
+
return searchParams.has('headless');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Appends ?headless to a given href if needed
|
|
13
|
+
export function appendHeadlessParam(href: string, isHeadless: boolean): string {
|
|
14
|
+
if (!isHeadless) return href;
|
|
15
|
+
if (href.includes('headless')) return href;
|
|
16
|
+
const hasQuery = href.includes('?');
|
|
17
|
+
return hasQuery ? `${href}&headless` : `${href}?headless`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Custom Link that preserves headless param
|
|
21
|
+
export function HeadlessLink({ href, children, ...rest }: LinkProps & { children: React.ReactNode } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
|
22
|
+
const isHeadless = useIsHeadless();
|
|
23
|
+
const finalHref = useMemo(() => appendHeadlessParam(String(href), isHeadless), [href, isHeadless]);
|
|
24
|
+
return (
|
|
25
|
+
<Link href={finalHref} {...rest}>
|
|
26
|
+
{children}
|
|
27
|
+
</Link>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
import { useRouter } from "next/navigation";
|
|
32
|
+
|
|
33
|
+
// Helper for router.push
|
|
34
|
+
export function pushWithHeadless(router: ReturnType<typeof useRouter>, href: string, isHeadless: boolean) {
|
|
35
|
+
router.push(appendHeadlessParam(href, isHeadless));
|
|
36
|
+
}
|
|
@@ -191,12 +191,16 @@ export async function middleware(req: NextRequest) {
|
|
|
191
191
|
'api',
|
|
192
192
|
'admin',
|
|
193
193
|
'docs',
|
|
194
|
-
'manifest.webmanifest',
|
|
195
|
-
'sw.js',
|
|
196
194
|
'test',
|
|
197
195
|
'embed',
|
|
198
196
|
'_next',
|
|
197
|
+
'manifest.webmanifest',
|
|
198
|
+
'sw.js',
|
|
199
199
|
'favicon.ico',
|
|
200
|
+
'sitemap.xml',
|
|
201
|
+
'robots.txt',
|
|
202
|
+
'security.txt',
|
|
203
|
+
'humans.txt',
|
|
200
204
|
].includes(potentialAgentName) &&
|
|
201
205
|
!potentialAgentName.startsWith('.') &&
|
|
202
206
|
// Note: Other static files are excluded by the matcher configuration below
|
|
@@ -14,10 +14,22 @@ let cdn: IIFilesStorageWithCdn | null = null;
|
|
|
14
14
|
export function $provideCdnForServer(): IIFilesStorageWithCdn {
|
|
15
15
|
if (!cdn) {
|
|
16
16
|
cdn = new VercelBlobStorage({
|
|
17
|
-
token: process.env.
|
|
17
|
+
token: process.env.VERCEL_BLOB_READ_WRITE_TOKEN!,
|
|
18
18
|
pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX!,
|
|
19
19
|
cdnPublicUrl: new URL(process.env.NEXT_PUBLIC_CDN_PUBLIC_URL!),
|
|
20
20
|
});
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
cdn = new DigitalOceanSpaces({
|
|
24
|
+
bucket: process.env.CDN_BUCKET!,
|
|
25
|
+
pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX!,
|
|
26
|
+
endpoint: process.env.CDN_ENDPOINT!,
|
|
27
|
+
accessKeyId: process.env.CDN_ACCESS_KEY_ID!,
|
|
28
|
+
secretAccessKey: process.env.CDN_SECRET_ACCESS_KEY!,
|
|
29
|
+
cdnPublicUrl: new URL(process.env.NEXT_PUBLIC_CDN_PUBLIC_URL!),
|
|
30
|
+
gzip: true,
|
|
31
|
+
});
|
|
32
|
+
*/
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
return cdn;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { GetObjectCommand, PutObjectCommand, PutObjectCommandInput, S3Client } from '@aws-sdk/client-s3';
|
|
2
|
+
import { NotYetImplementedError } from '@promptbook-local/core';
|
|
3
|
+
import { gzip, ungzip } from 'node-gzip';
|
|
4
|
+
import { TODO_USE } from '../../../../../../src/utils/organization/TODO_USE';
|
|
5
|
+
import { validateMimeType } from '../../validators/validateMimeType';
|
|
6
|
+
import type { IFile, IIFilesStorageWithCdn } from '../interfaces/IFilesStorage';
|
|
7
|
+
|
|
8
|
+
type IDigitalOceanSpacesConfig = {
|
|
9
|
+
readonly bucket: string;
|
|
10
|
+
readonly pathPrefix: string;
|
|
11
|
+
readonly endpoint: string;
|
|
12
|
+
readonly accessKeyId: string;
|
|
13
|
+
readonly secretAccessKey: string;
|
|
14
|
+
readonly cdnPublicUrl: URL;
|
|
15
|
+
readonly gzip: boolean;
|
|
16
|
+
|
|
17
|
+
// TODO: [âŗī¸] Probbably prefix should be in this config not on the consumer side
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
|
|
21
|
+
public get cdnPublicUrl() {
|
|
22
|
+
return this.config.cdnPublicUrl;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private s3: S3Client;
|
|
26
|
+
|
|
27
|
+
public constructor(private readonly config: IDigitalOceanSpacesConfig) {
|
|
28
|
+
this.s3 = new S3Client({
|
|
29
|
+
region: 'auto',
|
|
30
|
+
endpoint: 'https://' + config.endpoint,
|
|
31
|
+
credentials: {
|
|
32
|
+
accessKeyId: config.accessKeyId,
|
|
33
|
+
secretAccessKey: config.secretAccessKey,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public getItemUrl(key: string): URL {
|
|
39
|
+
return new URL(this.config.pathPrefix + '/' + key, this.cdnPublicUrl);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public async getItem(key: string): Promise<IFile | null> {
|
|
43
|
+
const parameters = {
|
|
44
|
+
Bucket: this.config.bucket,
|
|
45
|
+
Key: this.config.pathPrefix + '/' + key,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const { Body, ContentType, ContentEncoding } = await this.s3.send(new GetObjectCommand(parameters));
|
|
50
|
+
|
|
51
|
+
// const blob = new Blob([await Body?.transformToByteArray()!]);
|
|
52
|
+
|
|
53
|
+
if (ContentEncoding === 'gzip') {
|
|
54
|
+
return {
|
|
55
|
+
type: validateMimeType(ContentType),
|
|
56
|
+
data: await ungzip(await Body!.transformToByteArray()),
|
|
57
|
+
};
|
|
58
|
+
} else {
|
|
59
|
+
return {
|
|
60
|
+
type: validateMimeType(ContentType),
|
|
61
|
+
data: (await Body!.transformToByteArray()) as Buffer,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof Error && error.name.match(/^NoSuchKey/)) {
|
|
66
|
+
return null;
|
|
67
|
+
} else {
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public async removeItem(key: string): Promise<void> {
|
|
74
|
+
TODO_USE(key);
|
|
75
|
+
throw new NotYetImplementedError(`DigitalOceanSpaces.removeItem is not implemented yet`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
public async setItem(key: string, file: IFile): Promise<void> {
|
|
79
|
+
// TODO: Put putObjectRequestAdditional into processedFile
|
|
80
|
+
const putObjectRequestAdditional: Partial<PutObjectCommandInput> = {};
|
|
81
|
+
|
|
82
|
+
let processedFile: IFile;
|
|
83
|
+
if (this.config.gzip) {
|
|
84
|
+
const gzipped = await gzip(file.data);
|
|
85
|
+
const sizePercentageAfterCompression = gzipped.byteLength / file.data.byteLength;
|
|
86
|
+
if (sizePercentageAfterCompression < 0.7) {
|
|
87
|
+
// consolex.log(`Gzipping ${key} (${Math.floor(sizePercentageAfterCompression * 100)}%)`);
|
|
88
|
+
processedFile = { ...file, data: gzipped };
|
|
89
|
+
putObjectRequestAdditional.ContentEncoding = 'gzip';
|
|
90
|
+
} else {
|
|
91
|
+
processedFile = file;
|
|
92
|
+
// consolex.log(`NOT Gzipping ${key} (${Math.floor(sizePercentageAfterCompression * 100)}%)`);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
processedFile = file;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const uploadResult = await this.s3.send(
|
|
99
|
+
new PutObjectCommand({
|
|
100
|
+
Bucket: this.config.bucket,
|
|
101
|
+
Key: this.config.pathPrefix + '/' + key,
|
|
102
|
+
ContentType: processedFile.type,
|
|
103
|
+
...putObjectRequestAdditional,
|
|
104
|
+
Body: processedFile.data,
|
|
105
|
+
// TODO: Public read access / just private to extending class
|
|
106
|
+
ACL: 'public-read',
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (!uploadResult.ETag) {
|
|
111
|
+
throw new Error(`Upload result does not contain ETag`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* TODO: Implement Read-only mode
|
|
118
|
+
* TODO: [âšī¸] Unite with `PromptbookStorage` and move to `/src/...`
|
|
119
|
+
*/
|
|
@@ -51,12 +51,13 @@ export class VercelBlobStorage implements IIFilesStorageWithCdn {
|
|
|
51
51
|
|
|
52
52
|
public async setItem(key: string, file: IFile): Promise<void> {
|
|
53
53
|
const path = this.config.pathPrefix ? `${this.config.pathPrefix}/${key}` : key;
|
|
54
|
-
|
|
54
|
+
|
|
55
55
|
await put(path, file.data, {
|
|
56
56
|
access: 'public',
|
|
57
57
|
addRandomSuffix: false,
|
|
58
58
|
contentType: file.type,
|
|
59
59
|
token: this.config.token,
|
|
60
|
+
allowOverwrite: true, // <- TODO: This is inefficient, we should check first if the file exists and only then decide to overwrite or not
|
|
60
61
|
// Note: We rely on Vercel Blob for compression
|
|
61
62
|
});
|
|
62
63
|
}
|