@promptbook/cli 0.104.0-10 → 0.104.0-11

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 (47) hide show
  1. package/apps/agents-server/src/app/admin/files/FilesGalleryClient.tsx +263 -0
  2. package/apps/agents-server/src/app/admin/files/actions.ts +61 -0
  3. package/apps/agents-server/src/app/admin/files/page.tsx +13 -0
  4. package/apps/agents-server/src/app/admin/image-generator-test/ImageGeneratorTestClient.tsx +169 -0
  5. package/apps/agents-server/src/app/admin/image-generator-test/page.tsx +13 -0
  6. package/apps/agents-server/src/app/admin/images/ImagesGalleryClient.tsx +256 -0
  7. package/apps/agents-server/src/app/admin/images/actions.ts +60 -0
  8. package/apps/agents-server/src/app/admin/images/page.tsx +13 -0
  9. package/apps/agents-server/src/app/admin/search-engine-test/SearchEngineTestClient.tsx +109 -0
  10. package/apps/agents-server/src/app/admin/search-engine-test/actions.ts +17 -0
  11. package/apps/agents-server/src/app/admin/search-engine-test/page.tsx +13 -0
  12. package/apps/agents-server/src/app/api/images/[filename]/route.ts +22 -1
  13. package/apps/agents-server/src/components/AgentProfile/AgentCapabilityChips.tsx +38 -0
  14. package/apps/agents-server/src/components/AgentProfile/AgentProfile.tsx +3 -0
  15. package/apps/agents-server/src/components/Header/Header.tsx +16 -0
  16. package/apps/agents-server/src/components/Homepage/AgentCard.tsx +13 -8
  17. package/apps/agents-server/src/database/$getTableName.ts +1 -0
  18. package/apps/agents-server/src/database/migrations/2025-12-0830-image-purpose.sql +5 -0
  19. package/apps/agents-server/src/database/migrations/2025-12-0890-file-agent-id.sql +5 -0
  20. package/apps/agents-server/src/database/schema.ts +14 -1
  21. package/esm/index.es.js +53 -2
  22. package/esm/index.es.js.map +1 -1
  23. package/esm/typings/src/_packages/types.index.d.ts +6 -0
  24. package/esm/typings/src/book-2.0/agent-source/AgentBasicInformation.d.ts +23 -0
  25. package/esm/typings/src/book-components/Chat/types/ChatMessage.d.ts +2 -2
  26. package/esm/typings/src/book-components/_common/Dropdown/Dropdown.d.ts +1 -1
  27. package/esm/typings/src/book-components/_common/HamburgerMenu/HamburgerMenu.d.ts +1 -1
  28. package/esm/typings/src/book-components/icons/AboutIcon.d.ts +1 -1
  29. package/esm/typings/src/book-components/icons/AttachmentIcon.d.ts +1 -1
  30. package/esm/typings/src/book-components/icons/CameraIcon.d.ts +1 -1
  31. package/esm/typings/src/book-components/icons/DownloadIcon.d.ts +1 -1
  32. package/esm/typings/src/book-components/icons/MenuIcon.d.ts +1 -1
  33. package/esm/typings/src/book-components/icons/SaveIcon.d.ts +1 -1
  34. package/esm/typings/src/collection/agent-collection/constructors/agent-collection-in-supabase/AgentCollectionInSupabase.d.ts +2 -2
  35. package/esm/typings/src/collection/agent-collection/constructors/agent-collection-in-supabase/AgentsDatabaseSchema.d.ts +0 -54
  36. package/esm/typings/src/llm-providers/_common/utils/count-total-usage/countUsage.d.ts +1 -1
  37. package/esm/typings/src/llm-providers/agent/Agent.d.ts +6 -1
  38. package/esm/typings/src/remote-server/ui/ServerApp.d.ts +1 -1
  39. package/esm/typings/src/search-engines/SearchEngine.d.ts +9 -0
  40. package/esm/typings/src/search-engines/SearchResult.d.ts +18 -0
  41. package/esm/typings/src/search-engines/bing/BingSearchEngine.d.ts +15 -0
  42. package/esm/typings/src/search-engines/dummy/DummySearchEngine.d.ts +15 -0
  43. package/esm/typings/src/utils/random/$randomAgentPersona.d.ts +3 -2
  44. package/esm/typings/src/version.d.ts +1 -1
  45. package/package.json +1 -1
  46. package/umd/index.umd.js +53 -2
  47. package/umd/index.umd.js.map +1 -1
@@ -0,0 +1,263 @@
1
+ 'use client';
2
+
3
+ import { ChevronLeft, ChevronRight, File, Grid, LayoutList, Loader2 } from 'lucide-react';
4
+ import Link from 'next/link';
5
+ import { useEffect, useRef, useState } from 'react';
6
+ import { FileWithAgent, listFiles } from './actions';
7
+
8
+ type ViewMode = 'TABLE' | 'GRID';
9
+
10
+ export function FilesGalleryClient() {
11
+ const [viewMode, setViewMode] = useState<ViewMode>('TABLE');
12
+ const [files, setFiles] = useState<FileWithAgent[]>([]);
13
+ const [total, setTotal] = useState(0);
14
+ const [isLoading, setIsLoading] = useState(false);
15
+ const [page, setPage] = useState(1);
16
+ const [limit] = useState(20);
17
+ const [hasMore, setHasMore] = useState(true);
18
+
19
+ const loadFiles = async (pageNum: number, isNewView: boolean) => {
20
+ setIsLoading(true);
21
+ try {
22
+ const result = await listFiles({ page: pageNum, limit });
23
+ if (isNewView) {
24
+ setFiles(result.files);
25
+ } else {
26
+ setFiles((prev) => [...prev, ...result.files]);
27
+ }
28
+ setTotal(result.total);
29
+ setHasMore(result.files.length === limit);
30
+ } catch (error) {
31
+ console.error(error);
32
+ } finally {
33
+ setIsLoading(false);
34
+ }
35
+ };
36
+
37
+ useEffect(() => {
38
+ loadFiles(1, true);
39
+ setPage(1);
40
+ setHasMore(true);
41
+ }, [viewMode]);
42
+
43
+ const handleLoadMore = () => {
44
+ if (!isLoading && hasMore) {
45
+ const nextPage = page + 1;
46
+ setPage(nextPage);
47
+ loadFiles(nextPage, false);
48
+ }
49
+ };
50
+
51
+ // Table view pagination
52
+ const handlePageChange = (newPage: number) => {
53
+ setPage(newPage);
54
+ loadFiles(newPage, true);
55
+ };
56
+
57
+ const observerTarget = useRef<HTMLDivElement>(null);
58
+
59
+ useEffect(() => {
60
+ const observer = new IntersectionObserver(
61
+ (entries) => {
62
+ if (entries[0].isIntersecting && hasMore && !isLoading && viewMode === 'GRID') {
63
+ handleLoadMore();
64
+ }
65
+ },
66
+ { threshold: 0.1 }
67
+ );
68
+
69
+ if (observerTarget.current) {
70
+ observer.observe(observerTarget.current);
71
+ }
72
+
73
+ return () => {
74
+ // eslint-disable-next-line react-hooks/exhaustive-deps
75
+ if (observerTarget.current) {
76
+ observer.unobserve(observerTarget.current);
77
+ }
78
+ };
79
+ }, [hasMore, isLoading, viewMode, page]);
80
+
81
+ return (
82
+ <div className="container mx-auto px-4 py-8 space-y-6">
83
+ <div className="flex justify-between items-center mt-20 mb-4">
84
+ <h1 className="text-3xl text-gray-900 font-light">Files Gallery</h1>
85
+ <div className="flex items-center gap-2 bg-gray-100 p-1 rounded-lg">
86
+ <button
87
+ onClick={() => setViewMode('TABLE')}
88
+ className={`p-2 rounded-md transition-colors ${
89
+ viewMode === 'TABLE' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500 hover:text-gray-900'
90
+ }`}
91
+ title="Table View"
92
+ >
93
+ <LayoutList className="w-5 h-5" />
94
+ </button>
95
+ <button
96
+ onClick={() => setViewMode('GRID')}
97
+ className={`p-2 rounded-md transition-colors ${
98
+ viewMode === 'GRID' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500 hover:text-gray-900'
99
+ }`}
100
+ title="Grid View"
101
+ >
102
+ <Grid className="w-5 h-5" />
103
+ </button>
104
+ </div>
105
+ </div>
106
+
107
+ {viewMode === 'TABLE' ? (
108
+ <div className="bg-white border rounded-lg overflow-hidden shadow-sm">
109
+ <div className="overflow-x-auto">
110
+ <table className="w-full text-sm text-left text-gray-500">
111
+ <thead className="text-xs text-gray-700 uppercase bg-gray-50 border-b">
112
+ <tr>
113
+ <th className="px-6 py-3">File Name</th>
114
+ <th className="px-6 py-3">Type</th>
115
+ <th className="px-6 py-3">Size</th>
116
+ <th className="px-6 py-3">Agent</th>
117
+ <th className="px-6 py-3">Purpose</th>
118
+ <th className="px-6 py-3">Status</th>
119
+ <th className="px-6 py-3">Created At</th>
120
+ </tr>
121
+ </thead>
122
+ <tbody>
123
+ {files.map((file) => (
124
+ <tr key={file.id} className="bg-white border-b hover:bg-gray-50">
125
+ <td className="px-6 py-4">
126
+ {file.storageUrl ? (
127
+ <a href={file.storageUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
128
+ {file.fileName}
129
+ </a>
130
+ ) : (
131
+ file.fileName
132
+ )}
133
+ </td>
134
+ <td className="px-6 py-4">{file.fileType}</td>
135
+ <td className="px-6 py-4">{(file.fileSize / 1024).toFixed(2)} KB</td>
136
+ <td className="px-6 py-4">
137
+ {file.agent ? (
138
+ <Link href={`/${file.agent.agentName}`} className="text-blue-600 hover:underline">
139
+ {file.agent.agentName}
140
+ </Link>
141
+ ) : (
142
+ <span className="text-gray-400">-</span>
143
+ )}
144
+ </td>
145
+ <td className="px-6 py-4">
146
+ <span className="bg-gray-100 text-gray-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">
147
+ {file.purpose}
148
+ </span>
149
+ </td>
150
+ <td className="px-6 py-4">
151
+ <span className={`px-2 py-1 rounded-full text-xs font-medium ${
152
+ file.status === 'COMPLETED' ? 'bg-green-100 text-green-800' :
153
+ file.status === 'FAILED' ? 'bg-red-100 text-red-800' : 'bg-blue-100 text-blue-800'
154
+ }`}>
155
+ {file.status}
156
+ </span>
157
+ </td>
158
+ <td className="px-6 py-4 whitespace-nowrap">
159
+ {new Date(file.createdAt).toLocaleString()}
160
+ </td>
161
+ </tr>
162
+ ))}
163
+ {files.length === 0 && !isLoading && (
164
+ <tr>
165
+ <td colSpan={7} className="px-6 py-4 text-center text-gray-500">
166
+ No files found.
167
+ </td>
168
+ </tr>
169
+ )}
170
+ </tbody>
171
+ </table>
172
+ </div>
173
+ {/* Pagination for Table */}
174
+ <div className="flex items-center justify-between px-6 py-4 border-t bg-gray-50">
175
+ <span className="text-sm text-gray-700">
176
+ Showing <span className="font-medium">{(page - 1) * limit + 1}</span> to <span className="font-medium">{Math.min(page * limit, total)}</span> of <span className="font-medium">{total}</span> results
177
+ </span>
178
+ <div className="flex gap-2">
179
+ <button
180
+ onClick={() => handlePageChange(page - 1)}
181
+ disabled={page === 1 || isLoading}
182
+ className="p-2 border rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
183
+ >
184
+ <ChevronLeft className="w-4 h-4" />
185
+ </button>
186
+ <button
187
+ onClick={() => handlePageChange(page + 1)}
188
+ disabled={page * limit >= total || isLoading}
189
+ className="p-2 border rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
190
+ >
191
+ <ChevronRight className="w-4 h-4" />
192
+ </button>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ ) : (
197
+ <>
198
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
199
+ {files.map((file) => (
200
+ <div key={file.id} className="group relative border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow">
201
+ <a href={file.storageUrl || '#'} target="_blank" rel="noopener noreferrer" className="block aspect-square relative bg-gray-100 flex items-center justify-center">
202
+ {file.fileType.startsWith('image/') && file.storageUrl ? (
203
+ /* eslint-disable-next-line @next/next/no-img-element */
204
+ <img
205
+ src={file.storageUrl}
206
+ alt={file.fileName}
207
+ className="object-cover w-full h-full transition-transform duration-300 group-hover:scale-105"
208
+ loading="lazy"
209
+ />
210
+ ) : (
211
+ <File className="w-16 h-16 text-gray-400" />
212
+ )}
213
+ </a>
214
+ <div className="p-3">
215
+ <div className="flex items-center justify-between gap-2 mb-1">
216
+ {file.agent ? (
217
+ <Link href={`/${file.agent.agentName}`} className="text-xs font-medium text-blue-600 hover:underline truncate">
218
+ {file.agent.agentName}
219
+ </Link>
220
+ ) : (
221
+ <span className="text-xs text-gray-400">No agent</span>
222
+ )}
223
+ <span className="text-[10px] text-gray-400 whitespace-nowrap">
224
+ {new Date(file.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
225
+ </span>
226
+ </div>
227
+ <p className="text-xs text-gray-600 truncate" title={file.fileName}>
228
+ {file.fileName}
229
+ </p>
230
+ <div className="mt-1 flex justify-between items-center">
231
+ <span className="text-[10px] text-gray-500">{(file.fileSize / 1024).toFixed(1)} KB</span>
232
+ <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-medium ${
233
+ file.status === 'COMPLETED' ? 'bg-green-100 text-green-800' :
234
+ file.status === 'FAILED' ? 'bg-red-100 text-red-800' : 'bg-blue-100 text-blue-800'
235
+ }`}>
236
+ {file.status}
237
+ </span>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ ))}
242
+ </div>
243
+
244
+ {files.length === 0 && !isLoading && (
245
+ <div className="text-center text-gray-500 py-12">
246
+ No files found.
247
+ </div>
248
+ )}
249
+
250
+ {/* Infinite Scroll Loader */}
251
+ <div className="py-8 flex justify-center" ref={observerTarget}>
252
+ {isLoading && (
253
+ <Loader2 className="w-8 h-8 animate-spin text-blue-500" />
254
+ )}
255
+ {!isLoading && !hasMore && files.length > 0 && (
256
+ <p className="text-gray-400 text-sm">No more files</p>
257
+ )}
258
+ </div>
259
+ </>
260
+ )}
261
+ </div>
262
+ );
263
+ }
@@ -0,0 +1,61 @@
1
+ 'use server';
2
+
3
+ import { $getTableName } from '@/src/database/$getTableName';
4
+ import { TODO_any } from '@promptbook-local/types';
5
+ import { $provideSupabaseForServer } from '../../../database/$provideSupabaseForServer';
6
+
7
+ export type FileWithAgent = {
8
+ id: number;
9
+ createdAt: string;
10
+ fileName: string;
11
+ fileSize: number;
12
+ fileType: string;
13
+ storageUrl: string | null;
14
+ shortUrl: string | null;
15
+ purpose: string;
16
+ status: 'UPLOADING' | 'COMPLETED' | 'FAILED';
17
+ agentId: number | null;
18
+ agent: {
19
+ id: number;
20
+ agentName: string;
21
+ } | null;
22
+ };
23
+
24
+ export async function listFiles(options: {
25
+ page: number;
26
+ limit: number;
27
+ }): Promise<{ files: FileWithAgent[]; total: number }> {
28
+ const { page, limit } = options;
29
+ const offset = (page - 1) * limit;
30
+
31
+ const supabase = $provideSupabaseForServer();
32
+
33
+ const {
34
+ data: files,
35
+ error,
36
+ count,
37
+ } = await supabase
38
+ .from(await $getTableName('File'))
39
+ .select(
40
+ `
41
+ *,
42
+ agent:agentId (
43
+ id,
44
+ agentName
45
+ )
46
+ `,
47
+ { count: 'exact' },
48
+ )
49
+ .range(offset, offset + limit - 1)
50
+ .order('createdAt', { ascending: false });
51
+
52
+ if (error) {
53
+ console.error('Error fetching files:', error);
54
+ throw new Error(error.message);
55
+ }
56
+
57
+ return {
58
+ files: (files as TODO_any[]) || [],
59
+ total: count || 0,
60
+ };
61
+ }
@@ -0,0 +1,13 @@
1
+ import { ForbiddenPage } from '../../../components/ForbiddenPage/ForbiddenPage';
2
+ import { isUserAdmin } from '../../../utils/isUserAdmin';
3
+ import { FilesGalleryClient } from './FilesGalleryClient';
4
+
5
+ export default async function FilesGalleryPage() {
6
+ const isAdmin = await isUserAdmin();
7
+
8
+ if (!isAdmin) {
9
+ return <ForbiddenPage />;
10
+ }
11
+
12
+ return <FilesGalleryClient />;
13
+ }
@@ -0,0 +1,169 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Card } from '../../../components/Homepage/Card';
5
+
6
+ export function ImageGeneratorTestClient() {
7
+ const [prompt, setPrompt] = useState<string>('');
8
+ const [modelName, setModelName] = useState<string>('dall-e-3');
9
+ const [imageUrl, setImageUrl] = useState<string | null>(null);
10
+ const [rawResult, setRawResult] = useState<unknown | null>(null);
11
+ const [isLoading, setIsLoading] = useState(false);
12
+ const [error, setError] = useState<string | null>(null);
13
+ const [generatedFilename, setGeneratedFilename] = useState<string | null>(null);
14
+
15
+ const handleGenerateImage = () => {
16
+ if (!prompt) return;
17
+
18
+ setIsLoading(true);
19
+ setError(null);
20
+ setImageUrl(null);
21
+ setRawResult(null);
22
+ setGeneratedFilename(null);
23
+
24
+ try {
25
+ // detailed-painting-of-a-cute-cat.png
26
+ const filename = prompt
27
+ .trim()
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, '-')
30
+ .replace(/^-+|-+$/g, '') + '.png';
31
+
32
+ setGeneratedFilename(filename);
33
+
34
+ const queryParams = new URLSearchParams();
35
+ if (modelName) queryParams.set('modelName', modelName);
36
+ queryParams.set('raw', 'true');
37
+
38
+ fetch(`/api/images/${filename}?${queryParams.toString()}`)
39
+ .then(async (response) => {
40
+ const contentType = response.headers.get('content-type');
41
+
42
+ if (!response.ok) {
43
+ const text = await response.text();
44
+ let errorMessage;
45
+ try {
46
+ const json = JSON.parse(text);
47
+ errorMessage = json.error || response.statusText;
48
+ } catch {
49
+ errorMessage = text || response.statusText;
50
+ }
51
+ throw new Error(`Error: ${response.status} ${errorMessage}`);
52
+ }
53
+
54
+ if (contentType && contentType.includes('application/json')) {
55
+ const data = await response.json();
56
+ setRawResult(data);
57
+ if (data.cdnUrl) {
58
+ setImageUrl(data.cdnUrl);
59
+ }
60
+ } else {
61
+ // Fallback if it returns blob/image directly (shouldn't with raw=true)
62
+ const blob = await response.blob();
63
+ const url = URL.createObjectURL(blob);
64
+ setImageUrl(url);
65
+ }
66
+ })
67
+ .catch((err) => {
68
+ setError(String(err));
69
+ })
70
+ .finally(() => {
71
+ setIsLoading(false);
72
+ });
73
+
74
+ } catch (err) {
75
+ setError(String(err));
76
+ setIsLoading(false);
77
+ }
78
+ };
79
+
80
+ return (
81
+ <div className="container mx-auto px-4 py-8 space-y-6">
82
+ <div className="mt-20 mb-4 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
83
+ <div>
84
+ <h1 className="text-3xl text-gray-900 font-light">Image Generator Test</h1>
85
+ <p className="mt-1 text-sm text-gray-500">
86
+ Test the image generation capabilities by providing a prompt.
87
+ </p>
88
+ </div>
89
+ </div>
90
+
91
+ <Card>
92
+ <div className="mb-4 space-y-4">
93
+ <div className="space-y-2">
94
+ <label className="block text-sm font-medium text-gray-700">Image Prompt</label>
95
+ <input
96
+ type="text"
97
+ value={prompt}
98
+ onChange={(e) => setPrompt(e.target.value)}
99
+ placeholder="e.g., A futuristic city with flying cars"
100
+ className="w-full p-2 border border-gray-300 rounded"
101
+ disabled={isLoading}
102
+ onKeyDown={(e) => {
103
+ if (e.key === 'Enter') {
104
+ handleGenerateImage();
105
+ }
106
+ }}
107
+ />
108
+ </div>
109
+
110
+ <div className="space-y-2">
111
+ <label className="block text-sm font-medium text-gray-700">Model Name</label>
112
+ <input
113
+ type="text"
114
+ value={modelName}
115
+ onChange={(e) => setModelName(e.target.value)}
116
+ placeholder="e.g., dall-e-3"
117
+ className="w-full p-2 border border-gray-300 rounded"
118
+ disabled={isLoading}
119
+ />
120
+ <p className="text-xs text-gray-500">
121
+ Available models depend on the configured LLM provider. Common options: dall-e-3, dall-e-2, midjourney
122
+ </p>
123
+ </div>
124
+
125
+ <div className="flex justify-end">
126
+ <button
127
+ onClick={handleGenerateImage}
128
+ disabled={isLoading || !prompt}
129
+ className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50"
130
+ >
131
+ {isLoading ? 'Generating...' : 'Generate Image'}
132
+ </button>
133
+ </div>
134
+
135
+ {generatedFilename && (
136
+ <p className="text-xs text-gray-500">
137
+ Generated filename: <code className="bg-gray-100 px-1 rounded">{generatedFilename}</code>
138
+ </p>
139
+ )}
140
+ </div>
141
+
142
+ {error && (
143
+ <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
144
+ <strong className="font-bold">Error: </strong>
145
+ <span className="block sm:inline">{error}</span>
146
+ </div>
147
+ )}
148
+
149
+ {imageUrl && (
150
+ <div className="mb-6 border rounded shadow-lg overflow-hidden bg-gray-50 flex justify-center items-center min-h-[200px]">
151
+ {/* eslint-disable-next-line @next/next/no-img-element */}
152
+ <img src={imageUrl} alt={prompt} className="max-w-full h-auto" />
153
+ </div>
154
+ )}
155
+
156
+ {rawResult !== null && (
157
+ <div className="border rounded-md overflow-hidden">
158
+ <div className="bg-gray-100 px-4 py-2 border-b">
159
+ <h3 className="text-sm font-semibold text-gray-700">Raw Result</h3>
160
+ </div>
161
+ <pre className="p-4 bg-gray-50 text-xs overflow-auto max-h-[500px]">
162
+ {JSON.stringify(rawResult, null, 2)}
163
+ </pre>
164
+ </div>
165
+ )}
166
+ </Card>
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,13 @@
1
+ import { ForbiddenPage } from '../../../components/ForbiddenPage/ForbiddenPage';
2
+ import { isUserAdmin } from '../../../utils/isUserAdmin';
3
+ import { ImageGeneratorTestClient } from './ImageGeneratorTestClient';
4
+
5
+ export default async function ImageGeneratorTestPage() {
6
+ const isAdmin = await isUserAdmin();
7
+
8
+ if (!isAdmin) {
9
+ return <ForbiddenPage />;
10
+ }
11
+
12
+ return <ImageGeneratorTestClient />;
13
+ }