adminizer 5.0.0-build.10 → 5.0.0-build.11

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 (43) hide show
  1. package/assets/add-DQE53mIb.js +1 -0
  2. package/assets/add-group-BsizwU4R.js +1 -0
  3. package/assets/add-user-BkSrCzw4.js +1 -0
  4. package/assets/ai-assistant/agent.es.js +27075 -0
  5. package/assets/app-DcWwG3m4.css +1 -0
  6. package/assets/app.js +2 -2
  7. package/assets/{catalog-BGkKJyhR.js → catalog-DPbsKvEY.js} +2 -2
  8. package/assets/{dashboard-CvSStqLV.js → dashboard-DDbX6YWf.js} +2 -2
  9. package/assets/history-DzHm8RJs.js +1 -0
  10. package/assets/{list-cNVyzYi8.js → list-CMymFRqO.js} +2 -2
  11. package/assets/manifest.json +31 -31
  12. package/assets/module-DkFrKFpO.js +1 -0
  13. package/assets/notification-ChERJE5c.js +20 -0
  14. package/assets/user-filters-list-BytQFcmQ.js +1 -0
  15. package/assets/welcome-Daw8Hukl.js +1 -0
  16. package/assets/with-app-layout-0iXcLqi5.js +1 -0
  17. package/controllers/ai/AiAgentController.d.ts +66 -0
  18. package/controllers/ai/AiAgentController.js +432 -0
  19. package/controllers/ai/AiAssistantController.d.ts +0 -4
  20. package/controllers/ai/AiAssistantController.js +0 -75
  21. package/index.d.ts +2 -0
  22. package/index.js +2 -0
  23. package/interfaces/types.d.ts +114 -0
  24. package/lib/ai-assistant/AbstractAiConversationHistoryService.d.ts +39 -0
  25. package/lib/ai-assistant/AbstractAiConversationHistoryService.js +98 -0
  26. package/lib/ai-assistant/AbstractAiModelService.d.ts +98 -2
  27. package/lib/ai-assistant/AbstractAiModelService.js +95 -1
  28. package/lib/ai-assistant/AiAgentRunManager.d.ts +35 -0
  29. package/lib/ai-assistant/AiAgentRunManager.js +109 -0
  30. package/lib/ai-assistant/AiAssistantHandler.d.ts +1 -0
  31. package/lib/ai-assistant/AiAssistantHandler.js +4 -0
  32. package/package.json +27 -20
  33. package/system/Router.js +18 -3
  34. package/assets/add-BtctcXXV.js +0 -1
  35. package/assets/add-group-C0LgrR0Z.js +0 -1
  36. package/assets/add-user-CALUedTL.js +0 -1
  37. package/assets/app-DNXiApCk.css +0 -1
  38. package/assets/app-layout-CTavO9pF.js +0 -1
  39. package/assets/history-RlFpGr4e.js +0 -1
  40. package/assets/module-B_i1LQ3L.js +0 -1
  41. package/assets/notification-DXP7ACui.js +0 -20
  42. package/assets/user-filters-list-DWE5MBBP.js +0 -1
  43. package/assets/welcome-B6-atbAF.js +0 -1
@@ -0,0 +1,432 @@
1
+ import multer from 'multer';
2
+ import { Adminizer } from '../../lib/Adminizer.js';
3
+ import { AiAgentRunManager } from '../../lib/ai-assistant/AiAgentRunManager.js';
4
+ const MAX_FILES = 5;
5
+ const MAX_FILE_SIZE = 8 * 1024 * 1024;
6
+ /**
7
+ * Inline cap per text attachment: roughly 64k tokens, half of a typical 128k
8
+ * context window, so a single file cannot immediately trigger compaction.
9
+ */
10
+ const MAX_INLINE_TEXT_CHARS = 256 * 1024;
11
+ const TEXT_EXTENSIONS = new Set([
12
+ 'txt', 'md', 'markdown', 'csv', 'tsv', 'json', 'yaml', 'yml', 'xml', 'html', 'css',
13
+ 'js', 'ts', 'jsx', 'tsx', 'sql', 'log', 'ini', 'conf', 'env', 'sh', 'graphql',
14
+ ]);
15
+ const TEXT_MIME_TYPES = new Set([
16
+ 'application/json', 'application/xml', 'application/x-yaml', 'application/sql', 'image/svg+xml',
17
+ ]);
18
+ const runs = new AiAgentRunManager();
19
+ // multer only engages on multipart/form-data; JSON bodies pass through untouched.
20
+ const upload = multer({
21
+ storage: multer.memoryStorage(),
22
+ limits: { fileSize: MAX_FILE_SIZE, files: MAX_FILES, fieldSize: 1024 * 1024 },
23
+ }).array('files', MAX_FILES);
24
+ function isTextFile(file) {
25
+ const mime = file.mimetype || '';
26
+ if (mime.startsWith('text/'))
27
+ return true;
28
+ if (TEXT_MIME_TYPES.has(mime))
29
+ return true;
30
+ const ext = (file.originalname || '').split('.').pop()?.toLowerCase() || '';
31
+ return TEXT_EXTENSIONS.has(ext);
32
+ }
33
+ /** Turns prompt + uploaded files into an ai-sdk ModelMessage[] for the agent. */
34
+ function buildInput(prompt, files, vision, savedFiles = []) {
35
+ if (!files?.length)
36
+ return prompt;
37
+ const parts = [];
38
+ if (prompt)
39
+ parts.push({ type: 'text', text: prompt });
40
+ for (let index = 0; index < files.length; index += 1) {
41
+ const file = files[index];
42
+ const saved = savedFiles[index];
43
+ const name = file.originalname || 'file';
44
+ const savedNote = saved?.id
45
+ ? ` saved_file_id=${JSON.stringify(saved.id)} saved_file_name=${JSON.stringify(saved.name || name)}`
46
+ : '';
47
+ if (isTextFile(file)) {
48
+ let text = file.buffer.toString('utf8');
49
+ let note = '';
50
+ if (text.length > MAX_INLINE_TEXT_CHARS) {
51
+ text = text.slice(0, MAX_INLINE_TEXT_CHARS);
52
+ note = `\n[attachment truncated at ${MAX_INLINE_TEXT_CHARS} characters]`;
53
+ }
54
+ parts.push({
55
+ type: 'text',
56
+ text: `<attachment name=${JSON.stringify(name)}${savedNote}>\n${text}${note}\n</attachment>`,
57
+ });
58
+ }
59
+ else if ((file.mimetype || '').startsWith('image/')) {
60
+ if (vision) {
61
+ parts.push({ type: 'image', image: `data:${file.mimetype};base64,${file.buffer.toString('base64')}` });
62
+ }
63
+ else {
64
+ parts.push({
65
+ type: 'text',
66
+ text: `[Image attached: ${name}${saved?.id ? `, saved_file_id=${saved.id}` : ''} — the current model cannot see images]`,
67
+ });
68
+ }
69
+ }
70
+ else {
71
+ parts.push({
72
+ type: 'text',
73
+ text: `[Unsupported attachment saved but not inlined: ${name}${saved?.id ? `, saved_file_id=${saved.id}` : ''} (${file.mimetype || 'unknown type'})]`,
74
+ });
75
+ }
76
+ }
77
+ return [{ role: 'user', content: parts }];
78
+ }
79
+ /**
80
+ * Streaming agent endpoints, consumed by the assistant panel bundle
81
+ * (src/assets/js/ai-assistant).
82
+ *
83
+ * Every route is scoped to one registered model service; permissions are
84
+ * enforced by the router (`ai-assistant-<modelId>`). Services that implement
85
+ * only `generateReply` are supported too — their answer is streamed as a
86
+ * single text frame, so one UI serves both kinds.
87
+ */
88
+ export class AiAgentController {
89
+ /** GET /api/ai-assistant/agent/:modelId/status */
90
+ static async getStatus(req, res) {
91
+ const service = AiAgentController.resolveService(req, res);
92
+ if (!service)
93
+ return;
94
+ const locale = AiAgentController.resolveLocale(req);
95
+ const schema = service.getUiSchema();
96
+ if (typeof service.getConnectionStatus !== 'function') {
97
+ res.json({ state: 'ready', locale, schema });
98
+ return;
99
+ }
100
+ try {
101
+ const status = await service.getConnectionStatus();
102
+ res.json({ ...status, locale, schema });
103
+ }
104
+ catch (error) {
105
+ Adminizer.log.error('AI agent status check failed', error);
106
+ res.status(500).json({ error: error?.message || 'Status check failed' });
107
+ }
108
+ }
109
+ /** GET /api/ai-assistant/agent/:modelId/limits */
110
+ static async getLimits(req, res) {
111
+ const service = AiAgentController.resolveService(req, res);
112
+ if (!service || !await AiAgentController.ensureReady(service, res))
113
+ return;
114
+ if (typeof service.getLimits !== 'function') {
115
+ res.json({ provider: service.id, supported: false });
116
+ return;
117
+ }
118
+ const forceRefresh = req.query.refresh === '1' || req.query.refresh === 'true';
119
+ try {
120
+ const limits = await service.getLimits(forceRefresh);
121
+ res.json(limits ?? { provider: service.id, supported: false });
122
+ }
123
+ catch (error) {
124
+ res.status(500).json({ error: error?.message || 'Failed to read limits' });
125
+ }
126
+ }
127
+ /** GET /api/ai-assistant/agent/:modelId/meta */
128
+ static async getMeta(req, res) {
129
+ const service = AiAgentController.resolveService(req, res);
130
+ if (!service || !await AiAgentController.ensureReady(service, res))
131
+ return;
132
+ try {
133
+ res.json(await AiAgentController.buildMeta(service, req));
134
+ }
135
+ catch (error) {
136
+ Adminizer.log.error('AI agent meta failed', error);
137
+ res.status(500).json({ error: error?.message || 'Failed to load agent info' });
138
+ }
139
+ }
140
+ /** POST /api/ai-assistant/agent/:modelId/model */
141
+ static async setModel(req, res) {
142
+ const service = AiAgentController.resolveService(req, res);
143
+ if (!service || !await AiAgentController.ensureReady(service, res))
144
+ return;
145
+ if (typeof service.setCurrentModel !== 'function') {
146
+ res.status(501).json({ error: 'This assistant does not support switching models' });
147
+ return;
148
+ }
149
+ const model = typeof req.body?.model === 'string' ? req.body.model.trim() : '';
150
+ if (!model) {
151
+ res.status(400).json({ error: 'Model is required.' });
152
+ return;
153
+ }
154
+ try {
155
+ await service.setCurrentModel(req.user, model);
156
+ res.json(await AiAgentController.buildMeta(service, req));
157
+ }
158
+ catch (error) {
159
+ res.status(500).json({ error: error?.message || 'Failed to switch the model' });
160
+ }
161
+ }
162
+ /** GET /api/ai-assistant/agent/:modelId/history */
163
+ static async getHistory(req, res) {
164
+ const service = AiAgentController.resolveService(req, res);
165
+ if (!service)
166
+ return;
167
+ const messages = await AiAgentController.syncActiveConversation(service, req);
168
+ res.json({ messages });
169
+ }
170
+ /** GET /api/ai-assistant/agent/:modelId/conversations */
171
+ static async listConversations(req, res) {
172
+ const service = AiAgentController.resolveService(req, res);
173
+ if (!service)
174
+ return;
175
+ await AiAgentController.syncActiveConversation(service, req);
176
+ const active = service.conversationHistory.getActive(req.user);
177
+ res.json({ activeId: active.id, conversations: service.conversationHistory.list(req.user).map(AiAgentController.serializeConversation) });
178
+ }
179
+ /** POST /api/ai-assistant/agent/:modelId/conversations */
180
+ static async createConversation(req, res) {
181
+ const service = AiAgentController.resolveService(req, res);
182
+ if (!service)
183
+ return;
184
+ await AiAgentController.syncActiveConversation(service, req);
185
+ const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 120) : undefined;
186
+ const conversation = service.conversationHistory.create(req.user, title || undefined);
187
+ await AiAgentController.restoreConversation(service, req, conversation.messages);
188
+ res.status(201).json({ conversation: AiAgentController.serializeConversation(conversation) });
189
+ }
190
+ /** POST /api/ai-assistant/agent/:modelId/conversations/:conversationId/select */
191
+ static async selectConversation(req, res) {
192
+ const service = AiAgentController.resolveService(req, res);
193
+ if (!service)
194
+ return;
195
+ await AiAgentController.syncActiveConversation(service, req);
196
+ const conversation = service.conversationHistory.select(req.user, req.params.conversationId);
197
+ if (!conversation) {
198
+ res.sendStatus(404);
199
+ return;
200
+ }
201
+ await AiAgentController.restoreConversation(service, req, conversation.messages);
202
+ res.json({ conversation: AiAgentController.serializeConversation(conversation) });
203
+ }
204
+ /** DELETE /api/ai-assistant/agent/:modelId/conversations/:conversationId */
205
+ static async deleteConversation(req, res) {
206
+ const service = AiAgentController.resolveService(req, res);
207
+ if (!service)
208
+ return;
209
+ const conversationId = req.params.conversationId;
210
+ const active = service.conversationHistory.getActive(req.user);
211
+ if (active.id === conversationId) {
212
+ res.status(400).json({ error: 'Create or select another conversation before deleting this one' });
213
+ return;
214
+ }
215
+ res.json({ deleted: service.conversationHistory.remove(req.user, conversationId) });
216
+ }
217
+ /**
218
+ * POST /api/ai-assistant/agent/:modelId/runs
219
+ *
220
+ * Starts a turn and answers 202 with the stream URL. The run itself is
221
+ * kicked off after the response so the browser can open the EventSource
222
+ * before the first frame is published.
223
+ */
224
+ static async startRun(req, res) {
225
+ const service = AiAgentController.resolveService(req, res);
226
+ if (!service || !await AiAgentController.ensureReady(service, res))
227
+ return;
228
+ upload(req, res, async (uploadError) => {
229
+ if (uploadError) {
230
+ const message = uploadError.code === 'LIMIT_FILE_SIZE'
231
+ ? `File is too large (max ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB)`
232
+ : uploadError.message || 'Upload failed';
233
+ res.status(413).json({ error: message });
234
+ return;
235
+ }
236
+ const prompt = typeof req.body?.message === 'string' ? req.body.message.trim() : '';
237
+ const files = req.files || [];
238
+ if (!prompt && !files.length) {
239
+ res.status(400).json({ error: 'Message is required' });
240
+ return;
241
+ }
242
+ try {
243
+ const savedFiles = typeof service.saveUploadedFiles === 'function'
244
+ ? await service.saveUploadedFiles(req.user, files)
245
+ : [];
246
+ const vision = Boolean(service.getSessionMeta?.(req.user)?.vision);
247
+ const input = buildInput(prompt || 'See the attached files.', files, vision, savedFiles);
248
+ const run = runs.create(req.user.id, service.id);
249
+ const prefix = req.adminizer.config.routePrefix;
250
+ res.status(202).json({
251
+ runId: run.id,
252
+ stream: `${prefix}/api/ai-assistant/agent/${encodeURIComponent(service.id)}/runs/${run.id}/stream`,
253
+ });
254
+ setImmediate(() => {
255
+ void (async () => {
256
+ try {
257
+ await AiAgentController.execute(service, req, input, prompt, (event) => runs.publish(run, event));
258
+ runs.finish(run, { type: 'done' });
259
+ }
260
+ catch (error) {
261
+ Adminizer.log.error('AI agent run failed', error);
262
+ runs.finish(run, { type: 'error', error: error?.message || 'Agent run failed' });
263
+ }
264
+ })();
265
+ });
266
+ }
267
+ catch (error) {
268
+ Adminizer.log.error('AI agent run could not be started', error);
269
+ res.status(500).json({ error: error?.message || 'Failed to start the run' });
270
+ }
271
+ });
272
+ }
273
+ /** GET /api/ai-assistant/agent/:modelId/runs/:runId/stream */
274
+ static async streamRun(req, res) {
275
+ const service = AiAgentController.resolveService(req, res);
276
+ if (!service)
277
+ return;
278
+ const run = runs.get(req.params.runId, req.user.id);
279
+ if (!run || run.modelId !== service.id) {
280
+ res.sendStatus(404);
281
+ return;
282
+ }
283
+ runs.subscribe(run, req, res);
284
+ }
285
+ /**
286
+ * Streaming agents publish their own frames; a legacy `generateReply` model
287
+ * is wrapped into the same protocol so the panel needs no special case.
288
+ */
289
+ static async execute(service, req, input, prompt, publish) {
290
+ const commandResult = await service.executeChatCommand(prompt, input, req.user, publish);
291
+ if (commandResult) {
292
+ if (commandResult.text) {
293
+ publish({ type: 'text.delta', text: commandResult.text });
294
+ publish({ type: 'text.done' });
295
+ }
296
+ publish({ type: 'turn.done' });
297
+ if (commandResult.persist !== false && commandResult.text) {
298
+ await AiAgentController.appendCommandToSession(service, req, prompt, commandResult.text);
299
+ }
300
+ service.conversationHistory.saveActive(req.user, AiAgentController.getSessionMessages(service, req));
301
+ return;
302
+ }
303
+ if (service.isStreamingAgent()) {
304
+ await service.streamReply(input, req.user, publish);
305
+ service.conversationHistory.saveActive(req.user, AiAgentController.getSessionMessages(service, req));
306
+ return;
307
+ }
308
+ const { history } = await req.adminizer.aiAssistantHandler.sendMessage(req.user, service.id, prompt);
309
+ service.conversationHistory.saveActive(req.user, history.map((message) => ({
310
+ role: message.role,
311
+ content: [{ type: 'text', text: message.content }],
312
+ })));
313
+ const reply = history[history.length - 1]?.content ?? '';
314
+ publish({ type: 'text.delta', text: reply });
315
+ publish({ type: 'text.done' });
316
+ publish({ type: 'turn.done' });
317
+ }
318
+ /** Session meta plus the model catalogue and the upload caps of this transport. */
319
+ static async buildMeta(service, req) {
320
+ const availableModels = typeof service.getModelChoices === 'function'
321
+ // Warming the catalogue first means a cold-start meta call reports a
322
+ // real discovered model instead of a configured placeholder.
323
+ ? await service.getModelChoices()
324
+ : [];
325
+ const meta = service.getSessionMeta?.(req.user) ?? {
326
+ model: service.id,
327
+ contextWindow: 0,
328
+ vision: false,
329
+ turns: req.adminizer.aiAssistantHandler.getHistory(req.user.id, service.id).length,
330
+ contextTokens: 0,
331
+ totalUsage: {},
332
+ };
333
+ return { ...meta, availableModels, maxFiles: MAX_FILES, maxFileSize: MAX_FILE_SIZE };
334
+ }
335
+ static getSessionMessages(service, req) {
336
+ if (typeof service.getSessionHistory === 'function')
337
+ return service.getSessionHistory(req.user);
338
+ return req.adminizer.aiAssistantHandler.getHistory(req.user.id, service.id).map((message) => ({
339
+ role: message.role,
340
+ content: [{ type: 'text', text: message.content }],
341
+ }));
342
+ }
343
+ /**
344
+ * The persisted dialog is restored into a fresh agent session before it is
345
+ * saved again. This keeps initial demo dialogs and database-backed history
346
+ * intact when an agent has not created its in-memory session yet.
347
+ */
348
+ static async syncActiveConversation(service, req) {
349
+ const messages = AiAgentController.getSessionMessages(service, req);
350
+ const active = service.conversationHistory.getActive(req.user);
351
+ if (messages.length === 0 && active.messages.length > 0) {
352
+ await AiAgentController.restoreConversation(service, req, active.messages);
353
+ return active.messages;
354
+ }
355
+ service.conversationHistory.saveActive(req.user, messages);
356
+ return messages;
357
+ }
358
+ static async restoreConversation(service, req, messages) {
359
+ if (typeof service.restoreSessionHistory === 'function') {
360
+ await service.restoreSessionHistory(req.user, messages);
361
+ return;
362
+ }
363
+ if (typeof service.getSessionHistory === 'function') {
364
+ throw new Error('This agent does not support restoring conversations yet.');
365
+ }
366
+ req.adminizer.aiAssistantHandler.replaceHistory(req.user.id, service.id, messages.map((message) => {
367
+ const content = Array.isArray(message.content) ? message.content : [];
368
+ const text = content.find((part) => part?.type === 'text')?.text ?? '';
369
+ return { id: `${Date.now()}-${Math.random()}`, role: message.role, content: String(text), timestamp: new Date(), modelId: service.id };
370
+ }));
371
+ }
372
+ /** Persists command turns just like ordinary chat turns for both agent kinds. */
373
+ static async appendCommandToSession(service, req, prompt, response) {
374
+ const messages = [
375
+ ...AiAgentController.getSessionMessages(service, req),
376
+ { role: 'user', content: [{ type: 'text', text: prompt }] },
377
+ { role: 'assistant', content: [{ type: 'text', text: response }] },
378
+ ];
379
+ await AiAgentController.restoreConversation(service, req, messages);
380
+ }
381
+ static serializeConversation(conversation) {
382
+ return conversation;
383
+ }
384
+ /**
385
+ * Resolves the registered model service for this request, answering the
386
+ * response itself when the assistant is off, unknown or unusable.
387
+ */
388
+ static resolveService(req, res) {
389
+ if (!req.adminizer.config.aiAssistant?.enabled) {
390
+ res.status(503).json({ error: 'AI assistant is disabled' });
391
+ return null;
392
+ }
393
+ const handler = req.adminizer.aiAssistantHandler;
394
+ if (!handler) {
395
+ res.status(500).json({ error: 'AI assistant is not available' });
396
+ return null;
397
+ }
398
+ const modelId = req.params?.modelId;
399
+ const service = modelId ? handler.getModel(modelId) : undefined;
400
+ if (!service) {
401
+ res.status(404).json({ error: `AI assistant model not found: ${modelId}` });
402
+ return null;
403
+ }
404
+ return service;
405
+ }
406
+ /** Endpoints that talk to the LLM also require a provisioned connection. */
407
+ static async ensureReady(service, res) {
408
+ if (typeof service.getConnectionStatus !== 'function')
409
+ return true;
410
+ const status = await service.getConnectionStatus();
411
+ if (status?.state === 'ready')
412
+ return true;
413
+ res.status(503).json({
414
+ error: 'The assistant is not connected to an LLM endpoint yet.',
415
+ status,
416
+ });
417
+ return false;
418
+ }
419
+ /** Admin UI language, resolved the way Inertia shares it with the pages. */
420
+ static resolveLocale(req) {
421
+ const translation = req.adminizer.config.translation;
422
+ const defaultLocale = typeof translation !== 'boolean' ? translation?.defaultLocale ?? 'en' : 'en';
423
+ const available = typeof translation !== 'boolean' ? translation?.locales ?? [] : [];
424
+ const requested = String(req.user?.locale || defaultLocale);
425
+ const normalized = requested.replace('_', '-').split('-')[0];
426
+ if (available.includes(requested))
427
+ return requested;
428
+ if (available.includes(normalized))
429
+ return normalized;
430
+ return defaultLocale;
431
+ }
432
+ }
@@ -1,7 +1,3 @@
1
1
  export declare class AiAssistantController {
2
2
  static getModels(req: ReqType, res: ResType): Promise<ResType>;
3
- static getHistory(req: ReqType, res: ResType): Promise<ResType>;
4
- static sendMessage(req: ReqType, res: ResType): Promise<ResType>;
5
- static resetHistory(req: ReqType, res: ResType): Promise<ResType>;
6
- private static serializeMessage;
7
3
  }
@@ -14,79 +14,4 @@ export class AiAssistantController {
14
14
  .filter((model) => req.adminizer.accessRightsHelper.hasPermission(`ai-assistant-${model.id}`, req.user));
15
15
  return res.json(models);
16
16
  }
17
- static async getHistory(req, res) {
18
- if (!req.adminizer.config.aiAssistant?.enabled) {
19
- return res.json({ history: [] });
20
- }
21
- const { modelId } = req.params;
22
- if (!modelId) {
23
- return res.status(400).json({ error: 'Model id is required' });
24
- }
25
- if (!req.adminizer.accessRightsHelper.hasPermission(`ai-assistant-${modelId}`, req.user)) {
26
- return res.status(403).json({ error: 'Forbidden' });
27
- }
28
- const handler = req.adminizer.aiAssistantHandler;
29
- if (!handler) {
30
- return res.status(500).json({ error: 'AI assistant is not available' });
31
- }
32
- const history = handler.getHistory(req.user.id, modelId).map(AiAssistantController.serializeMessage);
33
- return res.json({ history });
34
- }
35
- static async sendMessage(req, res) {
36
- if (!req.adminizer.config.aiAssistant?.enabled) {
37
- return res.status(503).json({ error: 'AI assistant is disabled' });
38
- }
39
- const { modelId, message } = req.body;
40
- if (!modelId || typeof modelId !== 'string') {
41
- return res.status(400).json({ error: 'Model id is required' });
42
- }
43
- if (!message || typeof message !== 'string' || message.trim().length === 0) {
44
- return res.status(400).json({ error: 'Message is required' });
45
- }
46
- if (!req.adminizer.accessRightsHelper.hasPermission(`ai-assistant-${modelId}`, req.user)) {
47
- return res.status(403).json({ error: 'Forbidden' });
48
- }
49
- const handler = req.adminizer.aiAssistantHandler;
50
- if (!handler) {
51
- return res.status(500).json({ error: 'AI assistant is not available' });
52
- }
53
- try {
54
- const response = await handler.sendMessage(req.user, modelId, message.trim());
55
- return res.json({
56
- modelId,
57
- history: response.history.map(AiAssistantController.serializeMessage),
58
- });
59
- }
60
- catch (error) {
61
- Adminizer.log.error('AI assistant error', error);
62
- return res.status(500).json({ error: 'Failed to process request' });
63
- }
64
- }
65
- static async resetHistory(req, res) {
66
- if (!req.adminizer.config.aiAssistant?.enabled) {
67
- return res.status(503).json({ error: 'AI assistant is disabled' });
68
- }
69
- const { modelId } = req.params;
70
- if (!modelId) {
71
- return res.status(400).json({ error: 'Model id is required' });
72
- }
73
- if (!req.adminizer.accessRightsHelper.hasPermission(`ai-assistant-${modelId}`, req.user)) {
74
- return res.status(403).json({ error: 'Forbidden' });
75
- }
76
- const handler = req.adminizer.aiAssistantHandler;
77
- if (!handler) {
78
- return res.status(500).json({ error: 'AI assistant is not available' });
79
- }
80
- handler.resetHistory(req.user.id, modelId);
81
- return res.json({ history: [] });
82
- }
83
- static serializeMessage(message) {
84
- return {
85
- id: message.id,
86
- role: message.role,
87
- content: message.content,
88
- timestamp: message.timestamp instanceof Date ? message.timestamp.toISOString() : message.timestamp,
89
- modelId: message.modelId,
90
- };
91
- }
92
17
  }
package/index.d.ts CHANGED
@@ -43,7 +43,9 @@ export * from "./lib/helper/jwt.js";
43
43
  export * from "./lib/notifications/AbstractNotificationService.js";
44
44
  export * from "./lib/POWCaptcha.js";
45
45
  export * from "./lib/ai-assistant/AbstractAiModelService.js";
46
+ export * from "./lib/ai-assistant/AbstractAiConversationHistoryService.js";
46
47
  export * from "./lib/ai-assistant/AiAssistantHandler.js";
48
+ export * from "./lib/ai-assistant/AiAgentRunManager.js";
47
49
  export * from "./lib/DataAccessor.js";
48
50
  export * from "./lib/history-actions/AbstractHistoryAdapter.js";
49
51
  export * from "./lib/filters/CustomFilterHandler.js";
package/index.js CHANGED
@@ -43,7 +43,9 @@ export * from "./lib/helper/jwt.js";
43
43
  export * from "./lib/notifications/AbstractNotificationService.js";
44
44
  export * from "./lib/POWCaptcha.js";
45
45
  export * from "./lib/ai-assistant/AbstractAiModelService.js";
46
+ export * from "./lib/ai-assistant/AbstractAiConversationHistoryService.js";
46
47
  export * from "./lib/ai-assistant/AiAssistantHandler.js";
48
+ export * from "./lib/ai-assistant/AiAgentRunManager.js";
47
49
  export * from "./lib/DataAccessor.js";
48
50
  export * from "./lib/history-actions/AbstractHistoryAdapter.js";
49
51
  export * from "./lib/filters/CustomFilterHandler.js";
@@ -70,3 +70,117 @@ export interface AiAssistantModelInfo {
70
70
  name: string;
71
71
  description?: string;
72
72
  }
73
+ /**
74
+ * Streaming agent contract.
75
+ *
76
+ * A model service that only implements `generateReply` stays a plain chat
77
+ * model; services that also implement the optional agent members below are
78
+ * driven by the streaming UI (tool calls, reasoning, attachments, per-user
79
+ * server-side session). See AbstractAiModelService.
80
+ */
81
+ /** One SSE frame of a run. `type` drives how the UI folds it into the thread. */
82
+ export type AiAgentStreamEvent = {
83
+ type: 'text.delta' | 'text.done' | 'reasoning.delta' | 'reasoning.done' | 'tool.start' | 'tool.done' | 'tool.error' | 'step.done' | 'turn.done' | 'done' | 'error' | string;
84
+ [key: string]: unknown;
85
+ };
86
+ export type AiAgentPublish = (event: AiAgentStreamEvent) => void;
87
+ export interface AiAgentTokenUsage {
88
+ inputTokens?: number;
89
+ outputTokens?: number;
90
+ totalTokens?: number;
91
+ }
92
+ /** One selectable LLM inside an agent service. */
93
+ export interface AiAgentModelOption {
94
+ id: string;
95
+ contextWindow: number | null;
96
+ maxOutputTokens: number | null;
97
+ vision: boolean;
98
+ /** Relative spend multiplier (×1 = median-priced model); null if unknown. */
99
+ costCoefficient: number | null;
100
+ [key: string]: unknown;
101
+ }
102
+ /** State of the user's server-side agent session. */
103
+ export interface AiAgentSessionMeta {
104
+ model: string;
105
+ contextWindow: number;
106
+ vision: boolean;
107
+ turns: number;
108
+ contextTokens: number;
109
+ totalUsage: AiAgentTokenUsage;
110
+ [key: string]: unknown;
111
+ }
112
+ /**
113
+ * Whether the service can talk to its LLM yet. Services that need no
114
+ * provisioning simply omit `getConnectionStatus()` and are treated as ready.
115
+ */
116
+ export interface AiAgentConnectionStatus {
117
+ state: 'ready' | 'registering' | 'waiting_retry' | 'setup_required' | 'error';
118
+ provider?: string;
119
+ baseUrl?: string;
120
+ source?: string | null;
121
+ nextAttemptAt?: string | null;
122
+ lastError?: string | null;
123
+ expiresAt?: string | null;
124
+ [key: string]: unknown;
125
+ }
126
+ /** Normalized budget / rate limits of the provider key, when it exposes them. */
127
+ export interface AiAgentLimits {
128
+ provider: string;
129
+ supported: boolean;
130
+ overBudget?: boolean;
131
+ message?: string;
132
+ maxBudgetUsd?: number | null;
133
+ spentUsd?: number | null;
134
+ remainingBudgetUsd?: number | null;
135
+ budgetResetAt?: string | null;
136
+ burstMaxBudgetUsd?: number | null;
137
+ burstSpentUsd?: number | null;
138
+ burstRemainingBudgetUsd?: number | null;
139
+ burstResetAt?: string | null;
140
+ burstDurationLabel?: string | null;
141
+ rpmLimit?: number | null;
142
+ tpmLimit?: number | null;
143
+ fetchedAt?: string;
144
+ [key: string]: unknown;
145
+ }
146
+ /**
147
+ * Copy the panel shows instead of its neutral defaults. Strings go through the
148
+ * panel's own dictionary first, so English source strings it already knows are
149
+ * still translated.
150
+ */
151
+ export interface AiAgentUiHints {
152
+ title?: string;
153
+ welcomeHint?: string;
154
+ composerPlaceholder?: string;
155
+ suggestions?: string[];
156
+ /** Setting an operator must fill in before the service can connect. */
157
+ setupSetting?: string;
158
+ /** Where to fill it in; rendered as a button on the connection loader. */
159
+ setupUrl?: string;
160
+ [key: string]: unknown;
161
+ }
162
+ /** Declarative UI contract consumed by the shared assistant panel. */
163
+ export interface AiAgentUiSchema extends AiAgentUiHints {
164
+ commands: Array<{
165
+ id: string;
166
+ description?: string;
167
+ }>;
168
+ panels: {
169
+ history: boolean;
170
+ models: boolean;
171
+ limits: boolean;
172
+ };
173
+ }
174
+ /** An uploaded file as handed to the agent service (multer memory storage). */
175
+ export interface AiAgentUploadedFile {
176
+ originalname: string;
177
+ mimetype: string;
178
+ size: number;
179
+ buffer: Buffer;
180
+ }
181
+ /** What the service reports back about a stored upload, if it stores them. */
182
+ export interface AiAgentSavedFile {
183
+ id?: string;
184
+ name?: string;
185
+ [key: string]: unknown;
186
+ }