@promptbook/cli 0.112.0-135 → 0.112.0-137

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 (64) hide show
  1. package/README.md +20 -0
  2. package/apps/agents-server/README.md +0 -1
  3. package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +246 -15
  4. package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +237 -4
  5. package/apps/agents-server/src/app/api/admin/update/log/route.ts +42 -0
  6. package/apps/agents-server/src/app/api/agents/export/route.ts +41 -0
  7. package/apps/agents-server/src/app/api/agents/import/route.ts +168 -0
  8. package/apps/agents-server/src/app/api/federated-agents/route.ts +1 -1
  9. package/apps/agents-server/src/app/api/internal/agent-runner-limits/route.ts +2 -1
  10. package/apps/agents-server/src/app/api/internal/user-chat-jobs/run/route.ts +2 -1
  11. package/apps/agents-server/src/app/api/metadata/export/route.ts +34 -0
  12. package/apps/agents-server/src/app/api/metadata/import/route.ts +46 -0
  13. package/apps/agents-server/src/app/layout.tsx +1 -1
  14. package/apps/agents-server/src/components/Homepage/AgentsList.tsx +31 -1
  15. package/apps/agents-server/src/components/Homepage/AgentsListHeader.tsx +60 -1
  16. package/apps/agents-server/src/components/Homepage/useAgentsListImportExportState.ts +523 -0
  17. package/apps/agents-server/src/components/Homepage/useAgentsListState.ts +16 -0
  18. package/apps/agents-server/src/database/metadataDefaults.ts +0 -21
  19. package/apps/agents-server/src/database/seedCoreAgents.ts +235 -0
  20. package/apps/agents-server/src/database/seedDefaultAgents.ts +32 -1
  21. package/apps/agents-server/src/search/createDefaultServerSearchProviders/createFederatedAgentsSearchProvider.ts +1 -1
  22. package/apps/agents-server/src/tools/$provideAgentCollectionForServer.ts +2 -9
  23. package/apps/agents-server/src/utils/agentsTransfer/createAgentsExportZipStream.ts +68 -0
  24. package/apps/agents-server/src/utils/agentsTransfer/importAgentsFromFiles.ts +852 -0
  25. package/apps/agents-server/src/utils/backup/createBooksBackupZipStream.ts +15 -2
  26. package/apps/agents-server/src/utils/defaultAgents/loadDefaultAgentBooks.ts +46 -4
  27. package/apps/agents-server/src/utils/getFederatedServers.ts +3 -74
  28. package/apps/agents-server/src/utils/getWellKnownAgentUrl.ts +10 -4
  29. package/apps/agents-server/src/utils/localChatRunner/processLocalUserChatJob.ts +1 -1
  30. package/apps/agents-server/src/utils/metadataConfigurationTransfer.ts +426 -0
  31. package/apps/agents-server/src/utils/serverManagement/createManagedServer/bootstrapManagedServer.ts +2 -5
  32. package/apps/agents-server/src/utils/serverManagement/createManagedServer/seedServerCoreAgents.ts +162 -0
  33. package/apps/agents-server/src/utils/session.ts +2 -1
  34. package/apps/agents-server/src/utils/vpsSelfUpdate.ts +110 -0
  35. package/esm/index.es.js +1 -1
  36. package/esm/index.es.js.map +1 -1
  37. package/esm/src/_packages/core.index.d.ts +0 -2
  38. package/esm/src/utils/isTimingSafeEqualString.d.ts +25 -0
  39. package/esm/src/version.d.ts +1 -1
  40. package/package.json +1 -1
  41. package/servers.ts +1 -16
  42. package/src/_packages/core.index.ts +0 -2
  43. package/src/commitments/_common/teamInternalAgentAccess.ts +2 -1
  44. package/src/other/templates/getTemplatesPipelineCollection.ts +789 -716
  45. package/src/utils/isTimingSafeEqualString.ts +43 -0
  46. package/src/version.ts +2 -2
  47. package/src/versions.txt +2 -0
  48. package/umd/index.umd.js +1 -1
  49. package/umd/index.umd.js.map +1 -1
  50. package/umd/src/_packages/core.index.d.ts +0 -2
  51. package/umd/src/utils/isTimingSafeEqualString.d.ts +25 -0
  52. package/umd/src/version.d.ts +1 -1
  53. package/apps/agents-server/src/utils/defaultFederatedAgents/DefaultFederatedAgentsSyncOptions.ts +0 -9
  54. package/apps/agents-server/src/utils/defaultFederatedAgents/ensureDefaultFederatedAgentExists.ts +0 -277
  55. package/apps/agents-server/src/utils/defaultFederatedAgents/fetchCoreOrganizationPayload.ts +0 -39
  56. package/apps/agents-server/src/utils/defaultFederatedAgents/fetchFederatedAgentBook.ts +0 -43
  57. package/apps/agents-server/src/utils/defaultFederatedAgents/fetchWithDefaultFederatedAgentTimeout.ts +0 -28
  58. package/apps/agents-server/src/utils/defaultFederatedAgents/getDefaultFederatedAgentSyncPool.ts +0 -38
  59. package/apps/agents-server/src/utils/defaultFederatedAgents/loadActiveLocalAgentIdsByNormalizedName.ts +0 -41
  60. package/apps/agents-server/src/utils/defaultFederatedAgents/loadDefaultFederatedAgentSyncMetadata.ts +0 -76
  61. package/apps/agents-server/src/utils/defaultFederatedAgents/quoteIdentifier.ts +0 -11
  62. package/apps/agents-server/src/utils/defaultFederatedAgents/scheduleDefaultFederatedAgentsSync.ts +0 -88
  63. package/apps/agents-server/src/utils/defaultFederatedAgents/selectDefaultFederatedAgentsFromOrganizationPayload.ts +0 -181
  64. package/apps/agents-server/src/utils/defaultFederatedAgents/synchronizeDefaultFederatedAgents.ts +0 -77
@@ -149,7 +149,7 @@ async function getLayoutFederatedServers(options: {
149
149
  }
150
150
 
151
151
  try {
152
- const federatedServerUrls = await getFederatedServers({ excludeHiddenCoreServer: true });
152
+ const federatedServerUrls = await getFederatedServers();
153
153
  return Promise.all(
154
154
  federatedServerUrls.map(async (url) => ({
155
155
  title: `Federated: ${new URL(url).hostname}`,
@@ -6,6 +6,7 @@ import type { AgentOrganizationAgent, AgentOrganizationFolder } from '../../util
6
6
  import { AgentsListDialogs } from './AgentsListDialogs';
7
7
  import { AgentsListHeader } from './AgentsListHeader';
8
8
  import { AgentsListViewContent } from './AgentsListViewContent';
9
+ import { AGENTS_IMPORT_FILE_INPUT_ACCEPT } from './useAgentsListImportExportState';
9
10
  import type { AgentWithVisibility } from './useFederatedAgents';
10
11
  import { useAgentsListState } from './useAgentsListState';
11
12
 
@@ -72,14 +73,43 @@ export function AgentsList(props: AgentsListProps) {
72
73
  isAdmin && contextMenuFolder ? () => state.handleRequestFolderVisibilityUpdate(contextMenuFolder.id) : undefined;
73
74
 
74
75
  return (
75
- <section className="mt-16 first:mt-4 mb-4">
76
+ <section
77
+ className={`relative mt-16 first:mt-4 mb-4 ${
78
+ state.isAgentsImportDragActive ? 'rounded-xl ring-2 ring-blue-400 ring-offset-4' : ''
79
+ }`}
80
+ onDragEnter={isAdmin ? state.handleAgentsFileDragEnter : undefined}
81
+ onDragLeave={isAdmin ? state.handleAgentsFileDragLeave : undefined}
82
+ onDragOver={isAdmin ? state.handleAgentsFileDragOver : undefined}
83
+ onDrop={isAdmin ? state.handleAgentsFileDrop : undefined}
84
+ >
85
+ {isAdmin && (
86
+ <input
87
+ ref={state.importFileInputRef}
88
+ type="file"
89
+ multiple
90
+ accept={AGENTS_IMPORT_FILE_INPUT_ACCEPT}
91
+ className="hidden"
92
+ onChange={(event) => void state.handleAgentsImportFileChange(event)}
93
+ disabled={state.isAgentsImporting}
94
+ />
95
+ )}
96
+ {isAdmin && state.isAgentsImportDragActive && (
97
+ <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-xl border-2 border-dashed border-blue-400 bg-blue-50/85 text-sm font-medium text-blue-800 shadow-sm">
98
+ Drop books or ZIP archives to import agents
99
+ </div>
100
+ )}
76
101
  <AgentsListHeader
77
102
  agentCount={state.agentCount}
78
103
  allAgentsLabel={state.allAgentsLabel}
79
104
  breadcrumbFolders={state.breadcrumbFolders}
80
105
  canOrganize={canOrganize}
81
106
  headingTitle={state.headingTitle}
107
+ isAdmin={isAdmin}
108
+ isAgentsExporting={state.isAgentsExporting}
109
+ isAgentsImporting={state.isAgentsImporting}
82
110
  onCreateFolder={state.handleCreateFolder}
111
+ onExportAgents={() => void state.handleAgentsExport()}
112
+ onImportAgents={state.handleAgentsImportClick}
83
113
  onNavigateToFolder={state.navigateToFolder}
84
114
  onSetViewMode={state.setViewMode}
85
115
  viewMode={state.viewMode}
@@ -1,6 +1,16 @@
1
1
  'use client';
2
2
 
3
- import { Building2, FolderPlusIcon, Gamepad2, Grid, Map, Network, type LucideIcon } from 'lucide-react';
3
+ import {
4
+ Building2,
5
+ DownloadIcon,
6
+ FolderPlusIcon,
7
+ Gamepad2,
8
+ Grid,
9
+ Map,
10
+ Network,
11
+ UploadIcon,
12
+ type LucideIcon,
13
+ } from 'lucide-react';
4
14
  import type { AgentOrganizationFolder } from '../../utils/agentOrganization/types';
5
15
  import type { HomeViewMode } from './homeViewMode';
6
16
  import { BreadcrumbDropTarget } from './BreadcrumbDropTarget';
@@ -55,6 +65,18 @@ type AgentsListHeaderProps = {
55
65
  * Current heading title.
56
66
  */
57
67
  readonly headingTitle: string;
68
+ /**
69
+ * Whether agents import/export controls are available.
70
+ */
71
+ readonly isAdmin: boolean;
72
+ /**
73
+ * Downloads an agents export.
74
+ */
75
+ readonly onExportAgents: () => void;
76
+ /**
77
+ * Opens the agents import file picker.
78
+ */
79
+ readonly onImportAgents: () => void;
58
80
  /**
59
81
  * Opens the create-folder dialog.
60
82
  */
@@ -71,6 +93,14 @@ type AgentsListHeaderProps = {
71
93
  * Currently active homepage view mode.
72
94
  */
73
95
  readonly viewMode: HomeViewMode;
96
+ /**
97
+ * Whether agents export is currently running.
98
+ */
99
+ readonly isAgentsExporting: boolean;
100
+ /**
101
+ * Whether agents import is currently running.
102
+ */
103
+ readonly isAgentsImporting: boolean;
74
104
  };
75
105
 
76
106
  /**
@@ -100,7 +130,12 @@ export function AgentsListHeader({
100
130
  breadcrumbFolders,
101
131
  canOrganize,
102
132
  headingTitle,
133
+ isAdmin,
134
+ isAgentsExporting,
135
+ isAgentsImporting,
103
136
  onCreateFolder,
137
+ onExportAgents,
138
+ onImportAgents,
104
139
  onNavigateToFolder,
105
140
  onSetViewMode,
106
141
  viewMode,
@@ -138,6 +173,30 @@ export function AgentsListHeader({
138
173
  )}
139
174
  </div>
140
175
  <div className="flex items-center gap-2">
176
+ {isListView && isAdmin && (
177
+ <>
178
+ <button
179
+ type="button"
180
+ onClick={onExportAgents}
181
+ disabled={isAgentsExporting || isAgentsImporting}
182
+ className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-60"
183
+ title="Export agents"
184
+ >
185
+ <DownloadIcon className="w-4 h-4" />
186
+ {isAgentsExporting ? 'Exporting...' : 'Export'}
187
+ </button>
188
+ <button
189
+ type="button"
190
+ onClick={onImportAgents}
191
+ disabled={isAgentsExporting || isAgentsImporting}
192
+ className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-60"
193
+ title="Import agents"
194
+ >
195
+ <UploadIcon className="w-4 h-4" />
196
+ {isAgentsImporting ? 'Importing...' : 'Import'}
197
+ </button>
198
+ </>
199
+ )}
141
200
  {isListView && canOrganize && (
142
201
  <button
143
202
  type="button"
@@ -0,0 +1,523 @@
1
+ 'use client';
2
+
3
+ import JSZip from 'jszip';
4
+ import { useCallback, useRef, useState, type ChangeEvent, type DragEvent, type RefObject } from 'react';
5
+ import { downloadBlob, parseFilenameFromContentDisposition } from '../../utils/download/browserFileDownload';
6
+ import { showAlert, showConfirm, showPrompt } from '../AsyncDialogs/asyncDialogs';
7
+
8
+ /**
9
+ * Endpoint serving agents-only ZIP exports.
10
+ */
11
+ const AGENTS_EXPORT_ENDPOINT = '/api/agents/export';
12
+
13
+ /**
14
+ * Endpoint accepting dropped agents book/ZIP imports.
15
+ */
16
+ const AGENTS_IMPORT_ENDPOINT = '/api/agents/import';
17
+
18
+ /**
19
+ * Fallback export filename when the response omits a filename.
20
+ */
21
+ const DEFAULT_AGENTS_EXPORT_FILENAME = 'promptbook-agents-server.agents.zip';
22
+
23
+ /**
24
+ * Form field used for uploaded import files.
25
+ */
26
+ const FILES_FORM_FIELD = 'files';
27
+
28
+ /**
29
+ * Form field used for the target folder id.
30
+ */
31
+ const TARGET_FOLDER_ID_FORM_FIELD = 'targetFolderId';
32
+
33
+ /**
34
+ * Form field used for duplicate conflict handling.
35
+ */
36
+ const CONFLICT_RESOLUTION_FORM_FIELD = 'conflictResolution';
37
+
38
+ /**
39
+ * File input accept list for agents imports.
40
+ */
41
+ export const AGENTS_IMPORT_FILE_INPUT_ACCEPT = '.book,.zip,application/zip,application/x-zip-compressed';
42
+
43
+ /**
44
+ * Maximum number of warning or conflict paths shown in dialogs.
45
+ */
46
+ const MAX_DIALOG_PATH_COUNT = 6;
47
+
48
+ /**
49
+ * Successful import response shape.
50
+ */
51
+ type AgentsImportSuccessPayload = {
52
+ readonly success: true;
53
+ readonly importedCount: number;
54
+ readonly skippedCount: number;
55
+ readonly ignoredFileCount: number;
56
+ readonly warnings?: ReadonlyArray<AgentsImportWarningPayload>;
57
+ };
58
+
59
+ /**
60
+ * Duplicate conflict response shape.
61
+ */
62
+ type AgentsImportConflictPayload = {
63
+ readonly success: false;
64
+ readonly code: 'agent_import_conflicts';
65
+ readonly message?: string;
66
+ readonly conflicts: ReadonlyArray<AgentsImportConflictPayloadItem>;
67
+ readonly warnings?: ReadonlyArray<AgentsImportWarningPayload>;
68
+ readonly ignoredFileCount?: number;
69
+ };
70
+
71
+ /**
72
+ * Minimal import error response shape.
73
+ */
74
+ type AgentsImportErrorPayload = {
75
+ readonly error?: string;
76
+ readonly message?: string;
77
+ };
78
+
79
+ /**
80
+ * Warning payload returned by the agents import API.
81
+ */
82
+ type AgentsImportWarningPayload = {
83
+ readonly path: string;
84
+ readonly message: string;
85
+ };
86
+
87
+ /**
88
+ * Duplicate conflict payload returned by the agents import API.
89
+ */
90
+ type AgentsImportConflictPayloadItem = {
91
+ readonly agentName: string;
92
+ readonly path: string;
93
+ readonly existingDifferentBookCount: number;
94
+ };
95
+
96
+ /**
97
+ * Duplicate conflict handling accepted by the import API.
98
+ */
99
+ type AgentsImportConflictResolution = 'ASK' | 'SKIP' | 'DUPLICATE';
100
+
101
+ /**
102
+ * Hook options for Agents list import/export behavior.
103
+ */
104
+ type UseAgentsListImportExportStateOptions = {
105
+ /**
106
+ * Current folder selected in the Agents list.
107
+ */
108
+ readonly currentFolderId: number | null;
109
+ /**
110
+ * Refreshes the organization snapshot after a successful mutation.
111
+ */
112
+ readonly synchronizeAfterMutation: (mutationName: string) => void;
113
+ };
114
+
115
+ /**
116
+ * Hook state and handlers for Agents list import/export behavior.
117
+ */
118
+ type UseAgentsListImportExportStateResult = {
119
+ readonly importFileInputRef: RefObject<HTMLInputElement | null>;
120
+ readonly handleAgentsExport: () => Promise<void>;
121
+ readonly handleAgentsImportClick: () => void;
122
+ readonly handleAgentsImportFileChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
123
+ readonly handleAgentsFileDragEnter: (event: DragEvent<HTMLElement>) => void;
124
+ readonly handleAgentsFileDragLeave: (event: DragEvent<HTMLElement>) => void;
125
+ readonly handleAgentsFileDragOver: (event: DragEvent<HTMLElement>) => void;
126
+ readonly handleAgentsFileDrop: (event: DragEvent<HTMLElement>) => Promise<void>;
127
+ readonly isAgentsExporting: boolean;
128
+ readonly isAgentsImportDragActive: boolean;
129
+ readonly isAgentsImporting: boolean;
130
+ };
131
+
132
+ /**
133
+ * Owns agents import/export browser interactions for `AgentsList`.
134
+ *
135
+ * @param options - Current folder and synchronization callback.
136
+ * @returns Import/export handlers and state.
137
+ *
138
+ * @private function of AgentsList
139
+ */
140
+ export function useAgentsListImportExportState({
141
+ currentFolderId,
142
+ synchronizeAfterMutation,
143
+ }: UseAgentsListImportExportStateOptions): UseAgentsListImportExportStateResult {
144
+ const importFileInputRef = useRef<HTMLInputElement | null>(null);
145
+ const dragDepthRef = useRef(0);
146
+ const [isAgentsExporting, setIsAgentsExporting] = useState(false);
147
+ const [isAgentsImporting, setIsAgentsImporting] = useState(false);
148
+ const [isAgentsImportDragActive, setIsAgentsImportDragActive] = useState(false);
149
+
150
+ const handleAgentsExport = useCallback(async () => {
151
+ if (isAgentsExporting) {
152
+ return;
153
+ }
154
+
155
+ setIsAgentsExporting(true);
156
+
157
+ try {
158
+ const response = await fetch(AGENTS_EXPORT_ENDPOINT, { method: 'GET' });
159
+ if (!response.ok) {
160
+ throw new Error(await resolveAgentsTransferErrorMessage(response, 'Failed to export agents.'));
161
+ }
162
+
163
+ const filename =
164
+ parseFilenameFromContentDisposition(response.headers.get('Content-Disposition')) ||
165
+ DEFAULT_AGENTS_EXPORT_FILENAME;
166
+
167
+ downloadBlob(await response.blob(), filename);
168
+ } catch (error) {
169
+ await showAlert({
170
+ title: 'Export failed',
171
+ message: error instanceof Error ? error.message : 'Failed to export agents.',
172
+ }).catch(() => undefined);
173
+ } finally {
174
+ setIsAgentsExporting(false);
175
+ }
176
+ }, [isAgentsExporting]);
177
+
178
+ const importAgentsFiles = useCallback(
179
+ async (files: ReadonlyArray<File>) => {
180
+ if (isAgentsImporting || files.length === 0) {
181
+ return;
182
+ }
183
+
184
+ const isConfirmed = await confirmNonBookZipEntries(files);
185
+ if (!isConfirmed) {
186
+ return;
187
+ }
188
+
189
+ setIsAgentsImporting(true);
190
+
191
+ try {
192
+ const initialResponse = await uploadAgentsImportFiles({
193
+ files,
194
+ targetFolderId: currentFolderId,
195
+ conflictResolution: 'ASK',
196
+ });
197
+
198
+ if (initialResponse.status === 409) {
199
+ const conflictPayload = (await initialResponse.json()) as AgentsImportConflictPayload;
200
+ const conflictResolution = await promptAgentsImportConflictResolution(conflictPayload.conflicts);
201
+ if (!conflictResolution) {
202
+ return;
203
+ }
204
+
205
+ const resolvedResponse = await uploadAgentsImportFiles({
206
+ files,
207
+ targetFolderId: currentFolderId,
208
+ conflictResolution,
209
+ });
210
+ await handleAgentsImportResponse(resolvedResponse, synchronizeAfterMutation);
211
+ return;
212
+ }
213
+
214
+ await handleAgentsImportResponse(initialResponse, synchronizeAfterMutation);
215
+ } catch (error) {
216
+ await showAlert({
217
+ title: 'Import failed',
218
+ message: error instanceof Error ? error.message : 'Failed to import agents.',
219
+ }).catch(() => undefined);
220
+ } finally {
221
+ setIsAgentsImporting(false);
222
+ }
223
+ },
224
+ [currentFolderId, isAgentsImporting, synchronizeAfterMutation],
225
+ );
226
+
227
+ const handleAgentsImportClick = useCallback(() => {
228
+ if (isAgentsImporting) {
229
+ return;
230
+ }
231
+
232
+ importFileInputRef.current?.click();
233
+ }, [isAgentsImporting]);
234
+
235
+ const handleAgentsImportFileChange = useCallback(
236
+ async (event: ChangeEvent<HTMLInputElement>) => {
237
+ const files = Array.from(event.target.files || []);
238
+ await importAgentsFiles(files);
239
+
240
+ if (importFileInputRef.current) {
241
+ importFileInputRef.current.value = '';
242
+ }
243
+ },
244
+ [importAgentsFiles],
245
+ );
246
+
247
+ const handleAgentsFileDragEnter = useCallback((event: DragEvent<HTMLElement>) => {
248
+ if (!isFileDragEvent(event)) {
249
+ return;
250
+ }
251
+
252
+ event.preventDefault();
253
+ dragDepthRef.current += 1;
254
+ setIsAgentsImportDragActive(true);
255
+ }, []);
256
+
257
+ const handleAgentsFileDragLeave = useCallback((event: DragEvent<HTMLElement>) => {
258
+ if (!isFileDragEvent(event)) {
259
+ return;
260
+ }
261
+
262
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
263
+ if (dragDepthRef.current === 0) {
264
+ setIsAgentsImportDragActive(false);
265
+ }
266
+ }, []);
267
+
268
+ const handleAgentsFileDragOver = useCallback((event: DragEvent<HTMLElement>) => {
269
+ if (!isFileDragEvent(event)) {
270
+ return;
271
+ }
272
+
273
+ event.preventDefault();
274
+ event.dataTransfer.dropEffect = 'copy';
275
+ }, []);
276
+
277
+ const handleAgentsFileDrop = useCallback(
278
+ async (event: DragEvent<HTMLElement>) => {
279
+ if (!isFileDragEvent(event)) {
280
+ return;
281
+ }
282
+
283
+ event.preventDefault();
284
+ dragDepthRef.current = 0;
285
+ setIsAgentsImportDragActive(false);
286
+
287
+ await importAgentsFiles(Array.from(event.dataTransfer.files || []));
288
+ },
289
+ [importAgentsFiles],
290
+ );
291
+
292
+ return {
293
+ importFileInputRef,
294
+ handleAgentsExport,
295
+ handleAgentsImportClick,
296
+ handleAgentsImportFileChange,
297
+ handleAgentsFileDragEnter,
298
+ handleAgentsFileDragLeave,
299
+ handleAgentsFileDragOver,
300
+ handleAgentsFileDrop,
301
+ isAgentsExporting,
302
+ isAgentsImportDragActive,
303
+ isAgentsImporting,
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Uploads selected files to the agents import endpoint.
309
+ *
310
+ * @param options - Files, target folder, and duplicate behavior.
311
+ * @returns Fetch response.
312
+ */
313
+ async function uploadAgentsImportFiles(options: {
314
+ readonly files: ReadonlyArray<File>;
315
+ readonly targetFolderId: number | null;
316
+ readonly conflictResolution: AgentsImportConflictResolution;
317
+ }): Promise<Response> {
318
+ const formData = new FormData();
319
+
320
+ for (const file of options.files) {
321
+ formData.append(FILES_FORM_FIELD, file);
322
+ }
323
+
324
+ formData.append(TARGET_FOLDER_ID_FORM_FIELD, options.targetFolderId === null ? 'null' : String(options.targetFolderId));
325
+ formData.append(CONFLICT_RESOLUTION_FORM_FIELD, options.conflictResolution);
326
+
327
+ return fetch(AGENTS_IMPORT_ENDPOINT, {
328
+ method: 'POST',
329
+ body: formData,
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Handles a final agents import response.
335
+ *
336
+ * @param response - Final import response.
337
+ * @param synchronizeAfterMutation - Organization snapshot refresh callback.
338
+ */
339
+ async function handleAgentsImportResponse(
340
+ response: Response,
341
+ synchronizeAfterMutation: (mutationName: string) => void,
342
+ ): Promise<void> {
343
+ if (!response.ok) {
344
+ throw new Error(await resolveAgentsTransferErrorMessage(response, 'Failed to import agents.'));
345
+ }
346
+
347
+ const payload = (await response.json()) as AgentsImportSuccessPayload;
348
+ if (payload.importedCount > 0) {
349
+ synchronizeAfterMutation('import-agents');
350
+ }
351
+
352
+ await showAlert({
353
+ title: 'Import complete',
354
+ message: createAgentsImportSuccessMessage(payload),
355
+ }).catch(() => undefined);
356
+ }
357
+
358
+ /**
359
+ * Creates a concise import success message.
360
+ *
361
+ * @param payload - Successful import payload.
362
+ * @returns Dialog message.
363
+ */
364
+ function createAgentsImportSuccessMessage(payload: AgentsImportSuccessPayload): string {
365
+ const parts = [`Imported ${payload.importedCount} agents.`];
366
+
367
+ if (payload.skippedCount > 0) {
368
+ parts.push(`Skipped ${payload.skippedCount} duplicates.`);
369
+ }
370
+
371
+ if (payload.ignoredFileCount > 0) {
372
+ parts.push(`Ignored ${payload.ignoredFileCount} non-book files.`);
373
+ }
374
+
375
+ if (payload.warnings && payload.warnings.length > 0) {
376
+ parts.push(createWarningListMessage(payload.warnings.map((warning) => warning.path)));
377
+ }
378
+
379
+ return parts.join('\n\n');
380
+ }
381
+
382
+ /**
383
+ * Prompts the user for duplicate conflict behavior.
384
+ *
385
+ * @param conflicts - Duplicate conflicts returned by the import API.
386
+ * @returns Selected conflict resolution, or `null` when cancelled.
387
+ */
388
+ async function promptAgentsImportConflictResolution(
389
+ conflicts: ReadonlyArray<AgentsImportConflictPayloadItem>,
390
+ ): Promise<Exclude<AgentsImportConflictResolution, 'ASK'> | null> {
391
+ const decision = await showPrompt({
392
+ title: 'Duplicate agents',
393
+ message: [
394
+ 'Some dropped books have the same agent name as existing agents but different book source.',
395
+ 'Type `duplicate` to import them as additional agents, or `skip` to skip the conflicting books.',
396
+ createWarningListMessage(conflicts.map((conflict) => `${conflict.agentName}: ${conflict.path}`)),
397
+ ].join('\n\n'),
398
+ inputLabel: 'Decision',
399
+ defaultValue: 'skip',
400
+ placeholder: 'duplicate or skip',
401
+ confirmLabel: 'Continue',
402
+ cancelLabel: 'Cancel',
403
+ }).catch(() => '');
404
+
405
+ const normalizedDecision = decision.trim().toLowerCase();
406
+ if (normalizedDecision === 'duplicate') {
407
+ return 'DUPLICATE';
408
+ }
409
+
410
+ if (normalizedDecision === 'skip') {
411
+ return 'SKIP';
412
+ }
413
+
414
+ if (normalizedDecision) {
415
+ await showAlert({
416
+ title: 'Import cancelled',
417
+ message: 'Duplicate decision must be `duplicate` or `skip`.',
418
+ }).catch(() => undefined);
419
+ }
420
+
421
+ return null;
422
+ }
423
+
424
+ /**
425
+ * Warns when ZIP files contain non-book entries.
426
+ *
427
+ * @param files - Selected files.
428
+ * @returns Whether import should continue.
429
+ */
430
+ async function confirmNonBookZipEntries(files: ReadonlyArray<File>): Promise<boolean> {
431
+ const nonBookZipEntryPaths = await collectNonBookZipEntryPaths(files);
432
+ if (nonBookZipEntryPaths.length === 0) {
433
+ return true;
434
+ }
435
+
436
+ return showConfirm({
437
+ title: 'Import agents',
438
+ message: [
439
+ 'The ZIP archive contains files that are not `.book` files. They will be ignored.',
440
+ createWarningListMessage(nonBookZipEntryPaths),
441
+ ].join('\n\n'),
442
+ confirmLabel: 'Import agents',
443
+ cancelLabel: 'Cancel',
444
+ }).catch(() => false);
445
+ }
446
+
447
+ /**
448
+ * Collects non-book file paths from selected ZIP archives.
449
+ *
450
+ * @param files - Selected files.
451
+ * @returns Non-book ZIP entry paths.
452
+ */
453
+ async function collectNonBookZipEntryPaths(files: ReadonlyArray<File>): Promise<string[]> {
454
+ const nonBookZipEntryPaths: string[] = [];
455
+
456
+ for (const file of files) {
457
+ if (!file.name.toLowerCase().endsWith('.zip')) {
458
+ continue;
459
+ }
460
+
461
+ try {
462
+ const zip = await JSZip.loadAsync(await file.arrayBuffer());
463
+ for (const zipEntry of Object.values(zip.files)) {
464
+ if (!zipEntry.dir && !zipEntry.name.toLowerCase().endsWith('.book')) {
465
+ nonBookZipEntryPaths.push(`${file.name}/${zipEntry.name}`);
466
+ }
467
+ }
468
+ } catch {
469
+ // Let the server return the authoritative ZIP parse error during import.
470
+ }
471
+ }
472
+
473
+ return nonBookZipEntryPaths;
474
+ }
475
+
476
+ /**
477
+ * Creates a shortened path list for dialogs.
478
+ *
479
+ * @param paths - File paths.
480
+ * @returns Human-readable path list.
481
+ */
482
+ function createWarningListMessage(paths: ReadonlyArray<string>): string {
483
+ const visiblePaths = paths.slice(0, MAX_DIALOG_PATH_COUNT);
484
+ const remainingCount = paths.length - visiblePaths.length;
485
+ const visibleMessage = visiblePaths.map((path) => `- ${path}`).join('\n');
486
+
487
+ if (remainingCount <= 0) {
488
+ return visibleMessage;
489
+ }
490
+
491
+ return `${visibleMessage}\n- and ${remainingCount} more`;
492
+ }
493
+
494
+ /**
495
+ * Reads a user-facing transfer error message from a failed response.
496
+ *
497
+ * @param response - Failed response.
498
+ * @param fallbackMessage - Fallback message.
499
+ * @returns User-facing message.
500
+ */
501
+ async function resolveAgentsTransferErrorMessage(response: Response, fallbackMessage: string): Promise<string> {
502
+ try {
503
+ const payload = (await response.json()) as AgentsImportErrorPayload;
504
+ const message = payload.message || payload.error;
505
+ if (message && message.trim().length > 0) {
506
+ return message.trim();
507
+ }
508
+ } catch {
509
+ // Keep fallback when the response body is not JSON.
510
+ }
511
+
512
+ return fallbackMessage;
513
+ }
514
+
515
+ /**
516
+ * Checks whether a drag event contains files.
517
+ *
518
+ * @param event - Drag event.
519
+ * @returns `true` when files are being dragged.
520
+ */
521
+ function isFileDragEvent(event: DragEvent<HTMLElement>): boolean {
522
+ return Array.from(event.dataTransfer.types || []).includes('Files');
523
+ }