@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.
- package/README.md +1 -1
- package/apps/agents-server/README.md +9 -0
- package/apps/agents-server/src/app/actions.ts +18 -7
- package/apps/agents-server/src/app/admin/login-methods/shibboleth/page.tsx +7 -17
- package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +395 -30
- package/apps/agents-server/src/app/api/agents/export/route.ts +28 -2
- package/apps/agents-server/src/app/api/auth/change-password/route.ts +53 -6
- package/apps/agents-server/src/app/api/auth/login/route.ts +17 -8
- package/apps/agents-server/src/app/api/chat/export/pdf/route.ts +167 -2
- package/apps/agents-server/src/app/api/emails/incoming/sendgrid/route.ts +24 -2
- package/apps/agents-server/src/app/api/metadata/import/route.ts +2 -1
- package/apps/agents-server/src/app/api/users/[username]/route.ts +3 -2
- package/apps/agents-server/src/app/api/users/route.ts +5 -4
- package/apps/agents-server/src/components/Homepage/AgentsList.tsx +1 -13
- package/apps/agents-server/src/components/Homepage/AgentsListHeader.tsx +3 -19
- package/apps/agents-server/src/components/Homepage/AgentsListListView.tsx +6 -0
- package/apps/agents-server/src/components/Homepage/AgentsListViewContent.tsx +6 -0
- package/apps/agents-server/src/components/Homepage/SortableFolderCard.tsx +7 -1
- package/apps/agents-server/src/components/Homepage/useAgentsListImportExportState.ts +45 -43
- package/apps/agents-server/src/components/Homepage/useAgentsListState.ts +1 -3
- package/apps/agents-server/src/components/UsersList/useUsersAdmin.ts +2 -10
- package/apps/agents-server/src/database/seedCoreAgents.ts +8 -7
- package/apps/agents-server/src/message-providers/email/sendgrid/verifySendgridInboundParseWebhook.ts +345 -0
- package/apps/agents-server/src/utils/agentsTransfer/createAgentsExportZipStream.ts +15 -2
- package/apps/agents-server/src/utils/authenticateUser.ts +110 -3
- package/apps/agents-server/src/utils/authenticationAttemptRateLimit.ts +504 -0
- package/apps/agents-server/src/utils/backup/createBooksBackupZipStream.ts +76 -5
- package/apps/agents-server/src/utils/chatExport/renderHtmlToPdfOnServer.ts +32 -0
- package/apps/agents-server/src/utils/chatExport/sanitizeChatPdfExportHtml.ts +193 -0
- package/apps/agents-server/src/utils/currentUserIdentity.ts +22 -9
- package/apps/agents-server/src/utils/externalChatRunner/processExternalUserChatJob.ts +5 -0
- package/apps/agents-server/src/utils/getUserById.ts +19 -5
- package/apps/agents-server/src/utils/localChatRunner/processLocalUserChatJob.ts +5 -0
- package/apps/agents-server/src/utils/metadataConfigurationTransfer.ts +32 -16
- package/apps/agents-server/src/utils/publicUser.ts +59 -0
- package/apps/agents-server/src/utils/shibbolethAuthentication.ts +17 -9
- package/apps/agents-server/src/utils/userChat/createRunUserChatJobPersistenceController.ts +17 -11
- package/apps/agents-server/src/utils/userChat/createUserChatHarnessProgressCard.ts +99 -0
- package/apps/agents-server/src/utils/userChat/createUserChatRunnerProgressCard.ts +63 -0
- package/apps/agents-server/src/utils/userChat/userChatMessageLifecycle.ts +0 -3
- package/apps/agents-server/src/utils/vpsConfiguration.ts +3 -0
- package/esm/index.es.js +14 -5
- package/esm/index.es.js.map +1 -1
- package/esm/src/book-components/Chat/utils/isVisibleChatToolCall.d.ts +10 -0
- package/esm/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/book-components/Chat/Chat/createChatMessageToolCallRenderModel.ts +2 -9
- package/src/book-components/Chat/utils/isVisibleChatToolCall.ts +23 -0
- package/src/other/templates/getTemplatesPipelineCollection.ts +805 -736
- package/src/version.ts +2 -2
- package/src/versions.txt +2 -1
- package/umd/index.umd.js +14 -5
- package/umd/index.umd.js.map +1 -1
- package/umd/src/book-components/Chat/utils/isVisibleChatToolCall.d.ts +10 -0
- package/umd/src/version.d.ts +1 -1
- 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
|
|
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 {
|
|
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')
|
|
154
|
-
const password = formData.get('password')
|
|
155
|
+
const username = formData.get('username');
|
|
156
|
+
const password = formData.get('password');
|
|
155
157
|
|
|
156
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
105
|
-
for (const user of (users as unknown as
|
|
106
|
-
|
|
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 {
|
|
19
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
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:
|
|
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(
|
|
1380
|
+
definition={getMetadataDefinition(
|
|
1381
|
+
editingFormState.key,
|
|
1382
|
+
)}
|
|
1021
1383
|
fieldId={editValueFieldId}
|
|
1022
1384
|
value={editingFormState.value}
|
|
1023
1385
|
onValueChange={(value) =>
|
|
1024
|
-
setEditingFormState((prev) => ({
|
|
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
|
|
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
|
+
}
|