@promptbook/cli 0.103.0-56 → 0.103.0-67

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 (54) hide show
  1. package/apps/agents-server/TODO.txt +5 -1
  2. package/apps/agents-server/config.ts +3 -1
  3. package/apps/agents-server/package-lock.json +1220 -47
  4. package/apps/agents-server/package.json +4 -1
  5. package/apps/agents-server/src/app/actions.ts +3 -1
  6. package/apps/agents-server/src/app/agents/[agentName]/AgentOptionsMenu.tsx +72 -6
  7. package/apps/agents-server/src/app/agents/[agentName]/AgentProfileChat.tsx +21 -8
  8. package/apps/agents-server/src/app/agents/[agentName]/AgentProfileWrapper.tsx +44 -0
  9. package/apps/agents-server/src/app/agents/[agentName]/generateAgentMetadata.ts +7 -3
  10. package/apps/agents-server/src/app/agents/[agentName]/layout.tsx +41 -0
  11. package/apps/agents-server/src/app/agents/[agentName]/page.tsx +47 -100
  12. package/apps/agents-server/src/app/agents/[agentName]/website-integration/page.tsx +11 -2
  13. package/apps/agents-server/src/app/embed/page.tsx +2 -2
  14. package/apps/agents-server/src/app/layout.tsx +8 -24
  15. package/apps/agents-server/src/app/manifest.ts +8 -3
  16. package/apps/agents-server/src/components/AgentProfile/AgentProfile.tsx +339 -0
  17. package/apps/agents-server/src/components/AgentProfile/AgentProfileFromSource.tsx +23 -0
  18. package/apps/agents-server/src/{app/agents/[agentName] → components/AgentProfile}/AgentQrCode.tsx +8 -1
  19. package/apps/agents-server/src/components/LayoutWrapper/LayoutWrapper.tsx +7 -6
  20. package/apps/agents-server/src/database/metadataDefaults.ts +6 -0
  21. package/apps/agents-server/src/tools/$provideCdnForServer.ts +13 -1
  22. package/apps/agents-server/src/utils/cdn/classes/DigitalOceanSpaces.ts +119 -0
  23. package/apps/agents-server/src/utils/cdn/classes/VercelBlobStorage.ts +2 -1
  24. package/esm/index.es.js +65 -10
  25. package/esm/index.es.js.map +1 -1
  26. package/esm/typings/src/_packages/components.index.d.ts +2 -2
  27. package/esm/typings/src/_packages/types.index.d.ts +6 -0
  28. package/esm/typings/src/book-2.0/agent-source/AgentBasicInformation.d.ts +2 -1
  29. package/esm/typings/src/book-2.0/agent-source/createCommitmentRegex.d.ts +1 -1
  30. package/esm/typings/src/book-components/Chat/AgentChat/AgentChat.d.ts +3 -0
  31. package/esm/typings/src/book-components/Chat/Chat/ChatProps.d.ts +6 -0
  32. package/esm/typings/src/book-components/PromptbookAgent/PromptbookAgentIntegration.d.ts +52 -0
  33. package/esm/typings/src/book-components/PromptbookAgent/PromptbookAgentSeamlessIntegration.d.ts +14 -0
  34. package/esm/typings/src/book-components/icons/SendIcon.d.ts +3 -0
  35. package/esm/typings/src/commitments/CLOSED/CLOSED.d.ts +4 -0
  36. package/esm/typings/src/commitments/CLOSED/CLOSED.test.d.ts +4 -0
  37. package/esm/typings/src/commitments/USE_BROWSER/USE_BROWSER.d.ts +4 -0
  38. package/esm/typings/src/commitments/_base/BaseCommitmentDefinition.d.ts +6 -0
  39. package/esm/typings/src/llm-providers/agent/Agent.d.ts +3 -1
  40. package/esm/typings/src/other/templates/getTemplatesPipelineCollection.d.ts +1 -1
  41. package/esm/typings/src/types/typeAliases.d.ts +6 -0
  42. package/esm/typings/src/utils/color/Color.d.ts +1 -1
  43. package/esm/typings/src/utils/random/$generateBookBoilerplate.d.ts +6 -0
  44. package/esm/typings/src/utils/random/CzechNamePool.d.ts +7 -0
  45. package/esm/typings/src/utils/random/EnglishNamePool.d.ts +7 -0
  46. package/esm/typings/src/utils/random/NamePool.d.ts +17 -0
  47. package/esm/typings/src/utils/random/getNamePool.d.ts +10 -0
  48. package/esm/typings/src/version.d.ts +1 -1
  49. package/package.json +2 -2
  50. package/umd/index.umd.js +65 -10
  51. package/umd/index.umd.js.map +1 -1
  52. package/apps/agents-server/src/app/agents/[agentName]/AgentProfileView.tsx +0 -233
  53. package/esm/typings/src/book-components/PromptbookAgent/PromptbookAgent.d.ts +0 -29
  54. /package/apps/agents-server/src/{app/agents/[agentName] → components/AgentProfile}/QrCodeModal.tsx +0 -0
@@ -0,0 +1,339 @@
1
+ 'use client';
2
+
3
+ import { AgentBasicInformation } from '@promptbook-local/types';
4
+ import { RepeatIcon } from 'lucide-react';
5
+ import { useMemo, useState } from 'react';
6
+ import spaceTrim from 'spacetrim';
7
+ import { Color } from '../../../../../src/utils/color/Color';
8
+ import { darken } from '../../../../../src/utils/color/operators/darken';
9
+ import { lighten } from '../../../../../src/utils/color/operators/lighten';
10
+ import { AgentQrCode } from './AgentQrCode';
11
+ import { QrCodeModal } from './QrCodeModal';
12
+
13
+ type AgentProfileProps = {
14
+ /**
15
+ * The agent to display
16
+ */
17
+ readonly agent: AgentBasicInformation;
18
+
19
+ /**
20
+ * URL of the agent page
21
+ *
22
+ * @default undefined - If not provided, some features like QR code for link might be disabled or use generic link
23
+ */
24
+ readonly agentUrl?: string;
25
+
26
+ /**
27
+ * Email of the agent
28
+ */
29
+ readonly agentEmail?: string;
30
+
31
+ /**
32
+ * Content for the menu (top right)
33
+ *
34
+ * @param props.onShowQrCode - Function to open QR code modal
35
+ */
36
+ readonly renderMenu?: (props: { onShowQrCode: () => void }) => React.ReactNode;
37
+
38
+ /**
39
+ * Content for the chat area
40
+ */
41
+ readonly children?: React.ReactNode;
42
+
43
+ /**
44
+ * Content for the secondary actions (links)
45
+ */
46
+ readonly actions?: React.ReactNode;
47
+
48
+ /**
49
+ * If true, hides the menu and actions for fullscreen/embedded view
50
+ */
51
+ readonly isHeadless?: boolean;
52
+
53
+ /**
54
+ * CSS class name
55
+ */
56
+ readonly className?: string;
57
+ };
58
+
59
+ export function AgentProfile(props: AgentProfileProps) {
60
+ const {
61
+ agent,
62
+ agentUrl = '',
63
+ agentEmail = '',
64
+ renderMenu,
65
+ children,
66
+ actions,
67
+ isHeadless = false,
68
+ className,
69
+ } = props;
70
+ const { meta, agentName } = agent;
71
+ const fullname = (meta.fullname as string) || agentName || 'Agent';
72
+ const personaDescription = agent.personaDescription || '';
73
+ const imageUrl = (meta.image as string) || null;
74
+
75
+ const [isQrModalOpen, setIsQrModalOpen] = useState(false);
76
+ const [isFlipped, setIsFlipped] = useState(false);
77
+
78
+ // Dynamic Font Loading
79
+ const fontString = meta.font;
80
+ let fontStyle: React.CSSProperties = {};
81
+
82
+ if (fontString) {
83
+ // [🧠] TODO: Properly parse font string to get family name
84
+ const primaryFont = fontString.split(',')[0].trim().replace(/['"]/g, '');
85
+ fontStyle = {
86
+ fontFamily: fontString,
87
+ };
88
+ }
89
+
90
+ // Compute Colors and Background
91
+ const { brandColorHex, brandColorLightHex, brandColorDarkHex, backgroundImage } = useMemo(() => {
92
+ // [🧠] Default color should be imported constant, but for now hardcoded fallback
93
+ const PROMPTBOOK_COLOR_HEX = '#f15b24'; // TODO: Import PROMPTBOOK_COLOR
94
+ const brandColorString = meta.color || PROMPTBOOK_COLOR_HEX;
95
+
96
+ let brandColor;
97
+ try {
98
+ brandColor = Color.fromSafe(brandColorString.split(',')[0].trim());
99
+ } catch {
100
+ brandColor = Color.fromHex(PROMPTBOOK_COLOR_HEX);
101
+ }
102
+
103
+ const brandColorHex = brandColor.toHex();
104
+ const brandColorLightHex = brandColor.then(lighten(0.2)).toHex();
105
+ const brandColorDarkHex = brandColor.then(darken(0.15)).toHex();
106
+
107
+ // Generate Noisy SVG Background
108
+ const color1 = brandColor;
109
+ // const color2 = brandColors[1] || brandColors[0]!; // Use secondary color if available?
110
+ // For simplicity using primary color for now or derive second one
111
+ const color2 = brandColor;
112
+
113
+ // [🧠] Make colors much lighter for the background
114
+ const color1Light = color1.then(lighten(0.3)).toHex();
115
+ const color1Main = color1.toHex();
116
+ const color1Dark = color1.then(darken(0.3)).toHex();
117
+
118
+ const color2Light = color2.then(lighten(0.3)).toHex();
119
+ const color2Main = color2.toHex();
120
+ const color2Dark = color2.then(darken(0.3)).toHex();
121
+
122
+ const svgContent = spaceTrim(`
123
+ <svg xmlns="http://www.w3.org/2000/svg"
124
+ viewBox="0 0 1920 1080"
125
+ width="1920" height="1080"
126
+ preserveAspectRatio="xMidYMid slice">
127
+ <defs>
128
+ <!-- Bottom-left -->
129
+ <radialGradient id="grad1" cx="0%" cy="100%" r="90%">
130
+ <stop offset="0%" stop-color="${color1Light}" />
131
+ <stop offset="50%" stop-color="${color1Main}" />
132
+ <stop offset="100%" stop-color="${color1Dark}" />
133
+ </radialGradient>
134
+
135
+ <!-- Bottom-right -->
136
+ <radialGradient id="grad2" cx="100%" cy="100%" r="90%">
137
+ <stop offset="0%" stop-color="${color2Light}" />
138
+ <stop offset="50%" stop-color="${color2Main}" />
139
+ <stop offset="100%" stop-color="${color2Dark}" />
140
+ </radialGradient>
141
+
142
+ <!-- White top fade -->
143
+ <linearGradient id="whiteTopGrad" x1="0%" y1="0%" x2="0%" y2="100%">
144
+ <stop offset="0%" stop-color="#ffffff" stop-opacity="1" />
145
+ <stop offset="100%" stop-color="#ffffff" stop-opacity="0.3" />
146
+ </linearGradient>
147
+
148
+ <!-- Strong grain -->
149
+ <filter id="grain" x="-10%" y="-10%" width="120%" height="120%">
150
+ <feTurbulence type="fractalNoise" baseFrequency="3.5" numOctaves="3" seed="8" result="noise" />
151
+ <feComponentTransfer>
152
+ <feFuncR type="linear" slope="3.5" intercept="-1.2" />
153
+ <feFuncG type="linear" slope="3.5" intercept="-1.2" />
154
+ <feFuncB type="linear" slope="3.5" intercept="-1.2" />
155
+ <feFuncA type="table" tableValues="0 0.8" />
156
+ </feComponentTransfer>
157
+ </filter>
158
+ </defs>
159
+
160
+ <!-- White base -->
161
+ <rect width="100%" height="100%" fill="#ffffff" />
162
+
163
+ <!-- Gradients -->
164
+ <rect width="100%" height="100%" fill="url(#grad1)" />
165
+ <rect width="100%" height="100%" fill="url(#grad2)" style="mix-blend-mode:screen; opacity:0.85" />
166
+
167
+ <!-- White fade on top -->
168
+ <rect width="100%" height="100%" fill="url(#whiteTopGrad)" />
169
+
170
+ <!-- Strong visible noise -->
171
+ <rect width="100%" height="100%" filter="url(#grain)"
172
+ style="mix-blend-mode:soft-light; opacity:1.2" />
173
+ </svg>
174
+ `);
175
+
176
+ const backgroundImage = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
177
+
178
+ return { brandColorHex, brandColorLightHex, brandColorDarkHex, backgroundImage };
179
+ }, [meta.color]);
180
+
181
+ return (
182
+ <>
183
+ {fontString && (
184
+ <style jsx global>{`
185
+ @import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent(
186
+ fontString.split(',')[0].trim().replace(/['"]/g, ''),
187
+ )}:wght@400;600;700&display=swap');
188
+ `}</style>
189
+ )}
190
+
191
+ {/* Full-screen background with agent color */}
192
+ <div
193
+ className={`w-full flex flex-col items-center justify-center p-6 md:p-12 relative overflow-hidden ${
194
+ isHeadless ? 'min-h-screen' : 'min-h-[calc(100vh-60px)]'
195
+ } ${className || ''}`}
196
+ style={{
197
+ background: `url("${backgroundImage}")`,
198
+ backgroundSize: 'cover',
199
+ backgroundPosition: 'center',
200
+ ...fontStyle,
201
+ }}
202
+ >
203
+ {/* Options menu in top right */}
204
+ {!isHeadless && renderMenu && (
205
+ <div className="absolute top-4 right-4 z-[9999]">
206
+ {renderMenu({ onShowQrCode: () => setIsQrModalOpen(true) })}
207
+ </div>
208
+ )}
209
+
210
+ {/* Main profile content */}
211
+ <div className="relative z-10 grid grid-cols-[auto_1fr] gap-x-6 gap-y-4 md:gap-12 max-w-5xl w-full items-start">
212
+ {/* Agent image card (Flippable) */}
213
+ <div
214
+ className="flex-shrink-0 perspective-1000 group row-start-1 col-start-1 md:row-span-3"
215
+ style={{ perspective: '1000px' }}
216
+ >
217
+ <div
218
+ className="relative w-24 md:w-80 transition-all duration-700 transform-style-3d cursor-pointer"
219
+ style={{
220
+ aspectRatio: '1 / 1.618', // Golden Ratio
221
+ transformStyle: 'preserve-3d',
222
+ transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
223
+ }}
224
+ onClick={() => setIsFlipped(!isFlipped)}
225
+ >
226
+ {/* Front of Card (Image) */}
227
+ <div
228
+ className="absolute inset-0 w-full h-full backface-hidden rounded-lg md:rounded-3xl shadow-lg md:shadow-2xl overflow-hidden backdrop-blur-sm"
229
+ style={{
230
+ backfaceVisibility: 'hidden',
231
+ backgroundColor: brandColorDarkHex,
232
+ boxShadow: `0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px ${brandColorLightHex}40`,
233
+ }}
234
+ >
235
+ {imageUrl ? (
236
+ // eslint-disable-next-line @next/next/no-img-element
237
+ <img src={imageUrl} alt={fullname} className="w-full h-full object-cover" />
238
+ ) : (
239
+ <div
240
+ className="w-full h-full flex items-center justify-center text-3xl md:text-8xl font-bold text-white/80"
241
+ style={{ backgroundColor: brandColorDarkHex }}
242
+ >
243
+ {fullname.charAt(0).toUpperCase()}
244
+ </div>
245
+ )}
246
+
247
+ {/* Flip hint icon */}
248
+ <div className="absolute bottom-2 md:bottom-4 right-2 md:right-4 bg-black/30 p-1 md:p-2 rounded-full text-white/80 backdrop-blur-md opacity-0 group-hover:opacity-100 transition-opacity">
249
+ <RepeatIcon className="w-3 h-3 md:w-5 md:h-5" />
250
+ </div>
251
+ </div>
252
+
253
+ {/* Back of Card (QR Code) */}
254
+ <div
255
+ className="absolute inset-0 w-full h-full backface-hidden rounded-lg md:rounded-3xl shadow-lg md:shadow-2xl overflow-hidden backdrop-blur-sm flex flex-col items-center justify-center p-2 md:p-6"
256
+ style={{
257
+ backfaceVisibility: 'hidden',
258
+ transform: 'rotateY(180deg)',
259
+ background: `linear-gradient(135deg, ${brandColorLightHex} 0%, #ffffff 100%)`,
260
+ boxShadow: `0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px ${brandColorLightHex}40`,
261
+ }}
262
+ >
263
+ <div className="transform scale-90 md:scale-100">
264
+ <AgentQrCode
265
+ agentName={agentName}
266
+ agentUrl={agentUrl}
267
+ agentEmail={agentEmail}
268
+ personaDescription={personaDescription}
269
+ meta={meta}
270
+ isJustVcardShown
271
+ />
272
+ </div>
273
+
274
+ {/* Flip hint icon */}
275
+ <div className="absolute bottom-2 md:bottom-4 right-2 md:right-4 bg-black/10 p-1 md:p-2 rounded-full text-black/50 backdrop-blur-md">
276
+ <RepeatIcon className="w-3 h-3 md:w-5 md:h-5" />
277
+ </div>
278
+ </div>
279
+ </div>
280
+ </div>
281
+
282
+ {/* Agent info - Header Area */}
283
+ <div className="row-start-1 col-start-2 flex flex-col justify-center md:justify-start h-full md:h-auto gap-1 md:gap-6">
284
+ {/* Agent name with custom font */}
285
+ <h1
286
+ className="text-2xl md:text-5xl lg:text-6xl font-bold text-gray-900 tracking-tight leading-tight"
287
+ style={{
288
+ textShadow: '0 2px 20px rgba(255, 255, 255, 0.5)',
289
+ }}
290
+ >
291
+ {fullname}
292
+ </h1>
293
+
294
+ {/* Short description */}
295
+ <p className="text-sm md:text-xl text-gray-700 max-w-lg leading-relaxed font-medium line-clamp-3 md:line-clamp-none">
296
+ {personaDescription}
297
+ </p>
298
+ </div>
299
+
300
+ {/* Chat Area */}
301
+ <div className="col-span-2 md:col-span-1 md:col-start-2 w-full mt-2 md:mt-0">
302
+ {children}
303
+ </div>
304
+
305
+ {/* Secondary Actions */}
306
+ {!isHeadless && (
307
+ <div className="col-span-2 md:col-span-1 md:col-start-2 flex flex-wrap justify-center md:justify-start items-center gap-4 md:gap-6 mt-2">
308
+ {actions}
309
+ </div>
310
+ )}
311
+ </div>
312
+
313
+ {/* Subtle gradient overlay at bottom */}
314
+ <div
315
+ className="absolute bottom-0 left-0 right-0 h-32 pointer-events-none"
316
+ style={{
317
+ background: `linear-gradient(to top, ${brandColorDarkHex}40, transparent)`,
318
+ }}
319
+ />
320
+ </div>
321
+
322
+ {/* QR Code Modal */}
323
+ <QrCodeModal
324
+ isOpen={isQrModalOpen}
325
+ onClose={() => setIsQrModalOpen(false)}
326
+ agentName={agentName}
327
+ meta={meta}
328
+ personaDescription={personaDescription}
329
+ agentUrl={agentUrl}
330
+ agentEmail={agentEmail}
331
+ brandColorHex={brandColorHex}
332
+ />
333
+ </>
334
+ );
335
+ }
336
+
337
+ /**
338
+ * TODO: !!!! Use 3D badge @see https://vercel.com/blog/building-an-interactive-3d-event-badge-with-react-three-fiber
339
+ */
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+
3
+ import { string_book } from '@promptbook-local/types';
4
+ import { useMemo } from 'react';
5
+ import { parseAgentSource } from '../../../../../src/book-2.0/agent-source/parseAgentSource';
6
+ import { AgentProfile } from './AgentProfile';
7
+
8
+ type AgentProfileFromSourceProps = Omit<React.ComponentProps<typeof AgentProfile>, 'agent'> & {
9
+ /**
10
+ * Source code of the agent (book)
11
+ */
12
+ readonly source: string_book;
13
+ };
14
+
15
+ export function AgentProfileFromSource(props: AgentProfileFromSourceProps) {
16
+ const { source, ...rest } = props;
17
+
18
+ const agent = useMemo(() => {
19
+ return parseAgentSource(source);
20
+ }, [source]);
21
+
22
+ return <AgentProfile agent={agent} {...rest} />;
23
+ }
@@ -8,9 +8,12 @@ import spaceTrim from 'spacetrim';
8
8
  type AgentQrCodeProps = Pick<AgentBasicInformation, 'agentName' | 'personaDescription' | 'meta'> & {
9
9
  agentUrl: string;
10
10
  agentEmail: string;
11
+
12
+ isJustVcardShown?: boolean;
11
13
  };
12
14
 
13
- export function AgentQrCode({ agentName, agentUrl, agentEmail, personaDescription, meta }: AgentQrCodeProps) {
15
+ export function AgentQrCode(props: AgentQrCodeProps) {
16
+ const { agentName, agentUrl, agentEmail, personaDescription, meta, isJustVcardShown } = props;
14
17
  const [mode, setMode] = useState<'contact' | 'link'>('contact');
15
18
 
16
19
  // TODO: [🧠] Should we include more info in VCARD?
@@ -27,6 +30,10 @@ export function AgentQrCode({ agentName, agentUrl, agentEmail, personaDescriptio
27
30
  const qrValue = mode === 'contact' ? vcard : agentUrl;
28
31
  const label = mode === 'contact' ? 'Scan to add contact' : 'Scan to open agent';
29
32
 
33
+ if (isJustVcardShown) {
34
+ return <PromptbookQrCode value={vcard} className="" size={250} />;
35
+ }
36
+
30
37
  return (
31
38
  <div className="flex flex-col items-center">
32
39
  <div className="flex bg-gray-100 p-1 rounded-lg mb-4">
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { usePathname } from 'next/navigation';
3
+ import { usePathname, useSearchParams } from 'next/navigation';
4
4
  import { AgentBasicInformation } from '../../../../../src/book-2.0/agent-source/AgentBasicInformation';
5
5
  import type { UserInfo } from '../../utils/getCurrentUser';
6
6
  import { Footer, type FooterLink } from '../Footer/Footer';
@@ -28,12 +28,13 @@ export function LayoutWrapper({
28
28
  footerLinks,
29
29
  }: LayoutWrapperProps) {
30
30
  const pathname = usePathname();
31
- const isAdminChatPage =
32
- pathname?.startsWith('/admin/chat-history') || pathname?.startsWith('/admin/chat-feedback');
33
- const isHeaderHidden = pathname?.includes('/chat') && !isAdminChatPage;
34
- const isFooterHiddenOnPage = pathname ? /^\/agents\/[^/]+\/book(\+chat)?$/.test(pathname) : false;
31
+ const searchParams = useSearchParams();
32
+ const isHeadless = searchParams.has('headless');
33
+ // const isAdminChatPage = pathname?.startsWith('/admin/chat-history') || pathname?.startsWith('/admin/chat-feedback');
34
+ const isHeaderHidden = false; // pathname?.includes('/chat') && !isAdminChatPage;
35
+ const isFooterHiddenOnPage = pathname ? /^\/agents\/[^/]+\/(book|chat|book\+chat)$/.test(pathname) : false;
35
36
 
36
- if (isHeaderHidden) {
37
+ if (isHeaderHidden || isHeadless) {
37
38
  return <main className={`pt-0`}>{children}</main>;
38
39
  }
39
40
 
@@ -61,6 +61,12 @@ export const metadataDefaults = [
61
61
  note: 'Maximum size of file that can be uploaded in MB.',
62
62
  type: 'NUMBER',
63
63
  },
64
+ {
65
+ key: 'NAME_POOL',
66
+ value: 'ENGLISH',
67
+ note: 'Language for generating new agent names. Possible values: ENGLISH, CZECH.',
68
+ type: 'TEXT_SINGLE_LINE',
69
+ },
64
70
  ] as const satisfies ReadonlyArray<{
65
71
  key: string;
66
72
  value: string;
@@ -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.BLOB_READ_WRITE_TOKEN!,
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
  }