@promptbook/cli 0.112.0-135 → 0.112.0-136
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/apps/agents-server/README.md +0 -1
- package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +246 -15
- package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +237 -4
- package/apps/agents-server/src/app/api/admin/update/log/route.ts +42 -0
- package/apps/agents-server/src/app/api/agents/export/route.ts +41 -0
- package/apps/agents-server/src/app/api/agents/import/route.ts +168 -0
- package/apps/agents-server/src/app/api/federated-agents/route.ts +1 -1
- package/apps/agents-server/src/app/api/metadata/export/route.ts +34 -0
- package/apps/agents-server/src/app/api/metadata/import/route.ts +46 -0
- package/apps/agents-server/src/app/layout.tsx +1 -1
- package/apps/agents-server/src/components/Homepage/AgentsList.tsx +31 -1
- package/apps/agents-server/src/components/Homepage/AgentsListHeader.tsx +60 -1
- package/apps/agents-server/src/components/Homepage/useAgentsListImportExportState.ts +523 -0
- package/apps/agents-server/src/components/Homepage/useAgentsListState.ts +16 -0
- package/apps/agents-server/src/database/metadataDefaults.ts +0 -21
- package/apps/agents-server/src/database/seedCoreAgents.ts +235 -0
- package/apps/agents-server/src/database/seedDefaultAgents.ts +32 -1
- package/apps/agents-server/src/search/createDefaultServerSearchProviders/createFederatedAgentsSearchProvider.ts +1 -1
- package/apps/agents-server/src/tools/$provideAgentCollectionForServer.ts +2 -9
- package/apps/agents-server/src/utils/agentsTransfer/createAgentsExportZipStream.ts +68 -0
- package/apps/agents-server/src/utils/agentsTransfer/importAgentsFromFiles.ts +852 -0
- package/apps/agents-server/src/utils/backup/createBooksBackupZipStream.ts +15 -2
- package/apps/agents-server/src/utils/defaultAgents/loadDefaultAgentBooks.ts +46 -4
- package/apps/agents-server/src/utils/getFederatedServers.ts +3 -74
- package/apps/agents-server/src/utils/getWellKnownAgentUrl.ts +10 -4
- package/apps/agents-server/src/utils/metadataConfigurationTransfer.ts +426 -0
- package/apps/agents-server/src/utils/serverManagement/createManagedServer/bootstrapManagedServer.ts +2 -5
- package/apps/agents-server/src/utils/serverManagement/createManagedServer/seedServerCoreAgents.ts +162 -0
- package/apps/agents-server/src/utils/vpsSelfUpdate.ts +110 -0
- package/esm/index.es.js +1 -1
- package/esm/src/_packages/core.index.d.ts +0 -2
- package/esm/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/servers.ts +1 -16
- package/src/_packages/core.index.ts +0 -2
- package/src/other/templates/getTemplatesPipelineCollection.ts +770 -721
- package/src/version.ts +2 -2
- package/src/versions.txt +1 -0
- package/umd/index.umd.js +1 -1
- package/umd/src/_packages/core.index.d.ts +0 -2
- package/umd/src/version.d.ts +1 -1
- package/apps/agents-server/src/utils/defaultFederatedAgents/DefaultFederatedAgentsSyncOptions.ts +0 -9
- package/apps/agents-server/src/utils/defaultFederatedAgents/ensureDefaultFederatedAgentExists.ts +0 -277
- package/apps/agents-server/src/utils/defaultFederatedAgents/fetchCoreOrganizationPayload.ts +0 -39
- package/apps/agents-server/src/utils/defaultFederatedAgents/fetchFederatedAgentBook.ts +0 -43
- package/apps/agents-server/src/utils/defaultFederatedAgents/fetchWithDefaultFederatedAgentTimeout.ts +0 -28
- package/apps/agents-server/src/utils/defaultFederatedAgents/getDefaultFederatedAgentSyncPool.ts +0 -38
- package/apps/agents-server/src/utils/defaultFederatedAgents/loadActiveLocalAgentIdsByNormalizedName.ts +0 -41
- package/apps/agents-server/src/utils/defaultFederatedAgents/loadDefaultFederatedAgentSyncMetadata.ts +0 -76
- package/apps/agents-server/src/utils/defaultFederatedAgents/quoteIdentifier.ts +0 -11
- package/apps/agents-server/src/utils/defaultFederatedAgents/scheduleDefaultFederatedAgentsSync.ts +0 -88
- package/apps/agents-server/src/utils/defaultFederatedAgents/selectDefaultFederatedAgentsFromOrganizationPayload.ts +0 -181
- package/apps/agents-server/src/utils/defaultFederatedAgents/synchronizeDefaultFederatedAgents.ts +0 -77
|
@@ -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
|
|
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 {
|
|
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
|
+
}
|