@promptbook/cli 0.112.0-138 → 0.112.0-140

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 (56) hide show
  1. package/README.md +1 -1
  2. package/apps/agents-server/README.md +9 -0
  3. package/apps/agents-server/src/app/actions.ts +18 -7
  4. package/apps/agents-server/src/app/admin/login-methods/shibboleth/page.tsx +7 -17
  5. package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +395 -30
  6. package/apps/agents-server/src/app/api/agents/export/route.ts +28 -2
  7. package/apps/agents-server/src/app/api/auth/change-password/route.ts +53 -6
  8. package/apps/agents-server/src/app/api/auth/login/route.ts +17 -8
  9. package/apps/agents-server/src/app/api/chat/export/pdf/route.ts +167 -2
  10. package/apps/agents-server/src/app/api/emails/incoming/sendgrid/route.ts +24 -2
  11. package/apps/agents-server/src/app/api/metadata/import/route.ts +2 -1
  12. package/apps/agents-server/src/app/api/users/[username]/route.ts +3 -2
  13. package/apps/agents-server/src/app/api/users/route.ts +5 -4
  14. package/apps/agents-server/src/components/Homepage/AgentsList.tsx +1 -13
  15. package/apps/agents-server/src/components/Homepage/AgentsListHeader.tsx +3 -19
  16. package/apps/agents-server/src/components/Homepage/AgentsListListView.tsx +6 -0
  17. package/apps/agents-server/src/components/Homepage/AgentsListViewContent.tsx +6 -0
  18. package/apps/agents-server/src/components/Homepage/SortableFolderCard.tsx +7 -1
  19. package/apps/agents-server/src/components/Homepage/useAgentsListImportExportState.ts +45 -43
  20. package/apps/agents-server/src/components/Homepage/useAgentsListState.ts +1 -3
  21. package/apps/agents-server/src/components/UsersList/useUsersAdmin.ts +2 -10
  22. package/apps/agents-server/src/database/seedCoreAgents.ts +8 -7
  23. package/apps/agents-server/src/message-providers/email/sendgrid/verifySendgridInboundParseWebhook.ts +345 -0
  24. package/apps/agents-server/src/utils/agentsTransfer/createAgentsExportZipStream.ts +15 -2
  25. package/apps/agents-server/src/utils/authenticateUser.ts +110 -3
  26. package/apps/agents-server/src/utils/authenticationAttemptRateLimit.ts +504 -0
  27. package/apps/agents-server/src/utils/backup/createBooksBackupZipStream.ts +76 -5
  28. package/apps/agents-server/src/utils/chatExport/renderHtmlToPdfOnServer.ts +32 -0
  29. package/apps/agents-server/src/utils/chatExport/sanitizeChatPdfExportHtml.ts +193 -0
  30. package/apps/agents-server/src/utils/currentUserIdentity.ts +22 -9
  31. package/apps/agents-server/src/utils/externalChatRunner/processExternalUserChatJob.ts +5 -0
  32. package/apps/agents-server/src/utils/getUserById.ts +19 -5
  33. package/apps/agents-server/src/utils/localChatRunner/processLocalUserChatJob.ts +5 -0
  34. package/apps/agents-server/src/utils/metadataConfigurationTransfer.ts +32 -16
  35. package/apps/agents-server/src/utils/publicUser.ts +59 -0
  36. package/apps/agents-server/src/utils/shibbolethAuthentication.ts +17 -9
  37. package/apps/agents-server/src/utils/userChat/createRunUserChatJobPersistenceController.ts +17 -11
  38. package/apps/agents-server/src/utils/userChat/createUserChatHarnessProgressCard.ts +99 -0
  39. package/apps/agents-server/src/utils/userChat/createUserChatRunnerProgressCard.ts +63 -0
  40. package/apps/agents-server/src/utils/userChat/userChatMessageLifecycle.ts +0 -3
  41. package/apps/agents-server/src/utils/vpsConfiguration.ts +3 -0
  42. package/esm/index.es.js +14 -5
  43. package/esm/index.es.js.map +1 -1
  44. package/esm/src/book-components/Chat/utils/isVisibleChatToolCall.d.ts +10 -0
  45. package/esm/src/version.d.ts +1 -1
  46. package/package.json +1 -1
  47. package/src/book-components/Chat/Chat/createChatMessageToolCallRenderModel.ts +2 -9
  48. package/src/book-components/Chat/utils/isVisibleChatToolCall.ts +23 -0
  49. package/src/other/templates/getTemplatesPipelineCollection.ts +805 -736
  50. package/src/version.ts +2 -2
  51. package/src/versions.txt +2 -1
  52. package/umd/index.umd.js +14 -5
  53. package/umd/index.umd.js.map +1 -1
  54. package/umd/src/book-components/Chat/utils/isVisibleChatToolCall.d.ts +10 -0
  55. package/umd/src/version.d.ts +1 -1
  56. package/apps/agents-server/src/app/api/long-streaming/route.ts +0 -23
package/README.md CHANGED
@@ -35,7 +35,7 @@ Run the standalone VPS installer only on a fresh server. Interactive mode asks f
35
35
  sudo curl -fsSL https://raw.githubusercontent.com/webgptorg/promptbook/refs/heads/main/other/vps/install.sh | bash
36
36
  ```
37
37
 
38
- Non-interactive mode takes defaults from command-line options and skips initial code-runner CLI setup, which can be configured later from the UI or SSH:
38
+ Non-interactive mode takes defaults from command-line options. When `--openai-api-key` is provided with the default OpenAI Codex runner, the installer installs and configures Codex automatically; other runner authentication can be configured later from the UI or SSH:
39
39
 
40
40
  ```bash
41
41
  sudo curl -fsSL https://raw.githubusercontent.com/webgptorg/promptbook/refs/heads/main/other/vps/install.sh | bash -s -- \
@@ -50,6 +50,15 @@ ptbk agents-server start --harness github-copilot --model gpt-5.4 --thinking-lev
50
50
  <a id="agents-server-env-promptbook-team-agent-access-token"></a>
51
51
  - `PROMPTBOOK_TEAM_AGENT_ACCESS_TOKEN`: Dedicated secret used by same-server `TEAM` calls to authorize access to private teammate agents. Must be a dedicated random string — falling back to `ADMIN_PASSWORD` or `SUPABASE_SERVICE_ROLE_KEY` would let a leak of one credential compromise both authentication boundaries. When not configured, same-server team access stays disabled (the integration fails closed) so leaving the variable empty is safe but disables the feature. Use a long random string, for example the output of `openssl rand -hex 32`.
52
52
 
53
+ <a id="agents-server-env-sendgrid-inbound-parse-public-key"></a>
54
+ - `SENDGRID_INBOUND_PARSE_PUBLIC_KEY`: Public verification key from SendGrid Signed Webhook settings for `/api/emails/incoming/sendgrid`. The route verifies `X-Twilio-Email-Event-Webhook-Signature` over the raw multipart body before parsing or inserting an inbound `EMAIL` message. Configure either this public key or `SENDGRID_INBOUND_PARSE_WEBHOOK_SECRET`; the SendGrid public-key flow is preferred.
55
+
56
+ <a id="agents-server-env-sendgrid-inbound-parse-webhook-secret"></a>
57
+ - `SENDGRID_INBOUND_PARSE_WEBHOOK_SECRET`: Shared secret for deployments that sign SendGrid Inbound Parse callbacks with HMAC instead of SendGrid's public-key signature flow. Leave empty when `SENDGRID_INBOUND_PARSE_PUBLIC_KEY` is configured. Use a dedicated random string and keep it separate from `SENDGRID_API_KEY`, `ADMIN_PASSWORD`, and other server secrets.
58
+
59
+ <a id="agents-server-env-sendgrid-inbound-parse-hosts"></a>
60
+ - `SENDGRID_INBOUND_PARSE_HOSTS`: Comma-separated list of public hostnames allowed to receive SendGrid Inbound Parse callbacks, for example `mail.example.com,parse.example.com`. The route rejects signed requests delivered through any other `Host` / `X-Forwarded-Host`, so point SendGrid only at the hostnames that are also protected by your reverse-proxy or platform IP allowlisting.
61
+
53
62
  ## Creating servers
54
63
 
55
64
  When creating new Agents server, search across the repository for [☁]
@@ -3,6 +3,7 @@
3
3
  import { $generateBookBoilerplate } from '@promptbook-local/core';
4
4
  import { string_agent_name, string_book } from '@promptbook-local/types';
5
5
  import { revalidatePath } from 'next/cache';
6
+ import { headers } from 'next/headers';
6
7
  import { string_agent_permanent_id } from '../../../../src/types/typeAliases';
7
8
  import { DEFAULT_NAME_POOL, NAME_POOL_METADATA_KEY, parseNamePool } from '../constants/namePool';
8
9
  import { NEW_AGENT_WIZARD_METADATA_KEY, parseNewAgentWizardMode } from '../constants/newAgentWizard';
@@ -12,7 +13,8 @@ import { invalidateCachedActiveOrganizationSnapshots } from '../utils/agentOrgan
12
13
  import { resolveAgentRouteTarget } from '../utils/agentRouting/resolveAgentRouteTarget';
13
14
  import { buildAgentChatHref, buildAgentProfileHref } from '../utils/agentRouting/agentRouteHrefs';
14
15
  import { type AgentVisibility, parseAgentVisibility } from '../utils/agentVisibility';
15
- import { authenticateUser } from '../utils/authenticateUser';
16
+ import { authenticateUserWithRateLimit } from '../utils/authenticateUser';
17
+ import { resolveAuthenticationAttemptHeaderIp } from '../utils/authenticationAttemptRateLimit';
16
18
  import { createAgentWithDefaultVisibility } from '../utils/createAgentWithDefaultVisibility';
17
19
  import { resolveCurrentUserIdentity } from '../utils/currentUserIdentity';
18
20
  import { isUserAdmin } from '../utils/isUserAdmin';
@@ -150,15 +152,24 @@ export async function $createAgentFromBookAction(
150
152
  * @returns Success state for the login attempt.
151
153
  */
152
154
  export async function loginAction(formData: FormData) {
153
- const username = formData.get('username') as string;
154
- const password = formData.get('password') as string;
155
+ const username = formData.get('username');
156
+ const password = formData.get('password');
155
157
 
156
- console.info(`Login attempt for user: ${username}`);
158
+ if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) {
159
+ return { success: false, message: 'Username and password are required' };
160
+ }
161
+
162
+ const requestIp = resolveAuthenticationAttemptHeaderIp(await headers());
163
+ const authenticationResult = await authenticateUserWithRateLimit(username, password, {
164
+ requestIp,
165
+ });
157
166
 
158
- const user = await authenticateUser(username, password);
167
+ if (authenticationResult.isRateLimited) {
168
+ return { success: false, message: authenticationResult.message };
169
+ }
159
170
 
160
- if (user) {
161
- await setSession(user);
171
+ if (authenticationResult.user) {
172
+ await setSession(authenticationResult.user);
162
173
  revalidatePath('/', 'layout');
163
174
  return { success: true };
164
175
  } else {
@@ -8,6 +8,7 @@ import { isUserAdmin } from '../../../../utils/isUserAdmin';
8
8
  import type { ServerLanguageCode } from '../../../../languages/ServerLanguageRegistry';
9
9
  import { formatServerLanguageHumanReadableDate } from '../../../../utils/localization/formatServerLanguageHumanReadableDate';
10
10
  import { getRequestServerLanguage } from '../../../../utils/localization/getRequestServerLanguage';
11
+ import { PUBLIC_USER_SELECT_COLUMNS, toPublicUser, type PublicUser } from '../../../../utils/publicUser';
11
12
  import {
12
13
  getShibbolethAuthenticationAttemptTableName,
13
14
  getShibbolethUserIdentityTableName,
@@ -16,18 +17,6 @@ import {
16
17
  type ShibbolethUserIdentityRow,
17
18
  } from '../../../../utils/shibbolethAuthentication';
18
19
 
19
- /**
20
- * User row subset displayed on the Shibboleth dashboard.
21
- */
22
- type DashboardUserRow = {
23
- readonly id: number;
24
- readonly username: string;
25
- readonly email?: string | null;
26
- readonly displayName?: string | null;
27
- readonly authenticationProvider?: string | null;
28
- readonly isAdmin: boolean;
29
- };
30
-
31
20
  /**
32
21
  * Builds an absolute URL for Shibboleth route configuration inside server components.
33
22
  */
@@ -62,7 +51,7 @@ async function loadShibbolethAuthenticationAttempts(): Promise<ReadonlyArray<Shi
62
51
  */
63
52
  async function loadShibbolethIdentitiesWithUsers(): Promise<{
64
53
  readonly identities: ReadonlyArray<ShibbolethUserIdentityRow>;
65
- readonly usersById: ReadonlyMap<number, DashboardUserRow>;
54
+ readonly usersById: ReadonlyMap<number, PublicUser>;
66
55
  }> {
67
56
  const supabase = $provideSupabaseForServer();
68
57
  const { data: identities, error } = await supabase
@@ -90,7 +79,7 @@ async function loadShibbolethIdentitiesWithUsers(): Promise<{
90
79
 
91
80
  const { data: users, error: usersError } = await supabase
92
81
  .from(await $getTableName('User'))
93
- .select('*')
82
+ .select(PUBLIC_USER_SELECT_COLUMNS)
94
83
  .in('id', userIds);
95
84
 
96
85
  if (usersError) {
@@ -101,9 +90,10 @@ async function loadShibbolethIdentitiesWithUsers(): Promise<{
101
90
  };
102
91
  }
103
92
 
104
- const usersById = new Map<number, DashboardUserRow>();
105
- for (const user of (users as unknown as DashboardUserRow[] | null) || []) {
106
- usersById.set(user.id, user);
93
+ const usersById = new Map<number, PublicUser>();
94
+ for (const user of (users as unknown as PublicUser[] | null) || []) {
95
+ const publicUser = toPublicUser(user);
96
+ usersById.set(publicUser.id, publicUser);
107
97
  }
108
98
 
109
99
  return {
@@ -13,10 +13,19 @@ import {
13
13
  } from 'lucide-react';
14
14
  import Link from 'next/link';
15
15
  import { Fragment, useEffect, useRef, useState } from 'react';
16
+ import { spaceTrim } from 'spacetrim';
16
17
  import { showConfirm } from '../../../components/AsyncDialogs/asyncDialogs';
17
18
  import { useFileUploadAvailability } from '../../../components/FileUploadAvailability/FileUploadAvailabilityContext';
18
- import { getDeprecatedLimitMetadataDefinition, type DeprecatedLimitMetadataDefinition } from '../../../constants/serverLimits';
19
- import { getMetadataDefinition, metadataDefaults, type MetadataDefinition } from '../../../database/metadataDefaults';
19
+ import {
20
+ getDeprecatedLimitMetadataDefinition,
21
+ type DeprecatedLimitMetadataDefinition,
22
+ } from '../../../constants/serverLimits';
23
+ import {
24
+ getMetadataDefinition,
25
+ metadataDefaults,
26
+ validateMetadataValue,
27
+ type MetadataDefinition,
28
+ } from '../../../database/metadataDefaults';
20
29
  import { getSafeCdnPath } from '../../../utils/cdn/utils/getSafeCdnPath';
21
30
  import { downloadBlob, parseFilenameFromContentDisposition } from '../../../utils/download/browserFileDownload';
22
31
  import { normalizeUploadFilename } from '../../../utils/normalization/normalizeUploadFilename';
@@ -78,6 +87,62 @@ type MetadataImportSummary = {
78
87
  resetCount?: number;
79
88
  };
80
89
 
90
+ /**
91
+ * One metadata entry from a standalone metadata import JSON file.
92
+ *
93
+ * @private
94
+ */
95
+ type MetadataImportEntry = {
96
+ /**
97
+ * Metadata key.
98
+ */
99
+ readonly key: string;
100
+
101
+ /**
102
+ * Metadata value, omitted when the exported value matched the built-in default.
103
+ */
104
+ readonly value?: string;
105
+
106
+ /**
107
+ * Metadata note, omitted when the exported note matched the built-in default.
108
+ */
109
+ readonly note?: string | null;
110
+ };
111
+
112
+ /**
113
+ * Standalone metadata import payload parsed in the browser before upload.
114
+ *
115
+ * @private
116
+ */
117
+ type MetadataImportPayload = {
118
+ /**
119
+ * Promptbook version that generated the import file.
120
+ */
121
+ readonly promptbookVersion: string;
122
+
123
+ /**
124
+ * Metadata entries included in the import file.
125
+ */
126
+ readonly metadata: ReadonlyArray<MetadataImportEntry>;
127
+ };
128
+
129
+ /**
130
+ * Result of browser-side metadata import parsing.
131
+ *
132
+ * @private
133
+ */
134
+ type MetadataImportPayloadParseResult = {
135
+ /**
136
+ * Parsed payload when the import file is usable.
137
+ */
138
+ readonly payload: MetadataImportPayload | null;
139
+
140
+ /**
141
+ * User-facing error message when parsing failed.
142
+ */
143
+ readonly errorMessage: string | null;
144
+ };
145
+
81
146
  /**
82
147
  * Shared class names for single-line metadata inputs.
83
148
  *
@@ -93,6 +158,34 @@ const METADATA_INPUT_CLASS_NAME =
93
158
  */
94
159
  const METADATA_TEXTAREA_CLASS_NAME = `${METADATA_INPUT_CLASS_NAME} min-h-[100px]`;
95
160
 
161
+ /**
162
+ * Endpoint used by the interactive import flow after the admin selected individual entries.
163
+ *
164
+ * @private
165
+ */
166
+ const METADATA_IMPORT_WITHOUT_DEFAULT_RESET_ENDPOINT = `${METADATA_IMPORT_ENDPOINT}?isDefaultResetSkipped=true`;
167
+
168
+ /**
169
+ * Maximum value preview length inside one metadata import choice dialog.
170
+ *
171
+ * @private
172
+ */
173
+ const METADATA_IMPORT_VALUE_PREVIEW_MAX_LENGTH = 180;
174
+
175
+ /**
176
+ * Label used when a metadata value is intentionally empty.
177
+ *
178
+ * @private
179
+ */
180
+ const METADATA_IMPORT_EMPTY_VALUE_LABEL = '(empty)';
181
+
182
+ /**
183
+ * Label used when a metadata key is not currently persisted.
184
+ *
185
+ * @private
186
+ */
187
+ const METADATA_IMPORT_MISSING_VALUE_LABEL = '(not set)';
188
+
96
189
  /**
97
190
  * Normalizes metadata keys so they are safe to use as HTML id suffixes.
98
191
  *
@@ -146,6 +239,271 @@ async function resolveMetadataTransferErrorMessage(response: Response, fallbackM
146
239
  return fallbackMessage;
147
240
  }
148
241
 
242
+ /**
243
+ * Parses one metadata import JSON file for browser-side per-entry selection.
244
+ *
245
+ * @param fileContent - Raw JSON file content.
246
+ * @returns Parsed payload or user-facing error message.
247
+ *
248
+ * @private
249
+ */
250
+ function parseMetadataImportPayloadForSelection(fileContent: string): MetadataImportPayloadParseResult {
251
+ try {
252
+ return normalizeMetadataImportPayloadForSelection(JSON.parse(fileContent));
253
+ } catch {
254
+ return {
255
+ payload: null,
256
+ errorMessage: 'Metadata import must be valid JSON.',
257
+ };
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Normalizes parsed metadata import JSON enough to ask the admin about each entry.
263
+ *
264
+ * @param payload - Parsed JSON import payload.
265
+ * @returns Parsed payload or user-facing error message.
266
+ *
267
+ * @private
268
+ */
269
+ function normalizeMetadataImportPayloadForSelection(payload: unknown): MetadataImportPayloadParseResult {
270
+ if (!payload || typeof payload !== 'object') {
271
+ return {
272
+ payload: null,
273
+ errorMessage: 'Metadata import must be a JSON object.',
274
+ };
275
+ }
276
+
277
+ const payloadRecord = payload as Record<string, unknown>;
278
+
279
+ if (typeof payloadRecord.promptbookVersion !== 'string' || payloadRecord.promptbookVersion.trim() === '') {
280
+ return {
281
+ payload: null,
282
+ errorMessage: 'Metadata import is missing `promptbookVersion`.',
283
+ };
284
+ }
285
+
286
+ if (!Array.isArray(payloadRecord.metadata)) {
287
+ return {
288
+ payload: null,
289
+ errorMessage: 'Metadata import must contain a `metadata` array.',
290
+ };
291
+ }
292
+
293
+ const importedMetadataKeys = new Set<string>();
294
+ const metadataEntries: Array<MetadataImportEntry> = [];
295
+
296
+ for (const [index, rawMetadataEntry] of payloadRecord.metadata.entries()) {
297
+ const normalizedMetadataEntry = normalizeMetadataImportEntryForSelection(
298
+ rawMetadataEntry,
299
+ index,
300
+ importedMetadataKeys,
301
+ );
302
+
303
+ if (typeof normalizedMetadataEntry === 'string') {
304
+ return {
305
+ payload: null,
306
+ errorMessage: normalizedMetadataEntry,
307
+ };
308
+ }
309
+
310
+ metadataEntries.push(normalizedMetadataEntry);
311
+ }
312
+
313
+ return {
314
+ payload: {
315
+ promptbookVersion: payloadRecord.promptbookVersion,
316
+ metadata: metadataEntries,
317
+ },
318
+ errorMessage: null,
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Normalizes one metadata import entry for browser-side selection.
324
+ *
325
+ * @param rawMetadataEntry - Untrusted entry from the import JSON.
326
+ * @param index - Zero-based entry index for error messages.
327
+ * @param importedMetadataKeys - Mutable set used to reject duplicate keys.
328
+ * @returns Normalized entry or user-facing error message.
329
+ *
330
+ * @private
331
+ */
332
+ function normalizeMetadataImportEntryForSelection(
333
+ rawMetadataEntry: unknown,
334
+ index: number,
335
+ importedMetadataKeys: Set<string>,
336
+ ): MetadataImportEntry | string {
337
+ if (!rawMetadataEntry || typeof rawMetadataEntry !== 'object') {
338
+ return `Metadata entry #${index + 1} must be an object.`;
339
+ }
340
+
341
+ const rawMetadataEntryRecord = rawMetadataEntry as Record<string, unknown>;
342
+ const rawKey = rawMetadataEntryRecord.key;
343
+ if (typeof rawKey !== 'string' || rawKey.trim() === '') {
344
+ return `Metadata entry #${index + 1} is missing a non-empty \`key\`.`;
345
+ }
346
+
347
+ const key = rawKey.trim();
348
+ const isDuplicateKey = importedMetadataKeys.has(key);
349
+ if (isDuplicateKey) {
350
+ return `Metadata import contains duplicate key \`${key}\`.`;
351
+ }
352
+ importedMetadataKeys.add(key);
353
+
354
+ const metadataDefinition = getMetadataDefinition(key);
355
+ const isValueProvided = Object.prototype.hasOwnProperty.call(rawMetadataEntryRecord, 'value');
356
+ const isNoteProvided = Object.prototype.hasOwnProperty.call(rawMetadataEntryRecord, 'note');
357
+
358
+ if (isValueProvided && typeof rawMetadataEntryRecord.value !== 'string') {
359
+ return `Metadata entry \`${key}\` has invalid \`value\`; expected a string.`;
360
+ }
361
+
362
+ if (isNoteProvided && typeof rawMetadataEntryRecord.note !== 'string' && rawMetadataEntryRecord.note !== null) {
363
+ return `Metadata entry \`${key}\` has invalid \`note\`; expected a string or null.`;
364
+ }
365
+
366
+ if (!isValueProvided && !metadataDefinition) {
367
+ return spaceTrim(`
368
+ Metadata entry \`${key}\` is missing \`value\`.
369
+
370
+ Only built-in metadata keys can omit default values.
371
+ `);
372
+ }
373
+
374
+ const value = isValueProvided ? (rawMetadataEntryRecord.value as string) : metadataDefinition!.value;
375
+ const validationError = validateMetadataValue(key, value);
376
+
377
+ if (validationError) {
378
+ return validationError;
379
+ }
380
+
381
+ const metadataEntry: MetadataImportEntry = {
382
+ key,
383
+ };
384
+
385
+ if (isValueProvided) {
386
+ (metadataEntry as { value: string }).value = rawMetadataEntryRecord.value as string;
387
+ }
388
+
389
+ if (isNoteProvided) {
390
+ (metadataEntry as { note: string | null }).note = rawMetadataEntryRecord.note as string | null;
391
+ }
392
+
393
+ return metadataEntry;
394
+ }
395
+
396
+ /**
397
+ * Resolves the effective imported metadata value after omitted built-in defaults are applied.
398
+ *
399
+ * @param importEntry - Metadata import entry.
400
+ * @returns Effective imported value.
401
+ *
402
+ * @private
403
+ */
404
+ function resolveImportedMetadataValue(importEntry: MetadataImportEntry): string {
405
+ const isValueProvided = Object.prototype.hasOwnProperty.call(importEntry, 'value');
406
+
407
+ if (isValueProvided) {
408
+ return importEntry.value!;
409
+ }
410
+
411
+ return getMetadataDefinition(importEntry.key)?.value ?? '';
412
+ }
413
+
414
+ /**
415
+ * Resolves the effective imported metadata note after omitted built-in defaults are applied.
416
+ *
417
+ * @param importEntry - Metadata import entry.
418
+ * @returns Effective imported note.
419
+ *
420
+ * @private
421
+ */
422
+ function resolveImportedMetadataNote(importEntry: MetadataImportEntry): string | null {
423
+ const isNoteProvided = Object.prototype.hasOwnProperty.call(importEntry, 'note');
424
+
425
+ if (isNoteProvided) {
426
+ return importEntry.note!;
427
+ }
428
+
429
+ return getMetadataDefinition(importEntry.key)?.note ?? null;
430
+ }
431
+
432
+ /**
433
+ * Formats one metadata value for compact import choice dialogs.
434
+ *
435
+ * @param value - Raw value or note.
436
+ * @returns Single-line preview for the dialog body.
437
+ *
438
+ * @private
439
+ */
440
+ function formatMetadataImportDialogValue(value: string | null | undefined): string {
441
+ if (value === null || value === undefined) {
442
+ return METADATA_IMPORT_EMPTY_VALUE_LABEL;
443
+ }
444
+
445
+ const normalizedValue = value.replace(/\s+/g, ' ').trim();
446
+
447
+ if (normalizedValue === '') {
448
+ return METADATA_IMPORT_EMPTY_VALUE_LABEL;
449
+ }
450
+
451
+ if (normalizedValue.length <= METADATA_IMPORT_VALUE_PREVIEW_MAX_LENGTH) {
452
+ return normalizedValue;
453
+ }
454
+
455
+ return `${normalizedValue.slice(0, METADATA_IMPORT_VALUE_PREVIEW_MAX_LENGTH)}...`;
456
+ }
457
+
458
+ /**
459
+ * Asks the admin which imported metadata entries should replace current values.
460
+ *
461
+ * @param importPayload - Parsed metadata import payload.
462
+ * @param currentMetadataEntries - Metadata entries currently displayed in the admin table.
463
+ * @returns Filtered import payload containing only selected entries.
464
+ *
465
+ * @private
466
+ */
467
+ async function createSelectedMetadataImportPayload(
468
+ importPayload: MetadataImportPayload,
469
+ currentMetadataEntries: ReadonlyArray<MetadataEntry>,
470
+ ): Promise<MetadataImportPayload> {
471
+ const currentMetadataByKey = new Map<string, MetadataEntry>(
472
+ currentMetadataEntries.map((metadataEntry) => [metadataEntry.key, metadataEntry]),
473
+ );
474
+ const selectedMetadataEntries: Array<MetadataImportEntry> = [];
475
+
476
+ for (const importEntry of importPayload.metadata) {
477
+ const currentMetadataEntry = currentMetadataByKey.get(importEntry.key);
478
+ const currentValue = currentMetadataEntry?.value ?? METADATA_IMPORT_MISSING_VALUE_LABEL;
479
+ const currentNote = currentMetadataEntry?.note ?? null;
480
+ const importedValue = resolveImportedMetadataValue(importEntry);
481
+ const importedNote = resolveImportedMetadataNote(importEntry);
482
+ const isImportedSelected = await showConfirm({
483
+ title: `Import ${importEntry.key}`,
484
+ message: spaceTrim(`
485
+ Choose metadata for \`${importEntry.key}\`.
486
+
487
+ Current value: ${formatMetadataImportDialogValue(currentValue)}
488
+ Imported value: ${formatMetadataImportDialogValue(importedValue)}
489
+ Current note: ${formatMetadataImportDialogValue(currentNote)}
490
+ Imported note: ${formatMetadataImportDialogValue(importedNote)}
491
+ `),
492
+ confirmLabel: 'Use imported',
493
+ cancelLabel: 'Keep current',
494
+ }).catch(() => false);
495
+
496
+ if (isImportedSelected) {
497
+ selectedMetadataEntries.push(importEntry);
498
+ }
499
+ }
500
+
501
+ return {
502
+ promptbookVersion: importPayload.promptbookVersion,
503
+ metadata: selectedMetadataEntries,
504
+ };
505
+ }
506
+
149
507
  /**
150
508
  * Validates whether the provided string is an IPv4, IPv6, or CIDR range.
151
509
  *
@@ -380,16 +738,16 @@ function mergeMetadataWithDefaults(data: MetadataEntry[]): MetadataEntry[] {
380
738
 
381
739
  // First prefer existing (non-default) metadata coming from the database
382
740
  for (const entry of data) {
383
- const existing = byKey.get(entry.key);
384
- if (!existing || existing.isDefault) {
385
- const definition = getMetadataDefinition(entry.key);
386
- byKey.set(entry.key, {
387
- ...entry,
388
- definition,
389
- deprecatedLimitMetadata: getDeprecatedLimitMetadataDefinition(entry.key),
390
- });
391
- }
741
+ const existing = byKey.get(entry.key);
742
+ if (!existing || existing.isDefault) {
743
+ const definition = getMetadataDefinition(entry.key);
744
+ byKey.set(entry.key, {
745
+ ...entry,
746
+ definition,
747
+ deprecatedLimitMetadata: getDeprecatedLimitMetadataDefinition(entry.key),
748
+ });
392
749
  }
750
+ }
393
751
 
394
752
  // Then add defaults only for keys that are missing
395
753
  for (const def of metadataDefaults) {
@@ -534,29 +892,32 @@ export function MetadataClient() {
534
892
  return;
535
893
  }
536
894
 
537
- const isConfirmed = await showConfirm({
538
- title: 'Import metadata',
539
- message: 'Import metadata from this JSON file? Existing built-in metadata not present in the file will be reset to defaults.',
540
- confirmLabel: 'Import metadata',
541
- cancelLabel: 'Cancel',
542
- }).catch(() => false);
543
-
544
- if (!isConfirmed) {
545
- if (importFileInputRef.current) {
546
- importFileInputRef.current.value = '';
547
- }
548
- return;
549
- }
550
-
551
895
  setError(null);
552
896
  setSuccessMessage(null);
553
897
  setIsImportingMetadata(true);
554
898
 
555
899
  try {
556
- const response = await fetch(METADATA_IMPORT_ENDPOINT, {
900
+ const parsedImportPayload = parseMetadataImportPayloadForSelection(await file.text());
901
+
902
+ if (parsedImportPayload.errorMessage || !parsedImportPayload.payload) {
903
+ setError(parsedImportPayload.errorMessage || 'Metadata import failed.');
904
+ return;
905
+ }
906
+
907
+ const selectedImportPayload = await createSelectedMetadataImportPayload(
908
+ parsedImportPayload.payload,
909
+ metadata,
910
+ );
911
+
912
+ if (selectedImportPayload.metadata.length === 0) {
913
+ setSuccessMessage('Metadata import skipped; all imported entries kept current.');
914
+ return;
915
+ }
916
+
917
+ const response = await fetch(METADATA_IMPORT_WITHOUT_DEFAULT_RESET_ENDPOINT, {
557
918
  method: 'POST',
558
919
  headers: { 'Content-Type': 'application/json' },
559
- body: await file.text(),
920
+ body: JSON.stringify(selectedImportPayload),
560
921
  });
561
922
 
562
923
  if (!response.ok) {
@@ -676,7 +1037,6 @@ export function MetadataClient() {
676
1037
  }
677
1038
  };
678
1039
 
679
-
680
1040
  /**
681
1041
  * Parameters required to upload an image and store the resulting URL in a metadata form.
682
1042
  *
@@ -1017,11 +1377,16 @@ export function MetadataClient() {
1017
1377
  Value
1018
1378
  </label>
1019
1379
  <MetadataValueField
1020
- definition={getMetadataDefinition(editingFormState.key)}
1380
+ definition={getMetadataDefinition(
1381
+ editingFormState.key,
1382
+ )}
1021
1383
  fieldId={editValueFieldId}
1022
1384
  value={editingFormState.value}
1023
1385
  onValueChange={(value) =>
1024
- setEditingFormState((prev) => ({ ...prev, value }))
1386
+ setEditingFormState((prev) => ({
1387
+ ...prev,
1388
+ value,
1389
+ }))
1025
1390
  }
1026
1391
  isUploading={isEditUploading}
1027
1392
  isUploadAvailable={
@@ -1,3 +1,4 @@
1
+ import { ParseError } from '@promptbook-local/core';
1
2
  import { NextResponse } from 'next/server';
2
3
  import { Readable } from 'node:stream';
3
4
  import { createAgentsExportZipStream } from '../../../../utils/agentsTransfer/createAgentsExportZipStream';
@@ -16,15 +17,17 @@ export const runtime = 'nodejs';
16
17
  /**
17
18
  * Streams an agents-only ZIP export containing book files in folder structure.
18
19
  *
20
+ * @param request - Export request with an optional `folderId` search parameter.
19
21
  * @returns ZIP response for admins or `401` for non-admin callers.
20
22
  */
21
- export async function GET() {
23
+ export async function GET(request: Request) {
22
24
  if (!(await isUserAdmin())) {
23
25
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
24
26
  }
25
27
 
26
28
  try {
27
- const { filename, stream } = await createAgentsExportZipStream();
29
+ const folderId = parseAgentsExportFolderId(new URL(request.url).searchParams.get('folderId'));
30
+ const { filename, stream } = await createAgentsExportZipStream({ folderId });
28
31
  const body = Readable.toWeb(stream as Readable) as ReadableStream<Uint8Array>;
29
32
 
30
33
  return new NextResponse(body, {
@@ -35,7 +38,30 @@ export async function GET() {
35
38
  },
36
39
  });
37
40
  } catch (error) {
41
+ if (error instanceof ParseError) {
42
+ return NextResponse.json({ error: error.message }, { status: 400 });
43
+ }
44
+
38
45
  console.error('Agents export error:', error);
39
46
  return NextResponse.json({ error: 'Failed to generate agents export.' }, { status: 500 });
40
47
  }
41
48
  }
49
+
50
+ /**
51
+ * Parses the optional folder scope for agents export.
52
+ *
53
+ * @param value - Raw `folderId` search parameter.
54
+ * @returns Folder id or `undefined` for full-server exports.
55
+ */
56
+ function parseAgentsExportFolderId(value: string | null): number | undefined {
57
+ if (value === null || value === '' || value === 'null') {
58
+ return undefined;
59
+ }
60
+
61
+ const parsedValue = Number(value);
62
+ if (!Number.isInteger(parsedValue) || parsedValue <= 0) {
63
+ throw new ParseError(`Invalid export folder id \`${value}\`.`);
64
+ }
65
+
66
+ return parsedValue;
67
+ }