@promptbook/cli 0.104.0-3 → 0.104.0-5
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/src/app/admin/messages/MessagesClient.tsx +294 -0
- package/apps/agents-server/src/app/admin/messages/page.tsx +13 -0
- package/apps/agents-server/src/app/admin/messages/send-email/SendEmailClient.tsx +104 -0
- package/apps/agents-server/src/app/admin/messages/send-email/actions.ts +35 -0
- package/apps/agents-server/src/app/admin/messages/send-email/page.tsx +13 -0
- package/apps/agents-server/src/app/agents/[agentName]/api/profile/route.ts +4 -0
- package/apps/agents-server/src/app/agents/[agentName]/images/default-avatar.png/route.ts +139 -0
- package/apps/agents-server/src/app/api/messages/route.ts +102 -0
- package/apps/agents-server/src/components/Header/Header.tsx +4 -0
- package/apps/agents-server/src/database/$provideSupabaseForBrowser.ts +3 -3
- package/apps/agents-server/src/database/$provideSupabaseForServer.ts +1 -1
- package/apps/agents-server/src/database/$provideSupabaseForWorker.ts +3 -3
- package/apps/agents-server/src/database/migrate.ts +34 -1
- package/apps/agents-server/src/database/migrations/2025-11-0001-initial-schema.sql +1 -3
- package/apps/agents-server/src/database/migrations/2025-11-0002-metadata-table.sql +1 -3
- package/apps/agents-server/src/database/migrations/2025-12-0402-message-table.sql +42 -0
- package/apps/agents-server/src/database/schema.ts +95 -4
- package/apps/agents-server/src/message-providers/email/_common/Email.ts +73 -0
- package/apps/agents-server/src/message-providers/email/_common/utils/TODO.txt +1 -0
- package/apps/agents-server/src/message-providers/email/_common/utils/parseEmailAddress.test.ts.todo +108 -0
- package/apps/agents-server/src/message-providers/email/_common/utils/parseEmailAddress.ts +62 -0
- package/apps/agents-server/src/message-providers/email/_common/utils/parseEmailAddresses.test.ts.todo +117 -0
- package/apps/agents-server/src/message-providers/email/_common/utils/parseEmailAddresses.ts +19 -0
- package/apps/agents-server/src/message-providers/email/_common/utils/stringifyEmailAddress.test.ts.todo +119 -0
- package/apps/agents-server/src/message-providers/email/_common/utils/stringifyEmailAddress.ts +19 -0
- package/apps/agents-server/src/message-providers/email/_common/utils/stringifyEmailAddresses.test.ts.todo +74 -0
- package/apps/agents-server/src/message-providers/email/_common/utils/stringifyEmailAddresses.ts +14 -0
- package/apps/agents-server/src/message-providers/email/sendgrid/SendgridMessageProvider.ts +44 -0
- package/apps/agents-server/src/message-providers/email/zeptomail/ZeptomailMessageProvider.ts +51 -0
- package/apps/agents-server/src/message-providers/index.ts +13 -0
- package/apps/agents-server/src/message-providers/interfaces/MessageProvider.ts +11 -0
- package/apps/agents-server/src/utils/messages/sendMessage.ts +91 -0
- package/apps/agents-server/src/utils/messagesAdmin.ts +72 -0
- package/apps/agents-server/src/utils/normalization/filenameToPrompt.test.ts +36 -0
- package/apps/agents-server/src/utils/normalization/filenameToPrompt.ts +6 -2
- package/esm/index.es.js +8098 -8067
- package/esm/index.es.js.map +1 -1
- package/esm/typings/src/collection/agent-collection/constructors/agent-collection-in-supabase/AgentsDatabaseSchema.d.ts +18 -15
- package/esm/typings/src/llm-providers/_multiple/MultipleLlmExecutionTools.d.ts +6 -2
- package/esm/typings/src/llm-providers/remote/RemoteLlmExecutionTools.d.ts +1 -0
- package/esm/typings/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/umd/index.umd.js +8114 -8083
- package/umd/index.umd.js.map +1 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { Card } from '../../../components/Homepage/Card';
|
|
5
|
+
import {
|
|
6
|
+
$fetchMessages,
|
|
7
|
+
type MessageRow,
|
|
8
|
+
type MessageSendAttemptRow,
|
|
9
|
+
} from '../../../utils/messagesAdmin';
|
|
10
|
+
|
|
11
|
+
function formatDate(dateString: string | null | undefined): string {
|
|
12
|
+
if (!dateString) return '-';
|
|
13
|
+
const date = new Date(dateString);
|
|
14
|
+
if (Number.isNaN(date.getTime())) return dateString;
|
|
15
|
+
return date.toLocaleString();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getMessagePreview(content: string, maxLength = 120): string {
|
|
19
|
+
if (!content) return '-';
|
|
20
|
+
return content.length > maxLength ? `${content.slice(0, maxLength)}…` : content;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getStatusBadge(attempts: MessageSendAttemptRow[] | undefined) {
|
|
24
|
+
if (!attempts || attempts.length === 0) {
|
|
25
|
+
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">Pending</span>;
|
|
26
|
+
}
|
|
27
|
+
const success = attempts.find(a => a.isSuccessful);
|
|
28
|
+
if (success) {
|
|
29
|
+
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">Sent ({success.providerName})</span>;
|
|
30
|
+
}
|
|
31
|
+
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">Failed ({attempts.length})</span>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function MessagesClient() {
|
|
35
|
+
const [items, setItems] = useState<MessageRow[]>([]);
|
|
36
|
+
const [total, setTotal] = useState(0);
|
|
37
|
+
const [page, setPage] = useState(1);
|
|
38
|
+
const [pageSize, setPageSize] = useState(20);
|
|
39
|
+
const [searchInput, setSearchInput] = useState('');
|
|
40
|
+
const [search, setSearch] = useState('');
|
|
41
|
+
const [channel, setChannel] = useState('');
|
|
42
|
+
const [direction, setDirection] = useState('');
|
|
43
|
+
const [loading, setLoading] = useState(true);
|
|
44
|
+
const [error, setError] = useState<string | null>(null);
|
|
45
|
+
|
|
46
|
+
// Load messages whenever filters / pagination change
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
let isCancelled = false;
|
|
49
|
+
|
|
50
|
+
async function loadData() {
|
|
51
|
+
try {
|
|
52
|
+
setLoading(true);
|
|
53
|
+
setError(null);
|
|
54
|
+
|
|
55
|
+
const response = await $fetchMessages({
|
|
56
|
+
page,
|
|
57
|
+
pageSize,
|
|
58
|
+
search: search || undefined,
|
|
59
|
+
channel: channel || undefined,
|
|
60
|
+
direction: direction || undefined,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (isCancelled) return;
|
|
64
|
+
|
|
65
|
+
setItems(response.items);
|
|
66
|
+
setTotal(response.total);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (isCancelled) return;
|
|
69
|
+
setError(err instanceof Error ? err.message : 'Failed to load messages');
|
|
70
|
+
} finally {
|
|
71
|
+
if (!isCancelled) {
|
|
72
|
+
setLoading(false);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
loadData();
|
|
78
|
+
|
|
79
|
+
return () => {
|
|
80
|
+
isCancelled = true;
|
|
81
|
+
};
|
|
82
|
+
}, [page, pageSize, search, channel, direction]);
|
|
83
|
+
|
|
84
|
+
const totalPages = useMemo(() => {
|
|
85
|
+
if (total <= 0 || pageSize <= 0) return 1;
|
|
86
|
+
return Math.max(1, Math.ceil(total / pageSize));
|
|
87
|
+
}, [total, pageSize]);
|
|
88
|
+
|
|
89
|
+
const handleSearchSubmit = (event: React.FormEvent) => {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
setPage(1);
|
|
92
|
+
setSearch(searchInput.trim());
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const handlePageSizeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
|
96
|
+
const next = parseInt(event.target.value, 10);
|
|
97
|
+
if (!Number.isNaN(next) && next > 0) {
|
|
98
|
+
setPageSize(next);
|
|
99
|
+
setPage(1);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const pagination = (
|
|
104
|
+
<div className="mt-4 flex flex-col items-center justify-between gap-3 border-t border-gray-100 pt-4 text-xs text-gray-600 md:flex-row">
|
|
105
|
+
<div>
|
|
106
|
+
{total > 0 ? (
|
|
107
|
+
<>
|
|
108
|
+
Showing <span className="font-semibold">{Math.min((page - 1) * pageSize + 1, total)}</span> –{' '}
|
|
109
|
+
<span className="font-semibold">{Math.min(page * pageSize, total)}</span> of{' '}
|
|
110
|
+
<span className="font-semibold">{total}</span> messages
|
|
111
|
+
</>
|
|
112
|
+
) : (
|
|
113
|
+
'No messages'
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
<div className="flex items-center gap-2">
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
|
120
|
+
disabled={page <= 1}
|
|
121
|
+
className="inline-flex items-center justify-center rounded-md border border-gray-300 px-2 py-1 text-xs font-medium text-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
122
|
+
>
|
|
123
|
+
Previous
|
|
124
|
+
</button>
|
|
125
|
+
<span>
|
|
126
|
+
Page <span className="font-semibold">{page}</span> of{' '}
|
|
127
|
+
<span className="font-semibold">{totalPages}</span>
|
|
128
|
+
</span>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
|
132
|
+
disabled={page >= totalPages}
|
|
133
|
+
className="inline-flex items-center justify-center rounded-md border border-gray-300 px-2 py-1 text-xs font-medium text-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
134
|
+
>
|
|
135
|
+
Next
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="container mx-auto px-4 py-8 space-y-6">
|
|
143
|
+
<div className="mt-20 mb-4 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
|
144
|
+
<div>
|
|
145
|
+
<h1 className="text-3xl text-gray-900 font-light">Messages</h1>
|
|
146
|
+
<p className="mt-1 text-sm text-gray-500">
|
|
147
|
+
Inspect all inbound and outbound messages and their statuses.
|
|
148
|
+
</p>
|
|
149
|
+
</div>
|
|
150
|
+
<div className="flex items-end gap-4 text-sm text-gray-500 md:text-right">
|
|
151
|
+
<div>
|
|
152
|
+
<div className="text-xl font-semibold text-gray-900">{total.toLocaleString()}</div>
|
|
153
|
+
<div className="text-xs uppercase tracking-wide text-gray-400">Total messages</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<Card>
|
|
158
|
+
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
159
|
+
<form onSubmit={handleSearchSubmit} className="flex flex-col gap-2 md:flex-row md:items-end">
|
|
160
|
+
<div className="flex flex-col gap-1">
|
|
161
|
+
<label htmlFor="search" className="text-sm font-medium text-gray-700">
|
|
162
|
+
Search
|
|
163
|
+
</label>
|
|
164
|
+
<input
|
|
165
|
+
id="search"
|
|
166
|
+
type="text"
|
|
167
|
+
value={searchInput}
|
|
168
|
+
onChange={(event) => setSearchInput(event.target.value)}
|
|
169
|
+
placeholder="Search in content..."
|
|
170
|
+
className="w-full md:w-72 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
<button
|
|
174
|
+
type="submit"
|
|
175
|
+
className="mt-2 inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 md:mt-0 md:ml-3"
|
|
176
|
+
>
|
|
177
|
+
Apply
|
|
178
|
+
</button>
|
|
179
|
+
</form>
|
|
180
|
+
|
|
181
|
+
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-4">
|
|
182
|
+
<div className="flex flex-col gap-1">
|
|
183
|
+
<label htmlFor="channelFilter" className="text-sm font-medium text-gray-700">
|
|
184
|
+
Channel
|
|
185
|
+
</label>
|
|
186
|
+
<select
|
|
187
|
+
id="channelFilter"
|
|
188
|
+
value={channel}
|
|
189
|
+
onChange={(e) => { setChannel(e.target.value); setPage(1); }}
|
|
190
|
+
className="w-full md:w-40 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
191
|
+
>
|
|
192
|
+
<option value="">All</option>
|
|
193
|
+
<option value="EMAIL">Email</option>
|
|
194
|
+
<option value="PROMPTBOOK_CHAT">Chat</option>
|
|
195
|
+
</select>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div className="flex flex-col gap-1">
|
|
199
|
+
<label htmlFor="directionFilter" className="text-sm font-medium text-gray-700">
|
|
200
|
+
Direction
|
|
201
|
+
</label>
|
|
202
|
+
<select
|
|
203
|
+
id="directionFilter"
|
|
204
|
+
value={direction}
|
|
205
|
+
onChange={(e) => { setDirection(e.target.value); setPage(1); }}
|
|
206
|
+
className="w-full md:w-40 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
207
|
+
>
|
|
208
|
+
<option value="">All</option>
|
|
209
|
+
<option value="INBOUND">Inbound</option>
|
|
210
|
+
<option value="OUTBOUND">Outbound</option>
|
|
211
|
+
</select>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div className="flex flex-col gap-1">
|
|
215
|
+
<label htmlFor="pageSize" className="text-sm font-medium text-gray-700">
|
|
216
|
+
Page size
|
|
217
|
+
</label>
|
|
218
|
+
<select
|
|
219
|
+
id="pageSize"
|
|
220
|
+
value={pageSize}
|
|
221
|
+
onChange={handlePageSizeChange}
|
|
222
|
+
className="w-28 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
223
|
+
>
|
|
224
|
+
<option value={10}>10</option>
|
|
225
|
+
<option value={20}>20</option>
|
|
226
|
+
<option value={50}>50</option>
|
|
227
|
+
<option value={100}>100</option>
|
|
228
|
+
</select>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</Card>
|
|
233
|
+
|
|
234
|
+
<Card>
|
|
235
|
+
<div className="flex items-center justify-between mb-4">
|
|
236
|
+
<h2 className="text-lg font-medium text-gray-900">Message list</h2>
|
|
237
|
+
</div>
|
|
238
|
+
{error && <div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-800">{error}</div>}
|
|
239
|
+
|
|
240
|
+
{loading && items.length === 0 ? (
|
|
241
|
+
<div className="py-8 text-center text-gray-500">Loading messages…</div>
|
|
242
|
+
) : items.length === 0 ? (
|
|
243
|
+
<div className="py-8 text-center text-gray-500">No messages found.</div>
|
|
244
|
+
) : (
|
|
245
|
+
<div className="overflow-x-auto">
|
|
246
|
+
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
|
247
|
+
<thead className="bg-gray-50">
|
|
248
|
+
<tr>
|
|
249
|
+
<th className="px-4 py-3 text-left font-medium text-gray-500">Time</th>
|
|
250
|
+
<th className="px-4 py-3 text-left font-medium text-gray-500">Channel</th>
|
|
251
|
+
<th className="px-4 py-3 text-left font-medium text-gray-500">Direction</th>
|
|
252
|
+
<th className="px-4 py-3 text-left font-medium text-gray-500">Sender/Recipients</th>
|
|
253
|
+
<th className="px-4 py-3 text-left font-medium text-gray-500">Content</th>
|
|
254
|
+
<th className="px-4 py-3 text-left font-medium text-gray-500">Status</th>
|
|
255
|
+
</tr>
|
|
256
|
+
</thead>
|
|
257
|
+
<tbody className="divide-y divide-gray-200 bg-white">
|
|
258
|
+
{items.map((row) => (
|
|
259
|
+
<tr key={row.id}>
|
|
260
|
+
<td className="whitespace-nowrap px-4 py-3 text-gray-700">
|
|
261
|
+
{formatDate(row.createdAt)}
|
|
262
|
+
</td>
|
|
263
|
+
<td className="whitespace-nowrap px-4 py-3 text-gray-700">
|
|
264
|
+
{row.channel}
|
|
265
|
+
</td>
|
|
266
|
+
<td className="whitespace-nowrap px-4 py-3 text-gray-700">
|
|
267
|
+
{row.direction}
|
|
268
|
+
</td>
|
|
269
|
+
<td className="max-w-xs px-4 py-3 text-gray-700">
|
|
270
|
+
<div className="truncate" title={JSON.stringify({ sender: row.sender, recipients: row.recipients }, null, 2)}>
|
|
271
|
+
{/* Simple preview of sender/recipients */}
|
|
272
|
+
S: {JSON.stringify(row.sender)}<br/>
|
|
273
|
+
R: {JSON.stringify(row.recipients)}
|
|
274
|
+
</div>
|
|
275
|
+
</td>
|
|
276
|
+
<td className="max-w-md px-4 py-3 text-gray-700">
|
|
277
|
+
<div className="max-h-24 overflow-hidden overflow-ellipsis text-xs leading-snug">
|
|
278
|
+
{getMessagePreview(row.content)}
|
|
279
|
+
</div>
|
|
280
|
+
</td>
|
|
281
|
+
<td className="whitespace-nowrap px-4 py-3 text-gray-700">
|
|
282
|
+
{getStatusBadge(row.sendAttempts)}
|
|
283
|
+
</td>
|
|
284
|
+
</tr>
|
|
285
|
+
))}
|
|
286
|
+
</tbody>
|
|
287
|
+
</table>
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
{pagination}
|
|
291
|
+
</Card>
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ForbiddenPage } from '../../../components/ForbiddenPage/ForbiddenPage';
|
|
2
|
+
import { isUserAdmin } from '../../../utils/isUserAdmin';
|
|
3
|
+
import { MessagesClient } from './MessagesClient';
|
|
4
|
+
|
|
5
|
+
export default async function AdminMessagesPage() {
|
|
6
|
+
const isAdmin = await isUserAdmin();
|
|
7
|
+
|
|
8
|
+
if (!isAdmin) {
|
|
9
|
+
return <ForbiddenPage />;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return <MessagesClient />;
|
|
13
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { assertsError } from '../../../../../../../src/errors/assertsError';
|
|
5
|
+
import { sendEmailAction } from './actions';
|
|
6
|
+
|
|
7
|
+
export function SendEmailClient() {
|
|
8
|
+
const [status, setStatus] = useState<'IDLE' | 'LOADING' | 'SUCCESS' | 'ERROR'>('IDLE');
|
|
9
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
10
|
+
|
|
11
|
+
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
12
|
+
event.preventDefault();
|
|
13
|
+
setStatus('LOADING');
|
|
14
|
+
setErrorMessage(null);
|
|
15
|
+
|
|
16
|
+
const formData = new FormData(event.currentTarget);
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
await sendEmailAction(formData);
|
|
20
|
+
setStatus('SUCCESS');
|
|
21
|
+
} catch (error) {
|
|
22
|
+
assertsError(error);
|
|
23
|
+
console.error(error);
|
|
24
|
+
setStatus('ERROR');
|
|
25
|
+
setErrorMessage(error.message);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="container mx-auto p-4">
|
|
31
|
+
<h1 className="text-2xl font-bold mb-4">Send Email (Test)</h1>
|
|
32
|
+
|
|
33
|
+
{status === 'SUCCESS' && (
|
|
34
|
+
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
|
35
|
+
Email sent successfully!
|
|
36
|
+
</div>
|
|
37
|
+
)}
|
|
38
|
+
|
|
39
|
+
{status === 'ERROR' && (
|
|
40
|
+
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
|
41
|
+
Error: {errorMessage}
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
|
|
45
|
+
<form onSubmit={handleSubmit} className="space-y-4 max-w-lg">
|
|
46
|
+
<div>
|
|
47
|
+
<label className="block text-sm font-medium text-gray-700">From</label>
|
|
48
|
+
<input
|
|
49
|
+
name="from"
|
|
50
|
+
type="text"
|
|
51
|
+
defaultValue="Test Promptbook <test@ptbk.io>"
|
|
52
|
+
required
|
|
53
|
+
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 border p-2"
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div>
|
|
58
|
+
<label className="block text-sm font-medium text-gray-700">To</label>
|
|
59
|
+
<input
|
|
60
|
+
name="to"
|
|
61
|
+
type="text"
|
|
62
|
+
placeholder="recipient@example.com"
|
|
63
|
+
defaultValue="Pavol Hejný <pavol@ptbk.io>"
|
|
64
|
+
required
|
|
65
|
+
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 border p-2"
|
|
66
|
+
/>
|
|
67
|
+
<p className="text-xs text-gray-500 mt-1">Separate multiple addresses with commas</p>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div>
|
|
71
|
+
<label className="block text-sm font-medium text-gray-700">Subject</label>
|
|
72
|
+
<input
|
|
73
|
+
name="subject"
|
|
74
|
+
type="text"
|
|
75
|
+
placeholder="Test Email"
|
|
76
|
+
defaultValue="Test Email"
|
|
77
|
+
required
|
|
78
|
+
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 border p-2"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div>
|
|
83
|
+
<label className="block text-sm font-medium text-gray-700">Body</label>
|
|
84
|
+
<textarea
|
|
85
|
+
name="body"
|
|
86
|
+
rows={6}
|
|
87
|
+
placeholder="Hello, this is a test email."
|
|
88
|
+
defaultValue="Hello, this is a test email."
|
|
89
|
+
required
|
|
90
|
+
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 border p-2"
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<button
|
|
95
|
+
type="submit"
|
|
96
|
+
disabled={status === 'LOADING'}
|
|
97
|
+
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50"
|
|
98
|
+
>
|
|
99
|
+
{status === 'LOADING' ? 'Sending...' : 'Send Email'}
|
|
100
|
+
</button>
|
|
101
|
+
</form>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import type { string_email, string_emails, string_html } from '@promptbook-local/types';
|
|
4
|
+
import { parseEmailAddress } from '../../../../message-providers/email/_common/utils/parseEmailAddress';
|
|
5
|
+
import { parseEmailAddresses } from '../../../../message-providers/email/_common/utils/parseEmailAddresses';
|
|
6
|
+
import { stringifyEmailAddress } from '../../../../message-providers/email/_common/utils/stringifyEmailAddress';
|
|
7
|
+
import { sendMessage } from '../../../../utils/messages/sendMessage';
|
|
8
|
+
|
|
9
|
+
export async function sendEmailAction(formData: FormData) {
|
|
10
|
+
const from = formData.get('from') as string;
|
|
11
|
+
const to = formData.get('to') as string;
|
|
12
|
+
const subject = formData.get('subject') as string;
|
|
13
|
+
const body = formData.get('body') as string;
|
|
14
|
+
|
|
15
|
+
if (!from || !to || !subject || !body) {
|
|
16
|
+
throw new Error('All fields are required');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sender = stringifyEmailAddress(parseEmailAddress(from as string_email));
|
|
20
|
+
const recipients = parseEmailAddresses(to as string_emails).map(stringifyEmailAddress);
|
|
21
|
+
|
|
22
|
+
await sendMessage({
|
|
23
|
+
channel: 'EMAIL',
|
|
24
|
+
direction: 'OUTBOUND',
|
|
25
|
+
sender,
|
|
26
|
+
recipients,
|
|
27
|
+
subject,
|
|
28
|
+
content: body as string_html,
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
threadId: crypto.randomUUID() as any,
|
|
31
|
+
cc: [],
|
|
32
|
+
attachments: [],
|
|
33
|
+
metadata: {},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ForbiddenPage } from '../../../../components/ForbiddenPage/ForbiddenPage';
|
|
2
|
+
import { isUserAdmin } from '../../../../utils/isUserAdmin';
|
|
3
|
+
import { SendEmailClient } from './SendEmailClient';
|
|
4
|
+
|
|
5
|
+
export default async function AdminSendEmailPage() {
|
|
6
|
+
const isAdmin = await isUserAdmin();
|
|
7
|
+
|
|
8
|
+
if (!isAdmin) {
|
|
9
|
+
return <ForbiddenPage />;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return <SendEmailClient />;
|
|
13
|
+
}
|
|
@@ -29,6 +29,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
|
|
|
29
29
|
const agentHash = computeAgentHash(agentSource);
|
|
30
30
|
const isVoiceCallingEnabled = (await getMetadata('IS_EXPERIMENTAL_VOICE_CALLING_ENABLED')) === 'true';
|
|
31
31
|
|
|
32
|
+
if (!agentProfile.meta.image) {
|
|
33
|
+
agentProfile.meta.image = `/agents/${encodeURIComponent(agentName)}/images/default-avatar.png`;
|
|
34
|
+
}
|
|
35
|
+
|
|
32
36
|
return new Response(
|
|
33
37
|
JSON.stringify(
|
|
34
38
|
{
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { $getTableName } from '@/src/database/$getTableName';
|
|
2
|
+
import { $provideSupabaseForServer } from '@/src/database/$provideSupabaseForServer';
|
|
3
|
+
import { $provideAgentCollectionForServer } from '@/src/tools/$provideAgentCollectionForServer';
|
|
4
|
+
import { $provideCdnForServer } from '@/src/tools/$provideCdnForServer';
|
|
5
|
+
import { $provideExecutionToolsForServer } from '@/src/tools/$provideExecutionToolsForServer';
|
|
6
|
+
import { parseAgentSource } from '@promptbook-local/core';
|
|
7
|
+
import { serializeError } from '@promptbook-local/utils';
|
|
8
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
9
|
+
import { assertsError } from '../../../../../../../../src/errors/assertsError';
|
|
10
|
+
import { getSingleLlmExecutionTools } from '../../../../../../../../src/llm-providers/_multiple/getSingleLlmExecutionTools';
|
|
11
|
+
import type { LlmExecutionTools } from '../../../../../../../../src/execution/LlmExecutionTools';
|
|
12
|
+
import type { string_url } from '../../../../../../../../src/types/typeAliases';
|
|
13
|
+
|
|
14
|
+
export async function GET(request: NextRequest, { params }: { params: Promise<{ agentName: string }> }) {
|
|
15
|
+
try {
|
|
16
|
+
let { agentName } = await params;
|
|
17
|
+
agentName = decodeURIComponent(agentName);
|
|
18
|
+
|
|
19
|
+
if (!agentName) {
|
|
20
|
+
return NextResponse.json({ error: 'Agent name is required' }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Define a unique filename/key for this agent's default avatar
|
|
24
|
+
// This is used for DB lookup and CDN storage, distinct from generic images
|
|
25
|
+
const internalFilename = `agent-${agentName}-default-avatar.png`;
|
|
26
|
+
|
|
27
|
+
const supabase = $provideSupabaseForServer();
|
|
28
|
+
|
|
29
|
+
// Check if image already exists in database
|
|
30
|
+
const { data: existingImage, error: selectError } = await supabase
|
|
31
|
+
.from(await $getTableName(`Image`))
|
|
32
|
+
.select('cdnUrl')
|
|
33
|
+
.eq('filename', internalFilename)
|
|
34
|
+
.single();
|
|
35
|
+
|
|
36
|
+
if (selectError && selectError.code !== 'PGRST116') {
|
|
37
|
+
// PGRST116 is "not found"
|
|
38
|
+
throw selectError;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (existingImage) {
|
|
42
|
+
// Image exists, redirect to CDN
|
|
43
|
+
return NextResponse.redirect(existingImage.cdnUrl as string_url);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Image doesn't exist, generate it
|
|
47
|
+
|
|
48
|
+
// 1. Fetch agent data
|
|
49
|
+
const collection = await $provideAgentCollectionForServer();
|
|
50
|
+
let agentSource;
|
|
51
|
+
try {
|
|
52
|
+
agentSource = await collection.getAgentSource(agentName);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// If agent not found, return 404 or default generic image?
|
|
55
|
+
// User said: "Use the ... instead of Gravatar for agents that do not have custom uploaded avatar"
|
|
56
|
+
// If agent doesn't exist, we probably can't generate a specific avatar.
|
|
57
|
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const agentProfile = parseAgentSource(agentSource);
|
|
61
|
+
|
|
62
|
+
// Extract required fields
|
|
63
|
+
const name = agentProfile.meta?.title || agentProfile.agentName || agentName;
|
|
64
|
+
const persona = agentProfile.personaDescription || 'an AI agent';
|
|
65
|
+
const color = agentProfile.meta?.color || 'blue';
|
|
66
|
+
|
|
67
|
+
// Construct prompt
|
|
68
|
+
// "Image of {agent.name}, {agent.persona}, portrait, use color ${agent.meta.color}, detailed, high quality"
|
|
69
|
+
const prompt = `Image of ${name}, ${persona}, portrait, use color ${color}, detailed, high quality`;
|
|
70
|
+
|
|
71
|
+
// 2. Generate image
|
|
72
|
+
const executionTools = await $provideExecutionToolsForServer();
|
|
73
|
+
const llmTools = getSingleLlmExecutionTools(executionTools.llm) as LlmExecutionTools;
|
|
74
|
+
|
|
75
|
+
if (!llmTools.callImageGenerationModel) {
|
|
76
|
+
throw new Error('Image generation is not supported by the current LLM configuration');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const imageResult = await llmTools.callImageGenerationModel({
|
|
80
|
+
title: `Generate default avatar for ${agentName}`,
|
|
81
|
+
content: prompt,
|
|
82
|
+
parameters: {
|
|
83
|
+
size: '1024x1792', // Vertical orientation
|
|
84
|
+
},
|
|
85
|
+
modelRequirements: {
|
|
86
|
+
modelVariant: 'IMAGE_GENERATION',
|
|
87
|
+
modelName: 'dall-e-3',
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (!imageResult.content) {
|
|
92
|
+
throw new Error('Failed to generate image: no content returned');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. Download and Upload to CDN
|
|
96
|
+
const imageResponse = await fetch(imageResult.content);
|
|
97
|
+
if (!imageResponse.ok) {
|
|
98
|
+
throw new Error(`Failed to download generated image: ${imageResponse.status}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const imageBuffer = await imageResponse.arrayBuffer();
|
|
102
|
+
const buffer = Buffer.from(imageBuffer);
|
|
103
|
+
|
|
104
|
+
const cdn = $provideCdnForServer();
|
|
105
|
+
const cdnKey = `generated-images/${internalFilename}`;
|
|
106
|
+
await cdn.setItem(cdnKey, {
|
|
107
|
+
type: 'image/png',
|
|
108
|
+
data: buffer,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const cdnUrl = cdn.getItemUrl(cdnKey);
|
|
112
|
+
|
|
113
|
+
// 4. Save to database
|
|
114
|
+
const { error: insertError } = await supabase.from(await $getTableName(`Image`)).insert({
|
|
115
|
+
filename: internalFilename,
|
|
116
|
+
prompt,
|
|
117
|
+
cdnUrl: cdnUrl.href,
|
|
118
|
+
cdnKey,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (insertError) {
|
|
122
|
+
// Use upsert or handle race condition if needed, but insert is fine for now
|
|
123
|
+
// If parallel requests happen, one might fail. We can ignore dup key error or retry.
|
|
124
|
+
// But simple insert is what generic route does.
|
|
125
|
+
throw insertError;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Redirect to the newly created image
|
|
129
|
+
return NextResponse.redirect(cdnUrl.href as string_url);
|
|
130
|
+
|
|
131
|
+
} catch (error) {
|
|
132
|
+
assertsError(error);
|
|
133
|
+
console.error('Error serving default avatar:', error);
|
|
134
|
+
return new Response(JSON.stringify(serializeError(error), null, 4), {
|
|
135
|
+
status: 500,
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|