plugin-ai-api 1.0.7 → 1.0.9

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.
@@ -1,428 +1,450 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
- import { Context } from '@nocobase/actions';
11
- import {
12
- generateCompletionId,
13
- toOpenAIStreamChunk,
14
- toOpenAIError,
15
- formatSSE,
16
- formatSSEDone,
17
- } from '../utils/openai-format';
18
- import { resolveModelString } from '../utils/resolve-service';
19
- import { checkEmployeeAccess } from '../middleware/role-permission';
20
- import type PluginAiApiServer from '../plugin';
21
-
22
- /**
23
- * POST /api/ai-llm/v1/chat/completions (agent mode)
24
- *
25
- * Runs the full AI Employee pipeline by directly instantiating AIEmployee
26
- * from plugin-ai. This provides TRUE real-time streaming — no buffering.
27
- *
28
- * ## Architecture (vs old approach)
29
- *
30
- * OLD (fake streaming, HTTP loopback):
31
- * Client ai-api HTTP to localhost aiConversations:sendMessages
32
- * buffers entire response
33
- * Client ← 20-char fake chunks ← re-emit
34
- *
35
- * NEW (true streaming, direct instantiation):
36
- * Client ai-api → AIEmployee(ctx) writes directly to ctx.res
37
- * We intercept ctx.res.write to translate NocoBase SSEOpenAI SSE
38
- * Client real-time OpenAI SSE chunks write() intercept
39
- *
40
- * ## NocoBase → OpenAI SSE translation
41
- *
42
- * NocoBase emits: `data: {"type":"content","body":"chunk text"}\n\n`
43
- * We emit: `data: {"choices":[{"delta":{"content":"chunk text"},...}]}\n\n`
44
- *
45
- * Other NocoBase event types (tool_calls, stream_start, etc.) are silently
46
- * ignored — they are NocoBase-internal events not part of the OpenAI protocol.
47
- *
48
- * ## Known limitation
49
- * Token usage is always { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
50
- * in agent mode. ResponseMetadataCollector is private inside AIEmployee with no
51
- * public accessor. Modifying plugin-ai is out of scope for this plugin.
52
- */
53
- export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiServer) {
54
- const body = ctx.request.body as any;
55
-
56
- // ─── Validate ─────────────────────────────────────────────────────────────
57
- if (!body?.model) {
58
- ctx.status = 400;
59
- ctx.body = toOpenAIError(400, "'model' is required", 'invalid_request_error', 'missing_model');
60
- return;
61
- }
62
-
63
- if (!body?.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
64
- ctx.status = 400;
65
- ctx.body = toOpenAIError(400, "'messages' must be a non-empty array", 'invalid_request_error', 'missing_messages');
66
- return;
67
- }
68
-
69
- if (body.n !== undefined && body.n !== null && body.n !== 1) {
70
- ctx.status = 400;
71
- ctx.body = toOpenAIError(
72
- 400,
73
- `The 'n' parameter value ${body.n} is not supported. This gateway always returns n=1.`,
74
- 'invalid_request_error',
75
- 'unsupported_parameter',
76
- );
77
- return;
78
- }
79
-
80
- // ─── Load config and validate AI Employee ─────────────────────────────────
81
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
82
- const defaultAiEmployee = config ? (config.get('defaultAiEmployee') || config.defaultAiEmployee) : null;
83
- if (!defaultAiEmployee) {
84
- ctx.status = 400;
85
- ctx.body = toOpenAIError(
86
- 400,
87
- 'Agent mode requires a Default AI Employee. Configure one in Settings > AI API Gateway.',
88
- 'invalid_request_error',
89
- 'missing_config',
90
- );
91
- return;
92
- }
93
-
94
- // ─── Resolve model ─────────────────────────────────────────────────────────
95
- const resolved = await resolveModelString(ctx, body.model);
96
- if (!resolved) {
97
- ctx.status = 404;
98
- ctx.body = toOpenAIError(
99
- 404,
100
- `Could not resolve model '${body.model}'. Use GET /v1/models to see available models.`,
101
- 'invalid_request_error',
102
- 'model_not_found',
103
- );
104
- return;
105
- }
106
-
107
- const { service, modelId } = resolved;
108
- if (service.enabled === false) {
109
- ctx.status = 404;
110
- ctx.body = toOpenAIError(404, 'LLM service is disabled', 'invalid_request_error', 'model_not_found');
111
- return;
112
- }
113
-
114
- const employeeUsername = defaultAiEmployee;
115
-
116
- // ─── Check role is allowed to use this employee ────────────────────────────
117
- if (!checkEmployeeAccess(ctx, employeeUsername)) {
118
- ctx.status = 403;
119
- ctx.body = toOpenAIError(
120
- 403,
121
- `Role is not permitted to use AI Employee '${employeeUsername}'. ` +
122
- `An admin must grant access in Settings Users & Permissions → [Role] → AI API.`,
123
- 'permission_denied',
124
- 'employee_not_permitted',
125
- );
126
- return;
127
- }
128
-
129
- const wantStream = body.stream === true;
130
-
131
- try {
132
- // ─── Load AI Employee record ────────────────────────────────────────────
133
- const employeeRecord = await ctx.db.getRepository('aiEmployees').findOne({
134
- filter: { username: employeeUsername },
135
- });
136
- if (!employeeRecord) {
137
- ctx.status = 400;
138
- ctx.body = toOpenAIError(
139
- 400,
140
- `AI employee '${employeeUsername}' not found. Check Settings > AI API Gateway.`,
141
- 'invalid_request_error',
142
- 'missing_config',
143
- );
144
- return;
145
- }
146
-
147
- // ─── Create ephemeral conversation directly in DB ───────────────────────
148
- // Avoids the HTTP loopback of the old implementation.
149
- // thread: 1 = LangGraph-enabled (non-legacy) mode for full agent capabilities.
150
- const userId = ctx.state.currentUser?.id;
151
- const conversation = await ctx.db.getRepository('aiConversations').create({
152
- values: {
153
- userId,
154
- aiEmployee: { username: employeeUsername },
155
- options: {},
156
- thread: 1,
157
- },
158
- });
159
- const sessionId = conversation.sessionId ?? conversation.id ?? String(conversation.get('id'));
160
-
161
- // ─── Convert OpenAI messages → NocoBase AIMessageInput format ──────────
162
- // AIMessageInput = Omit<AIMessage, 'messageId' | 'sessionId'>
163
- // = { role, content: { type, content }, toolCalls?, attachments?, ... }
164
- const userMessages = body.messages.map((msg: any) => ({
165
- // 'assistant' role maps to the AI employee's username in NocoBase's system
166
- role: msg.role === 'assistant' ? employeeUsername : msg.role,
167
- content: {
168
- type: 'text',
169
- content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
170
- },
171
- }));
172
-
173
- const completionId = generateCompletionId();
174
-
175
- // ─── Dynamic import of AIEmployee ──────────────────────────────────────
176
- // Uses dynamic import() to avoid a hard compile-time path dependency.
177
- // plugin-ai is a peerDependency and is always available at runtime.
178
- let AIEmployee: any;
179
- try {
180
- const mod = await import(
181
- /* webpackIgnore: true */
182
- '@nocobase/plugin-ai/dist/server/ai-employees/ai-employee.js' as any
183
- );
184
- AIEmployee = mod.AIEmployee;
185
- } catch (importErr) {
186
- ctx.log.error(
187
- 'AI API: Failed to import AIEmployee from plugin-ai. Ensure plugin-ai is installed and built.',
188
- importErr,
189
- );
190
- ctx.status = 500;
191
- ctx.body = toOpenAIError(500, 'Agent mode unavailable: plugin-ai not found or not built', 'server_error');
192
- return;
193
- }
194
- if (!AIEmployee) {
195
- ctx.status = 500;
196
- ctx.body = toOpenAIError(
197
- 500,
198
- 'Agent mode unavailable: AIEmployee class not exported by plugin-ai',
199
- 'server_error',
200
- );
201
- return;
202
- }
203
-
204
- // ─── Ensure timezone/locale headers exist for AIEmployee.parseVariables ──
205
- // External clients don't send X-Timezone / X-Locale, but plugin-ai's
206
- // parseVariables() calls ctx.get('x-timezone') to resolve date variables
207
- // in the AI employee system prompt. Without it, utc2unit() crashes.
208
- if (!ctx.get('x-timezone')) {
209
- ctx.req.headers['x-timezone'] = 'UTC';
210
- }
211
- if (!ctx.get('x-locale')) {
212
- ctx.req.headers['x-locale'] = 'en-US';
213
- }
214
-
215
- if (wantStream) {
216
- // ── TRUE STREAMING ────────────────────────────────────────────────────
217
- //
218
- // ChatStreamProtocol.write() inside AIEmployee calls ctx.res.write() directly.
219
- // We monkey-patch ctx.res.write to intercept NocoBase SSE events and translate
220
- // them into OpenAI SSE format before they reach the wire.
221
- //
222
- // NocoBase format: data: {"type":"content","body":"Hello "}\n\n
223
- // OpenAI format: data: {"choices":[{"delta":{"content":"Hello "},...}]}\n\n
224
- //
225
- // We also intercept ctx.res.end to prevent AIEmployee from closing the stream
226
- // before we can send the final [DONE] marker.
227
-
228
- ctx.set({
229
- 'Content-Type': 'text/event-stream',
230
- 'Cache-Control': 'no-cache',
231
- Connection: 'keep-alive',
232
- 'X-Accel-Buffering': 'no',
233
- });
234
- ctx.status = 200;
235
-
236
- // Send the initial role delta (OpenAI streaming convention)
237
- ctx.res.write(
238
- formatSSE(
239
- toOpenAIStreamChunk({
240
- id: completionId,
241
- model: body.model,
242
- delta: { role: 'assistant', content: '' },
243
- }),
244
- ),
245
- );
246
-
247
- const originalWrite = ctx.res.write.bind(ctx.res);
248
- const originalEnd = ctx.res.end.bind(ctx.res);
249
-
250
- // Intercept end() — prevent AIEmployee from terminating the stream early.
251
- // We restore and call it ourselves in the finally block.
252
- (ctx.res as any).end = (...args: any[]) => {
253
- // If called with data (error paths), flush it via the intercepted write
254
- if (args[0]) {
255
- (ctx.res as any).write(args[0]);
256
- }
257
- // Don't actually end — we control this
258
- };
259
-
260
- // Intercept write() — translate NocoBase SSE OpenAI SSE
261
- (ctx.res as any).write = (data: Buffer | string): boolean => {
262
- const text = typeof data === 'string' ? data : data.toString('utf8');
263
-
264
- for (const line of text.split('\n')) {
265
- const trimmed = line.trim();
266
- if (!trimmed.startsWith('data: ')) continue;
267
-
268
- const jsonStr = trimmed.substring(6);
269
- if (!jsonStr) continue;
270
-
271
- try {
272
- const event = JSON.parse(jsonStr);
273
-
274
- if (event.type === 'content' && event.body) {
275
- // Content chunk — forward as OpenAI delta
276
- originalWrite(
277
- formatSSE(
278
- toOpenAIStreamChunk({
279
- id: completionId,
280
- model: body.model,
281
- delta: { content: String(event.body) },
282
- }),
283
- ),
284
- );
285
- } else if (event.type === 'error' && event.body) {
286
- // Error from the agent — surface as SSE error object
287
- originalWrite(
288
- formatSSE({
289
- error: {
290
- message: String(event.body),
291
- type: 'server_error',
292
- code: 'agent_error',
293
- },
294
- }),
295
- );
296
- }
297
- // stream_start, stream_end, tool_calls, tool_call_status,
298
- // web_search, reasoning, new_message, tool_call_chunks → silently ignored.
299
- // These are NocoBase-internal events not part of the OpenAI protocol.
300
- } catch {
301
- // Non-JSON SSE line — ignore
302
- }
303
- }
304
- return true;
305
- };
306
-
307
- try {
308
- const aiEmployee = new AIEmployee(
309
- ctx,
310
- employeeRecord,
311
- sessionId,
312
- undefined, // systemMessage — use AI employee's default
313
- undefined, // skillSettings
314
- false, // webSearch
315
- { llmService: service.name, model: modelId },
316
- false, // legacy — use LangGraph (thread: 1)
317
- );
318
-
319
- await aiEmployee.stream({ userMessages });
320
- } finally {
321
- // Restore original write/end before sending our closing frames
322
- (ctx.res as any).write = originalWrite;
323
- (ctx.res as any).end = originalEnd;
324
-
325
- // Send the finish chunk and [DONE] signal
326
- originalWrite(
327
- formatSSE(
328
- toOpenAIStreamChunk({
329
- id: completionId,
330
- model: body.model,
331
- delta: {},
332
- finishReason: 'stop',
333
- }),
334
- ),
335
- );
336
- originalWrite(formatSSEDone());
337
- originalEnd();
338
- }
339
- } else {
340
- // ── NON-STREAMING (invoke) ─────────────────────────────────────────────
341
- //
342
- // AIEmployee.invoke() returns the final LangGraph state object.
343
- // The last AI message in state.messages contains the response content.
344
-
345
- const aiEmployee = new AIEmployee(
346
- ctx,
347
- employeeRecord,
348
- sessionId,
349
- undefined,
350
- undefined,
351
- false,
352
- { llmService: service.name, model: modelId },
353
- false,
354
- );
355
-
356
- const result = await aiEmployee.invoke({ userMessages });
357
- const content = extractLastAiMessageContent(result);
358
-
359
- ctx.status = 200;
360
- ctx.set('Content-Type', 'application/json');
361
- ctx.body = {
362
- id: completionId,
363
- object: 'chat.completion',
364
- created: Math.floor(Date.now() / 1000),
365
- model: body.model,
366
- system_fingerprint: null,
367
- choices: [
368
- {
369
- index: 0,
370
- message: { role: 'assistant', content },
371
- logprobs: null,
372
- finish_reason: 'stop',
373
- },
374
- ],
375
- // NOTE: Token usage is unavailable in agent mode.
376
- // ResponseMetadataCollector is private inside AIEmployee and not exposed publicly.
377
- // Clients can detect agent mode by checking usage.total_tokens === 0.
378
- usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
379
- };
380
- }
381
-
382
- // ─── Cleanup: delete the ephemeral conversation (best-effort) ────────────
383
- // Run after response is sent so cleanup latency doesn't affect the client.
384
- setImmediate(async () => {
385
- try {
386
- await ctx.db.getRepository('aiConversations').destroy({
387
- filterByTk: sessionId,
388
- });
389
- } catch {
390
- // Best-effort: if cleanup fails, the conversation stays in DB.
391
- // CheckpointCleaner in plugin-ai will handle stale conversations after 48h.
392
- }
393
- });
394
- } catch (err) {
395
- ctx.log.error('AI API agent completions error:', err);
396
- if (!ctx.res.headersSent) {
397
- ctx.status = 500;
398
- ctx.body = toOpenAIError(500, err.message || 'Internal server error', 'server_error');
399
- }
400
- }
401
- }
402
-
403
- /**
404
- * Extract the last AI message content from a LangGraph invoke() result.
405
- *
406
- * LangGraph state has a `messages` array of LangChain BaseMessage instances.
407
- * We iterate from the end to find the last message that is NOT a HumanMessage
408
- * or ToolMessage (those are user/tool inputs, not AI output).
409
- */
410
- function extractLastAiMessageContent(result: any): string {
411
- if (!result?.messages || !Array.isArray(result.messages)) return '';
412
-
413
- for (let i = result.messages.length - 1; i >= 0; i--) {
414
- const msg = result.messages[i];
415
- if (!msg) continue;
416
-
417
- const className = msg?.constructor?.name;
418
- if (className === 'HumanMessage' || className === 'ToolMessage') continue;
419
-
420
- if (typeof msg.content === 'string') return msg.content;
421
- if (Array.isArray(msg.content)) {
422
- const textBlock = msg.content.find((c: any) => c.type === 'text');
423
- return textBlock?.text || '';
424
- }
425
- }
426
-
427
- return '';
428
- }
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { Context } from '@nocobase/actions';
11
+ import {
12
+ generateCompletionId,
13
+ toOpenAIStreamChunk,
14
+ toOpenAIError,
15
+ formatSSE,
16
+ formatSSEDone,
17
+ } from '../utils/openai-format';
18
+ import { resolveModelString } from '../utils/resolve-service';
19
+ import { checkEmployeeAccess } from '../middleware/role-permission';
20
+ import {
21
+ AgentRuntimeContext,
22
+ createAIEmployeeOptions,
23
+ getAgentRuntimeLifecycle,
24
+ loadAIEmployeeConstructor,
25
+ } from '../utils/ai-employee-runtime';
26
+ import type PluginAiApiServer from '../plugin';
27
+
28
+ /**
29
+ * POST /api/ai-llm/v1/chat/completions (agent mode)
30
+ *
31
+ * Runs the full AI Employee pipeline by directly instantiating AIEmployee
32
+ * from plugin-ai. This provides TRUE real-time streaming — no buffering.
33
+ *
34
+ * ## Architecture (vs old approach)
35
+ *
36
+ * OLD (fake streaming, HTTP loopback):
37
+ * Client ai-api HTTP to localhostaiConversations:sendMessages
38
+ * buffers entire response
39
+ * Client ← 20-char fake chunks ← re-emit
40
+ *
41
+ * NEW (true streaming, direct instantiation):
42
+ * Client ai-api AIEmployee(ctx) ← writes directly to ctx.res
43
+ * We intercept ctx.res.write to translate NocoBase SSE → OpenAI SSE
44
+ * Client ← real-time OpenAI SSE chunks ← write() intercept
45
+ *
46
+ * ## NocoBase OpenAI SSE translation
47
+ *
48
+ * NocoBase emits: `data: {"type":"content","body":"chunk text"}\n\n`
49
+ * We emit: `data: {"choices":[{"delta":{"content":"chunk text"},...}]}\n\n`
50
+ *
51
+ * Other NocoBase event types (tool_calls, stream_start, etc.) are silently
52
+ * ignored — they are NocoBase-internal events not part of the OpenAI protocol.
53
+ *
54
+ * ## Known limitation
55
+ * Token usage is always { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
56
+ * in agent mode. ResponseMetadataCollector is private inside AIEmployee with no
57
+ * public accessor. Modifying plugin-ai is out of scope for this plugin.
58
+ */
59
+ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiServer) {
60
+ const body = ctx.request.body as any;
61
+
62
+ // ─── Validate ─────────────────────────────────────────────────────────────
63
+ if (!body?.model) {
64
+ ctx.status = 400;
65
+ ctx.body = toOpenAIError(400, "'model' is required", 'invalid_request_error', 'missing_model');
66
+ return;
67
+ }
68
+
69
+ if (!body?.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
70
+ ctx.status = 400;
71
+ ctx.body = toOpenAIError(400, "'messages' must be a non-empty array", 'invalid_request_error', 'missing_messages');
72
+ return;
73
+ }
74
+
75
+ if (body.n !== undefined && body.n !== null && body.n !== 1) {
76
+ ctx.status = 400;
77
+ ctx.body = toOpenAIError(
78
+ 400,
79
+ `The 'n' parameter value ${body.n} is not supported. This gateway always returns n=1.`,
80
+ 'invalid_request_error',
81
+ 'unsupported_parameter',
82
+ );
83
+ return;
84
+ }
85
+
86
+ // ─── Load config and validate AI Employee ─────────────────────────────────
87
+ const config = await ctx.db.getRepository('aiApiConfig').findOne();
88
+ const defaultAiEmployee = config ? config.get('defaultAiEmployee') || config.defaultAiEmployee : null;
89
+ if (!defaultAiEmployee) {
90
+ ctx.status = 400;
91
+ ctx.body = toOpenAIError(
92
+ 400,
93
+ 'Agent mode requires a Default AI Employee. Configure one in Settings > AI API Gateway.',
94
+ 'invalid_request_error',
95
+ 'missing_config',
96
+ );
97
+ return;
98
+ }
99
+
100
+ // ─── Resolve model ─────────────────────────────────────────────────────────
101
+ const resolved = await resolveModelString(ctx, body.model);
102
+ if (!resolved) {
103
+ ctx.status = 404;
104
+ ctx.body = toOpenAIError(
105
+ 404,
106
+ `Could not resolve model '${body.model}'. Use GET /v1/models to see available models.`,
107
+ 'invalid_request_error',
108
+ 'model_not_found',
109
+ );
110
+ return;
111
+ }
112
+
113
+ const { service, modelId } = resolved;
114
+ if (service.enabled === false) {
115
+ ctx.status = 404;
116
+ ctx.body = toOpenAIError(404, 'LLM service is disabled', 'invalid_request_error', 'model_not_found');
117
+ return;
118
+ }
119
+
120
+ const employeeUsername = defaultAiEmployee;
121
+
122
+ // ─── Check role is allowed to use this employee ────────────────────────────
123
+ if (!checkEmployeeAccess(ctx, employeeUsername)) {
124
+ ctx.status = 403;
125
+ ctx.body = toOpenAIError(
126
+ 403,
127
+ `Role is not permitted to use AI Employee '${employeeUsername}'. ` +
128
+ `An admin must grant access in Settings → Users & Permissions → [Role] → AI API.`,
129
+ 'permission_denied',
130
+ 'employee_not_permitted',
131
+ );
132
+ return;
133
+ }
134
+
135
+ const wantStream = body.stream === true;
136
+ const lifecycle = getAgentRuntimeLifecycle(ctx);
137
+ let runtimeContext: AgentRuntimeContext | undefined;
138
+ let lifecycleCompleted = false;
139
+ let ephemeralSessionId: string | undefined;
140
+
141
+ try {
142
+ // ─── Load AI Employee record ────────────────────────────────────────────
143
+ const employeeRecord = await ctx.db.getRepository('aiEmployees').findOne({
144
+ filter: { username: employeeUsername },
145
+ });
146
+ if (!employeeRecord) {
147
+ ctx.status = 400;
148
+ ctx.body = toOpenAIError(
149
+ 400,
150
+ `AI employee '${employeeUsername}' not found. Check Settings > AI API Gateway.`,
151
+ 'invalid_request_error',
152
+ 'missing_config',
153
+ );
154
+ return;
155
+ }
156
+
157
+ // ─── Create ephemeral conversation directly in DB ───────────────────────
158
+ // Avoids the HTTP loopback of the old implementation.
159
+ // thread: 1 = LangGraph-enabled (non-legacy) mode for full agent capabilities.
160
+ const userId = ctx.state.currentUser?.id;
161
+ const conversation = await ctx.db.getRepository('aiConversations').create({
162
+ values: {
163
+ userId,
164
+ aiEmployee: { username: employeeUsername },
165
+ options: {},
166
+ thread: 1,
167
+ },
168
+ });
169
+ const sessionId = conversation.sessionId ?? conversation.id ?? String(conversation.get('id'));
170
+ ephemeralSessionId = String(sessionId);
171
+
172
+ // ─── Convert OpenAI messages → NocoBase AIMessageInput format ──────────
173
+ // AIMessageInput = Omit<AIMessage, 'messageId' | 'sessionId'>
174
+ // = { role, content: { type, content }, toolCalls?, attachments?, ... }
175
+ const userMessages = body.messages.map((msg: any) => ({
176
+ // 'assistant' role maps to the AI employee's username in NocoBase's system
177
+ role: msg.role === 'assistant' ? employeeUsername : msg.role,
178
+ content: {
179
+ type: 'text',
180
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
181
+ },
182
+ }));
183
+
184
+ runtimeContext = {
185
+ ctx,
186
+ source: 'api',
187
+ employee: employeeRecord,
188
+ sessionId,
189
+ userId,
190
+ messages: userMessages,
191
+ metadata: {},
192
+ };
193
+ const completionId = generateCompletionId();
194
+
195
+ // ─── Dynamic import of AIEmployee ──────────────────────────────────────
196
+ // Uses dynamic import() to avoid a hard compile-time path dependency.
197
+ // plugin-ai is a peerDependency and is always available at runtime.
198
+ let AIEmployee;
199
+ try {
200
+ AIEmployee = await loadAIEmployeeConstructor();
201
+ } catch (importErr) {
202
+ ctx.log.error(
203
+ 'AI API: Failed to import AIEmployee from plugin-ai. Ensure plugin-ai is installed and built.',
204
+ importErr,
205
+ );
206
+ ctx.status = 500;
207
+ ctx.body = toOpenAIError(500, 'Agent mode unavailable: plugin-ai not found or not built', 'server_error');
208
+ return;
209
+ }
210
+ // ─── Ensure timezone/locale headers exist for AIEmployee.parseVariables ──
211
+ // External clients don't send X-Timezone / X-Locale, but plugin-ai's
212
+ // parseVariables() calls ctx.get('x-timezone') to resolve date variables
213
+ // in the AI employee system prompt. Without it, utc2unit() crashes.
214
+ if (!ctx.get('x-timezone')) {
215
+ ctx.req.headers['x-timezone'] = 'UTC';
216
+ }
217
+ if (!ctx.get('x-locale')) {
218
+ ctx.req.headers['x-locale'] = 'en-US';
219
+ }
220
+
221
+ // Run extension hooks only after the employee runtime is available. This keeps
222
+ // beforeRun/afterRun symmetrical: an import/configuration failure never starts a lifecycle run.
223
+ await lifecycle?.runBeforeHooks(runtimeContext);
224
+
225
+ if (wantStream) {
226
+ // ── TRUE STREAMING ────────────────────────────────────────────────────
227
+ //
228
+ // ChatStreamProtocol.write() inside AIEmployee calls ctx.res.write() directly.
229
+ // We monkey-patch ctx.res.write to intercept NocoBase SSE events and translate
230
+ // them into OpenAI SSE format before they reach the wire.
231
+ //
232
+ // NocoBase format: data: {"type":"content","body":"Hello "}\n\n
233
+ // OpenAI format: data: {"choices":[{"delta":{"content":"Hello "},...}]}\n\n
234
+ //
235
+ // We also intercept ctx.res.end to prevent AIEmployee from closing the stream
236
+ // before we can send the final [DONE] marker.
237
+
238
+ ctx.set({
239
+ 'Content-Type': 'text/event-stream',
240
+ 'Cache-Control': 'no-cache',
241
+ Connection: 'keep-alive',
242
+ 'X-Accel-Buffering': 'no',
243
+ });
244
+ ctx.status = 200;
245
+
246
+ // Send the initial role delta (OpenAI streaming convention)
247
+ ctx.res.write(
248
+ formatSSE(
249
+ toOpenAIStreamChunk({
250
+ id: completionId,
251
+ model: body.model,
252
+ delta: { role: 'assistant', content: '' },
253
+ }),
254
+ ),
255
+ );
256
+
257
+ const originalWrite = ctx.res.write.bind(ctx.res);
258
+ const originalEnd = ctx.res.end.bind(ctx.res);
259
+
260
+ // Intercept end() — prevent AIEmployee from terminating the stream early.
261
+ // We restore and call it ourselves in the finally block.
262
+ (ctx.res as any).end = (...args: any[]) => {
263
+ // If called with data (error paths), flush it via the intercepted write
264
+ if (args[0]) {
265
+ (ctx.res as any).write(args[0]);
266
+ }
267
+ // Don't actually end — we control this
268
+ };
269
+
270
+ // Intercept write() — translate NocoBase SSE → OpenAI SSE
271
+ (ctx.res as any).write = (data: Buffer | string): boolean => {
272
+ const text = typeof data === 'string' ? data : data.toString('utf8');
273
+
274
+ for (const line of text.split('\n')) {
275
+ const trimmed = line.trim();
276
+ if (!trimmed.startsWith('data: ')) continue;
277
+
278
+ const jsonStr = trimmed.substring(6);
279
+ if (!jsonStr) continue;
280
+
281
+ try {
282
+ const event = JSON.parse(jsonStr);
283
+
284
+ if (event.type === 'content' && event.body) {
285
+ // Content chunk forward as OpenAI delta
286
+ originalWrite(
287
+ formatSSE(
288
+ toOpenAIStreamChunk({
289
+ id: completionId,
290
+ model: body.model,
291
+ delta: { content: String(event.body) },
292
+ }),
293
+ ),
294
+ );
295
+ } else if (event.type === 'error' && event.body) {
296
+ // Error from the agent — surface as SSE error object
297
+ originalWrite(
298
+ formatSSE({
299
+ error: {
300
+ message: String(event.body),
301
+ type: 'server_error',
302
+ code: 'agent_error',
303
+ },
304
+ }),
305
+ );
306
+ }
307
+ // stream_start, stream_end, tool_calls, tool_call_status,
308
+ // web_search, reasoning, new_message, tool_call_chunks → silently ignored.
309
+ // These are NocoBase-internal events not part of the OpenAI protocol.
310
+ } catch {
311
+ // Non-JSON SSE line — ignore
312
+ }
313
+ }
314
+ return true;
315
+ };
316
+
317
+ try {
318
+ const aiEmployee = new AIEmployee(
319
+ createAIEmployeeOptions(ctx, employeeRecord, sessionId, {
320
+ llmService: service.name,
321
+ model: modelId,
322
+ }),
323
+ );
324
+
325
+ await aiEmployee.stream({ userMessages });
326
+ try {
327
+ await lifecycle?.runAfterHooks(runtimeContext, { succeeded: true });
328
+ } finally {
329
+ lifecycleCompleted = true;
330
+ }
331
+ } finally {
332
+ // Restore original write/end before sending our closing frames
333
+ (ctx.res as any).write = originalWrite;
334
+ (ctx.res as any).end = originalEnd;
335
+
336
+ // Send the finish chunk and [DONE] signal
337
+ originalWrite(
338
+ formatSSE(
339
+ toOpenAIStreamChunk({
340
+ id: completionId,
341
+ model: body.model,
342
+ delta: {},
343
+ finishReason: 'stop',
344
+ }),
345
+ ),
346
+ );
347
+ originalWrite(formatSSEDone());
348
+ originalEnd();
349
+ }
350
+ } else {
351
+ // ── NON-STREAMING (invoke) ─────────────────────────────────────────────
352
+ //
353
+ // AIEmployee.invoke() returns the final LangGraph state object.
354
+ // The last AI message in state.messages contains the response content.
355
+
356
+ const aiEmployee = new AIEmployee(
357
+ createAIEmployeeOptions(ctx, employeeRecord, sessionId, {
358
+ llmService: service.name,
359
+ model: modelId,
360
+ }),
361
+ );
362
+
363
+ const result = await aiEmployee.invoke({ userMessages });
364
+ try {
365
+ await lifecycle?.runAfterHooks(runtimeContext, { succeeded: true, value: result });
366
+ } finally {
367
+ lifecycleCompleted = true;
368
+ }
369
+ const content = extractLastAiMessageContent(result);
370
+
371
+ ctx.status = 200;
372
+ ctx.set('Content-Type', 'application/json');
373
+ ctx.body = {
374
+ id: completionId,
375
+ object: 'chat.completion',
376
+ created: Math.floor(Date.now() / 1000),
377
+ model: body.model,
378
+ system_fingerprint: null,
379
+ choices: [
380
+ {
381
+ index: 0,
382
+ message: { role: 'assistant', content },
383
+ logprobs: null,
384
+ finish_reason: 'stop',
385
+ },
386
+ ],
387
+ // NOTE: Token usage is unavailable in agent mode.
388
+ // ResponseMetadataCollector is private inside AIEmployee and not exposed publicly.
389
+ // Clients can detect agent mode by checking usage.total_tokens === 0.
390
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
391
+ };
392
+ }
393
+
394
+ // ─── Cleanup: delete the ephemeral conversation (best-effort) ────────────
395
+ // Cleanup is scheduled from the outer finally block for both success and failure paths.
396
+ } catch (err: unknown) {
397
+ const error = err instanceof Error ? err : new Error(String(err));
398
+ if (runtimeContext && !lifecycleCompleted) {
399
+ await lifecycle?.runAfterHooks(runtimeContext, { succeeded: false, error });
400
+ lifecycleCompleted = true;
401
+ }
402
+ ctx.log.error('AI API agent completions error:', err);
403
+ if (!ctx.res.headersSent) {
404
+ ctx.status = 500;
405
+ ctx.body = toOpenAIError(500, error.message || 'Internal server error', 'server_error');
406
+ }
407
+ } finally {
408
+ if (ephemeralSessionId) {
409
+ const sessionId = ephemeralSessionId;
410
+ setImmediate(() => {
411
+ ctx.db
412
+ .getRepository('aiConversations')
413
+ .destroy({ filterByTk: sessionId })
414
+ .catch((cleanupError: unknown) => {
415
+ ctx.log.warn('AI API: Failed to clean up ephemeral conversation.', {
416
+ sessionId,
417
+ error: cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)),
418
+ });
419
+ });
420
+ });
421
+ }
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Extract the last AI message content from a LangGraph invoke() result.
427
+ *
428
+ * LangGraph state has a `messages` array of LangChain BaseMessage instances.
429
+ * We iterate from the end to find the last message that is NOT a HumanMessage
430
+ * or ToolMessage (those are user/tool inputs, not AI output).
431
+ */
432
+ function extractLastAiMessageContent(result: any): string {
433
+ if (!result?.messages || !Array.isArray(result.messages)) return '';
434
+
435
+ for (let i = result.messages.length - 1; i >= 0; i--) {
436
+ const msg = result.messages[i];
437
+ if (!msg) continue;
438
+
439
+ const className = msg?.constructor?.name;
440
+ if (className === 'HumanMessage' || className === 'ToolMessage') continue;
441
+
442
+ if (typeof msg.content === 'string') return msg.content;
443
+ if (Array.isArray(msg.content)) {
444
+ const textBlock = msg.content.find((c: any) => c.type === 'text');
445
+ return textBlock?.text || '';
446
+ }
447
+ }
448
+
449
+ return '';
450
+ }