@promptbook/cli 0.113.0-0 → 0.113.0-1
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/task-manager/TaskManagerTaskRow.tsx +7 -2
- package/apps/agents-server/src/app/api/page-preview/check/route.ts +5 -20
- package/apps/agents-server/src/app/api/page-preview/interact/route.ts +179 -0
- package/apps/agents-server/src/app/api/page-preview/live/route.ts +67 -0
- package/apps/agents-server/src/app/api/page-preview/screenshot/route.ts +13 -21
- package/apps/agents-server/src/constants/chatVisualMode.ts +1 -1
- package/apps/agents-server/src/constants/themeMode.ts +1 -1
- package/apps/agents-server/src/database/migrations/2026-05-2600-default-theme.sql +2 -2
- package/apps/agents-server/src/utils/chatTasksAdmin.ts +3 -1
- package/apps/agents-server/src/utils/getAdminChatTasksResponse/mapVpsSelfUpdateJobToAdminChatTask.ts +84 -0
- package/apps/agents-server/src/utils/getAdminChatTasksResponse.ts +195 -5
- package/apps/agents-server/src/utils/pagePreview/livePagePreviewSessions.ts +365 -0
- package/apps/agents-server/src/utils/pagePreview/resolvePagePreviewRequestUrl.ts +74 -0
- package/apps/agents-server/src/utils/vpsSelfUpdate.ts +12 -0
- package/esm/index.es.js +1 -1
- package/esm/src/book-components/Chat/Chat/CitationIframePreview.d.ts +2 -2
- package/esm/src/book-components/Chat/utils/livePagePreviewConstants.d.ts +12 -0
- package/esm/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/book-components/Chat/Chat/Chat.module.css +14 -5
- package/src/book-components/Chat/Chat/CitationIframePreview.tsx +197 -22
- package/src/book-components/Chat/utils/livePagePreviewConstants.ts +17 -0
- package/src/other/templates/getTemplatesPipelineCollection.ts +764 -750
- package/src/version.ts +2 -2
- package/src/versions.txt +1 -0
- package/umd/index.umd.js +1 -1
- package/umd/src/book-components/Chat/Chat/CitationIframePreview.d.ts +2 -2
- package/umd/src/book-components/Chat/utils/livePagePreviewConstants.d.ts +12 -0
- package/umd/src/version.d.ts +1 -1
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { ensureUserChatTimeoutWorkerBootstrapped } from '@/src/utils/userChatTimeout/ensureUserChatTimeoutWorkerBootstrapped';
|
|
2
|
-
import type { AdminChatTaskListResponse } from './chatTasksAdmin';
|
|
2
|
+
import type { AdminChatTaskCounters, AdminChatTaskListResponse, AdminChatTaskRecord } from './chatTasksAdmin';
|
|
3
3
|
import { getAdminChatTasks } from './getAdminChatTasksResponse/getAdminChatTasks';
|
|
4
|
-
import {
|
|
4
|
+
import { mapVpsSelfUpdateJobToAdminChatTask } from './getAdminChatTasksResponse/mapVpsSelfUpdateJobToAdminChatTask';
|
|
5
|
+
import { parseAdminChatTaskQuery, type ParsedAdminChatTaskQuery } from './getAdminChatTasksResponse/parseAdminChatTaskQuery';
|
|
5
6
|
import { throttledAdminRecovery } from './getAdminChatTasksResponse/throttledAdminRecovery';
|
|
7
|
+
import { readVpsSelfUpdateJobSnapshot } from './vpsSelfUpdate';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Milliseconds in one hour.
|
|
11
|
+
*
|
|
12
|
+
* @private internal constant of `getAdminChatTasksResponse`
|
|
13
|
+
*/
|
|
14
|
+
const HOUR_IN_MILLISECONDS = 60 * 60 * 1000;
|
|
6
15
|
|
|
7
16
|
/**
|
|
8
17
|
* Successful task-manager response envelope.
|
|
@@ -53,13 +62,23 @@ export async function getAdminChatTasksResponse(
|
|
|
53
62
|
await throttledAdminRecovery();
|
|
54
63
|
|
|
55
64
|
const adminChatTasks = await getAdminChatTasks(parsedQuery);
|
|
65
|
+
const vpsSelfUpdateTask = await loadVpsSelfUpdateAdminChatTask();
|
|
66
|
+
const injectedTasks = collectAdminChatTasksToInject(vpsSelfUpdateTask, parsedQuery);
|
|
67
|
+
const { items, total } = mergeInjectedAdminChatTasks({
|
|
68
|
+
databaseItems: adminChatTasks.items,
|
|
69
|
+
databaseTotal: adminChatTasks.total,
|
|
70
|
+
injectedTasks,
|
|
71
|
+
page: parsedQuery.page,
|
|
72
|
+
pageSize: parsedQuery.pageSize,
|
|
73
|
+
});
|
|
74
|
+
const counters = mergeInjectedAdminChatTaskCounters(adminChatTasks.counters, vpsSelfUpdateTask);
|
|
56
75
|
|
|
57
76
|
return {
|
|
58
77
|
status: 200,
|
|
59
78
|
response: {
|
|
60
|
-
items
|
|
61
|
-
counters
|
|
62
|
-
total
|
|
79
|
+
items,
|
|
80
|
+
counters,
|
|
81
|
+
total,
|
|
63
82
|
page: parsedQuery.page,
|
|
64
83
|
pageSize: parsedQuery.pageSize,
|
|
65
84
|
view: parsedQuery.view,
|
|
@@ -69,3 +88,174 @@ export async function getAdminChatTasksResponse(
|
|
|
69
88
|
},
|
|
70
89
|
};
|
|
71
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Loads the currently persisted standalone VPS self-update task, if any.
|
|
94
|
+
*
|
|
95
|
+
* The read is defensive so a corrupt status file cannot block the admin task manager from rendering.
|
|
96
|
+
*
|
|
97
|
+
* @returns Injectable task record or `null` when no self-update has ever been triggered on this server.
|
|
98
|
+
*/
|
|
99
|
+
async function loadVpsSelfUpdateAdminChatTask(): Promise<AdminChatTaskRecord | null> {
|
|
100
|
+
try {
|
|
101
|
+
const jobSnapshot = await readVpsSelfUpdateJobSnapshot();
|
|
102
|
+
return mapVpsSelfUpdateJobToAdminChatTask(jobSnapshot);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error('[admin-chat-task] failed to load VPS self-update task snapshot', error);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Filters out injected tasks that do not belong to the requested admin task-manager view or search.
|
|
111
|
+
*
|
|
112
|
+
* @param vpsSelfUpdateTask - Currently persisted self-update task or `null`.
|
|
113
|
+
* @param query - Parsed admin task-manager query.
|
|
114
|
+
* @returns Tasks to inject on top of the database-backed items.
|
|
115
|
+
*/
|
|
116
|
+
function collectAdminChatTasksToInject(
|
|
117
|
+
vpsSelfUpdateTask: AdminChatTaskRecord | null,
|
|
118
|
+
query: ParsedAdminChatTaskQuery,
|
|
119
|
+
): ReadonlyArray<AdminChatTaskRecord> {
|
|
120
|
+
if (!vpsSelfUpdateTask) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!matchesAdminChatTaskView(vpsSelfUpdateTask, query, Date.now())) {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!matchesAdminChatTaskSearch(vpsSelfUpdateTask, query.search)) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return [vpsSelfUpdateTask];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Prepends the injected task rows to the paginated database items so the self-update stays visible on page 1.
|
|
137
|
+
*
|
|
138
|
+
* @param options - Merge inputs.
|
|
139
|
+
* @returns Merged items for the current page and the updated total row count.
|
|
140
|
+
*/
|
|
141
|
+
function mergeInjectedAdminChatTasks(options: {
|
|
142
|
+
readonly databaseItems: ReadonlyArray<AdminChatTaskRecord>;
|
|
143
|
+
readonly databaseTotal: number;
|
|
144
|
+
readonly injectedTasks: ReadonlyArray<AdminChatTaskRecord>;
|
|
145
|
+
readonly page: number;
|
|
146
|
+
readonly pageSize: number;
|
|
147
|
+
}): { items: Array<AdminChatTaskRecord>; total: number } {
|
|
148
|
+
const total = options.databaseTotal + options.injectedTasks.length;
|
|
149
|
+
|
|
150
|
+
if (options.injectedTasks.length === 0) {
|
|
151
|
+
return { items: [...options.databaseItems], total };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (options.page !== 1) {
|
|
155
|
+
return { items: [...options.databaseItems], total };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const items = [...options.injectedTasks, ...options.databaseItems].slice(0, options.pageSize);
|
|
159
|
+
return { items, total };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Adds the injected self-update task to the summary counters so the header metrics stay accurate.
|
|
164
|
+
*
|
|
165
|
+
* @param databaseCounters - Counters computed from durable database rows.
|
|
166
|
+
* @param vpsSelfUpdateTask - Currently persisted self-update task or `null`.
|
|
167
|
+
* @returns Merged counters including the injected task.
|
|
168
|
+
*/
|
|
169
|
+
function mergeInjectedAdminChatTaskCounters(
|
|
170
|
+
databaseCounters: AdminChatTaskCounters,
|
|
171
|
+
vpsSelfUpdateTask: AdminChatTaskRecord | null,
|
|
172
|
+
): AdminChatTaskCounters {
|
|
173
|
+
if (!vpsSelfUpdateTask) {
|
|
174
|
+
return databaseCounters;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const nowTimestamp = Date.now();
|
|
178
|
+
const isRunning = vpsSelfUpdateTask.status === 'RUNNING';
|
|
179
|
+
const isFailedInWindow =
|
|
180
|
+
vpsSelfUpdateTask.status === 'FAILED' &&
|
|
181
|
+
isIsoTimestampAtOrAfter(vpsSelfUpdateTask.finishedAt, nowTimestamp - 24 * HOUR_IN_MILLISECONDS);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
runningCount: databaseCounters.runningCount + (isRunning ? 1 : 0),
|
|
185
|
+
queuedCount: databaseCounters.queuedCount,
|
|
186
|
+
failedLast24hCount: databaseCounters.failedLast24hCount + (isFailedInWindow ? 1 : 0),
|
|
187
|
+
oldestQueuedAgeMs: databaseCounters.oldestQueuedAgeMs,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Returns whether the injected task belongs in the requested admin task-manager view.
|
|
193
|
+
*
|
|
194
|
+
* @param task - Injected task.
|
|
195
|
+
* @param query - Parsed admin task-manager query.
|
|
196
|
+
* @param nowTimestamp - Current epoch used for time-window filtering.
|
|
197
|
+
* @returns `true` when the task should be included.
|
|
198
|
+
*/
|
|
199
|
+
function matchesAdminChatTaskView(
|
|
200
|
+
task: AdminChatTaskRecord,
|
|
201
|
+
query: ParsedAdminChatTaskQuery,
|
|
202
|
+
nowTimestamp: number,
|
|
203
|
+
): boolean {
|
|
204
|
+
switch (query.view) {
|
|
205
|
+
case 'running':
|
|
206
|
+
return task.status === 'RUNNING';
|
|
207
|
+
case 'queued':
|
|
208
|
+
return task.status === 'QUEUED';
|
|
209
|
+
case 'failed':
|
|
210
|
+
return (
|
|
211
|
+
task.status === 'FAILED' &&
|
|
212
|
+
isIsoTimestampAtOrAfter(task.finishedAt, nowTimestamp - 24 * HOUR_IN_MILLISECONDS)
|
|
213
|
+
);
|
|
214
|
+
case 'all':
|
|
215
|
+
return isIsoTimestampAtOrAfter(task.updatedAt, nowTimestamp - query.timeWindowHours * HOUR_IN_MILLISECONDS);
|
|
216
|
+
case 'active':
|
|
217
|
+
default:
|
|
218
|
+
return task.status === 'QUEUED' || task.status === 'RUNNING';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Returns whether the injected task matches the free-text admin search input.
|
|
224
|
+
*
|
|
225
|
+
* @param task - Injected task.
|
|
226
|
+
* @param search - Trimmed search text from the parsed query.
|
|
227
|
+
* @returns `true` when the task should be included.
|
|
228
|
+
*/
|
|
229
|
+
function matchesAdminChatTaskSearch(task: AdminChatTaskRecord, search: string): boolean {
|
|
230
|
+
if (!search) {
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const normalizedSearch = search.toLowerCase();
|
|
235
|
+
if (task.id.toLowerCase().includes(normalizedSearch)) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
if (task.agentPermanentId.toLowerCase().includes(normalizedSearch)) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
if ((task.agentName || '').toLowerCase().includes(normalizedSearch)) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
return task.chatId.toLowerCase().includes(normalizedSearch);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Returns whether one ISO timestamp is at or after the given cutoff.
|
|
249
|
+
*
|
|
250
|
+
* @param timestampIso - Optional ISO timestamp.
|
|
251
|
+
* @param cutoffTimestamp - Epoch cutoff in milliseconds.
|
|
252
|
+
* @returns `true` when the timestamp is at or after the cutoff.
|
|
253
|
+
*/
|
|
254
|
+
function isIsoTimestampAtOrAfter(timestampIso: string | null, cutoffTimestamp: number): boolean {
|
|
255
|
+
if (!timestampIso) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const timestamp = Date.parse(timestampIso);
|
|
260
|
+
return Number.isFinite(timestamp) && timestamp >= cutoffTimestamp;
|
|
261
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import { spaceTrim } from 'spacetrim';
|
|
3
|
+
import {
|
|
4
|
+
LIVE_PAGE_PREVIEW_VIEWPORT_HEIGHT,
|
|
5
|
+
LIVE_PAGE_PREVIEW_VIEWPORT_WIDTH,
|
|
6
|
+
} from '../../../../../src/book-components/Chat/utils/livePagePreviewConstants';
|
|
7
|
+
import { KnowledgeScrapeError } from '../../../../../src/errors/KnowledgeScrapeError';
|
|
8
|
+
import { $provideBrowserForServer } from '../../tools/$provideBrowserForServer';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pattern for live-preview session identifiers created by the browser client.
|
|
12
|
+
*/
|
|
13
|
+
const LIVE_PAGE_PREVIEW_SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Navigation timeout for opening a live knowledge preview page.
|
|
17
|
+
*/
|
|
18
|
+
const LIVE_PAGE_PREVIEW_NAVIGATION_TIMEOUT_MS = 30_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Playwright action timeout for live-preview interactions.
|
|
22
|
+
*/
|
|
23
|
+
const LIVE_PAGE_PREVIEW_ACTION_TIMEOUT_MS = 5_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Idle session lifetime before a stale live-preview page is closed.
|
|
27
|
+
*/
|
|
28
|
+
const LIVE_PAGE_PREVIEW_SESSION_IDLE_TIMEOUT_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Multipart response boundary used by the live-preview image stream.
|
|
32
|
+
*/
|
|
33
|
+
const LIVE_PAGE_PREVIEW_STREAM_BOUNDARY = 'page-preview-frame';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Delay between live-preview browser frames.
|
|
37
|
+
*/
|
|
38
|
+
const LIVE_PAGE_PREVIEW_FRAME_INTERVAL_MS = 500;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* JPEG quality for live-preview browser frames.
|
|
42
|
+
*/
|
|
43
|
+
const LIVE_PAGE_PREVIEW_JPEG_QUALITY = 65;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Browser page stored for a live knowledge preview session.
|
|
47
|
+
*/
|
|
48
|
+
type LivePagePreviewSession = {
|
|
49
|
+
readonly page: Page;
|
|
50
|
+
readonly url: string;
|
|
51
|
+
lastAccessedAt: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pointer interaction sent from the live preview image to the browser page.
|
|
56
|
+
*/
|
|
57
|
+
type LivePagePreviewClickInteraction = {
|
|
58
|
+
readonly type: 'click';
|
|
59
|
+
readonly x: number;
|
|
60
|
+
readonly y: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Wheel interaction sent from the live preview image to the browser page.
|
|
65
|
+
*/
|
|
66
|
+
type LivePagePreviewWheelInteraction = {
|
|
67
|
+
readonly type: 'wheel';
|
|
68
|
+
readonly deltaX: number;
|
|
69
|
+
readonly deltaY: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Keyboard interaction sent from the live preview image to the browser page.
|
|
74
|
+
*/
|
|
75
|
+
type LivePagePreviewKeyDownInteraction = {
|
|
76
|
+
readonly type: 'keyDown';
|
|
77
|
+
readonly key: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Supported interaction payload for a live knowledge preview session.
|
|
82
|
+
*/
|
|
83
|
+
export type LivePagePreviewInteraction =
|
|
84
|
+
| LivePagePreviewClickInteraction
|
|
85
|
+
| LivePagePreviewWheelInteraction
|
|
86
|
+
| LivePagePreviewKeyDownInteraction;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Active browser pages for live knowledge previews.
|
|
90
|
+
*/
|
|
91
|
+
const LIVE_PAGE_PREVIEW_SESSIONS = new Map<string, LivePagePreviewSession>();
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Content type for the live-preview multipart image stream.
|
|
95
|
+
*/
|
|
96
|
+
export const LIVE_PAGE_PREVIEW_STREAM_CONTENT_TYPE = `multipart/x-mixed-replace; boundary=${LIVE_PAGE_PREVIEW_STREAM_BOUNDARY}`;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Checks whether a live-preview session identifier is safe to use as a map key.
|
|
100
|
+
*
|
|
101
|
+
* @param sessionId - Candidate session identifier.
|
|
102
|
+
* @returns True when the value is a valid live-preview session id.
|
|
103
|
+
*/
|
|
104
|
+
export function isLivePagePreviewSessionId(sessionId: string): boolean {
|
|
105
|
+
return LIVE_PAGE_PREVIEW_SESSION_ID_PATTERN.test(sessionId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Opens or reuses the browser page for one live knowledge preview.
|
|
110
|
+
*
|
|
111
|
+
* @param options - Session, target URL, and request cancellation signal.
|
|
112
|
+
* @returns Playwright page backing the live preview.
|
|
113
|
+
*/
|
|
114
|
+
export async function getOrCreateLivePagePreviewSession(options: {
|
|
115
|
+
readonly sessionId: string;
|
|
116
|
+
readonly url: string;
|
|
117
|
+
readonly signal: AbortSignal;
|
|
118
|
+
}): Promise<Page> {
|
|
119
|
+
pruneExpiredLivePagePreviewSessions();
|
|
120
|
+
|
|
121
|
+
const existingSession = LIVE_PAGE_PREVIEW_SESSIONS.get(options.sessionId);
|
|
122
|
+
if (existingSession && existingSession.url === options.url && !existingSession.page.isClosed()) {
|
|
123
|
+
existingSession.lastAccessedAt = Date.now();
|
|
124
|
+
return existingSession.page;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (existingSession) {
|
|
128
|
+
await closeLivePagePreviewSession(options.sessionId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const browserContext = await $provideBrowserForServer({
|
|
132
|
+
signal: options.signal,
|
|
133
|
+
sessionId: options.sessionId,
|
|
134
|
+
});
|
|
135
|
+
const page = await browserContext.newPage();
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
page.setDefaultNavigationTimeout(LIVE_PAGE_PREVIEW_NAVIGATION_TIMEOUT_MS);
|
|
139
|
+
page.setDefaultTimeout(LIVE_PAGE_PREVIEW_ACTION_TIMEOUT_MS);
|
|
140
|
+
await page.setViewportSize({
|
|
141
|
+
width: LIVE_PAGE_PREVIEW_VIEWPORT_WIDTH,
|
|
142
|
+
height: LIVE_PAGE_PREVIEW_VIEWPORT_HEIGHT,
|
|
143
|
+
});
|
|
144
|
+
await page.goto(options.url, {
|
|
145
|
+
waitUntil: 'domcontentloaded',
|
|
146
|
+
timeout: LIVE_PAGE_PREVIEW_NAVIGATION_TIMEOUT_MS,
|
|
147
|
+
});
|
|
148
|
+
} catch {
|
|
149
|
+
await closeLivePagePreviewPage(page);
|
|
150
|
+
throw new KnowledgeScrapeError(
|
|
151
|
+
spaceTrim(`
|
|
152
|
+
Failed to open live knowledge preview for \`${options.url}\`.
|
|
153
|
+
|
|
154
|
+
The browser session could not navigate to the requested source.
|
|
155
|
+
`),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
LIVE_PAGE_PREVIEW_SESSIONS.set(options.sessionId, {
|
|
160
|
+
page,
|
|
161
|
+
url: options.url,
|
|
162
|
+
lastAccessedAt: Date.now(),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return page;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Closes one live knowledge preview session if it exists.
|
|
170
|
+
*
|
|
171
|
+
* @param sessionId - Live-preview session identifier.
|
|
172
|
+
*/
|
|
173
|
+
export async function closeLivePagePreviewSession(sessionId: string): Promise<void> {
|
|
174
|
+
const existingSession = LIVE_PAGE_PREVIEW_SESSIONS.get(sessionId);
|
|
175
|
+
if (!existingSession) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
LIVE_PAGE_PREVIEW_SESSIONS.delete(sessionId);
|
|
180
|
+
await closeLivePagePreviewPage(existingSession.page);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Creates an MJPEG stream from an active live-preview browser page.
|
|
185
|
+
*
|
|
186
|
+
* @param options - Session identifier, browser page, and request cancellation signal.
|
|
187
|
+
* @returns Readable stream that emits JPEG browser frames until the client disconnects.
|
|
188
|
+
*/
|
|
189
|
+
export function createLivePagePreviewStream(options: {
|
|
190
|
+
readonly sessionId: string;
|
|
191
|
+
readonly page: Page;
|
|
192
|
+
readonly signal: AbortSignal;
|
|
193
|
+
}): ReadableStream<Uint8Array> {
|
|
194
|
+
const encoder = new TextEncoder();
|
|
195
|
+
let isStreamClosed = false;
|
|
196
|
+
let isSessionClosed = false;
|
|
197
|
+
|
|
198
|
+
const closeSessionOnce = async (): Promise<void> => {
|
|
199
|
+
if (isSessionClosed) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
isSessionClosed = true;
|
|
204
|
+
await closeLivePagePreviewSession(options.sessionId);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
return new ReadableStream<Uint8Array>({
|
|
208
|
+
async start(controller) {
|
|
209
|
+
const handleAbort = (): void => {
|
|
210
|
+
isStreamClosed = true;
|
|
211
|
+
void closeSessionOnce();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
options.signal.addEventListener('abort', handleAbort, { once: true });
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
while (!isStreamClosed && !options.signal.aborted && !options.page.isClosed()) {
|
|
218
|
+
const buffer = await options.page.screenshot({
|
|
219
|
+
type: 'jpeg',
|
|
220
|
+
quality: LIVE_PAGE_PREVIEW_JPEG_QUALITY,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (isStreamClosed || options.signal.aborted || options.page.isClosed()) {
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const header = [
|
|
228
|
+
'',
|
|
229
|
+
`--${LIVE_PAGE_PREVIEW_STREAM_BOUNDARY}`,
|
|
230
|
+
'Content-Type: image/jpeg',
|
|
231
|
+
`Content-Length: ${buffer.length}`,
|
|
232
|
+
'',
|
|
233
|
+
'',
|
|
234
|
+
].join('\r\n');
|
|
235
|
+
|
|
236
|
+
controller.enqueue(encoder.encode(header));
|
|
237
|
+
controller.enqueue(buffer);
|
|
238
|
+
|
|
239
|
+
await waitForLivePagePreviewFrame(options.signal);
|
|
240
|
+
}
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (!isStreamClosed && !options.signal.aborted) {
|
|
243
|
+
console.error('Error streaming live page preview:', error);
|
|
244
|
+
}
|
|
245
|
+
} finally {
|
|
246
|
+
isStreamClosed = true;
|
|
247
|
+
options.signal.removeEventListener('abort', handleAbort);
|
|
248
|
+
await closeSessionOnce();
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
controller.close();
|
|
252
|
+
} catch {
|
|
253
|
+
// The client can disconnect before the server closes the stream.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
async cancel() {
|
|
258
|
+
isStreamClosed = true;
|
|
259
|
+
await closeSessionOnce();
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Applies one browser interaction to an active live preview session.
|
|
266
|
+
*
|
|
267
|
+
* @param options - Session identifier and normalized interaction.
|
|
268
|
+
* @returns True when the interaction was applied to an active session.
|
|
269
|
+
*/
|
|
270
|
+
export async function applyLivePagePreviewInteraction(options: {
|
|
271
|
+
readonly sessionId: string;
|
|
272
|
+
readonly interaction: LivePagePreviewInteraction;
|
|
273
|
+
}): Promise<boolean> {
|
|
274
|
+
const session = LIVE_PAGE_PREVIEW_SESSIONS.get(options.sessionId);
|
|
275
|
+
if (!session || session.page.isClosed()) {
|
|
276
|
+
LIVE_PAGE_PREVIEW_SESSIONS.delete(options.sessionId);
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
session.lastAccessedAt = Date.now();
|
|
281
|
+
|
|
282
|
+
switch (options.interaction.type) {
|
|
283
|
+
case 'click':
|
|
284
|
+
await session.page.mouse.click(
|
|
285
|
+
clampLivePagePreviewCoordinate(options.interaction.x, LIVE_PAGE_PREVIEW_VIEWPORT_WIDTH),
|
|
286
|
+
clampLivePagePreviewCoordinate(options.interaction.y, LIVE_PAGE_PREVIEW_VIEWPORT_HEIGHT),
|
|
287
|
+
);
|
|
288
|
+
return true;
|
|
289
|
+
|
|
290
|
+
case 'wheel':
|
|
291
|
+
await session.page.mouse.wheel(options.interaction.deltaX, options.interaction.deltaY);
|
|
292
|
+
return true;
|
|
293
|
+
|
|
294
|
+
case 'keyDown':
|
|
295
|
+
await session.page.keyboard.press(options.interaction.key);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Removes stale live-preview sessions left behind by disconnected clients.
|
|
302
|
+
*/
|
|
303
|
+
function pruneExpiredLivePagePreviewSessions(): void {
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
for (const [sessionId, session] of LIVE_PAGE_PREVIEW_SESSIONS) {
|
|
306
|
+
const isExpired = now - session.lastAccessedAt > LIVE_PAGE_PREVIEW_SESSION_IDLE_TIMEOUT_MS;
|
|
307
|
+
if (isExpired || session.page.isClosed()) {
|
|
308
|
+
LIVE_PAGE_PREVIEW_SESSIONS.delete(sessionId);
|
|
309
|
+
void closeLivePagePreviewPage(session.page);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Waits between streamed browser frames, resolving early when the request is aborted.
|
|
316
|
+
*
|
|
317
|
+
* @param signal - Request cancellation signal.
|
|
318
|
+
*/
|
|
319
|
+
async function waitForLivePagePreviewFrame(signal: AbortSignal): Promise<void> {
|
|
320
|
+
if (signal.aborted) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
await new Promise<void>((resolve) => {
|
|
325
|
+
const timeout = setTimeout(() => {
|
|
326
|
+
signal.removeEventListener('abort', handleAbort);
|
|
327
|
+
resolve();
|
|
328
|
+
}, LIVE_PAGE_PREVIEW_FRAME_INTERVAL_MS);
|
|
329
|
+
|
|
330
|
+
function handleAbort(): void {
|
|
331
|
+
clearTimeout(timeout);
|
|
332
|
+
resolve();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
signal.addEventListener('abort', handleAbort, { once: true });
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Closes a Playwright page while ignoring cleanup errors.
|
|
341
|
+
*
|
|
342
|
+
* @param page - Browser page to close.
|
|
343
|
+
*/
|
|
344
|
+
async function closeLivePagePreviewPage(page: Page): Promise<void> {
|
|
345
|
+
if (page.isClosed()) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
await page.close().catch(() => undefined);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Clamps a pointer coordinate to the configured live-preview viewport.
|
|
354
|
+
*
|
|
355
|
+
* @param value - Raw coordinate from the web UI.
|
|
356
|
+
* @param maximum - Maximum coordinate value for one axis.
|
|
357
|
+
* @returns Coordinate safe to pass into Playwright.
|
|
358
|
+
*/
|
|
359
|
+
function clampLivePagePreviewCoordinate(value: number, maximum: number): number {
|
|
360
|
+
if (!Number.isFinite(value)) {
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return Math.min(Math.max(value, 0), maximum);
|
|
365
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { assertsError } from '../../../../../src/errors/assertsError';
|
|
4
|
+
import { assertSafeUrl } from '../assertSafeUrl';
|
|
5
|
+
import { getCurrentUser } from '../getCurrentUser';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Query parameter used by page-preview API endpoints.
|
|
9
|
+
*/
|
|
10
|
+
const PAGE_PREVIEW_URL_QUERY_PARAMETER = 'url';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Successful page-preview request URL resolution.
|
|
14
|
+
*/
|
|
15
|
+
type PagePreviewRequestUrlSuccess = {
|
|
16
|
+
readonly url: string;
|
|
17
|
+
readonly errorResponse: null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Failed page-preview request URL resolution.
|
|
22
|
+
*/
|
|
23
|
+
type PagePreviewRequestUrlFailure = {
|
|
24
|
+
readonly url: null;
|
|
25
|
+
readonly errorResponse: NextResponse;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Result of authenticating and validating a page-preview request URL.
|
|
30
|
+
*/
|
|
31
|
+
export type PagePreviewRequestUrlResolution = PagePreviewRequestUrlSuccess | PagePreviewRequestUrlFailure;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Authenticates a page-preview request and validates its target URL against SSRF-sensitive destinations.
|
|
35
|
+
*
|
|
36
|
+
* @param request - Incoming Next.js request.
|
|
37
|
+
* @returns Valid target URL or a ready-made error response.
|
|
38
|
+
*/
|
|
39
|
+
export async function resolvePagePreviewRequestUrl(request: NextRequest): Promise<PagePreviewRequestUrlResolution> {
|
|
40
|
+
const currentUser = await getCurrentUser();
|
|
41
|
+
if (!currentUser) {
|
|
42
|
+
return {
|
|
43
|
+
url: null,
|
|
44
|
+
errorResponse: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const url = request.nextUrl.searchParams.get(PAGE_PREVIEW_URL_QUERY_PARAMETER);
|
|
49
|
+
|
|
50
|
+
if (!url) {
|
|
51
|
+
return {
|
|
52
|
+
url: null,
|
|
53
|
+
errorResponse: NextResponse.json(
|
|
54
|
+
{ error: `Missing required query parameter: ${PAGE_PREVIEW_URL_QUERY_PARAMETER}` },
|
|
55
|
+
{ status: 400 },
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
assertSafeUrl(url);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
assertsError(error);
|
|
64
|
+
return {
|
|
65
|
+
url: null,
|
|
66
|
+
errorResponse: NextResponse.json({ error: error.message }, { status: 400 }),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
url,
|
|
72
|
+
errorResponse: null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -1042,6 +1042,18 @@ function resolveVpsSelfUpdateStateDirectory(): string {
|
|
|
1042
1042
|
return resolve(dirname(resolveVpsEnvironmentFilePath()), '.promptbook', 'self-update');
|
|
1043
1043
|
}
|
|
1044
1044
|
|
|
1045
|
+
/**
|
|
1046
|
+
* Reads a lightweight snapshot of the currently persisted standalone VPS self-update job.
|
|
1047
|
+
*
|
|
1048
|
+
* Reads only the persisted status file and its log tail — no git access, no remote fetching —
|
|
1049
|
+
* so it is safe to call from frequent polling loops such as the admin task manager.
|
|
1050
|
+
*
|
|
1051
|
+
* @returns Parsed job snapshot (status `idle` when no job has ever been persisted).
|
|
1052
|
+
*/
|
|
1053
|
+
export async function readVpsSelfUpdateJobSnapshot(): Promise<VpsSelfUpdateJobSnapshot> {
|
|
1054
|
+
return readPersistedVpsSelfUpdateJob();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1045
1057
|
/**
|
|
1046
1058
|
* Reads one persisted update-job snapshot from disk.
|
|
1047
1059
|
*
|
package/esm/index.es.js
CHANGED
|
@@ -59,7 +59,7 @@ const BOOK_LANGUAGE_VERSION = '2.0.0';
|
|
|
59
59
|
* @generated
|
|
60
60
|
* @see https://github.com/webgptorg/promptbook
|
|
61
61
|
*/
|
|
62
|
-
const PROMPTBOOK_ENGINE_VERSION = '0.113.0-
|
|
62
|
+
const PROMPTBOOK_ENGINE_VERSION = '0.113.0-1';
|
|
63
63
|
/**
|
|
64
64
|
* TODO: string_promptbook_version should be constrained to the all versions of Promptbook engine
|
|
65
65
|
* Note: [💞] Ignore a discrepancy between file name and entity name
|
|
@@ -9,8 +9,8 @@ export type CitationIframePreviewProps = {
|
|
|
9
9
|
};
|
|
10
10
|
/**
|
|
11
11
|
* Renders a citation URL preview as an iframe when the target page allows embedding,
|
|
12
|
-
* or falls back to a server-side
|
|
13
|
-
* does not (e.g. X-Frame-Options: DENY / SAMEORIGIN).
|
|
12
|
+
* or falls back to a live server-side browser preview with an "Open in new tab" link
|
|
13
|
+
* when it does not (e.g. X-Frame-Options: DENY / SAMEORIGIN).
|
|
14
14
|
*
|
|
15
15
|
* Embedding capability is determined by `GET /api/page-preview/check?url=<url>`.
|
|
16
16
|
* If that endpoint is unavailable the component falls back to the iframe directly.
|