@promptbook/cli 0.112.0-102 → 0.112.0-104

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/README.md +0 -6
  2. package/apps/agents-server/src/app/AddAgentButton.tsx +0 -5
  3. package/apps/agents-server/src/app/actions.ts +50 -0
  4. package/apps/agents-server/src/app/admin/image-generator-test/ImageAttachmentsEditor.tsx +11 -6
  5. package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +13 -15
  6. package/apps/agents-server/src/app/admin/servers/useCreateServerWizard.ts +13 -14
  7. package/apps/agents-server/src/app/agents/[agentName]/AgentProfileChat.tsx +3 -4
  8. package/apps/agents-server/src/app/api/health/route.ts +18 -0
  9. package/apps/agents-server/src/app/api/upload/route.ts +110 -383
  10. package/apps/agents-server/src/components/AgentProfile/AgentProfile.tsx +1 -4
  11. package/apps/agents-server/src/components/Header/Header.tsx +0 -11
  12. package/apps/agents-server/src/components/Header/useHeaderAgentMenus.tsx +0 -5
  13. package/apps/agents-server/src/components/NewAgentDialog/useNewAgentDialog.tsx +39 -16
  14. package/apps/agents-server/src/constants/defaultAgentAvatarVisual.ts +1 -1
  15. package/apps/agents-server/src/database/migrations/2026-06-0200-default-agent-avatar-visual-octopus3d3.sql +16 -0
  16. package/apps/agents-server/src/middleware.ts +2 -1
  17. package/apps/agents-server/src/tools/$provideCdnForServer.ts +87 -49
  18. package/apps/agents-server/src/utils/agentRouting/resolveAgentRouteTarget.ts +27 -4
  19. package/apps/agents-server/src/utils/cdn/classes/DigitalOceanSpaces.ts +17 -49
  20. package/apps/agents-server/src/utils/cdn/classes/TrackedFilesStorage.ts +5 -2
  21. package/apps/agents-server/src/utils/cdn/interfaces/IFilesStorage.ts +5 -0
  22. package/apps/agents-server/src/utils/defaultAgents/defaultAgents.ts +168 -0
  23. package/apps/agents-server/src/utils/defaultAgents/installDefaultAgents.ts +139 -0
  24. package/apps/agents-server/src/utils/shareTargetPayloads.ts +15 -63
  25. package/apps/agents-server/src/utils/upload/createBookEditorUploadHandler.ts +23 -150
  26. package/apps/agents-server/src/utils/upload/uploadFileToServer.ts +113 -0
  27. package/esm/index.es.js +711 -41
  28. package/esm/index.es.js.map +1 -1
  29. package/esm/scripts/run-codex-prompts/common/waitForPause.d.ts +12 -0
  30. package/esm/scripts/run-codex-prompts/main/runPromptRound.d.ts +2 -1
  31. package/esm/scripts/run-codex-prompts/ui/buildCoderRunUiFrame.d.ts +1 -0
  32. package/esm/scripts/run-codex-prompts/ui/buildRunUiFrameShared.d.ts +1 -1
  33. package/esm/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
  34. package/esm/src/avatars/visuals/octopus3d3AvatarVisual.d.ts +7 -0
  35. package/esm/src/version.d.ts +1 -1
  36. package/package.json +1 -1
  37. package/src/avatars/types/AvatarVisualDefinition.ts +1 -0
  38. package/src/avatars/visuals/avatarVisualRegistry.ts +2 -0
  39. package/src/avatars/visuals/octopus3d3AvatarVisual.ts +903 -0
  40. package/src/book-components/Chat/MarkdownContent/MarkdownContent.tsx +1 -3
  41. package/src/other/templates/getTemplatesPipelineCollection.ts +799 -809
  42. package/src/utils/agents/resolveAgentAvatarImageUrl.ts +1 -1
  43. package/src/version.ts +2 -2
  44. package/src/versions.txt +1 -0
  45. package/umd/index.umd.js +711 -41
  46. package/umd/index.umd.js.map +1 -1
  47. package/umd/scripts/run-codex-prompts/common/waitForPause.d.ts +12 -0
  48. package/umd/scripts/run-codex-prompts/main/runPromptRound.d.ts +2 -1
  49. package/umd/scripts/run-codex-prompts/ui/buildCoderRunUiFrame.d.ts +1 -0
  50. package/umd/scripts/run-codex-prompts/ui/buildRunUiFrameShared.d.ts +1 -1
  51. package/umd/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
  52. package/umd/src/avatars/visuals/octopus3d3AvatarVisual.d.ts +7 -0
  53. package/umd/src/version.d.ts +1 -1
  54. package/apps/agents-server/src/utils/cdn/resolveCdnStorageProvider.ts +0 -40
@@ -0,0 +1,168 @@
1
+ import { readdir, readFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import type { AgentBasicInformation, AgentCollection, string_book } from '@promptbook-local/types';
4
+ import { parseAgentSource } from '../../../../../src/book-2.0/agent-source/parseAgentSource';
5
+ import { DEFAULT_AGENT_VISIBILITY } from '../agentVisibility';
6
+
7
+ /**
8
+ * Installation status for one default agent book.
9
+ *
10
+ * @private shared utility for Agents Server default-agent installation
11
+ */
12
+ export type DefaultAgentInstallStatus = 'installed' | 'skipped';
13
+
14
+ /**
15
+ * Result for one processed default agent book.
16
+ *
17
+ * @private shared utility for Agents Server default-agent installation
18
+ */
19
+ export type DefaultAgentInstallRecord = {
20
+ /**
21
+ * Source book filename.
22
+ */
23
+ readonly fileName: string;
24
+
25
+ /**
26
+ * Parsed canonical agent name.
27
+ */
28
+ readonly agentName: string;
29
+
30
+ /**
31
+ * Whether the agent was created or skipped because an active agent with the same name already exists.
32
+ */
33
+ readonly status: DefaultAgentInstallStatus;
34
+
35
+ /**
36
+ * Permanent id of the newly created agent, if created.
37
+ */
38
+ readonly permanentId?: string;
39
+ };
40
+
41
+ /**
42
+ * Aggregate default-agent installation result.
43
+ *
44
+ * @private shared utility for Agents Server default-agent installation
45
+ */
46
+ export type DefaultAgentInstallResult = {
47
+ /**
48
+ * Number of newly created agents.
49
+ */
50
+ readonly installedCount: number;
51
+
52
+ /**
53
+ * Number of already-present agents skipped by name.
54
+ */
55
+ readonly skippedCount: number;
56
+
57
+ /**
58
+ * Per-book processing records.
59
+ */
60
+ readonly records: ReadonlyArray<DefaultAgentInstallRecord>;
61
+ };
62
+
63
+ /**
64
+ * Options for installing default agents from a directory.
65
+ *
66
+ * @private shared utility for Agents Server default-agent installation
67
+ */
68
+ export type InstallDefaultAgentsFromDirectoryOptions = {
69
+ /**
70
+ * Agent collection used for persistence.
71
+ */
72
+ readonly collection: AgentCollection;
73
+
74
+ /**
75
+ * Directory containing the repository default `*.book` files.
76
+ */
77
+ readonly defaultAgentsDirectoryPath: string;
78
+
79
+ /**
80
+ * Optional logger for install-time progress.
81
+ */
82
+ readonly logger?: Pick<Console, 'info'>;
83
+ };
84
+
85
+ /**
86
+ * Lists repository default book filenames in stable install order.
87
+ *
88
+ * @param defaultAgentsDirectoryPath - Directory containing default `*.book` files.
89
+ * @returns Sorted book filenames.
90
+ *
91
+ * @private shared utility for Agents Server default-agent installation
92
+ */
93
+ export async function listDefaultAgentBookFileNames(defaultAgentsDirectoryPath: string): Promise<ReadonlyArray<string>> {
94
+ const directoryEntries = await readdir(defaultAgentsDirectoryPath, { withFileTypes: true });
95
+
96
+ return directoryEntries
97
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.book'))
98
+ .map((entry) => entry.name)
99
+ .sort((left, right) => left.localeCompare(right));
100
+ }
101
+
102
+ /**
103
+ * Creates default agents from repository `*.book` files, skipping already-active agents with matching names.
104
+ *
105
+ * @param options - Installation options.
106
+ * @returns Aggregate installation result.
107
+ *
108
+ * @private shared utility for Agents Server default-agent installation
109
+ */
110
+ export async function installDefaultAgentsFromDirectory(
111
+ options: InstallDefaultAgentsFromDirectoryOptions,
112
+ ): Promise<DefaultAgentInstallResult> {
113
+ const fileNames = await listDefaultAgentBookFileNames(options.defaultAgentsDirectoryPath);
114
+ const existingAgentNames = new Set((await options.collection.listAgents()).map((agent) => agent.agentName));
115
+ const records: Array<DefaultAgentInstallRecord> = [];
116
+
117
+ for (const [index, fileName] of fileNames.entries()) {
118
+ const agentSource = (await readFile(join(options.defaultAgentsDirectoryPath, fileName), 'utf-8')) as string_book;
119
+ const parsedAgentProfile = parseAgentSource(agentSource);
120
+ const agentName = parsedAgentProfile.agentName;
121
+
122
+ if (existingAgentNames.has(agentName)) {
123
+ options.logger?.info(`[default-agents] Skipping existing agent "${agentName}" from ${fileName}.`);
124
+ records.push({
125
+ fileName,
126
+ agentName,
127
+ status: 'skipped',
128
+ });
129
+ continue;
130
+ }
131
+
132
+ const createdAgent = await options.collection.createAgent(agentSource, {
133
+ sortOrder: index + 1,
134
+ visibility: DEFAULT_AGENT_VISIBILITY,
135
+ });
136
+
137
+ existingAgentNames.add(createdAgent.agentName);
138
+ options.logger?.info(`[default-agents] Installed "${createdAgent.agentName}" from ${fileName}.`);
139
+ records.push(createInstalledDefaultAgentRecord(fileName, createdAgent));
140
+ }
141
+
142
+ return {
143
+ installedCount: records.filter((record) => record.status === 'installed').length,
144
+ skippedCount: records.filter((record) => record.status === 'skipped').length,
145
+ records,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Creates a typed result record for a newly installed default agent.
151
+ *
152
+ * @param fileName - Source book filename.
153
+ * @param createdAgent - Created agent profile returned by the collection.
154
+ * @returns Installation record.
155
+ *
156
+ * @private utility of `installDefaultAgentsFromDirectory`
157
+ */
158
+ function createInstalledDefaultAgentRecord(
159
+ fileName: string,
160
+ createdAgent: AgentBasicInformation & Required<Pick<AgentBasicInformation, 'permanentId'>>,
161
+ ): DefaultAgentInstallRecord {
162
+ return {
163
+ fileName,
164
+ agentName: createdAgent.agentName,
165
+ status: 'installed',
166
+ permanentId: createdAgent.permanentId,
167
+ };
168
+ }
@@ -0,0 +1,139 @@
1
+ import * as dotenv from 'dotenv';
2
+ import { createClient, type SupabaseClient } from '@supabase/supabase-js';
3
+ import { AgentCollectionInSupabase } from '../../../../../src/collection/agent-collection/constructors/agent-collection-in-supabase/AgentCollectionInSupabase';
4
+ import type { AgentCollection } from '../../../../../src/collection/agent-collection/AgentCollection';
5
+ import { isAgentsServerSqliteMode } from '../../database/agentsServerDatabaseMode';
6
+ import { $provideLocalSqliteSupabase } from '../../database/sqlite/$provideLocalSqliteSupabase';
7
+ import { installDefaultAgentsFromDirectory } from './defaultAgents';
8
+
9
+ /**
10
+ * Environment variable pointing to the installed Agents Server `.env` file.
11
+ *
12
+ * @private install-time default-agent utility
13
+ */
14
+ const AGENTS_SERVER_ENV_FILE_ENV_NAME = 'PTBK_AGENTS_SERVER_ENV_FILE';
15
+
16
+ /**
17
+ * Environment variable pointing to the repository `agents/default` directory.
18
+ *
19
+ * @private install-time default-agent utility
20
+ */
21
+ const DEFAULT_AGENTS_DIRECTORY_ENV_NAME = 'PTBK_DEFAULT_AGENTS_DIR';
22
+
23
+ /**
24
+ * Environment variable containing the current server table prefix.
25
+ *
26
+ * @private install-time default-agent utility
27
+ */
28
+ const SUPABASE_TABLE_PREFIX_ENV_NAME = 'SUPABASE_TABLE_PREFIX';
29
+
30
+ /**
31
+ * Public Supabase URL environment variable.
32
+ *
33
+ * @private install-time default-agent utility
34
+ */
35
+ const NEXT_PUBLIC_SUPABASE_URL_ENV_NAME = 'NEXT_PUBLIC_SUPABASE_URL';
36
+
37
+ /**
38
+ * Supabase service role key environment variable.
39
+ *
40
+ * @private install-time default-agent utility
41
+ */
42
+ const SUPABASE_SERVICE_ROLE_KEY_ENV_NAME = 'SUPABASE_SERVICE_ROLE_KEY';
43
+
44
+ /**
45
+ * Public Supabase anon key environment variable used as a fallback when service role is unavailable.
46
+ *
47
+ * @private install-time default-agent utility
48
+ */
49
+ const NEXT_PUBLIC_SUPABASE_ANON_KEY_ENV_NAME = 'NEXT_PUBLIC_SUPABASE_ANON_KEY';
50
+
51
+ /**
52
+ * Loads the installed Agents Server environment for the detached installer script.
53
+ *
54
+ * @private install-time default-agent utility
55
+ */
56
+ function loadDefaultAgentInstallEnvironment(): void {
57
+ const explicitEnvFilePath = process.env[AGENTS_SERVER_ENV_FILE_ENV_NAME]?.trim();
58
+ if (explicitEnvFilePath) {
59
+ const explicitLoadResult = dotenv.config({ path: explicitEnvFilePath });
60
+ if (!explicitLoadResult.error) {
61
+ return;
62
+ }
63
+ }
64
+
65
+ dotenv.config();
66
+ }
67
+
68
+ /**
69
+ * Creates the install-time agent collection using the same database backend as the server.
70
+ *
71
+ * @returns Agent collection bound to the configured table prefix.
72
+ *
73
+ * @private install-time default-agent utility
74
+ */
75
+ function createDefaultAgentInstallCollection(): AgentCollection {
76
+ return new AgentCollectionInSupabase(createDefaultAgentInstallSupabaseClient(), {
77
+ tablePrefix: process.env[SUPABASE_TABLE_PREFIX_ENV_NAME] || '',
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Creates the Supabase-shaped client used by the install-time agent collection.
83
+ *
84
+ * @returns Supabase or local SQLite client.
85
+ *
86
+ * @private install-time default-agent utility
87
+ */
88
+ function createDefaultAgentInstallSupabaseClient(): SupabaseClient {
89
+ if (isAgentsServerSqliteMode()) {
90
+ return $provideLocalSqliteSupabase();
91
+ }
92
+
93
+ const supabaseUrl = process.env[NEXT_PUBLIC_SUPABASE_URL_ENV_NAME];
94
+ const supabaseKey =
95
+ process.env[SUPABASE_SERVICE_ROLE_KEY_ENV_NAME] || process.env[NEXT_PUBLIC_SUPABASE_ANON_KEY_ENV_NAME];
96
+
97
+ if (!supabaseUrl || !supabaseKey) {
98
+ throw new Error(
99
+ `Missing \`${NEXT_PUBLIC_SUPABASE_URL_ENV_NAME}\` and \`${SUPABASE_SERVICE_ROLE_KEY_ENV_NAME}\` for Supabase default-agent installation.`,
100
+ );
101
+ }
102
+
103
+ return createClient(supabaseUrl, supabaseKey, {
104
+ auth: {
105
+ autoRefreshToken: false,
106
+ persistSession: false,
107
+ },
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Runs the default-agent install command.
113
+ *
114
+ * @private install-time default-agent utility
115
+ */
116
+ async function installDefaultAgents(): Promise<void> {
117
+ loadDefaultAgentInstallEnvironment();
118
+
119
+ const defaultAgentsDirectoryPath = process.env[DEFAULT_AGENTS_DIRECTORY_ENV_NAME]?.trim();
120
+ if (!defaultAgentsDirectoryPath) {
121
+ throw new Error(`Missing \`${DEFAULT_AGENTS_DIRECTORY_ENV_NAME}\` environment variable.`);
122
+ }
123
+
124
+ const result = await installDefaultAgentsFromDirectory({
125
+ collection: createDefaultAgentInstallCollection(),
126
+ defaultAgentsDirectoryPath,
127
+ logger: console,
128
+ });
129
+
130
+ console.info(
131
+ `[default-agents] Finished. Installed ${result.installedCount}, skipped ${result.skippedCount}.`,
132
+ );
133
+ }
134
+
135
+ installDefaultAgents().catch((error) => {
136
+ console.error('[default-agents] Failed to install default agents.');
137
+ console.error(error instanceof Error ? error.message : error);
138
+ process.exitCode = 1;
139
+ });
@@ -2,7 +2,7 @@ import { $getTableName } from '@/src/database/$getTableName';
2
2
  import { $provideSupabaseForServer } from '@/src/database/$provideSupabaseForServer';
3
3
  import type { Json } from '@/src/database/schema';
4
4
  import { FILE_SECURITY_CHECKERS } from '@/src/file-security-checkers';
5
- import { $provideUntrackedCdnForServer } from '@/src/tools/$provideCdnForServer';
5
+ import { $provideCdnForServer } from '@/src/tools/$provideCdnForServer';
6
6
  import { $provideServer } from '@/src/tools/$provideServer';
7
7
  import { getUserFileCdnKey } from '@/src/utils/cdn/utils/getUserFileCdnKey';
8
8
  import { validateMimeType } from '@/src/utils/validators/validateMimeType';
@@ -231,12 +231,14 @@ async function createShareTargetAttachment(file: File, maxFileUploadBytes: numbe
231
231
 
232
232
  const mimeType = resolveShareTargetMimeType(file.type);
233
233
  const blobPath = getUserFileCdnKey(buffer, normalizedFilename);
234
- const cdn = $provideUntrackedCdnForServer();
234
+ const cdn = $provideCdnForServer();
235
+ const storageUrl = cdn.getItemUrl(blobPath).href;
236
+
235
237
  await cdn.setItem(blobPath, {
236
- data: buffer,
237
238
  type: mimeType,
238
- fileSize: buffer.byteLength,
239
+ data: buffer,
239
240
  purpose: SHARE_TARGET_FILE_PURPOSE,
241
+ fileSize: buffer.byteLength,
240
242
  }).catch((error) => {
241
243
  throw new DatabaseError(
242
244
  spaceTrim(`
@@ -246,24 +248,14 @@ async function createShareTargetAttachment(file: File, maxFileUploadBytes: numbe
246
248
  `),
247
249
  );
248
250
  });
249
- const storageUrl = cdn.getItemUrl(blobPath).href;
250
- const fileRecordId = await insertShareTargetFileRecord({
251
- fileName: normalizedFilename,
252
- fileSize: buffer.byteLength,
253
- fileType: mimeType,
254
- storageUrl,
255
- });
256
251
 
257
- if (fileRecordId !== null) {
258
- after(() =>
259
- populateShareTargetFileSecurityResult({
260
- fileId: fileRecordId,
261
- storageUrl,
262
- }).catch((error) => {
263
- console.error('[share-target] Failed to finalize file security result', error);
264
- }),
265
- );
266
- }
252
+ after(() =>
253
+ populateShareTargetFileSecurityResult({
254
+ storageUrl,
255
+ }).catch((error) => {
256
+ console.error('[share-target] Failed to finalize file security result', error);
257
+ }),
258
+ );
267
259
 
268
260
  return {
269
261
  name: normalizedFilename,
@@ -272,50 +264,10 @@ async function createShareTargetAttachment(file: File, maxFileUploadBytes: numbe
272
264
  };
273
265
  }
274
266
 
275
- /**
276
- * Inserts one admin-visible file row for a shared attachment.
277
- */
278
- async function insertShareTargetFileRecord(options: {
279
- fileName: string;
280
- fileSize: number;
281
- fileType: string;
282
- storageUrl: string;
283
- }): Promise<number | null> {
284
- try {
285
- const supabase = $provideSupabaseForServer() as TODO_any;
286
- const fileTableName = await $getTableName('File');
287
- const { data, error } = await supabase
288
- .from(fileTableName)
289
- .insert({
290
- fileName: options.fileName,
291
- fileSize: options.fileSize,
292
- fileType: options.fileType,
293
- storageUrl: options.storageUrl,
294
- shortUrl: null,
295
- purpose: SHARE_TARGET_FILE_PURPOSE,
296
- status: 'COMPLETED',
297
- agentId: null,
298
- securityResult: null,
299
- })
300
- .select('id')
301
- .maybeSingle();
302
-
303
- if (error) {
304
- console.error('[share-target] Failed to store uploaded file metadata', error);
305
- return null;
306
- }
307
-
308
- return typeof data?.id === 'number' ? data.id : null;
309
- } catch (error) {
310
- console.error('[share-target] Failed to insert shared file metadata', error);
311
- return null;
312
- }
313
- }
314
-
315
267
  /**
316
268
  * Populates best-effort file-security results after the share redirect has already returned.
317
269
  */
318
- async function populateShareTargetFileSecurityResult(options: { fileId: number; storageUrl: string }): Promise<void> {
270
+ async function populateShareTargetFileSecurityResult(options: { storageUrl: string }): Promise<void> {
319
271
  const securityResult: Record<string, unknown> = {};
320
272
 
321
273
  for (const checkerId of Object.keys(FILE_SECURITY_CHECKERS)) {
@@ -343,7 +295,7 @@ async function populateShareTargetFileSecurityResult(options: { fileId: number;
343
295
  .update({
344
296
  securityResult,
345
297
  })
346
- .eq('id', options.fileId);
298
+ .eq('storageUrl', options.storageUrl);
347
299
 
348
300
  if (error) {
349
301
  throw new DatabaseError(
@@ -1,23 +1,20 @@
1
1
  'use client';
2
2
 
3
- import { upload } from '@vercel/blob/client';
4
- import type { number_percent, string_knowledge_source_content } from '@promptbook-local/types';
5
- import { isServerRoutedCdnUploadProvider, resolveCdnStorageProvider } from '../cdn/resolveCdnStorageProvider';
3
+ import type { string_knowledge_source_content } from '@promptbook-local/types';
6
4
  import { getSafeCdnPath } from '../cdn/utils/getSafeCdnPath';
7
5
  import { normalizeUploadFilename } from '../normalization/normalizeUploadFilename';
6
+ import {
7
+ buildDefaultUserFileUploadPath,
8
+ uploadFileToServer,
9
+ type FileUploadProgressCallback,
10
+ } from './uploadFileToServer';
8
11
 
9
12
  /**
10
13
  * Progress callback invoked during file uploads.
11
14
  *
12
15
  * @private used by chat and book editors.
13
16
  */
14
- export type FileUploadProgressCallback = (
15
- progress: number_percent,
16
- stats?: {
17
- loadedBytes: number;
18
- totalBytes: number;
19
- },
20
- ) => void;
17
+ export type { FileUploadProgressCallback };
21
18
 
22
19
  /**
23
20
  * Optional upload configuration such as progress reporting or cancellation.
@@ -56,8 +53,8 @@ type UploadPathBuilder = (normalizedFilename: string, pathPrefix: string) => str
56
53
  /**
57
54
  * Constant for build default user file path.
58
55
  */
59
- const buildDefaultUserFilePath: UploadPathBuilder = (normalizedFilename, pathPrefix) =>
60
- pathPrefix ? `${pathPrefix}/user/files/${normalizedFilename}` : `user/files/${normalizedFilename}`;
56
+ const buildDefaultUserFilePath: UploadPathBuilder = (normalizedFilename) =>
57
+ buildDefaultUserFileUploadPath(normalizedFilename);
61
58
 
62
59
  /**
63
60
  * Configuration of the shared upload handler.
@@ -76,109 +73,6 @@ type SharedUploadHandlerConfig = {
76
73
  */
77
74
  const DEFAULT_SHORT_URL_PREFIX = 'https://ptbk.io/k/';
78
75
 
79
- /**
80
- * Response returned by the server-routed upload API.
81
- *
82
- * @private used by chat and book editors.
83
- */
84
- type ServerRoutedUploadResponse = {
85
- url: string;
86
- };
87
-
88
- /**
89
- * Uploads a file through `/api/upload` for S3-compatible storage providers.
90
- *
91
- * @private used by chat and book editors.
92
- */
93
- function uploadFileThroughServer(options: {
94
- safeUploadPath: string;
95
- file: File;
96
- purpose: string;
97
- abortSignal?: AbortSignal;
98
- onProgress?: FileUploadProgressCallback;
99
- }): Promise<ServerRoutedUploadResponse> {
100
- const { safeUploadPath, file, purpose, abortSignal, onProgress } = options;
101
- const formData = new FormData();
102
- formData.set('file', file);
103
- formData.set('pathname', safeUploadPath);
104
- formData.set('purpose', purpose);
105
- formData.set('contentType', file.type || 'application/octet-stream');
106
-
107
- return new Promise((resolve, reject) => {
108
- const request = new XMLHttpRequest();
109
-
110
- /**
111
- * Removes abort listeners once the upload finishes.
112
- */
113
- const cleanup = () => {
114
- abortSignal?.removeEventListener('abort', handleAbort);
115
- };
116
-
117
- /**
118
- * Aborts the active XHR upload when the caller aborts.
119
- */
120
- const handleAbort = () => {
121
- request.abort();
122
- cleanup();
123
- reject(new DOMException('Upload aborted.', 'AbortError'));
124
- };
125
-
126
- request.upload.addEventListener('progress', (event) => {
127
- if (!event.lengthComputable) {
128
- return;
129
- }
130
-
131
- onProgress?.((event.loaded / event.total) as number_percent, {
132
- loadedBytes: event.loaded,
133
- totalBytes: event.total,
134
- });
135
- });
136
-
137
- request.addEventListener('load', () => {
138
- cleanup();
139
-
140
- if (request.status < 200 || request.status >= 300) {
141
- reject(new Error(`Upload failed with HTTP ${request.status}: ${request.responseText}`));
142
- return;
143
- }
144
-
145
- try {
146
- const parsedResponse = JSON.parse(request.responseText) as Partial<ServerRoutedUploadResponse>;
147
- if (typeof parsedResponse.url !== 'string' || parsedResponse.url.trim() === '') {
148
- reject(new Error('Upload response did not contain a file URL.'));
149
- return;
150
- }
151
-
152
- onProgress?.(1 as number_percent, {
153
- loadedBytes: file.size,
154
- totalBytes: file.size,
155
- });
156
- resolve({ url: parsedResponse.url });
157
- } catch (error) {
158
- reject(error);
159
- }
160
- });
161
-
162
- request.addEventListener('error', () => {
163
- cleanup();
164
- reject(new Error('Upload failed before the server responded.'));
165
- });
166
-
167
- request.addEventListener('abort', () => {
168
- cleanup();
169
- });
170
-
171
- if (abortSignal?.aborted) {
172
- handleAbort();
173
- return;
174
- }
175
-
176
- abortSignal?.addEventListener('abort', handleAbort);
177
- request.open('POST', '/api/upload');
178
- request.send(formData);
179
- });
180
- }
181
-
182
76
  /**
183
77
  * Upload handler that normalizes the filename, uploads via `/api/upload`, and optionally returns a short URL.
184
78
  *
@@ -196,50 +90,29 @@ export function createFileUploadHandler<ReturnType extends string = string>(
196
90
 
197
91
  return async (file, optionsOrOnProgress) => {
198
92
  const { onProgress, abortSignal } = normalizeUploadOptions(optionsOrOnProgress);
199
- const provider = resolveCdnStorageProvider();
200
- const isServerRoutedUploadEnabled = isServerRoutedCdnUploadProvider(provider);
201
93
  const pathPrefix = process.env.NEXT_PUBLIC_CDN_PATH_PREFIX || '';
202
94
  const normalizedFilename = normalizeUploadFilename(file.name);
203
- const publicUploadPath = pathBuilder(normalizedFilename, pathPrefix);
204
- const storageUploadPath = pathBuilder(normalizedFilename, isServerRoutedUploadEnabled ? '' : pathPrefix);
205
- const safeUploadPath = getSafeCdnPath({
206
- pathname: storageUploadPath,
207
- pathPrefix: isServerRoutedUploadEnabled ? pathPrefix : undefined,
95
+ const uploadPath = pathBuilder(normalizedFilename, pathPrefix);
96
+ const safeUploadPath = getSafeCdnPath({ pathname: uploadPath, pathPrefix });
97
+
98
+ const uploadResult = await uploadFileToServer({
99
+ file,
100
+ pathname: safeUploadPath,
101
+ purpose,
102
+ contentType: file.type || 'application/octet-stream',
103
+ abortSignal,
104
+ onProgress,
208
105
  });
209
106
 
210
- const blob = isServerRoutedUploadEnabled
211
- ? await uploadFileThroughServer({
212
- safeUploadPath,
213
- file,
214
- purpose,
215
- abortSignal,
216
- onProgress,
217
- })
218
- : await upload(safeUploadPath, file, {
219
- access: 'public',
220
- handleUploadUrl: '/api/upload',
221
- clientPayload: JSON.stringify({
222
- purpose,
223
- contentType: file.type || 'application/octet-stream',
224
- }),
225
- abortSignal,
226
- onUploadProgress: (progressEvent) => {
227
- onProgress?.(progressEvent.percentage / 100, {
228
- loadedBytes: progressEvent.loaded,
229
- totalBytes: progressEvent.total,
230
- });
231
- },
232
- });
233
-
234
107
  if (returnShortUrl && process.env.NEXT_PUBLIC_CDN_PUBLIC_URL) {
235
- const slashIndex = publicUploadPath.lastIndexOf('/');
236
- const directoryPath = slashIndex === -1 ? '' : `${publicUploadPath.slice(0, slashIndex + 1)}`;
108
+ const slashIndex = uploadPath.lastIndexOf('/');
109
+ const directoryPath = slashIndex === -1 ? '' : `${uploadPath.slice(0, slashIndex + 1)}`;
237
110
  const longUrlPrefix = `${process.env.NEXT_PUBLIC_CDN_PUBLIC_URL}/${directoryPath}`;
238
- const shortUrl = blob.url.split(longUrlPrefix).join(shortUrlPrefix);
111
+ const shortUrl = uploadResult.url.split(longUrlPrefix).join(shortUrlPrefix);
239
112
  return shortUrl as ReturnType;
240
113
  }
241
114
 
242
- return blob.url as ReturnType;
115
+ return uploadResult.url as ReturnType;
243
116
  };
244
117
  }
245
118