@adminforth/agent 1.37.0 → 1.38.0

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 (55) hide show
  1. package/agent/languageDetect.ts +0 -8
  2. package/agent/simpleAgent.ts +5 -5
  3. package/agent/systemPrompt.ts +35 -4
  4. package/agent/toolCallEvents.ts +31 -2
  5. package/agent/tools/apiTool.ts +1 -1
  6. package/agentResponseEvents.ts +197 -0
  7. package/apiBasedTools.ts +118 -284
  8. package/build.log +12 -2
  9. package/custom/ChatSurface.vue +31 -21
  10. package/custom/composables/agentAudio/agent-processing.mp3 +0 -0
  11. package/custom/composables/agentStore/constants.ts +8 -1
  12. package/custom/composables/agentStore/useAgentSessions.ts +85 -12
  13. package/custom/composables/useAgentAudio.ts +392 -0
  14. package/custom/composables/useAgentStore.ts +52 -5
  15. package/custom/conversation_area/ConversationArea.vue +1 -1
  16. package/custom/conversation_area/MessageRenderer.vue +12 -1
  17. package/custom/conversation_area/SystemMessageRenderer.vue +28 -0
  18. package/custom/conversation_area/TextRenderer.vue +4 -3
  19. package/custom/conversation_area/ToolRenderer.vue +1 -1
  20. package/custom/package.json +2 -1
  21. package/custom/pnpm-lock.yaml +29 -0
  22. package/custom/speech_recognition_frontend/AudioLines.vue +97 -0
  23. package/custom/speech_recognition_frontend/MicrophoneButon.vue +157 -0
  24. package/custom/speech_recognition_frontend/types/voice-activity-detection.d.ts +22 -0
  25. package/custom/speech_recognition_frontend/voiceActivityDetection.ts +151 -0
  26. package/custom/types.ts +52 -2
  27. package/dist/agent/languageDetect.js +0 -6
  28. package/dist/agent/simpleAgent.js +4 -3
  29. package/dist/agent/systemPrompt.js +24 -3
  30. package/dist/agent/toolCallEvents.js +24 -2
  31. package/dist/agent/tools/apiTool.js +1 -1
  32. package/dist/agentResponseEvents.js +141 -0
  33. package/dist/apiBasedTools.js +95 -211
  34. package/dist/custom/ChatSurface.vue +31 -21
  35. package/dist/custom/composables/agentAudio/agent-processing.mp3 +0 -0
  36. package/dist/custom/composables/agentStore/constants.ts +8 -1
  37. package/dist/custom/composables/agentStore/useAgentSessions.ts +85 -12
  38. package/dist/custom/composables/useAgentAudio.ts +392 -0
  39. package/dist/custom/composables/useAgentStore.ts +52 -5
  40. package/dist/custom/conversation_area/ConversationArea.vue +1 -1
  41. package/dist/custom/conversation_area/MessageRenderer.vue +12 -1
  42. package/dist/custom/conversation_area/SystemMessageRenderer.vue +28 -0
  43. package/dist/custom/conversation_area/TextRenderer.vue +4 -3
  44. package/dist/custom/conversation_area/ToolRenderer.vue +1 -1
  45. package/dist/custom/package.json +2 -1
  46. package/dist/custom/pnpm-lock.yaml +29 -0
  47. package/dist/custom/speech_recognition_frontend/AudioLines.vue +97 -0
  48. package/dist/custom/speech_recognition_frontend/MicrophoneButon.vue +157 -0
  49. package/dist/custom/speech_recognition_frontend/types/voice-activity-detection.d.ts +22 -0
  50. package/dist/custom/speech_recognition_frontend/voiceActivityDetection.ts +151 -0
  51. package/dist/custom/types.ts +52 -2
  52. package/dist/index.js +290 -400
  53. package/index.ts +318 -492
  54. package/package.json +3 -2
  55. package/types.ts +1 -1
package/index.ts CHANGED
@@ -1,11 +1,4 @@
1
- import type {
2
- AdminUser,
3
- AdminForthResource,
4
- HttpExtra,
5
- IAdminForth,
6
- IHttpServer,
7
- TextToSpeechInput,
8
- } from "adminforth";
1
+ import type { AdminUser, AdminForthResource, IAdminForth, IHttpServer } from "adminforth";
9
2
 
10
3
  import { AdminForthPlugin, logger, Filters, Sorts } from "adminforth";
11
4
 
@@ -13,44 +6,24 @@ import type { PluginOptions } from './types.js';
13
6
  import { randomUUID } from 'crypto';
14
7
  import { HumanMessage, SystemMessage } from "langchain";
15
8
  import { MemorySaver, type BaseCheckpointSaver } from "@langchain/langgraph";
16
- import {
17
- createAgentChatModel,
18
- callAgent,
19
- type AgentChatModel,
20
- } from "./agent/simpleAgent.js";
9
+ import { z } from "zod";
10
+ import { createAgentChatModel, callAgent } from "./agent/simpleAgent.js";
21
11
  import { AdminForthCheckpointSaver } from "./agent/checkpointer.js";
22
12
  import { createSequenceDebugCollector } from "./agent/middleware/sequenceDebug.js";
23
- import {
24
- detectUserLanguage,
25
- formatLanguagePrompt,
26
- } from "./agent/languageDetect.js";
27
- import {
28
- prepareApiBasedTools as buildApiBasedTools,
29
- } from './apiBasedTools.js';
30
- import type { ApiBasedTool } from './apiBasedTools.js';
31
- import {
32
- appendCustomSystemPrompt,
33
- buildAgentSystemPrompt,
34
- DEFAULT_AGENT_SYSTEM_PROMPT,
35
- } from "./agent/systemPrompt.js";
36
- import { ALWAYS_AVAILABLE_API_TOOL_NAMES } from "./agent/tools/index.js";
13
+ import { detectUserLanguage } from "./agent/languageDetect.js";
14
+ import { prepareApiBasedTools as buildApiBasedTools } from './apiBasedTools.js';
15
+ import { createAgentEventStream } from "./agentResponseEvents.js";
16
+ import { appendCustomSystemPrompt, buildAgentSystemPrompt, buildAgentTurnSystemPrompt, DEFAULT_AGENT_SYSTEM_PROMPT} from "./agent/systemPrompt.js";
37
17
  import type { ToolCallEvent } from "./agent/toolCallEvents.js";
38
18
  import type { CurrentPageContext } from "./agent/tools/getUserLocation.js";
39
19
 
40
- type CurrentPageRequestBody = {
41
- currentPage?: CurrentPageContext;
20
+ type MulterFile = {
21
+ buffer: Buffer;
22
+ originalname: string;
23
+ mimetype: string;
42
24
  };
43
25
 
44
- type SpeechResponseRequestBody = CurrentPageRequestBody & {
45
- audioBase64: string;
46
- filename: string;
47
- mimeType: string;
48
- prompt?: string;
49
- sessionId?: string | null;
50
- mode?: string | null;
51
- timeZone?: string;
52
- tts?: Omit<TextToSpeechInput, "text">;
53
- };
26
+ type ExpressMulterRequest = { file?: MulterFile };
54
27
 
55
28
  type AgentTurnRunInput = {
56
29
  prompt: string;
@@ -59,102 +32,65 @@ type AgentTurnRunInput = {
59
32
  modeName?: string | null;
60
33
  userTimeZone: string;
61
34
  currentPage?: CurrentPageContext;
35
+ abortSignal?: AbortSignal;
62
36
  adminUser: AdminUser;
63
- httpExtra: HttpExtra;
64
37
  sequenceDebugCollector: ReturnType<typeof createSequenceDebugCollector>;
65
38
  emitReasoningDelta?: (delta: string) => void;
66
39
  emitTextDelta?: (delta: string) => void;
67
40
  emitToolCallEvent?: (event: ToolCallEvent) => void;
68
41
  };
69
42
 
70
- function isAggregateErrorLike(
71
- error: unknown,
72
- ): error is { errors: unknown[]; message?: string; stack?: string } {
73
- return typeof error === "object" && error !== null && Array.isArray((error as { errors?: unknown[] }).errors);
74
- }
75
-
76
- function formatAgentError(error: unknown) {
77
- if (isAggregateErrorLike(error)) {
78
- const nestedErrors = error.errors
79
- .map((nestedError, index) => {
80
- if (nestedError instanceof Error) {
81
- return `${index + 1}. ${nestedError.stack ?? nestedError.message}`;
82
- }
83
-
84
- return `${index + 1}. ${String(nestedError)}`;
85
- })
86
- .join("\n");
87
-
88
- return `${error.stack ?? error.message}\nNested errors:\n${nestedErrors}`;
89
- }
90
-
91
- if (error instanceof Error) {
92
- return error.stack ?? error.message;
93
- }
94
-
95
- return String(error);
96
- }
43
+ type RunAndPersistAgentResponseInput =
44
+ Omit<AgentTurnRunInput, "turnId" | "sequenceDebugCollector"> & {
45
+ emitErrorResponse?: (response: string) => void;
46
+ failureLogMessage: string;
47
+ abortLogMessage: string;
48
+ };
97
49
 
98
- function formatAgentResponseError(error: unknown): string {
99
- if (isAggregateErrorLike(error)) {
100
- const nestedErrors = error.errors.map(formatAgentResponseError);
50
+ const agentResponseBodySchema = z.object({
51
+ message: z.string(),
52
+ sessionId: z.string(),
53
+ mode: z.string().nullish(),
54
+ timeZone: z.string().optional(),
55
+ currentPage: z.custom<CurrentPageContext>().optional(),
56
+ }).strict();
101
57
 
102
- if (nestedErrors.length) {
103
- return nestedErrors.join("\n");
104
- }
58
+ const agentSpeechResponseBodySchema = agentResponseBodySchema.omit({message: true})
105
59
 
106
- return error.message || "Agent response failed";
107
- }
60
+ const addSystemMessageBodySchema = z.object({
61
+ sessionId: z.string(),
62
+ systemMessage: z.string(),
63
+ }).strict();
108
64
 
109
- if (error instanceof Error) {
110
- return error.toString();
111
- }
65
+ const getSessionsBodySchema = z.object({
66
+ limit: z.number().optional(),
67
+ }).strict();
112
68
 
113
- return String(error);
114
- }
69
+ const sessionIdBodySchema = z.object({
70
+ sessionId: z.string(),
71
+ }).strict();
115
72
 
116
- function formatAdminUserPrompt(adminUser: AdminUser, usernameField: string) {
117
- const dbUser = adminUser.dbUser as Record<string, unknown>;
118
- const adminUserContext = {
119
- id: adminUser.pk,
120
- email: dbUser[usernameField],
121
- };
73
+ const createSessionBodySchema = z.object({
74
+ triggerMessage: z.string().optional(),
75
+ }).strict();
122
76
 
123
- return [
124
- "Current admin user context:",
125
- JSON.stringify(adminUserContext, null, 2),
126
- "Use this admin user email when the user asks to send information to themselves, the current admin, or the logged-in user.",
127
- ].join("\n");
128
- }
129
-
130
- function assertRequiredApiTool(
131
- apiBasedTools: Record<string, ApiBasedTool>,
132
- toolName: string,
133
- ) {
134
- if (toolName in apiBasedTools) {
135
- return;
136
- }
137
-
138
- const availableToolNames = Object.keys(apiBasedTools).sort().join(", ");
139
- throw new Error(
140
- `Required API tool "${toolName}" is missing from AdminForth Agent tools. Available tools: ${availableToolNames}`,
141
- );
142
- }
143
77
 
144
78
  export default class AdminForthAgentPlugin extends AdminForthPlugin {
145
79
  options: PluginOptions;
146
- apiBasedTools: Record<string, ApiBasedTool> = {};
147
80
  agentSystemPromptPromise: Promise<string>;
148
81
  private checkpointer: BaseCheckpointSaver | null = null;
149
- private readonly modelsByModeName = new Map<
150
- string,
151
- Promise<{
152
- model: AgentChatModel;
153
- summaryModel: AgentChatModel;
154
- modelMiddleware: Awaited<ReturnType<typeof createAgentChatModel>>["middleware"];
155
- }>
156
- >();
157
-
82
+ private parseBody<T>(
83
+ schema: z.ZodType<T>,
84
+ body: unknown,
85
+ response: { setStatus: (code: number, message?: string) => void },
86
+ ): T | null {
87
+ const parsed = schema.safeParse(body ?? {});
88
+ if (!parsed.success) {
89
+ response.setStatus(422, parsed.error.message);
90
+ return null;
91
+ }
92
+ return parsed.data;
93
+ }
158
94
  private async createNewTurn(sessionId: string, prompt: string, response?: string) {
159
95
  const turnId = randomUUID();
160
96
  const turnRecord = {
@@ -167,18 +103,6 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
167
103
  return newTurn.createdRecord[this.options.turnResource.idField];
168
104
  }
169
105
 
170
- private async updateTurn(turnId: string, updates: Record<string, unknown>) {
171
- await this.adminforth.resource(this.options.turnResource.resourceId).update(turnId, updates);
172
- return {ok: true};
173
- }
174
-
175
- private async updateSessionDate(sessionId: string) {
176
- await this.adminforth.resource(this.options.sessionResource.resourceId).update(sessionId, {
177
- [this.options.sessionResource.createdAtField]: new Date().toISOString(),
178
- });
179
- return {ok: true};
180
- }
181
-
182
106
  private async getSessionTurns(sessionId: string) {
183
107
  const turns = await this.adminforth.resource(this.options.turnResource.resourceId).list(
184
108
  [Filters.EQ(this.options.turnResource.sessionIdField, sessionId)],
@@ -192,47 +116,8 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
192
116
  }));
193
117
  }
194
118
 
195
- private async getModeModels(
196
- mode: PluginOptions["modes"][number],
197
- maxTokens: number,
198
- ) {
199
- const cachedModels = this.modelsByModeName.get(mode.name);
200
-
201
- if (cachedModels) {
202
- return await cachedModels;
203
- }
204
-
205
- const modelsPromise = Promise.all([
206
- createAgentChatModel({
207
- adapter: mode.completionAdapter,
208
- maxTokens,
209
- purpose: "primary",
210
- }),
211
- createAgentChatModel({
212
- adapter: mode.completionAdapter,
213
- maxTokens,
214
- purpose: "summary",
215
- }),
216
- ]).then(([primaryModel, summaryModel]) => ({
217
- model: primaryModel.model,
218
- summaryModel: summaryModel.model,
219
- modelMiddleware: primaryModel.middleware,
220
- }));
221
-
222
- this.modelsByModeName.set(mode.name, modelsPromise);
223
-
224
- try {
225
- return await modelsPromise;
226
- } catch (error) {
227
- this.modelsByModeName.delete(mode.name);
228
- throw error;
229
- }
230
- }
231
-
232
119
  private getCheckpointer() {
233
- if (this.checkpointer) {
234
- return this.checkpointer;
235
- }
120
+ if (this.checkpointer) return this.checkpointer;
236
121
 
237
122
  this.checkpointer = this.options.checkpointResource
238
123
  ? new AdminForthCheckpointSaver(this.adminforth, this.options)
@@ -276,7 +161,7 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
276
161
  hasAudioAdapter: Boolean(this.options.audioAdapter),
277
162
  }
278
163
  });
279
- if (!this.pluginOptions.sessionResource) {
164
+ if (!this.options.sessionResource) {
280
165
  throw new Error("sessionResource is required for AdminForthAgentPlugin");
281
166
  }
282
167
  }
@@ -296,31 +181,40 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
296
181
 
297
182
  private async runAgentTurn(input: AgentTurnRunInput) {
298
183
  let fullResponse = "";
299
- const maxTokens = this.options.maxTokens ?? 10000;
300
- const selectedMode =
301
- this.options.modes.find((mode) => mode.name === input.modeName) ??
302
- this.options.modes[0];
303
- const { model, summaryModel, modelMiddleware } =
304
- await this.getModeModels(selectedMode, maxTokens);
184
+ const maxTokens = this.options.maxTokens ?? 1000;
185
+ const selectedMode = this.options.modes.find((mode) => mode.name === input.modeName) ?? this.options.modes[0];
186
+ const [primaryModelSpec, summaryModelSpec] = await Promise.all([
187
+ createAgentChatModel({
188
+ adapter: selectedMode.completionAdapter,
189
+ maxTokens,
190
+ purpose: "primary",
191
+ }),
192
+ createAgentChatModel({
193
+ adapter: selectedMode.completionAdapter,
194
+ maxTokens,
195
+ purpose: "summary",
196
+ }),
197
+ ]);
198
+ const model = primaryModelSpec.model;
199
+ const summaryModel = summaryModelSpec.model;
200
+ const modelMiddleware = primaryModelSpec.middleware;
201
+
305
202
  const userLanguage = await detectUserLanguage(selectedMode.completionAdapter, input.prompt)
306
203
  .catch((error) => {
307
- logger.warn(`Failed to detect user language: ${error instanceof Error ? error.message : String(error)}`);
204
+ logger.warn(`Failed to detect user language: ${error.message}`);
308
205
  return null;
309
206
  });
310
- const systemPrompt = [
311
- await this.agentSystemPromptPromise,
312
- formatAdminUserPrompt(input.adminUser, this.adminforth.config.auth.usernameField),
313
- formatLanguagePrompt(userLanguage),
314
- ].join("\n\n");
207
+ const systemPrompt = buildAgentTurnSystemPrompt({
208
+ agentSystemPrompt: await this.agentSystemPromptPromise,
209
+ adminUser: input.adminUser,
210
+ usernameField: this.adminforth.config.auth.usernameField,
211
+ userLanguage,
212
+ });
315
213
  const apiBasedTools = buildApiBasedTools(
316
214
  this.adminforth,
317
215
  this.getInternalAgentResourceIds(),
318
216
  );
319
- for (const toolName of ALWAYS_AVAILABLE_API_TOOL_NAMES) {
320
- assertRequiredApiTool(apiBasedTools, toolName);
321
- }
322
- assertRequiredApiTool(apiBasedTools, "update_record");
323
- this.apiBasedTools = apiBasedTools;
217
+
324
218
  const stream = await callAgent({
325
219
  name: `adminforth-agent-${this.pluginInstanceId}`,
326
220
  model,
@@ -338,8 +232,8 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
338
232
  sessionId: input.sessionId,
339
233
  turnId: input.turnId,
340
234
  currentPage: input.currentPage,
341
- httpExtra: input.httpExtra,
342
235
  userTimeZone: input.userTimeZone,
236
+ abortSignal: input.abortSignal,
343
237
  emitToolCallEvent: (event) => {
344
238
  input.sequenceDebugCollector.handleToolCallEvent(event);
345
239
  input.emitToolCallEvent?.(event);
@@ -389,11 +283,68 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
389
283
  };
390
284
  }
391
285
 
286
+ private async runAndPersistAgentResponse(input: RunAndPersistAgentResponseInput) {
287
+ const turnId = await this.createNewTurn(input.sessionId, input.prompt);
288
+ await this.adminforth.resource(this.options.sessionResource.resourceId).update(input.sessionId, {
289
+ [this.options.sessionResource.createdAtField]: new Date().toISOString(),
290
+ });
291
+ const sequenceDebugCollector = createSequenceDebugCollector();
292
+ let fullResponse = "";
293
+ let aborted = false;
294
+ let failed = false;
295
+
296
+ try {
297
+ const agentResponse = await this.runAgentTurn({
298
+ prompt: input.prompt,
299
+ sessionId: input.sessionId,
300
+ turnId,
301
+ modeName: input.modeName,
302
+ userTimeZone: input.userTimeZone,
303
+ currentPage: input.currentPage,
304
+ abortSignal: input.abortSignal,
305
+ adminUser: input.adminUser,
306
+ sequenceDebugCollector,
307
+ emitToolCallEvent: input.emitToolCallEvent,
308
+ emitReasoningDelta: input.emitReasoningDelta,
309
+ emitTextDelta: input.emitTextDelta,
310
+ });
311
+ fullResponse = agentResponse.text;
312
+ } catch (error) {
313
+ if (input.abortSignal?.aborted) {
314
+ aborted = true;
315
+ logger.info(input.abortLogMessage);
316
+ } else {
317
+ failed = true;
318
+ logger.error(`${input.failureLogMessage}:\n${error.message}`);
319
+ fullResponse = error.message;
320
+ input.emitErrorResponse?.(fullResponse);
321
+ }
322
+ }
323
+
324
+ sequenceDebugCollector.flush();
325
+ const turnUpdates: Record<string, unknown> = {
326
+ [this.options.turnResource.responseField]: fullResponse,
327
+ };
328
+
329
+ if (this.options.turnResource.debugField) {
330
+ turnUpdates[this.options.turnResource.debugField] = sequenceDebugCollector.getHistory();
331
+ }
332
+
333
+ await this.adminforth.resource(this.options.turnResource.resourceId).update(turnId, turnUpdates);
334
+
335
+ return {
336
+ text: fullResponse,
337
+ turnId,
338
+ aborted,
339
+ failed,
340
+ };
341
+ }
342
+
392
343
  setupEndpoints(server: IHttpServer) {
393
344
  server.endpoint({
394
345
  method: 'POST',
395
346
  path: `/agent/get-placeholder-messages`,
396
- handler: async ({ body, query, headers, cookies, adminUser, response, requestUrl }) => {
347
+ handler: async ({ headers, adminUser }) => {
397
348
  if (!this.options.placeholderMessages) {
398
349
  return {
399
350
  messages: [],
@@ -402,14 +353,7 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
402
353
 
403
354
  const messages = await this.options.placeholderMessages({
404
355
  adminUser,
405
- httpExtra: {
406
- body,
407
- query,
408
- headers,
409
- cookies,
410
- requestUrl,
411
- response,
412
- },
356
+ headers,
413
357
  });
414
358
 
415
359
  return {
@@ -420,362 +364,242 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
420
364
  server.endpoint({
421
365
  method: 'POST',
422
366
  path: `/agent/response`,
423
- handler: async ({ body, query, headers, cookies, adminUser, response, requestUrl, _raw_express_res, abortSignal }) => {
424
- const res = _raw_express_res;
367
+ handler: async ({ body, adminUser, response, _raw_express_res, abortSignal }) => {
368
+ const data = this.parseBody(agentResponseBodySchema, body, response);
369
+ if (!data) return;
370
+ const stream = createAgentEventStream(_raw_express_res, {vercelAiUiMessageStream: true, closeActiveBlockOnToolStart: true});
425
371
  const messageId = randomUUID();
426
- const prompt = body.message;
427
- const userTimeZone = (body.timeZone as string | undefined) ?? 'UTC';
428
- const currentPage = (body as CurrentPageRequestBody).currentPage;
429
- const sessionId = body.sessionId || adminUser?.pk || adminUser?.username || 'default';
430
- const turnId = await this.createNewTurn(sessionId, prompt);
431
- await this.updateSessionDate(sessionId);
432
- let fullResponse = "";
433
- let isStreamClosed = false;
434
- const sequenceDebugCollector = createSequenceDebugCollector();
435
-
436
- res.writeHead(200, {
437
- 'Content-Type': 'text/event-stream',
438
- 'Cache-Control': 'no-cache',
439
- 'Connection': 'keep-alive',
440
- 'x-vercel-ai-ui-message-stream': 'v1',
441
- });
442
-
443
- const send = (obj: unknown) => {
444
- if (isStreamClosed || res.writableEnded || res.destroyed) {
445
- return;
446
- }
447
- res.write(`data: ${JSON.stringify(obj)}\n\n`);
448
- };
449
-
450
- const emitToolCallEvent = (event: ToolCallEvent) => {
451
- if (event.phase === "start") {
452
- endActiveBlock();
453
- }
454
-
455
- send({
456
- type: "data-tool-call",
457
- data: event,
458
- });
459
- };
460
-
461
- let activeBlock: { type: 'text' | 'reasoning'; id: string } | null = null;
462
-
463
- const endActiveBlock = () => {
464
- if (!activeBlock) {
465
- return;
466
- }
467
-
468
- send({
469
- type: `${activeBlock.type}-end`,
470
- id: activeBlock.id,
471
- });
472
-
473
- activeBlock = null;
474
- };
475
-
476
- const startBlock = (type: 'text' | 'reasoning') => {
477
- if (activeBlock?.type === type) {
478
- return activeBlock.id;
479
- }
480
-
481
- endActiveBlock();
482
-
483
- const id = randomUUID();
484
- activeBlock = { type, id };
485
-
486
- send({
487
- type: `${type}-start`,
488
- id,
489
- });
490
-
491
- return id;
492
- };
493
-
494
- const endStream = () => {
495
- if (isStreamClosed || res.writableEnded || res.destroyed) {
496
- return;
497
- }
498
- endActiveBlock();
499
-
500
- send({
501
- type: 'finish',
502
- });
503
372
 
504
- res.write(`data: [DONE]\n\n`);
505
- isStreamClosed = true;
506
- res.end();
507
- }
508
-
509
- try {
510
- send({
511
- type: 'start',
512
- messageId,
513
- });
514
-
515
- const agentResponse = await this.runAgentTurn({
516
- prompt,
517
- sessionId,
518
- turnId,
519
- modeName: body.mode,
520
- userTimeZone,
521
- currentPage,
522
- adminUser,
523
- httpExtra: {
524
- body,
525
- query,
526
- headers,
527
- cookies,
528
- requestUrl,
529
- response,
530
- },
531
- sequenceDebugCollector,
532
- emitToolCallEvent,
533
- emitReasoningDelta: (reasoningDelta) => {
534
- const reasoningId = startBlock('reasoning');
535
- send({
536
- type: 'reasoning-delta',
537
- id: reasoningId,
538
- delta: reasoningDelta,
539
- });
540
- },
541
- emitTextDelta: (textDelta) => {
542
- const textId = startBlock('text');
543
- fullResponse += textDelta;
544
- send({
545
- type: 'text-delta',
546
- id: textId,
547
- delta: textDelta,
548
- });
549
- },
550
- });
551
- fullResponse = agentResponse.text;
552
- } catch (error) {
553
- logger.error(`Agent response streaming failed:\n${formatAgentError(error)}`);
554
- sequenceDebugCollector.flush();
555
- fullResponse = formatAgentResponseError(error);
556
- const textId = startBlock('text');
557
- send({
558
- type: 'text-delta',
559
- id: textId,
560
- delta: fullResponse,
561
- });
562
- }
563
- sequenceDebugCollector.flush();
564
- const turnUpdates: Record<string, unknown> = {
565
- [this.options.turnResource.responseField]: fullResponse,
566
- };
567
-
568
- if (this.options.turnResource.debugField) {
569
- turnUpdates[this.options.turnResource.debugField] = sequenceDebugCollector.getHistory();
570
- }
571
-
572
- await this.updateTurn(turnId, turnUpdates);
573
- endStream();
373
+ stream.start(messageId);
374
+ await this.runAndPersistAgentResponse({
375
+ prompt: data.message,
376
+ sessionId: data.sessionId,
377
+ modeName: data.mode,
378
+ userTimeZone: data.timeZone ?? 'UTC',
379
+ currentPage: data.currentPage,
380
+ abortSignal,
381
+ adminUser,
382
+ emitToolCallEvent: stream.toolCall,
383
+ emitReasoningDelta: stream.reasoningDelta,
384
+ emitTextDelta: stream.textDelta,
385
+ emitErrorResponse: stream.textDelta,
386
+ failureLogMessage: "Agent response streaming failed",
387
+ abortLogMessage: "Agent response streaming aborted by the client",
388
+ });
389
+ stream.end();
574
390
  return null;
575
391
  }
576
392
  });
577
393
  server.endpoint({
578
394
  method: 'POST',
579
395
  path: `/agent/speech-response`,
580
- handler: async ({ body, query, headers, cookies, adminUser, response, requestUrl }) => {
396
+ target: 'upload',
397
+ handler: async ({ body, adminUser, response, _raw_express_req, _raw_express_res, abortSignal }) => {
398
+ const req = _raw_express_req as ExpressMulterRequest;
581
399
  const audioAdapter = this.options.audioAdapter;
582
400
  if (!audioAdapter) {
583
401
  response.setStatus(400, undefined);
584
- return {
585
- error: "Audio adapter is not configured for AdminForth Agent",
586
- };
402
+ return { error: "Audio adapter is not configured for AdminForth Agent" };
587
403
  }
588
-
589
- const speechBody = body as SpeechResponseRequestBody;
404
+ const data = this.parseBody(agentSpeechResponseBodySchema, body, response);
405
+ if (!data) return;
406
+ if (!req.file) {
407
+ response.setStatus(400, undefined);
408
+ return { error: "Audio file is required" };
409
+ }
410
+ const stream = createAgentEventStream(_raw_express_res);
411
+
590
412
  let transcription;
591
413
 
592
414
  try {
593
415
  transcription = await audioAdapter.transcribe({
594
- buffer: Buffer.from(speechBody.audioBase64, "base64"),
595
- filename: speechBody.filename,
596
- mimeType: speechBody.mimeType,
416
+ buffer: req.file.buffer,
417
+ filename: req.file.originalname,
418
+ mimeType: req.file.mimetype,
597
419
  language: "auto",
598
- prompt: speechBody.prompt,
599
420
  });
600
421
  } catch (error) {
601
- logger.error(`Agent speech transcription failed:\n${formatAgentError(error)}`);
602
- response.setStatus(500, undefined);
603
- return {
604
- error: "Speech transcription failed. Check server logs for details.",
605
- };
422
+ logger.error(`Agent speech transcription failed:\n${error.message}`);
423
+ stream.error("Speech transcription failed. Check server logs for details.");
424
+ stream.end();
425
+ return null;
606
426
  }
607
427
 
608
428
  const prompt = transcription.text;
609
429
  if (!prompt) {
610
- response.setStatus(400, undefined);
611
- return {
612
- error: "Speech transcription is empty",
613
- };
430
+ stream.error("Speech transcription is empty");
431
+ stream.end();
432
+ return null;
433
+ }
434
+ stream.transcript(transcription.text, transcription.language);
435
+
436
+ const sessionId = data.sessionId as string;
437
+ const currentPage = data.currentPage;
438
+ const agentResponse = await this.runAndPersistAgentResponse({
439
+ prompt,
440
+ sessionId,
441
+ modeName: data.mode,
442
+ userTimeZone: data.timeZone ?? 'UTC',
443
+ currentPage,
444
+ abortSignal,
445
+ adminUser,
446
+ emitToolCallEvent: stream.toolCall,
447
+ failureLogMessage: "Agent speech response failed",
448
+ abortLogMessage: "Agent speech response aborted by the client",
449
+ });
450
+
451
+ if (agentResponse.aborted) {
452
+ stream.end();
453
+ return null;
614
454
  }
615
455
 
616
- const sessionId = speechBody.sessionId || adminUser?.pk || adminUser?.username || 'default';
617
- const turnId = await this.createNewTurn(sessionId, prompt);
618
- await this.updateSessionDate(sessionId);
619
- const sequenceDebugCollector = createSequenceDebugCollector();
620
- let fullResponse = "";
456
+ if (agentResponse.failed) {
457
+ stream.error(agentResponse.text);
458
+ stream.end();
459
+ return null;
460
+ }
621
461
 
622
462
  try {
623
- const agentResponse = await this.runAgentTurn({
624
- prompt,
625
- sessionId,
626
- turnId,
627
- modeName: speechBody.mode,
628
- userTimeZone: speechBody.timeZone ?? 'UTC',
629
- currentPage: speechBody.currentPage,
630
- adminUser,
631
- httpExtra: {
632
- body,
633
- query,
634
- headers,
635
- cookies,
636
- requestUrl,
637
- response,
463
+ stream.speechResponse(
464
+ {
465
+ text: transcription.text,
466
+ language: transcription.language,
638
467
  },
639
- sequenceDebugCollector,
640
- emitTextDelta: (textDelta) => {
641
- fullResponse += textDelta;
468
+ {
469
+ text: agentResponse.text,
642
470
  },
643
- });
644
- fullResponse = agentResponse.text;
471
+ sessionId,
472
+ agentResponse.turnId,
473
+ );
645
474
  const speech = await audioAdapter.synthesize({
646
- text: fullResponse,
647
- ...speechBody.tts,
475
+ text: agentResponse.text,
476
+ stream: true,
477
+ streamFormat: "audio",
478
+ format: "mp3",
648
479
  });
649
- sequenceDebugCollector.flush();
650
- const turnUpdates: Record<string, unknown> = {
651
- [this.options.turnResource.responseField]: fullResponse,
652
- };
653
480
 
654
- if (this.options.turnResource.debugField) {
655
- turnUpdates[this.options.turnResource.debugField] = sequenceDebugCollector.getHistory();
656
- }
481
+ stream.audioStart(speech.mimeType, speech.format);
657
482
 
658
- await this.updateTurn(turnId, turnUpdates);
483
+ const reader = speech.audioStream.getReader();
659
484
 
660
- return {
661
- transcript: {
662
- text: transcription.text,
663
- language: transcription.language,
664
- },
665
- response: {
666
- text: fullResponse,
667
- },
668
- audio: {
669
- base64: speech.audio.toString("base64"),
670
- mimeType: speech.mimeType,
671
- format: speech.format,
672
- },
673
- sessionId,
674
- turnId,
675
- };
676
- } catch (error) {
677
- logger.error(`Agent speech response failed:\n${formatAgentError(error)}`);
678
- sequenceDebugCollector.flush();
679
- fullResponse = formatAgentResponseError(error);
680
- const turnUpdates: Record<string, unknown> = {
681
- [this.options.turnResource.responseField]: fullResponse,
682
- };
485
+ try {
486
+ while (true) {
487
+ const { value, done } = await reader.read();
488
+
489
+ if (done) {
490
+ break;
491
+ }
683
492
 
684
- if (this.options.turnResource.debugField) {
685
- turnUpdates[this.options.turnResource.debugField] = sequenceDebugCollector.getHistory();
493
+ stream.audioDelta(value);
494
+ }
495
+ } finally {
496
+ reader.releaseLock();
686
497
  }
687
498
 
688
- await this.updateTurn(turnId, turnUpdates);
689
- response.setStatus(500, undefined);
690
- return {
691
- error: fullResponse,
692
- };
499
+ stream.audioDone();
500
+ stream.end();
501
+ return null;
502
+ } catch (error) {
503
+ if (abortSignal.aborted) {
504
+ logger.info("Agent speech audio streaming aborted by the client");
505
+ } else {
506
+ logger.error(`Agent speech audio streaming failed:\n${error}`);
507
+ stream.error(error);
508
+ }
509
+ stream.end();
510
+ return null;
693
511
  }
694
512
  }
695
513
  });
696
514
  server.endpoint({
697
515
  method: 'POST',
698
516
  path: `/agent/get-sessions`,
699
- handler: async ({body, adminUser }) => {
517
+ handler: async ({body, adminUser, response }) => {
518
+ const data = this.parseBody(getSessionsBodySchema, body, response);
519
+ if (!data) return;
700
520
  const userId = adminUser.pk;
701
- const limit = typeof body.limit === 'number' ? body.limit : 20;
702
- const sessions = await this.adminforth.resource(this.pluginOptions.sessionResource.resourceId).list(
703
- [Filters.EQ(this.pluginOptions.sessionResource.askerIdField, userId)], limit, undefined, [Sorts.DESC(this.pluginOptions.sessionResource.createdAtField)]
521
+ const limit = data.limit ?? 20;
522
+ const sessions = await this.adminforth.resource(this.options.sessionResource.resourceId).list(
523
+ [Filters.EQ(this.options.sessionResource.askerIdField, userId)], limit, undefined, [Sorts.DESC(this.options.sessionResource.createdAtField)]
704
524
  );
705
- const sessionsToReturn = [];
706
- for (const session of sessions) {
707
- sessionsToReturn.push({
708
- sessionId: session[this.pluginOptions.sessionResource.idField],
709
- title: session[this.pluginOptions.sessionResource.titleField],
710
- timestamp: session[this.pluginOptions.sessionResource.createdAtField],
711
- })
712
- }
713
525
  return {
714
- sessions: sessionsToReturn
526
+ sessions: sessions.map((session) => ({
527
+ sessionId: session[this.options.sessionResource.idField],
528
+ title: session[this.options.sessionResource.titleField],
529
+ timestamp: session[this.options.sessionResource.createdAtField],
530
+ })),
715
531
  };
716
532
  }
717
533
  });
718
534
  server.endpoint({
719
535
  method: 'POST',
720
536
  path: `/agent/get-session-info`,
721
- handler: async ({body, adminUser }) => {
537
+ handler: async ({body, adminUser, response }) => {
538
+ const parsedBody = sessionIdBodySchema.safeParse(body);
539
+ if (!parsedBody.success) {
540
+ response.setStatus(422, parsedBody.error.message);
541
+ return;
542
+ }
722
543
  const userId = adminUser.pk;
723
- const sessionId = body.sessionId;
724
- const session = await this.adminforth.resource(this.pluginOptions.sessionResource.resourceId).get(
725
- [Filters.EQ(this.pluginOptions.sessionResource.idField, sessionId)]
544
+ const sessionId = parsedBody.data.sessionId;
545
+ const session = await this.adminforth.resource(this.options.sessionResource.resourceId).get(
546
+ [Filters.EQ(this.options.sessionResource.idField, sessionId)]
726
547
  );
727
548
  if (!session) {
728
549
  return {
729
550
  error: 'Session not found'
730
551
  };
731
552
  }
732
- if (session[this.pluginOptions.sessionResource.askerIdField] !== userId) {
553
+ if (session[this.options.sessionResource.askerIdField] !== userId) {
733
554
  return {
734
555
  error: 'Unauthorized'
735
556
  };
736
557
  }
737
558
  const turns = await this.getSessionTurns(sessionId);
738
- const messagesToReturn = [];
739
- for (const turn of turns) {
740
- messagesToReturn.push({
741
- text: turn.prompt,
742
- role: 'user',
743
- });
744
- if (turn.response !== "not_finished") {
745
- messagesToReturn.push({
746
- text: turn.response,
747
- role: 'assistant',
748
- });
749
- }
750
- }
751
- const sessionToReturn = {
752
- sessionId: session[this.pluginOptions.sessionResource.idField],
753
- title: session[this.pluginOptions.sessionResource.titleField],
754
- timestamp: session[this.pluginOptions.sessionResource.createdAtField],
755
- messages: messagesToReturn
756
- }
757
559
  return {
758
- session: sessionToReturn
759
- }
560
+ session: {
561
+ sessionId,
562
+ title: session[this.options.sessionResource.titleField],
563
+ timestamp: session[this.options.sessionResource.createdAtField],
564
+ messages: turns.flatMap(turn => {
565
+ const messages = [];
566
+ if (turn.prompt) {
567
+ messages.push({
568
+ text: turn.prompt,
569
+ role: 'user',
570
+ });
571
+ }
572
+ if (turn.response && turn.response !== "not_finished") {
573
+ messages.push({
574
+ text: turn.response,
575
+ role: 'assistant',
576
+ });
577
+ }
578
+ return messages;
579
+ }),
580
+ },
581
+ };
760
582
  }
761
583
  });
762
584
  server.endpoint({
763
585
  method: 'POST',
764
586
  path: `/agent/create-session`,
765
- handler: async ({body, adminUser }) => {
766
- const triggerMessage = body.triggerMessage;
587
+ handler: async ({body, adminUser, response }) => {
588
+ const data = this.parseBody(createSessionBodySchema, body, response);
589
+ if (!data) return;
590
+ const triggerMessage = data.triggerMessage;
767
591
  const userId = adminUser.pk;
768
- const title = triggerMessage ? (triggerMessage.length > 40 ? triggerMessage.slice(0, 40) : triggerMessage) : 'New Session';
592
+ const title = triggerMessage?.slice(0, 40) || "New Session";
769
593
  const newSession = {
770
- [this.pluginOptions.sessionResource.idField]: randomUUID(),
771
- [this.pluginOptions.sessionResource.titleField]: title,
772
- [this.pluginOptions.sessionResource.askerIdField]: userId,
594
+ [this.options.sessionResource.idField]: randomUUID(),
595
+ [this.options.sessionResource.titleField]: title,
596
+ [this.options.sessionResource.askerIdField]: userId,
773
597
  };
774
- await this.adminforth.resource(this.pluginOptions.sessionResource.resourceId).create(newSession);
598
+ await this.adminforth.resource(this.options.sessionResource.resourceId).create(newSession);
775
599
  return {
776
- sessionId: newSession[this.pluginOptions.sessionResource.idField],
777
- title: newSession[this.pluginOptions.sessionResource.titleField],
778
- timestamp: newSession[this.pluginOptions.sessionResource.createdAtField],
600
+ sessionId: newSession[this.options.sessionResource.idField],
601
+ title: newSession[this.options.sessionResource.titleField],
602
+ timestamp: newSession[this.options.sessionResource.createdAtField],
779
603
  messages: []
780
604
  };
781
605
  }
@@ -783,28 +607,30 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
783
607
  server.endpoint({
784
608
  method: 'POST',
785
609
  path: `/agent/delete-session`,
786
- handler: async ({body, adminUser }) => {
787
- const sessionId = body.sessionId;
610
+ handler: async ({body, adminUser, response }) => {
611
+ const data = this.parseBody(sessionIdBodySchema, body, response);
612
+ if (!data) return;
613
+ const sessionId = data.sessionId;
788
614
  const userId = adminUser.pk;
789
- const session = await this.adminforth.resource(this.pluginOptions.sessionResource.resourceId).get(
790
- [Filters.EQ(this.pluginOptions.sessionResource.idField, sessionId)]
615
+ const session = await this.adminforth.resource(this.options.sessionResource.resourceId).get(
616
+ [Filters.EQ(this.options.sessionResource.idField, sessionId)]
791
617
  );
792
618
  if (!session) {
793
619
  return {
794
620
  error: 'Session not found'
795
621
  };
796
622
  }
797
- if (session[this.pluginOptions.sessionResource.askerIdField] !== userId) {
623
+ if (session[this.options.sessionResource.askerIdField] !== userId) {
798
624
  return {
799
625
  error: 'Unauthorized'
800
626
  };
801
627
  }
802
- await this.adminforth.resource(this.pluginOptions.sessionResource.resourceId).delete(sessionId);
803
- const turns = await this.adminforth.resource(this.pluginOptions.turnResource.resourceId).list(
804
- [Filters.EQ(this.pluginOptions.turnResource.sessionIdField, sessionId)]
628
+ await this.adminforth.resource(this.options.sessionResource.resourceId).delete(sessionId);
629
+ const turns = await this.adminforth.resource(this.options.turnResource.resourceId).list(
630
+ [Filters.EQ(this.options.turnResource.sessionIdField, sessionId)]
805
631
  );
806
632
  for (const turn of turns) {
807
- await this.adminforth.resource(this.pluginOptions.turnResource.resourceId).delete(turn[this.pluginOptions.turnResource.idField]);
633
+ await this.adminforth.resource(this.options.turnResource.resourceId).delete(turn[this.options.turnResource.idField]);
808
634
  }
809
635
  return {
810
636
  ok: true
@@ -814,14 +640,14 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
814
640
  server.endpoint({
815
641
  method: 'POST',
816
642
  path: `/agent/add-system-message-to-turns`,
817
- handler: async ({body, adminUser, _raw_express_req }) => {
818
- const sessionId = body.sessionId;
819
- const systemMessage = body.systemMessage;
820
- await this.createNewTurn(sessionId, systemMessage);
643
+ handler: async ({body, response }) => {
644
+ const data = this.parseBody(addSystemMessageBodySchema, body, response);
645
+ if (!data) return;
646
+ await this.createNewTurn(data.sessionId, data.systemMessage);
821
647
  return {
822
648
  ok: true
823
649
  }
824
650
  }
825
- });
651
+ })
826
652
  }
827
653
  }