@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.
Files changed (39) hide show
  1. package/README.md +1 -17
  2. package/agents/default/developer.book +3 -0
  3. package/apps/agents-server/src/app/admin/task-manager/TaskManagerClient.tsx +3 -3
  4. package/apps/agents-server/src/app/admin/task-manager/TaskManagerTaskRow.tsx +4 -0
  5. package/apps/agents-server/src/app/admin/task-manager/TaskManagerTasksCard.tsx +5 -5
  6. package/apps/agents-server/src/app/api/page-preview/check/route.ts +20 -5
  7. package/apps/agents-server/src/app/api/page-preview/input/route.ts +161 -0
  8. package/apps/agents-server/src/app/api/page-preview/screenshot/route.ts +21 -13
  9. package/apps/agents-server/src/app/api/page-preview/stream/route.ts +81 -0
  10. package/apps/agents-server/src/components/Header/HeaderHomepageLink.tsx +1 -1
  11. package/apps/agents-server/src/utils/chatTasksAdmin.ts +2 -1
  12. package/apps/agents-server/src/utils/createPagePreviewBrowserStream.ts +179 -0
  13. package/apps/agents-server/src/utils/getAdminChatTasksResponse.ts +56 -30
  14. package/apps/agents-server/src/utils/pagePreviewBrowserSessions.ts +234 -0
  15. package/esm/index.es.js +26 -5
  16. package/esm/index.es.js.map +1 -1
  17. package/esm/src/book-components/Chat/Chat/CitationIframePreview.d.ts +1 -1
  18. package/esm/src/book-components/Chat/Chat/CitationIframePreview.test.d.ts +2 -0
  19. package/esm/src/version.d.ts +1 -1
  20. package/package.json +1 -1
  21. package/src/book-components/Chat/Chat/Chat.module.css +40 -12
  22. package/src/book-components/Chat/Chat/CitationIframePreview.tsx +108 -156
  23. package/src/book-components/Chat/Chat/createProgressCardChecklistMarkdown.ts +1 -8
  24. package/src/cli/promptbookCli.ts +28 -3
  25. package/src/other/templates/getTemplatesPipelineCollection.ts +738 -761
  26. package/src/version.ts +2 -2
  27. package/src/versions.txt +1 -0
  28. package/umd/index.umd.js +26 -5
  29. package/umd/index.umd.js.map +1 -1
  30. package/umd/src/book-components/Chat/Chat/CitationIframePreview.d.ts +1 -1
  31. package/umd/src/book-components/Chat/Chat/CitationIframePreview.test.d.ts +2 -0
  32. package/umd/src/version.d.ts +1 -1
  33. package/apps/agents-server/src/app/api/page-preview/interact/route.ts +0 -179
  34. package/apps/agents-server/src/app/api/page-preview/live/route.ts +0 -67
  35. package/apps/agents-server/src/utils/pagePreview/livePagePreviewSessions.ts +0 -365
  36. package/apps/agents-server/src/utils/pagePreview/resolvePagePreviewRequestUrl.ts +0 -74
  37. package/esm/src/book-components/Chat/utils/livePagePreviewConstants.d.ts +0 -12
  38. package/src/book-components/Chat/utils/livePagePreviewConstants.ts +0 -17
  39. 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
- [![Vercel](https://img.shields.io/badge/Vercel-Deploy%20Agents%20Server-black?logo=vercel)](https://vercel.com/new/clone?project-name=promptbook-agents-server&repository-name=promptbook-agents-server&repository-url=https%3A%2F%2Fgithub.com%2Fwebgptorg%2Fpromptbook&root-directory=apps%2Fagents-server&demo-title=Promptbook+Agents+Server&demo-description=Persistent+AI+agents+with+a+built-in+admin+UI.+This+deploy+creates+a+Supabase+project+automatically%2C+injects+the+required+database+credentials%2C+and+Promptbook+runs+the+SQL+migrations+on+first+startup.&demo-url=https%3A%2F%2Fgallery.ptbk.io%2F&external-id=https%3A%2F%2Fgithub.com%2Fwebgptorg%2Fpromptbook%2Ftree%2Fmain%2Fapps%2Fagents-server&env=OPENAI_API_KEY%2CADMIN_PASSWORD%2CSUPABASE_AUTO_MIGRATE&envDescription=Supabase+is+created+automatically+by+the+required+Vercel+integration.+Add+OPENAI_API_KEY+from+https%3A%2F%2Fplatform.openai.com%2Fapi-keys%2C+choose+ADMIN_PASSWORD+for+your+first+admin+login%2C+and+leave+SUPABASE_AUTO_MIGRATE+enabled.&envLink=https%3A%2F%2Fgithub.com%2Fwebgptorg%2Fpromptbook%2Fblob%2Fmain%2FREADME.md%23vercel-form-fields&envDefaults=%7B%22SUPABASE_AUTO_MIGRATE%22%3A%22true%22%7D&stores=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22supabase%22%2C%22productSlug%22%3A%22supabase%22%7D%5D)
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)
@@ -1,4 +1,7 @@
1
1
  Developer
2
+
3
+
4
+
2
5
  META VISIBILITY PRIVATE
3
6
 
4
7
  RULE
@@ -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 durable background chat work across all users, including chat
26
- completions and scheduled timeout wake-ups. This dashboard shows queue and worker state, not
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">
@@ -134,6 +134,10 @@ function formatTaskKind(kind: AdminChatTaskRecord['kind']): string {
134
134
  return 'Self-update';
135
135
  }
136
136
 
137
+ if (kind === 'BROWSER_PREVIEW') {
138
+ return 'Browser preview';
139
+ }
140
+
137
141
  return kind;
138
142
  }
139
143
 
@@ -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 chat tasks matched the current search.';
23
+ return 'No background tasks matched the current search.';
24
24
  }
25
25
  if (view === 'running') {
26
- return 'No running background chat tasks right now.';
26
+ return 'No running background tasks right now.';
27
27
  }
28
28
  if (view === 'queued') {
29
- return 'No queued background chat tasks right now.';
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 chat tasks right now.';
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 chat tasks ({state.total.toLocaleString()})</h2>
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 requestUrlResolution = await resolvePagePreviewRequestUrl(request);
21
- if (requestUrlResolution.errorResponse !== null) {
22
- return requestUrlResolution.errorResponse;
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(requestUrlResolution.url);
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 { resolvePagePreviewRequestUrl } from '../../../../utils/pagePreview/resolvePagePreviewRequestUrl';
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 requestUrlResolution = await resolvePagePreviewRequestUrl(request);
25
- if (requestUrlResolution.errorResponse !== null) {
26
- return requestUrlResolution.errorResponse;
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
- width: LIVE_PAGE_PREVIEW_VIEWPORT_WIDTH,
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-8 sm:w-8 ${logoClassName}`.trim();
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
+ }