@promptbook/cli 0.113.0-1 → 0.113.0-3
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/README.md +1 -17
- package/agents/default/developer.book +3 -0
- package/apps/agents-server/src/app/admin/task-manager/TaskManagerClient.tsx +3 -3
- package/apps/agents-server/src/app/admin/task-manager/TaskManagerTaskRow.tsx +4 -0
- package/apps/agents-server/src/app/admin/task-manager/TaskManagerTasksCard.tsx +5 -5
- package/apps/agents-server/src/app/api/page-preview/check/route.ts +20 -5
- package/apps/agents-server/src/app/api/page-preview/input/route.ts +161 -0
- package/apps/agents-server/src/app/api/page-preview/screenshot/route.ts +21 -13
- package/apps/agents-server/src/app/api/page-preview/stream/route.ts +81 -0
- package/apps/agents-server/src/components/Header/HeaderHomepageLink.tsx +1 -1
- package/apps/agents-server/src/utils/chatTasksAdmin.ts +2 -1
- package/apps/agents-server/src/utils/createPagePreviewBrowserStream.ts +179 -0
- package/apps/agents-server/src/utils/getAdminChatTasksResponse.ts +56 -30
- package/apps/agents-server/src/utils/pagePreviewBrowserSessions.ts +234 -0
- package/esm/index.es.js +26 -5
- package/esm/index.es.js.map +1 -1
- package/esm/src/book-components/Chat/Chat/CitationIframePreview.d.ts +1 -1
- package/esm/src/book-components/Chat/Chat/CitationIframePreview.test.d.ts +2 -0
- package/esm/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/book-components/Chat/Chat/Chat.module.css +40 -12
- package/src/book-components/Chat/Chat/CitationIframePreview.tsx +108 -156
- package/src/book-components/Chat/Chat/createProgressCardChecklistMarkdown.ts +1 -8
- package/src/cli/promptbookCli.ts +28 -3
- package/src/other/templates/getTemplatesPipelineCollection.ts +738 -761
- package/src/version.ts +2 -2
- package/src/versions.txt +1 -0
- package/umd/index.umd.js +26 -5
- package/umd/index.umd.js.map +1 -1
- package/umd/src/book-components/Chat/Chat/CitationIframePreview.d.ts +1 -1
- package/umd/src/book-components/Chat/Chat/CitationIframePreview.test.d.ts +2 -0
- package/umd/src/version.d.ts +1 -1
- package/apps/agents-server/src/app/api/page-preview/interact/route.ts +0 -179
- package/apps/agents-server/src/app/api/page-preview/live/route.ts +0 -67
- package/apps/agents-server/src/utils/pagePreview/livePagePreviewSessions.ts +0 -365
- package/apps/agents-server/src/utils/pagePreview/resolvePagePreviewRequestUrl.ts +0 -74
- package/esm/src/book-components/Chat/utils/livePagePreviewConstants.d.ts +0 -12
- package/src/book-components/Chat/utils/livePagePreviewConstants.ts +0 -17
- package/umd/src/book-components/Chat/utils/livePagePreviewConstants.d.ts +0 -12
package/README.md
CHANGED
|
@@ -23,13 +23,7 @@ Create persistent AI agents that turn your company's scattered knowledge into ac
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
### Standalone VPS
|
|
31
|
-
|
|
32
|
-
Run the standalone VPS installer only on a fresh server. Interactive mode asks for the installation values:
|
|
26
|
+
Run the standalone VPS installer _(for example on DigitalOcean)_ only on a fresh server. Interactive mode asks for the installation values:
|
|
33
27
|
|
|
34
28
|
```bash
|
|
35
29
|
sudo curl -fsSL https://raw.githubusercontent.com/webgptorg/promptbook/refs/heads/main/other/vps/install.sh | bash
|
|
@@ -357,8 +351,6 @@ TEAM You are part of the legal team of Paul Smith & Associés, you discuss with
|
|
|
357
351
|
|
|
358
352
|
</td></tr></table>
|
|
359
353
|
|
|
360
|
-
|
|
361
|
-
|
|
362
354
|
### Promptbook Ecosystem
|
|
363
355
|
|
|
364
356
|
Promptbook is an ecosystem of tools centered around the **Agents Server** - a production-ready platform for running persistent AI agents.
|
|
@@ -381,8 +373,6 @@ The [Promptbook Engine](https://github.com/webgptorg/promptbook) is the open-sou
|
|
|
381
373
|
|
|
382
374
|
|
|
383
375
|
|
|
384
|
-
|
|
385
|
-
|
|
386
376
|
## 💜 The Promptbook Project
|
|
387
377
|
|
|
388
378
|
Promptbook project is an ecosystem centered around the **Agents Server** - a platform for creating, deploying, and running persistent AI agents. Following is a list of the most important pieces of the project:
|
|
@@ -420,8 +410,6 @@ Promptbook project is an ecosystem centered around the **Agents Server** - a pla
|
|
|
420
410
|
</tbody>
|
|
421
411
|
</table>
|
|
422
412
|
|
|
423
|
-
|
|
424
|
-
|
|
425
413
|
### 🌐 Community & Social Media
|
|
426
414
|
|
|
427
415
|
Join our growing community of developers and users:
|
|
@@ -478,8 +466,6 @@ Join our growing community of developers and users:
|
|
|
478
466
|
|
|
479
467
|
|
|
480
468
|
|
|
481
|
-
|
|
482
|
-
|
|
483
469
|
## 📚 Documentation
|
|
484
470
|
|
|
485
471
|
See detailed guides and API reference in the [docs](https://github.com/webgptorg/promptbook/discussions/categories/concepts) or [online](https://discord.gg/x3QWNaa89N).
|
|
@@ -687,8 +673,6 @@ The following glossary is used to clarify certain concepts:
|
|
|
687
673
|
|
|
688
674
|
_Note: This section is not a complete dictionary, more list of general AI / LLM terms that has connection with Promptbook_
|
|
689
675
|
|
|
690
|
-
|
|
691
|
-
|
|
692
676
|
### 💯 Core concepts
|
|
693
677
|
|
|
694
678
|
- [📚 Collection of pipelines](https://github.com/webgptorg/promptbook/discussions/65)
|
|
@@ -22,9 +22,9 @@ export function TaskManagerClient() {
|
|
|
22
22
|
<div>
|
|
23
23
|
<h1 className="text-3xl font-light text-gray-900">Task manager</h1>
|
|
24
24
|
<p className="mt-1 max-w-3xl text-sm text-gray-500">
|
|
25
|
-
Admin-only operational view of
|
|
26
|
-
|
|
27
|
-
chat transcript content.
|
|
25
|
+
Admin-only operational view of background work across all users, including chat completions,
|
|
26
|
+
scheduled timeout wake-ups, self-updates, and live browser previews. This dashboard shows queue
|
|
27
|
+
and worker state, not chat transcript content.
|
|
28
28
|
</p>
|
|
29
29
|
</div>
|
|
30
30
|
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
|
@@ -20,13 +20,13 @@ type TaskManagerTasksCardProps = {
|
|
|
20
20
|
*/
|
|
21
21
|
function resolveEmptyStateMessage(view: ReturnType<typeof useTaskManagerState>['view'], search: string): string {
|
|
22
22
|
if (search) {
|
|
23
|
-
return 'No background
|
|
23
|
+
return 'No background tasks matched the current search.';
|
|
24
24
|
}
|
|
25
25
|
if (view === 'running') {
|
|
26
|
-
return 'No running background
|
|
26
|
+
return 'No running background tasks right now.';
|
|
27
27
|
}
|
|
28
28
|
if (view === 'queued') {
|
|
29
|
-
return 'No queued background
|
|
29
|
+
return 'No queued background tasks right now.';
|
|
30
30
|
}
|
|
31
31
|
if (view === 'failed') {
|
|
32
32
|
return 'No failed background chat tasks were recorded in the last 24 hours.';
|
|
@@ -35,7 +35,7 @@ function resolveEmptyStateMessage(view: ReturnType<typeof useTaskManagerState>['
|
|
|
35
35
|
return 'No background chat tasks matched the selected time window.';
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
return 'No active background
|
|
38
|
+
return 'No active background tasks right now.';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
@@ -51,7 +51,7 @@ export function TaskManagerTasksCard({ language, state }: TaskManagerTasksCardPr
|
|
|
51
51
|
<Card className="hover:border-gray-200 hover:shadow-md">
|
|
52
52
|
<div className="flex items-center justify-between gap-4">
|
|
53
53
|
<div>
|
|
54
|
-
<h2 className="text-lg font-medium text-gray-900">Background
|
|
54
|
+
<h2 className="text-lg font-medium text-gray-900">Background tasks ({state.total.toLocaleString()})</h2>
|
|
55
55
|
<p className="mt-1 text-sm text-gray-500">
|
|
56
56
|
Sorted by started time for running work and creation time for queued work.
|
|
57
57
|
</p>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { NextRequest } from 'next/server';
|
|
2
2
|
import { NextResponse } from 'next/server';
|
|
3
3
|
import { assertsError } from '../../../../../../../src/errors/assertsError';
|
|
4
|
+
import { assertSafeUrl } from '../../../../utils/assertSafeUrl';
|
|
5
|
+
import { getCurrentUser } from '../../../../utils/getCurrentUser';
|
|
4
6
|
import { checkIfUrlCanBeEmbedded } from '../../../../utils/iframe/checkIfUrlCanBeEmbedded';
|
|
5
|
-
import { resolvePagePreviewRequestUrl } from '../../../../utils/pagePreview/resolvePagePreviewRequestUrl';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Checks whether a given URL can be embedded in an iframe by inspecting
|
|
@@ -17,13 +18,27 @@ import { resolvePagePreviewRequestUrl } from '../../../../utils/pagePreview/reso
|
|
|
17
18
|
* The destination URL is validated against private/internal IP ranges before fetching.
|
|
18
19
|
*/
|
|
19
20
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
22
|
-
return
|
|
21
|
+
const currentUser = await getCurrentUser();
|
|
22
|
+
if (!currentUser) {
|
|
23
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const url = request.nextUrl.searchParams.get('url');
|
|
27
|
+
|
|
28
|
+
if (!url) {
|
|
29
|
+
return NextResponse.json({ error: 'Missing required query parameter: url' }, { status: 400 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Guard against SSRF: reject private/internal IPs and non-HTTP(S) schemes
|
|
33
|
+
try {
|
|
34
|
+
assertSafeUrl(url);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
assertsError(error);
|
|
37
|
+
return NextResponse.json({ error: error.message }, { status: 400 });
|
|
23
38
|
}
|
|
24
39
|
|
|
25
40
|
try {
|
|
26
|
-
const canEmbed = await checkIfUrlCanBeEmbedded(
|
|
41
|
+
const canEmbed = await checkIfUrlCanBeEmbedded(url);
|
|
27
42
|
return NextResponse.json({ canEmbed });
|
|
28
43
|
} catch (error) {
|
|
29
44
|
assertsError(error);
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { getCurrentUser } from '../../../../utils/getCurrentUser';
|
|
4
|
+
import {
|
|
5
|
+
findUserPagePreviewBrowserSession,
|
|
6
|
+
normalizePagePreviewBrowserSessionId,
|
|
7
|
+
} from '../../../../utils/pagePreviewBrowserSessions';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Maximum absolute wheel delta accepted from one preview input event.
|
|
11
|
+
*
|
|
12
|
+
* @private route constant of `/api/page-preview/input`
|
|
13
|
+
*/
|
|
14
|
+
const MAX_WHEEL_DELTA = 1600;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Payload accepted by the live page-preview input route.
|
|
18
|
+
*
|
|
19
|
+
* @private route type of `/api/page-preview/input`
|
|
20
|
+
*/
|
|
21
|
+
type PagePreviewInputPayload = {
|
|
22
|
+
readonly sessionId?: unknown;
|
|
23
|
+
readonly type?: unknown;
|
|
24
|
+
readonly xRatio?: unknown;
|
|
25
|
+
readonly yRatio?: unknown;
|
|
26
|
+
readonly deltaX?: unknown;
|
|
27
|
+
readonly deltaY?: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalized browser input event.
|
|
32
|
+
*
|
|
33
|
+
* @private route type of `/api/page-preview/input`
|
|
34
|
+
*/
|
|
35
|
+
type PagePreviewInputEvent =
|
|
36
|
+
| {
|
|
37
|
+
readonly sessionId: string;
|
|
38
|
+
readonly type: 'click';
|
|
39
|
+
readonly xRatio: number;
|
|
40
|
+
readonly yRatio: number;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
readonly sessionId: string;
|
|
44
|
+
readonly type: 'wheel';
|
|
45
|
+
readonly xRatio: number;
|
|
46
|
+
readonly yRatio: number;
|
|
47
|
+
readonly deltaX: number;
|
|
48
|
+
readonly deltaY: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Applies one pointer or wheel event to an active live browser preview session.
|
|
53
|
+
*/
|
|
54
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
55
|
+
const currentUser = await getCurrentUser();
|
|
56
|
+
if (!currentUser) {
|
|
57
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const payload = (await request.json().catch(() => null)) as PagePreviewInputPayload | null;
|
|
61
|
+
const inputEvent = normalizePagePreviewInputEvent(payload);
|
|
62
|
+
if (!inputEvent) {
|
|
63
|
+
return NextResponse.json({ error: 'Invalid browser preview input.' }, { status: 400 });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const session = findUserPagePreviewBrowserSession(inputEvent.sessionId, currentUser);
|
|
67
|
+
if (!session?.page || !session.viewport) {
|
|
68
|
+
return NextResponse.json({ error: 'Browser preview session is not active.' }, { status: 404 });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const x = inputEvent.xRatio * session.viewport.width;
|
|
72
|
+
const y = inputEvent.yRatio * session.viewport.height;
|
|
73
|
+
|
|
74
|
+
if (inputEvent.type === 'click') {
|
|
75
|
+
await session.page.mouse.click(x, y);
|
|
76
|
+
} else {
|
|
77
|
+
await session.page.mouse.move(x, y);
|
|
78
|
+
await session.page.mouse.wheel(inputEvent.deltaX, inputEvent.deltaY);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return NextResponse.json({ ok: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Normalizes and validates one browser-preview input payload.
|
|
86
|
+
*
|
|
87
|
+
* @param payload - Unknown JSON payload.
|
|
88
|
+
* @returns Normalized input event or `null` when invalid.
|
|
89
|
+
*/
|
|
90
|
+
function normalizePagePreviewInputEvent(payload: PagePreviewInputPayload | null): PagePreviewInputEvent | null {
|
|
91
|
+
if (!payload) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sessionId = normalizePagePreviewBrowserSessionId(
|
|
96
|
+
typeof payload.sessionId === 'string' ? payload.sessionId : null,
|
|
97
|
+
);
|
|
98
|
+
const xRatio = normalizeRatio(payload.xRatio);
|
|
99
|
+
const yRatio = normalizeRatio(payload.yRatio);
|
|
100
|
+
|
|
101
|
+
if (!sessionId || xRatio === null || yRatio === null) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (payload.type === 'click') {
|
|
106
|
+
return {
|
|
107
|
+
sessionId,
|
|
108
|
+
type: 'click',
|
|
109
|
+
xRatio,
|
|
110
|
+
yRatio,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (payload.type !== 'wheel') {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const deltaX = normalizeWheelDelta(payload.deltaX);
|
|
119
|
+
const deltaY = normalizeWheelDelta(payload.deltaY);
|
|
120
|
+
if (deltaX === null || deltaY === null) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
sessionId,
|
|
126
|
+
type: 'wheel',
|
|
127
|
+
xRatio,
|
|
128
|
+
yRatio,
|
|
129
|
+
deltaX,
|
|
130
|
+
deltaY,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Normalizes a pointer coordinate ratio.
|
|
136
|
+
*
|
|
137
|
+
* @param value - Unknown payload value.
|
|
138
|
+
* @returns Ratio clamped to the viewport bounds, or `null` when invalid.
|
|
139
|
+
*/
|
|
140
|
+
function normalizeRatio(value: unknown): number | null {
|
|
141
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return Math.max(0, Math.min(1, value));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Normalizes one mouse-wheel delta.
|
|
150
|
+
*
|
|
151
|
+
* @param value - Unknown payload value.
|
|
152
|
+
* @returns Clamped wheel delta, or `null` when invalid.
|
|
153
|
+
*/
|
|
154
|
+
function normalizeWheelDelta(value: unknown): number | null {
|
|
155
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return Math.max(-MAX_WHEEL_DELTA, Math.min(MAX_WHEEL_DELTA, value));
|
|
160
|
+
}
|
|
161
|
+
|
|
@@ -2,12 +2,9 @@ import { $provideBrowserForServer } from '@/src/tools/$provideBrowserForServer';
|
|
|
2
2
|
import { serializeError } from '@promptbook-local/utils';
|
|
3
3
|
import type { NextRequest } from 'next/server';
|
|
4
4
|
import { NextResponse } from 'next/server';
|
|
5
|
-
import {
|
|
6
|
-
LIVE_PAGE_PREVIEW_VIEWPORT_HEIGHT,
|
|
7
|
-
LIVE_PAGE_PREVIEW_VIEWPORT_WIDTH,
|
|
8
|
-
} from '../../../../../../../src/book-components/Chat/utils/livePagePreviewConstants';
|
|
9
5
|
import { assertsError } from '../../../../../../../src/errors/assertsError';
|
|
10
|
-
import {
|
|
6
|
+
import { assertSafeUrl } from '../../../../utils/assertSafeUrl';
|
|
7
|
+
import { getCurrentUser } from '../../../../utils/getCurrentUser';
|
|
11
8
|
|
|
12
9
|
/**
|
|
13
10
|
* Takes a screenshot of the given URL using a headless browser.
|
|
@@ -21,9 +18,23 @@ import { resolvePagePreviewRequestUrl } from '../../../../utils/pagePreview/reso
|
|
|
21
18
|
* The destination URL is validated against private/internal IP ranges before fetching.
|
|
22
19
|
*/
|
|
23
20
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
return
|
|
21
|
+
const currentUser = await getCurrentUser();
|
|
22
|
+
if (!currentUser) {
|
|
23
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const url = request.nextUrl.searchParams.get('url');
|
|
27
|
+
|
|
28
|
+
if (!url) {
|
|
29
|
+
return NextResponse.json({ error: 'Missing required query parameter: url' }, { status: 400 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Guard against SSRF: reject private/internal IPs and non-HTTP(S) schemes
|
|
33
|
+
try {
|
|
34
|
+
assertSafeUrl(url);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
assertsError(error);
|
|
37
|
+
return NextResponse.json({ error: error.message }, { status: 400 });
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
try {
|
|
@@ -31,11 +42,8 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
31
42
|
const page = await browserContext.newPage();
|
|
32
43
|
|
|
33
44
|
try {
|
|
34
|
-
await page.setViewportSize({
|
|
35
|
-
|
|
36
|
-
height: LIVE_PAGE_PREVIEW_VIEWPORT_HEIGHT,
|
|
37
|
-
});
|
|
38
|
-
await page.goto(requestUrlResolution.url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
45
|
+
await page.setViewportSize({ width: 1280, height: 800 });
|
|
46
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
39
47
|
const screenshotBuffer = await page.screenshot({ type: 'png' });
|
|
40
48
|
|
|
41
49
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { serializeError } from '@promptbook-local/utils';
|
|
2
|
+
import type { NextRequest } from 'next/server';
|
|
3
|
+
import { NextResponse } from 'next/server';
|
|
4
|
+
import { assertsError } from '../../../../../../../src/errors/assertsError';
|
|
5
|
+
import { assertSafeUrl } from '../../../../utils/assertSafeUrl';
|
|
6
|
+
import {
|
|
7
|
+
createPagePreviewBrowserStream,
|
|
8
|
+
PAGE_PREVIEW_STREAM_CONTENT_TYPE,
|
|
9
|
+
} from '../../../../utils/createPagePreviewBrowserStream';
|
|
10
|
+
import { getCurrentUser } from '../../../../utils/getCurrentUser';
|
|
11
|
+
import {
|
|
12
|
+
normalizePagePreviewBrowserSessionId,
|
|
13
|
+
registerPagePreviewBrowserSession,
|
|
14
|
+
} from '../../../../utils/pagePreviewBrowserSessions';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Forces this route to stream a fresh live browser session for every request.
|
|
18
|
+
*/
|
|
19
|
+
export const dynamic = 'force-dynamic';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Streams a live browser preview of a public URL.
|
|
23
|
+
*
|
|
24
|
+
* Query parameters:
|
|
25
|
+
* - `url` — fully-qualified public HTTP(S) URL to preview
|
|
26
|
+
* - `sessionId` — client-created browser-preview session id used for interaction events
|
|
27
|
+
*
|
|
28
|
+
* Requires authentication to prevent unauthenticated SSRF and browser-process abuse.
|
|
29
|
+
*/
|
|
30
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
31
|
+
const currentUser = await getCurrentUser();
|
|
32
|
+
if (!currentUser) {
|
|
33
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const url = request.nextUrl.searchParams.get('url');
|
|
37
|
+
const sessionId = normalizePagePreviewBrowserSessionId(request.nextUrl.searchParams.get('sessionId'));
|
|
38
|
+
|
|
39
|
+
if (!url) {
|
|
40
|
+
return NextResponse.json({ error: 'Missing required query parameter: url' }, { status: 400 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!sessionId) {
|
|
44
|
+
return NextResponse.json({ error: 'Missing or invalid query parameter: sessionId' }, { status: 400 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
assertSafeUrl(url);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
assertsError(error);
|
|
51
|
+
return NextResponse.json({ error: error.message }, { status: 400 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
registerPagePreviewBrowserSession({
|
|
56
|
+
sessionId,
|
|
57
|
+
url,
|
|
58
|
+
user: currentUser,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const stream = createPagePreviewBrowserStream({
|
|
62
|
+
request,
|
|
63
|
+
sessionId,
|
|
64
|
+
url,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return new NextResponse(stream, {
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': PAGE_PREVIEW_STREAM_CONTENT_TYPE,
|
|
70
|
+
'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate',
|
|
71
|
+
Connection: 'keep-alive',
|
|
72
|
+
'X-Accel-Buffering': 'no',
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
} catch (error) {
|
|
76
|
+
assertsError(error);
|
|
77
|
+
console.error('[page-preview] failed to start live browser stream', error);
|
|
78
|
+
return NextResponse.json({ error: serializeError(error) }, { status: 500 });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
@@ -31,7 +31,7 @@ export function HeaderHomepageLink({
|
|
|
31
31
|
onClick,
|
|
32
32
|
}: HeaderHomepageLinkProps) {
|
|
33
33
|
const compactServerName = serverName.split(' ')[0] || serverName;
|
|
34
|
-
const mergedLogoClassName = `h-6 w-6 shrink-0 object-contain sm:h-
|
|
34
|
+
const mergedLogoClassName = `h-6 w-6 shrink-0 object-contain sm:h-6 sm:w-6 ${logoClassName}`.trim();
|
|
35
35
|
const mergedLabelClassName =
|
|
36
36
|
`min-w-0 truncate text-base font-bold tracking-tight text-gray-900 dark:text-slate-100 ${labelClassName}`.trim();
|
|
37
37
|
|
|
@@ -11,10 +11,11 @@ export type AdminChatTaskView = 'active' | 'running' | 'queued' | 'failed' | 'al
|
|
|
11
11
|
* Durable task kinds currently surfaced by the admin task manager.
|
|
12
12
|
*
|
|
13
13
|
* `VPS_SELF_UPDATE` represents the standalone VPS self-update session triggered from `/admin/update`.
|
|
14
|
+
* `BROWSER_PREVIEW` represents an ephemeral live browser stream used by citation previews.
|
|
14
15
|
*
|
|
15
16
|
* @private internal admin utility of Agents Server
|
|
16
17
|
*/
|
|
17
|
-
export type AdminChatTaskKind = 'CHAT_COMPLETION' | 'CHAT_TIMEOUT' | 'VPS_SELF_UPDATE';
|
|
18
|
+
export type AdminChatTaskKind = 'CHAT_COMPLETION' | 'CHAT_TIMEOUT' | 'VPS_SELF_UPDATE' | 'BROWSER_PREVIEW';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* One row shown in the admin task-manager table.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import { $provideBrowserForServer } from '../tools/$provideBrowserForServer';
|
|
3
|
+
import type { PagePreviewBrowserViewport } from './pagePreviewBrowserSessions';
|
|
4
|
+
import {
|
|
5
|
+
attachPagePreviewBrowserSessionPage,
|
|
6
|
+
finishPagePreviewBrowserSession,
|
|
7
|
+
markPagePreviewBrowserSessionFrame,
|
|
8
|
+
} from './pagePreviewBrowserSessions';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Boundary used for MJPEG page-preview streams.
|
|
12
|
+
*
|
|
13
|
+
* @private internal constant of Agents Server page-preview streaming
|
|
14
|
+
*/
|
|
15
|
+
const PAGE_PREVIEW_STREAM_BOUNDARY = 'pagepreviewboundary';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Width of the live browser preview viewport.
|
|
19
|
+
*
|
|
20
|
+
* @private internal constant of Agents Server page-preview streaming
|
|
21
|
+
*/
|
|
22
|
+
const PAGE_PREVIEW_VIEWPORT_WIDTH = 1280;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Height of the live browser preview viewport.
|
|
26
|
+
*
|
|
27
|
+
* @private internal constant of Agents Server page-preview streaming
|
|
28
|
+
*/
|
|
29
|
+
const PAGE_PREVIEW_VIEWPORT_HEIGHT = 800;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Milliseconds between streamed browser frames.
|
|
33
|
+
*
|
|
34
|
+
* @private internal constant of Agents Server page-preview streaming
|
|
35
|
+
*/
|
|
36
|
+
const PAGE_PREVIEW_FRAME_INTERVAL_MS = 350;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* JPEG quality used by the live browser preview stream.
|
|
40
|
+
*
|
|
41
|
+
* @private internal constant of Agents Server page-preview streaming
|
|
42
|
+
*/
|
|
43
|
+
const PAGE_PREVIEW_JPEG_QUALITY = 60;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Navigation timeout for one page-preview browser stream.
|
|
47
|
+
*
|
|
48
|
+
* @private internal constant of Agents Server page-preview streaming
|
|
49
|
+
*/
|
|
50
|
+
const PAGE_PREVIEW_NAVIGATION_TIMEOUT_MS = 30_000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Response content type for the live page-preview stream.
|
|
54
|
+
*
|
|
55
|
+
* @private internal constant of Agents Server page-preview streaming
|
|
56
|
+
*/
|
|
57
|
+
export const PAGE_PREVIEW_STREAM_CONTENT_TYPE = `multipart/x-mixed-replace; boundary=${PAGE_PREVIEW_STREAM_BOUNDARY}`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Options used when creating one live browser preview stream.
|
|
61
|
+
*
|
|
62
|
+
* @private internal type of Agents Server page-preview streaming
|
|
63
|
+
*/
|
|
64
|
+
export type CreatePagePreviewBrowserStreamOptions = {
|
|
65
|
+
readonly request: Request;
|
|
66
|
+
readonly sessionId: string;
|
|
67
|
+
readonly url: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Creates a Playwright-backed MJPEG stream for one page-preview browser session.
|
|
72
|
+
*
|
|
73
|
+
* @param options - Stream options.
|
|
74
|
+
* @returns Readable multipart stream.
|
|
75
|
+
*/
|
|
76
|
+
export function createPagePreviewBrowserStream(options: CreatePagePreviewBrowserStreamOptions): ReadableStream<Uint8Array> {
|
|
77
|
+
const encoder = new TextEncoder();
|
|
78
|
+
const viewport: PagePreviewBrowserViewport = {
|
|
79
|
+
width: PAGE_PREVIEW_VIEWPORT_WIDTH,
|
|
80
|
+
height: PAGE_PREVIEW_VIEWPORT_HEIGHT,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return new ReadableStream<Uint8Array>({
|
|
84
|
+
async start(controller) {
|
|
85
|
+
let isStreamOpen = true;
|
|
86
|
+
let page: Page | null = null;
|
|
87
|
+
|
|
88
|
+
const closeStream = async (): Promise<void> => {
|
|
89
|
+
if (!isStreamOpen) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
isStreamOpen = false;
|
|
94
|
+
finishPagePreviewBrowserSession(options.sessionId);
|
|
95
|
+
await page?.close().catch(() => undefined);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
controller.close();
|
|
99
|
+
} catch {
|
|
100
|
+
// The browser may close the stream first when the modal is dismissed.
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
options.request.signal.addEventListener(
|
|
105
|
+
'abort',
|
|
106
|
+
() => {
|
|
107
|
+
void closeStream();
|
|
108
|
+
},
|
|
109
|
+
{ once: true },
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const browserContext = await $provideBrowserForServer({ sessionId: options.sessionId });
|
|
114
|
+
page = await browserContext.newPage();
|
|
115
|
+
attachPagePreviewBrowserSessionPage(options.sessionId, page, viewport);
|
|
116
|
+
|
|
117
|
+
await page.setViewportSize(viewport);
|
|
118
|
+
await page.goto(options.url, {
|
|
119
|
+
waitUntil: 'domcontentloaded',
|
|
120
|
+
timeout: PAGE_PREVIEW_NAVIGATION_TIMEOUT_MS,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
while (isStreamOpen && !options.request.signal.aborted) {
|
|
124
|
+
const buffer = await page.screenshot({
|
|
125
|
+
type: 'jpeg',
|
|
126
|
+
quality: PAGE_PREVIEW_JPEG_QUALITY,
|
|
127
|
+
});
|
|
128
|
+
const header = createPagePreviewFrameHeader(buffer.byteLength);
|
|
129
|
+
|
|
130
|
+
controller.enqueue(encoder.encode(header));
|
|
131
|
+
controller.enqueue(buffer);
|
|
132
|
+
markPagePreviewBrowserSessionFrame(options.sessionId);
|
|
133
|
+
await waitForNextPagePreviewFrame(options.request.signal);
|
|
134
|
+
}
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (!options.request.signal.aborted) {
|
|
137
|
+
console.error('[page-preview] live browser stream failed', error);
|
|
138
|
+
controller.error(error);
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
await closeStream();
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Creates one multipart frame header.
|
|
149
|
+
*
|
|
150
|
+
* @param byteLength - JPEG payload length.
|
|
151
|
+
* @returns Multipart frame header.
|
|
152
|
+
*/
|
|
153
|
+
function createPagePreviewFrameHeader(byteLength: number): string {
|
|
154
|
+
return `\r\n--${PAGE_PREVIEW_STREAM_BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${byteLength}\r\n\r\n`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Waits before sending the next stream frame, resolving early on abort.
|
|
159
|
+
*
|
|
160
|
+
* @param signal - Request abort signal.
|
|
161
|
+
*/
|
|
162
|
+
function waitForNextPagePreviewFrame(signal: AbortSignal): Promise<void> {
|
|
163
|
+
if (signal.aborted) {
|
|
164
|
+
return Promise.resolve();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
const timeout = setTimeout(resolve, PAGE_PREVIEW_FRAME_INTERVAL_MS);
|
|
169
|
+
|
|
170
|
+
signal.addEventListener(
|
|
171
|
+
'abort',
|
|
172
|
+
() => {
|
|
173
|
+
clearTimeout(timeout);
|
|
174
|
+
resolve();
|
|
175
|
+
},
|
|
176
|
+
{ once: true },
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
}
|