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.
- package/dist/client/index.js +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +9 -9
- package/dist/server/collections/ai-api-usage-records.js +63 -0
- package/dist/server/middleware/role-permission.js +15 -5
- package/dist/server/plugin.js +8 -1
- package/dist/server/routes/agent-completions.js +53 -53
- package/dist/server/routes/auth.js +37 -7
- package/dist/server/routes/router.js +22 -1
- package/dist/server/usage.js +80 -0
- package/dist/server/utils/ai-employee-runtime.js +59 -0
- package/package.json +1 -1
- package/src/server/__tests__/agent-completions.test.ts +26 -0
- package/src/server/collections/ai-api-usage-records.ts +33 -0
- package/src/server/middleware/role-permission.ts +79 -66
- package/src/server/plugin.ts +99 -89
- package/src/server/routes/agent-completions.ts +450 -428
- package/src/server/routes/auth.ts +142 -111
- package/src/server/routes/router.ts +304 -283
- package/src/server/usage.ts +52 -0
- package/src/server/utils/ai-employee-runtime.ts +71 -0
|
@@ -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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (!body?.
|
|
64
|
-
ctx.status = 400;
|
|
65
|
-
ctx.body = toOpenAIError(400, "'
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (body
|
|
70
|
-
ctx.status = 400;
|
|
71
|
-
ctx.body = toOpenAIError(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
ctx.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
// Intercept
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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 localhost → aiConversations: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
|
+
}
|