@promptbook/cli 0.103.0-54 → 0.103.0-55

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 (36) hide show
  1. package/apps/agents-server/config.ts +0 -2
  2. package/apps/agents-server/src/app/admin/chat-feedback/ChatFeedbackClient.tsx +79 -6
  3. package/apps/agents-server/src/app/admin/chat-history/ChatHistoryClient.tsx +171 -69
  4. package/apps/agents-server/src/app/agents/[agentName]/api/mcp/route.ts +203 -0
  5. package/apps/agents-server/src/app/agents/[agentName]/api/modelRequirements/route.ts +3 -1
  6. package/apps/agents-server/src/app/agents/[agentName]/api/modelRequirements/systemMessage/route.ts +3 -1
  7. package/apps/agents-server/src/app/agents/[agentName]/api/openai/chat/completions/route.ts +3 -169
  8. package/apps/agents-server/src/app/agents/[agentName]/api/openrouter/chat/completions/route.ts +10 -0
  9. package/apps/agents-server/src/app/agents/[agentName]/links/page.tsx +218 -0
  10. package/apps/agents-server/src/app/api/auth/change-password/route.ts +75 -0
  11. package/apps/agents-server/src/app/api/chat-feedback/export/route.ts +55 -0
  12. package/apps/agents-server/src/app/api/chat-history/export/route.ts +55 -0
  13. package/apps/agents-server/src/components/ChangePasswordDialog/ChangePasswordDialog.tsx +41 -0
  14. package/apps/agents-server/src/components/ChangePasswordForm/ChangePasswordForm.tsx +159 -0
  15. package/apps/agents-server/src/components/Header/Header.tsx +94 -38
  16. package/apps/agents-server/src/middleware.ts +1 -1
  17. package/apps/agents-server/src/utils/convertToCsv.ts +31 -0
  18. package/apps/agents-server/src/utils/handleChatCompletion.ts +183 -0
  19. package/apps/agents-server/src/utils/resolveInheritedAgentSource.ts +93 -0
  20. package/esm/index.es.js +770 -181
  21. package/esm/index.es.js.map +1 -1
  22. package/esm/typings/src/_packages/core.index.d.ts +8 -6
  23. package/esm/typings/src/_packages/types.index.d.ts +1 -1
  24. package/esm/typings/src/book-2.0/agent-source/AgentModelRequirements.d.ts +4 -0
  25. package/esm/typings/src/commitments/CLOSED/CLOSED.d.ts +35 -0
  26. package/esm/typings/src/commitments/COMPONENT/COMPONENT.d.ts +28 -0
  27. package/esm/typings/src/commitments/FROM/FROM.d.ts +34 -0
  28. package/esm/typings/src/commitments/IMPORTANT/IMPORTANT.d.ts +26 -0
  29. package/esm/typings/src/commitments/LANGUAGE/LANGUAGE.d.ts +35 -0
  30. package/esm/typings/src/commitments/OPEN/OPEN.d.ts +35 -0
  31. package/esm/typings/src/commitments/index.d.ts +1 -82
  32. package/esm/typings/src/commitments/registry.d.ts +68 -0
  33. package/esm/typings/src/version.d.ts +1 -1
  34. package/package.json +2 -2
  35. package/umd/index.umd.js +770 -181
  36. package/umd/index.umd.js.map +1 -1
@@ -0,0 +1,159 @@
1
+ 'use client';
2
+
3
+ import { Loader2, Lock } from 'lucide-react';
4
+ import { useState } from 'react';
5
+
6
+ type ChangePasswordFormProps = {
7
+ onSuccess?: () => void;
8
+ className?: string;
9
+ };
10
+
11
+ export function ChangePasswordForm(props: ChangePasswordFormProps) {
12
+ const { onSuccess, className } = props;
13
+ const [isLoading, setIsLoading] = useState(false);
14
+ const [error, setError] = useState<string | null>(null);
15
+ const [successMessage, setSuccessMessage] = useState<string | null>(null);
16
+
17
+ const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
18
+ event.preventDefault();
19
+ setIsLoading(true);
20
+ setError(null);
21
+ setSuccessMessage(null);
22
+
23
+ const formData = new FormData(event.currentTarget);
24
+ const currentPassword = formData.get('currentPassword') as string;
25
+ const newPassword = formData.get('newPassword') as string;
26
+ const confirmNewPassword = formData.get('confirmNewPassword') as string;
27
+
28
+ if (newPassword !== confirmNewPassword) {
29
+ setError('New passwords do not match');
30
+ setIsLoading(false);
31
+ return;
32
+ }
33
+
34
+ try {
35
+ const response = await fetch('/api/auth/change-password', {
36
+ method: 'POST',
37
+ headers: {
38
+ 'Content-Type': 'application/json',
39
+ },
40
+ body: JSON.stringify({ currentPassword, newPassword }),
41
+ });
42
+
43
+ const result = await response.json();
44
+
45
+ if (response.ok) {
46
+ setSuccessMessage('Password changed successfully');
47
+ // Reset form
48
+ event.currentTarget.reset();
49
+ if (onSuccess) {
50
+ setTimeout(onSuccess, 1500);
51
+ }
52
+ } else {
53
+ setError(result.error || 'An error occurred');
54
+ }
55
+ } catch (error) {
56
+ setError('An unexpected error occurred');
57
+ console.error(error);
58
+ } finally {
59
+ setIsLoading(false);
60
+ }
61
+ };
62
+
63
+ return (
64
+ <form onSubmit={handleSubmit} className={`space-y-4 ${className || ''}`}>
65
+ <div className="space-y-2">
66
+ <label
67
+ htmlFor="currentPassword"
68
+ className="text-sm font-medium text-gray-700 block"
69
+ >
70
+ Current Password
71
+ </label>
72
+ <div className="relative">
73
+ <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
74
+ <Lock className="w-4 h-4" />
75
+ </div>
76
+ <input
77
+ id="currentPassword"
78
+ name="currentPassword"
79
+ type="password"
80
+ required
81
+ className="block w-full pl-10 h-10 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-promptbook-blue focus:border-transparent disabled:opacity-50"
82
+ placeholder="Enter current password"
83
+ />
84
+ </div>
85
+ </div>
86
+
87
+ <div className="space-y-2">
88
+ <label
89
+ htmlFor="newPassword"
90
+ className="text-sm font-medium text-gray-700 block"
91
+ >
92
+ New Password
93
+ </label>
94
+ <div className="relative">
95
+ <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
96
+ <Lock className="w-4 h-4" />
97
+ </div>
98
+ <input
99
+ id="newPassword"
100
+ name="newPassword"
101
+ type="password"
102
+ required
103
+ className="block w-full pl-10 h-10 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-promptbook-blue focus:border-transparent disabled:opacity-50"
104
+ placeholder="Enter new password"
105
+ />
106
+ </div>
107
+ </div>
108
+
109
+ <div className="space-y-2">
110
+ <label
111
+ htmlFor="confirmNewPassword"
112
+ className="text-sm font-medium text-gray-700 block"
113
+ >
114
+ Confirm New Password
115
+ </label>
116
+ <div className="relative">
117
+ <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
118
+ <Lock className="w-4 h-4" />
119
+ </div>
120
+ <input
121
+ id="confirmNewPassword"
122
+ name="confirmNewPassword"
123
+ type="password"
124
+ required
125
+ className="block w-full pl-10 h-10 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-promptbook-blue focus:border-transparent disabled:opacity-50"
126
+ placeholder="Confirm new password"
127
+ />
128
+ </div>
129
+ </div>
130
+
131
+ {error && (
132
+ <div className="p-3 text-sm text-red-500 bg-red-50 border border-red-200 rounded-md">
133
+ {error}
134
+ </div>
135
+ )}
136
+
137
+ {successMessage && (
138
+ <div className="p-3 text-sm text-green-500 bg-green-50 border border-green-200 rounded-md">
139
+ {successMessage}
140
+ </div>
141
+ )}
142
+
143
+ <button
144
+ type="submit"
145
+ disabled={isLoading}
146
+ className="w-full inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2 bg-promptbook-blue-dark text-white hover:bg-promptbook-blue-dark/90 focus:outline-none focus:ring-2 focus:ring-promptbook-blue focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none transition-colors"
147
+ >
148
+ {isLoading ? (
149
+ <>
150
+ <Loader2 className="mr-2 w-4 h-4 animate-spin" />
151
+ Changing Password...
152
+ </>
153
+ ) : (
154
+ 'Change Password'
155
+ )}
156
+ </button>
157
+ </form>
158
+ );
159
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import promptbookLogoBlueTransparent from '@/public/logo-blue-white-256.png';
4
4
  import { $createAgentAction, logoutAction } from '@/src/app/actions';
5
- import { ArrowRight, ChevronDown, LogIn, LogOut } from 'lucide-react';
5
+ import { ArrowRight, ChevronDown, Lock, LogIn, LogOut, User } from 'lucide-react';
6
6
  import Image from 'next/image';
7
7
  import Link from 'next/link';
8
8
  import { useRouter } from 'next/navigation';
@@ -12,6 +12,7 @@ import { HamburgerMenu } from '../../../../../src/book-components/_common/Hambur
12
12
  import { just } from '../../../../../src/utils/organization/just';
13
13
  import type { UserInfo } from '../../utils/getCurrentUser';
14
14
  import { getVisibleCommitmentDefinitions } from '../../utils/getVisibleCommitmentDefinitions';
15
+ import { ChangePasswordDialog } from '../ChangePasswordDialog/ChangePasswordDialog';
15
16
  import { LoginDialog } from '../LoginDialog/LoginDialog';
16
17
  import { useUsersAdmin } from '../UsersList/useUsersAdmin';
17
18
 
@@ -73,12 +74,16 @@ export function Header(props: HeaderProps) {
73
74
 
74
75
  const [isMenuOpen, setIsMenuOpen] = useState(false);
75
76
  const [isLoginOpen, setIsLoginOpen] = useState(false);
77
+ const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
76
78
  const [isAgentsOpen, setIsAgentsOpen] = useState(false);
77
79
  const [isDocsOpen, setIsDocsOpen] = useState(false);
78
80
  const [isUsersOpen, setIsUsersOpen] = useState(false);
81
+ const [isSystemOpen, setIsSystemOpen] = useState(false);
82
+ const [isProfileOpen, setIsProfileOpen] = useState(false);
79
83
  const [isMobileAgentsOpen, setIsMobileAgentsOpen] = useState(false);
80
84
  const [isMobileDocsOpen, setIsMobileDocsOpen] = useState(false);
81
85
  const [isMobileUsersOpen, setIsMobileUsersOpen] = useState(false);
86
+ const [isMobileSystemOpen, setIsMobileSystemOpen] = useState(false);
82
87
  const [isCreatingAgent, setIsCreatingAgent] = useState(false);
83
88
  const router = useRouter();
84
89
 
@@ -131,7 +136,9 @@ export function Header(props: HeaderProps) {
131
136
  label: (
132
137
  <>
133
138
  {primary.type}
134
- {aliases.length > 0 && <span className="text-gray-400 font-normal"> / {aliases.join(' / ')}</span>}
139
+ {aliases.length > 0 && (
140
+ <span className="text-gray-400 font-normal"> / {aliases.join(' / ')}</span>
141
+ )}
135
142
  </>
136
143
  ),
137
144
  href: `/docs/${primary.type}`,
@@ -210,24 +217,30 @@ export function Header(props: HeaderProps) {
210
217
  ],
211
218
  },
212
219
  {
213
- type: 'link' as const,
214
- label: 'API Tokens',
215
- href: '/admin/api-tokens',
216
- },
217
- {
218
- type: 'link' as const,
219
- label: 'Metadata',
220
- href: '/admin/metadata',
221
- },
222
- {
223
- type: 'link' as const,
224
- label: 'Chat history',
225
- href: '/admin/chat-history',
226
- },
227
- {
228
- type: 'link' as const,
229
- label: 'Chat feedback',
230
- href: '/admin/chat-feedback',
220
+ type: 'dropdown' as const,
221
+ label: 'System',
222
+ isOpen: isSystemOpen,
223
+ setIsOpen: setIsSystemOpen,
224
+ isMobileOpen: isMobileSystemOpen,
225
+ setIsMobileOpen: setIsMobileSystemOpen,
226
+ items: [
227
+ {
228
+ label: 'API Tokens',
229
+ href: '/admin/api-tokens',
230
+ },
231
+ {
232
+ label: 'Metadata',
233
+ href: '/admin/metadata',
234
+ },
235
+ {
236
+ label: 'Chat history',
237
+ href: '/admin/chat-history',
238
+ },
239
+ {
240
+ label: 'Chat feedback',
241
+ href: '/admin/chat-feedback',
242
+ },
243
+ ],
231
244
  },
232
245
  {
233
246
  type: 'link' as const,
@@ -241,6 +254,7 @@ export function Header(props: HeaderProps) {
241
254
  return (
242
255
  <header className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-200 h-16">
243
256
  <LoginDialog isOpen={isLoginOpen} onClose={() => setIsLoginOpen(false)} />
257
+ <ChangePasswordDialog isOpen={isChangePasswordOpen} onClose={() => setIsChangePasswordOpen(false)} />
244
258
  <div className="container mx-auto px-4 h-full">
245
259
  <div className="flex items-center justify-between h-full">
246
260
  {/* Logo */}
@@ -370,24 +384,56 @@ export function Header(props: HeaderProps) {
370
384
 
371
385
  {(currentUser || isAdmin) && (
372
386
  <div className="hidden lg:flex items-center gap-3">
373
- <span className="inline text-sm text-gray-600">
374
- Logged in as <strong>{currentUser?.username || 'Admin'}</strong>
375
- {(currentUser?.isAdmin || isAdmin) && (
376
- <span className="ml-2 bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">
377
- Admin
378
- </span>
387
+ <div className="relative">
388
+ <button
389
+ onClick={() => setIsProfileOpen(!isProfileOpen)}
390
+ onBlur={() => setTimeout(() => setIsProfileOpen(false), 200)}
391
+ className="flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors px-3 py-2 rounded-md hover:bg-gray-50"
392
+ >
393
+ <div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600">
394
+ <User className="w-4 h-4" />
395
+ </div>
396
+ <div className="flex flex-col items-start">
397
+ <span className="leading-none">{currentUser?.username || 'Admin'}</span>
398
+ {(currentUser?.isAdmin || isAdmin) && (
399
+ <span className="text-xs text-blue-600">Admin</span>
400
+ )}
401
+ </div>
402
+ <ChevronDown className="w-4 h-4 ml-1 opacity-50" />
403
+ </button>
404
+
405
+ {isProfileOpen && (
406
+ <div className="absolute top-full right-0 mt-2 w-56 bg-white rounded-md shadow-lg border border-gray-100 py-1 z-50 animate-in fade-in zoom-in-95 duration-200">
407
+ <div className="px-4 py-3 border-b border-gray-100">
408
+ <p className="text-sm font-medium text-gray-900">
409
+ {currentUser?.username || 'Admin'}
410
+ </p>
411
+ {(currentUser?.isAdmin || isAdmin) && (
412
+ <p className="text-xs text-blue-600 mt-1">Administrator</p>
413
+ )}
414
+ </div>
415
+
416
+ <button
417
+ onClick={() => setIsChangePasswordOpen(true)}
418
+ className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 hover:text-gray-900 flex items-center gap-2"
419
+ >
420
+ <Lock className="w-4 h-4" />
421
+ Change Password
422
+ </button>
423
+
424
+ <button
425
+ onClick={() => {
426
+ handleLogout();
427
+ setIsMenuOpen(false);
428
+ }}
429
+ className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 hover:text-red-700 flex items-center gap-2"
430
+ >
431
+ <LogOut className="w-4 h-4" />
432
+ Log out
433
+ </button>
434
+ </div>
379
435
  )}
380
- </span>
381
- <button
382
- onClick={() => {
383
- handleLogout();
384
- setIsMenuOpen(false);
385
- }}
386
- className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium h-10 px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors"
387
- >
388
- Log out
389
- <LogOut className="ml-2 w-4 h-4" />
390
- </button>
436
+ </div>
391
437
  </div>
392
438
  )}
393
439
 
@@ -403,7 +449,7 @@ export function Header(props: HeaderProps) {
403
449
  {/* Mobile Navigation */}
404
450
  {isMenuOpen && (
405
451
  <div
406
- className="lg:hidden absolute top-16 left-0 right-0 z-50 bg-white/80 shadow-xl py-4 border-t border-gray-100 animate-in slide-in-from-top-2 h-[calc(100vh-4rem)] overflow-y-auto"
452
+ className="lg:hidden absolute top-16 left-0 right-0 z-50 bg-white shadow-xl py-4 border-t border-gray-100 animate-in slide-in-from-top-2 h-[calc(100vh-4rem)] overflow-y-auto"
407
453
  style={{
408
454
  backdropFilter: 'blur(20px)',
409
455
  WebkitBackdropFilter: 'blur(20px)',
@@ -435,6 +481,16 @@ export function Header(props: HeaderProps) {
435
481
  </span>
436
482
  )}
437
483
  </div>
484
+ <button
485
+ onClick={() => {
486
+ setIsChangePasswordOpen(true);
487
+ setIsMenuOpen(false);
488
+ }}
489
+ className="flex items-center gap-2 text-base font-medium text-gray-600 hover:text-gray-900 w-full"
490
+ >
491
+ <Lock className="w-4 h-4" />
492
+ Change Password
493
+ </button>
438
494
  <button
439
495
  onClick={() => {
440
496
  handleLogout();
@@ -134,7 +134,7 @@ export async function middleware(req: NextRequest) {
134
134
  headers: {
135
135
  'Access-Control-Allow-Origin': '*',
136
136
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
137
- 'Access-Control-Allow-Headers': 'Content-Type',
137
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
138
138
  },
139
139
  });
140
140
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Converts an array of objects to a CSV string
3
+ */
4
+ export function convertToCsv(data: Array<Record<string, unknown>>): string {
5
+ if (data.length === 0) {
6
+ return '';
7
+ }
8
+
9
+ const headers = Object.keys(data[0]);
10
+ const csvRows = [headers.join(',')];
11
+
12
+ for (const row of data) {
13
+ const values = headers.map((header) => {
14
+ let value = row[header];
15
+
16
+ if (value === null || value === undefined) {
17
+ value = '';
18
+ } else if (typeof value === 'object') {
19
+ value = JSON.stringify(value);
20
+ } else {
21
+ value = String(value);
22
+ }
23
+
24
+ const escaped = (value as string).replace(/"/g, '""');
25
+ return `"${escaped}"`;
26
+ });
27
+ csvRows.push(values.join(','));
28
+ }
29
+
30
+ return csvRows.join('\n');
31
+ }
@@ -0,0 +1,183 @@
1
+ import { $provideAgentCollectionForServer } from '@/src/tools/$provideAgentCollectionForServer';
2
+ import { $provideExecutionToolsForServer } from '@/src/tools/$provideExecutionToolsForServer';
3
+ import { Agent } from '@promptbook-local/core';
4
+ import { ChatMessage, ChatPromptResult, Prompt, TODO_any } from '@promptbook-local/types';
5
+ import { NextRequest, NextResponse } from 'next/server';
6
+
7
+ export async function handleChatCompletion(
8
+ request: NextRequest,
9
+ params: { agentName: string },
10
+ title: string = 'API Chat Completion'
11
+ ) {
12
+ const { agentName } = params;
13
+
14
+ // Note: Authentication is handled by middleware
15
+ // If we are here, the request is either authenticated or public access is allowed (but middleware blocks it if not)
16
+
17
+ try {
18
+ const body = await request.json();
19
+ const { messages, stream, model } = body;
20
+
21
+ if (!messages || !Array.isArray(messages) || messages.length === 0) {
22
+ return NextResponse.json(
23
+ {
24
+ error: {
25
+ message: 'Messages array is required and cannot be empty.',
26
+ type: 'invalid_request_error',
27
+ },
28
+ },
29
+ { status: 400 },
30
+ );
31
+ }
32
+
33
+ const collection = await $provideAgentCollectionForServer();
34
+ let agentSource;
35
+ try {
36
+ agentSource = await collection.getAgentSource(agentName);
37
+ } catch (error) {
38
+ return NextResponse.json(
39
+ { error: { message: `Agent '${agentName}' not found.`, type: 'invalid_request_error' } },
40
+ { status: 404 },
41
+ );
42
+ }
43
+
44
+ if (!agentSource) {
45
+ return NextResponse.json(
46
+ { error: { message: `Agent '${agentName}' not found.`, type: 'invalid_request_error' } },
47
+ { status: 404 },
48
+ );
49
+ }
50
+
51
+ const executionTools = await $provideExecutionToolsForServer();
52
+ const agent = new Agent({
53
+ agentSource,
54
+ executionTools,
55
+ isVerbose: true, // or false
56
+ });
57
+
58
+ // Prepare thread and content
59
+ const lastMessage = messages[messages.length - 1];
60
+ const previousMessages = messages.slice(0, -1);
61
+
62
+ const thread: ChatMessage[] = previousMessages.map((msg: TODO_any, index: number) => ({
63
+ id: `msg-${index}`, // Placeholder ID
64
+ from: msg.role === 'assistant' ? 'agent' : 'user', // Mapping standard OpenAI roles
65
+ content: msg.content,
66
+ isComplete: true,
67
+ date: new Date(), // We don't have the real date, using current
68
+ }));
69
+
70
+ const prompt: Prompt = {
71
+ title,
72
+ content: lastMessage.content,
73
+ modelRequirements: {
74
+ modelVariant: 'CHAT',
75
+ // We could pass 'model' from body if we wanted to enforce it, but Agent usually has its own config
76
+ },
77
+ parameters: {},
78
+ thread,
79
+ } as Prompt;
80
+ // Note: Casting as Prompt because the type definition might require properties we don't strictly use or that are optional but TS complains
81
+
82
+ if (stream) {
83
+ const encoder = new TextEncoder();
84
+ const readableStream = new ReadableStream({
85
+ async start(controller) {
86
+ const runId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}`;
87
+ const created = Math.floor(Date.now() / 1000);
88
+
89
+ let previousContent = '';
90
+
91
+ try {
92
+ await agent.callChatModelStream(prompt, (chunk: ChatPromptResult) => {
93
+ const fullContent = chunk.content;
94
+ const deltaContent = fullContent.substring(previousContent.length);
95
+ previousContent = fullContent;
96
+
97
+ if (deltaContent) {
98
+ const chunkData = {
99
+ id: runId,
100
+ object: 'chat.completion.chunk',
101
+ created,
102
+ model: model || 'promptbook-agent',
103
+ choices: [
104
+ {
105
+ index: 0,
106
+ delta: {
107
+ content: deltaContent,
108
+ },
109
+ finish_reason: null,
110
+ },
111
+ ],
112
+ };
113
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunkData)}\n\n`));
114
+ }
115
+ });
116
+
117
+ const doneChunkData = {
118
+ id: runId,
119
+ object: 'chat.completion.chunk',
120
+ created,
121
+ model: model || 'promptbook-agent',
122
+ choices: [
123
+ {
124
+ index: 0,
125
+ delta: {},
126
+ finish_reason: 'stop',
127
+ },
128
+ ],
129
+ };
130
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(doneChunkData)}\n\n`));
131
+ controller.enqueue(encoder.encode('[DONE]'));
132
+ } catch (error) {
133
+ console.error('Error during streaming:', error);
134
+ // OpenAI stream doesn't usually send error JSON in stream, just closes or sends error text?
135
+ // But we should try to close gracefully or error.
136
+ controller.error(error);
137
+ }
138
+ controller.close();
139
+ },
140
+ });
141
+
142
+ return new Response(readableStream, {
143
+ headers: {
144
+ 'Content-Type': 'text/event-stream',
145
+ 'Cache-Control': 'no-cache',
146
+ Connection: 'keep-alive',
147
+ },
148
+ });
149
+ } else {
150
+ const result = await agent.callChatModel(prompt);
151
+
152
+ return NextResponse.json({
153
+ id: `chatcmpl-${Math.random().toString(36).substring(2, 15)}`,
154
+ object: 'chat.completion',
155
+ created: Math.floor(Date.now() / 1000),
156
+ model: model || 'promptbook-agent',
157
+ choices: [
158
+ {
159
+ index: 0,
160
+ message: {
161
+ role: 'assistant',
162
+ content: result.content,
163
+ },
164
+ finish_reason: 'stop',
165
+ },
166
+ ],
167
+ usage: {
168
+ prompt_tokens: result.usage?.input?.tokensCount?.value || 0,
169
+ completion_tokens: result.usage?.output?.tokensCount?.value || 0,
170
+ total_tokens:
171
+ (result.usage?.input?.tokensCount?.value || 0) +
172
+ (result.usage?.output?.tokensCount?.value || 0),
173
+ },
174
+ });
175
+ }
176
+ } catch (error) {
177
+ console.error(`Error in ${title} handler:`, error);
178
+ return NextResponse.json(
179
+ { error: { message: (error as Error).message || 'Internal Server Error', type: 'server_error' } },
180
+ { status: 500 },
181
+ );
182
+ }
183
+ }