adminizer 5.0.0-build.11 → 5.0.0-build.13

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 (62) hide show
  1. package/assets/{add-DQE53mIb.js → add-B_hn__Ba.js} +1 -1
  2. package/assets/{add-group-BsizwU4R.js → add-group-0SdYgiXZ.js} +1 -1
  3. package/assets/{add-user-BkSrCzw4.js → add-user-Bh1iqSem.js} +1 -1
  4. package/assets/ai-assistant/agent.es.js +5036 -4931
  5. package/assets/app.js +32 -32
  6. package/assets/{catalog-DPbsKvEY.js → catalog-DjK8r0Mn.js} +1 -1
  7. package/assets/controls/handsontable.es.js +11637 -11632
  8. package/assets/controls/jsoneditor.es.js +6308 -6297
  9. package/assets/controls/toast-ui.es.js +18 -17
  10. package/assets/{dashboard-DDbX6YWf.js → dashboard-CbJIK7ka.js} +1 -1
  11. package/assets/{history-DzHm8RJs.js → history-CZAJgVXP.js} +1 -1
  12. package/assets/{list-CMymFRqO.js → list-C6w_xDK2.js} +1 -1
  13. package/assets/manifest.json +24 -24
  14. package/assets/{module-DkFrKFpO.js → module-DvPjKyF_.js} +1 -1
  15. package/assets/{notification-ChERJE5c.js → notification-N_b-N0Zu.js} +1 -1
  16. package/assets/{user-filters-list-BytQFcmQ.js → user-filters-list-xbJoY_5_.js} +1 -1
  17. package/assets/{welcome-Daw8Hukl.js → welcome-BUkdkKa5.js} +1 -1
  18. package/assets/{with-app-layout-0iXcLqi5.js → with-app-layout-WhRyO5WK.js} +1 -1
  19. package/controllers/ai/AiAgentController.js +4 -1
  20. package/controllers/view.js +1 -1
  21. package/helpers/configHelper.js +12 -17
  22. package/helpers/controllerHelper.d.ts +1 -0
  23. package/helpers/controllerHelper.js +25 -15
  24. package/helpers/fieldsHelper.js +3 -3
  25. package/helpers/inertiaAddHelper.js +1 -1
  26. package/helpers/inertiaMenuHelper.d.ts +0 -1
  27. package/helpers/inertiaMenuHelper.js +3 -26
  28. package/helpers/modelResourceHelper.d.ts +9 -0
  29. package/helpers/modelResourceHelper.js +43 -0
  30. package/helpers/navigationAccessHelper.d.ts +13 -0
  31. package/helpers/navigationAccessHelper.js +40 -0
  32. package/index.d.ts +6 -0
  33. package/index.js +6 -0
  34. package/interfaces/adminpanelConfig.d.ts +6 -1
  35. package/interfaces/types.d.ts +40 -3
  36. package/lib/Adminizer.d.ts +6 -0
  37. package/lib/Adminizer.js +9 -0
  38. package/lib/DataAccessor.d.ts +1 -0
  39. package/lib/DataAccessor.js +45 -47
  40. package/lib/admin-links/AdminLinkHandler.d.ts +72 -0
  41. package/lib/admin-links/AdminLinkHandler.js +175 -0
  42. package/lib/ai-assistant/AbstractAiModelService.d.ts +44 -3
  43. package/lib/ai-assistant/AbstractAiModelService.js +61 -2
  44. package/lib/ai-assistant/AiAssistantAgentSkillHandler.d.ts +58 -0
  45. package/lib/ai-assistant/AiAssistantAgentSkillHandler.js +87 -0
  46. package/lib/ai-assistant/AiAssistantUiMethodHandler.d.ts +60 -0
  47. package/lib/ai-assistant/AiAssistantUiMethodHandler.js +138 -0
  48. package/lib/ai-assistant/builtinAgentSkills.d.ts +9 -0
  49. package/lib/ai-assistant/builtinAgentSkills.js +256 -0
  50. package/lib/ai-assistant/jsonSafe.d.ts +13 -0
  51. package/lib/ai-assistant/jsonSafe.js +81 -0
  52. package/lib/app-manager/AdminizerApp.d.ts +24 -0
  53. package/lib/app-manager/AppManager.js +39 -42
  54. package/lib/filters/FilterService.js +4 -3
  55. package/lib/history-actions/AbstractHistoryAdapter.js +11 -9
  56. package/lib/model/AbstractModel.d.ts +2 -0
  57. package/lib/model/ModelHandler.d.ts +47 -2
  58. package/lib/model/ModelHandler.js +107 -8
  59. package/package.json +1 -1
  60. package/system/bindAccessRights.js +2 -3
  61. package/system/buildInternalModelAccess.js +5 -2
  62. package/system/validateSystemModels.js +29 -9
@@ -0,0 +1,138 @@
1
+ import { listAccessibleMenuItems } from '../../helpers/navigationAccessHelper.js';
2
+ export class AiAssistantUiMethodHandler {
3
+ adminizer;
4
+ methods = new Map();
5
+ owners = new Map();
6
+ constructor(adminizer) {
7
+ this.adminizer = adminizer;
8
+ this.register({
9
+ id: 'navigate',
10
+ title: 'Open admin section',
11
+ description: 'Open an Adminizer page in the current browser tab using the Inertia router.'
12
+ + ' Pass `href` for a concrete link, or `template` plus `params` for a parametrized page'
13
+ + ' such as a single record. Both come from search-admin-links.',
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {
17
+ href: { type: 'string', description: 'Adminizer-relative URL of a concrete page to open.' },
18
+ template: { type: 'string', description: 'Id of a link template returned by search-admin-links.' },
19
+ params: {
20
+ type: 'object',
21
+ description: 'Values for the template placeholders, e.g. {"id": "3860d321-…"}.',
22
+ additionalProperties: { type: 'string' },
23
+ },
24
+ },
25
+ additionalProperties: false,
26
+ },
27
+ action: 'navigate',
28
+ });
29
+ this.register({
30
+ id: 'search-admin-links',
31
+ title: 'Search admin sections and links',
32
+ description: 'Search the current user\'s accessible Adminizer navigation sections, links and'
33
+ + ' link templates (record pages, catalog items and other parametrized pages).',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: { query: { type: 'string', description: 'Words to search in section, link and template titles.' } },
37
+ required: ['query'], additionalProperties: false,
38
+ },
39
+ action: 'search-admin-links',
40
+ });
41
+ }
42
+ register(method, owner) {
43
+ const id = method.id.trim().toLowerCase();
44
+ if (!/^[a-z][a-z0-9_-]*$/.test(id))
45
+ throw new Error(`Invalid AI assistant UI method id: ${method.id}`);
46
+ if (!method.title.trim() || !method.description.trim())
47
+ throw new Error(`AI assistant UI method "${id}" requires title and description`);
48
+ this.methods.set(id, { ...method, id });
49
+ if (owner)
50
+ this.owners.set(id, owner);
51
+ else
52
+ this.owners.delete(id);
53
+ }
54
+ unregister(id, owner) {
55
+ const normalized = id.trim().toLowerCase();
56
+ if (owner && this.owners.get(normalized) !== owner)
57
+ return;
58
+ this.methods.delete(normalized);
59
+ this.owners.delete(normalized);
60
+ }
61
+ getAvailable(user) {
62
+ return [...this.methods.values()]
63
+ .filter((method) => !method.accessRightsToken || this.adminizer.accessRightsHelper.hasPermission(method.accessRightsToken, user))
64
+ .map((method) => ({ ...method, inputSchema: { ...method.inputSchema } }));
65
+ }
66
+ /**
67
+ * Search exactly the navigation a user can open. This intentionally uses
68
+ * MenuHelper rather than client-side markup, therefore a tool result is
69
+ * permission-safe and works when the assistant panel is not visible.
70
+ *
71
+ * Parametrized pages are returned as templates, so a record page is
72
+ * reachable even though its URL only exists once the id is known.
73
+ */
74
+ searchAdminLinks(user, query = '') {
75
+ const needle = this.slug(query);
76
+ const result = [];
77
+ const matches = (...values) => !needle || this.slug(values.filter(Boolean).join(' ')).includes(needle);
78
+ const add = (item, section) => {
79
+ if (!item?.link || !item?.title)
80
+ return;
81
+ const link = {
82
+ id: String(item.id || item.title), title: String(item.title), link: String(item.link),
83
+ section: item.section || section,
84
+ };
85
+ // A link with placeholders is surfaced as a template instead.
86
+ if (!/:[A-Za-z0-9_]+/.test(link.link) && matches(link.title, link.id, link.section))
87
+ result.push(link);
88
+ for (const child of item.actions ?? item.subItems ?? [])
89
+ add(child, link.section);
90
+ };
91
+ // Exactly the menu the sidebar renders for this user, sub-items included.
92
+ for (const item of listAccessibleMenuItems(this.adminizer, user))
93
+ add(item, item.section);
94
+ for (const template of this.adminizer.adminLinkHandler.listTemplates(user)) {
95
+ if (!matches(template.title, template.id, template.section, template.description, template.template))
96
+ continue;
97
+ result.push({
98
+ id: template.id,
99
+ title: template.title,
100
+ template: template.template,
101
+ params: template.params,
102
+ description: template.description,
103
+ section: template.section,
104
+ // A template without placeholders is already a usable link.
105
+ link: template.params.length ? undefined : template.template,
106
+ });
107
+ }
108
+ return result;
109
+ }
110
+ /**
111
+ * Validates what an agent asked to open and returns the concrete admin URL.
112
+ * Templates are resolved against this user's own permissions, so the LLM
113
+ * can only reach pages that user could reach by clicking.
114
+ */
115
+ resolveNavigation(user, target) {
116
+ const template = target.template?.trim();
117
+ if (template) {
118
+ return this.adminizer.adminLinkHandler.resolveTemplate(user, template, target.params ?? {});
119
+ }
120
+ const href = target.href?.trim();
121
+ if (!href)
122
+ throw new Error('Navigation requires either href or template');
123
+ const prefix = (this.adminizer.config.routePrefix || '').replace(/\/+$/, '');
124
+ if (href !== prefix && !href.startsWith(`${prefix}/`)) {
125
+ throw new Error('Only Adminizer-relative links may be opened.');
126
+ }
127
+ if (/:[A-Za-z0-9_]+/.test(href)) {
128
+ throw new Error('This link is a template: pass it as `template` together with `params`.');
129
+ }
130
+ if (this.adminizer.adminLinkHandler.isDestructivePath(href)) {
131
+ throw new Error('This page changes data and cannot be opened by the assistant.');
132
+ }
133
+ return href;
134
+ }
135
+ slug(value) {
136
+ return String(value ?? '').trim().toLowerCase().replace(/[\s_-]+/g, '-');
137
+ }
138
+ }
@@ -0,0 +1,9 @@
1
+ import type { Adminizer } from '../Adminizer.js';
2
+ import type { AiAssistantAgentSkill } from './AiAssistantAgentSkillHandler.js';
3
+ /**
4
+ * Skills every agent gets for free: who it is talking to, and read/edit access
5
+ * scoped to exactly the data that user may reach in the admin panel. Because
6
+ * every call re-checks the user's permissions, an agent built on them can be
7
+ * offered to non-administrator accounts as well.
8
+ */
9
+ export declare function buildBuiltinAgentSkills(adminizer: Adminizer): AiAssistantAgentSkill[];
@@ -0,0 +1,256 @@
1
+ import { listModelResources, resolveModelResource } from '../../helpers/modelResourceHelper.js';
2
+ import { listAccessibleMenuItems } from '../../helpers/navigationAccessHelper.js';
3
+ import { DataAccessor } from '../DataAccessor.js';
4
+ import { toJsonSafe } from './jsonSafe.js';
5
+ const MAX_RECORDS = 50;
6
+ const DEFAULT_RECORDS = 10;
7
+ /** What the current user may do with every configured model resource. */
8
+ function listPermittedModels(adminizer, user) {
9
+ const has = (token) => adminizer.accessRightsHelper.hasPermission(token, user);
10
+ return listModelResources(adminizer)
11
+ .map((resource) => ({
12
+ resource,
13
+ canRead: has(`read-${resource.name}-model`),
14
+ canUpdate: has(`update-${resource.name}-model`),
15
+ canCreate: has(`create-${resource.name}-model`),
16
+ }))
17
+ .filter((entry) => entry.canRead || entry.canUpdate);
18
+ }
19
+ /**
20
+ * Resolves a model this user may use for `action`. Nothing outside the user's
21
+ * own permissions is reachable, which is what makes these skills safe to hand
22
+ * to non-administrator accounts.
23
+ */
24
+ function requireModel(adminizer, user, name, action) {
25
+ const resource = resolveModelResource(adminizer, name);
26
+ if (!resource?.model)
27
+ throw new Error(`Model "${name}" is not available.`);
28
+ const token = action === 'read' ? `read-${resource.name}-model` : `update-${resource.name}-model`;
29
+ if (!adminizer.accessRightsHelper.hasPermission(token, user)) {
30
+ throw new Error(`You are not allowed to ${action} the "${resource.name}" model.`);
31
+ }
32
+ return resource;
33
+ }
34
+ function modelNames(adminizer, user, action) {
35
+ return listPermittedModels(adminizer, user)
36
+ .filter((entry) => (action === 'read' ? entry.canRead : entry.canUpdate))
37
+ .map((entry) => entry.resource.name);
38
+ }
39
+ /** Adds an `enum` of permitted models, so the agent cannot invent a model name. */
40
+ function withModelEnum(schema, models) {
41
+ const properties = { ...schema.properties };
42
+ properties.model = { ...properties.model, enum: models };
43
+ return { ...schema, properties };
44
+ }
45
+ function identifierField(adminizer, resource) {
46
+ return resource.config?.identifierField
47
+ ?? adminizer.config.identifierField
48
+ ?? resource.model?.primaryKey
49
+ ?? 'id';
50
+ }
51
+ function parseCriteria(filter) {
52
+ if (filter === undefined || filter === null || filter === '')
53
+ return {};
54
+ if (typeof filter === 'object')
55
+ return filter;
56
+ try {
57
+ const parsed = JSON.parse(String(filter));
58
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
59
+ throw new Error('not an object');
60
+ return parsed;
61
+ }
62
+ catch {
63
+ throw new Error('filter must be a JSON object, e.g. {"title": "example"}');
64
+ }
65
+ }
66
+ function pickFields(record, fields) {
67
+ return Object.fromEntries(fields.filter((field) => field in record).map((field) => [field, record[field]]));
68
+ }
69
+ function slugify(value) {
70
+ return String(value ?? '').trim().toLowerCase().replace(/[\s_-]+/g, '-');
71
+ }
72
+ /** A navigation entry as the agent sees it: the sub-actions of a menu item. */
73
+ function describeNavigationActions(actions) {
74
+ return (actions ?? []).map((action) => ({
75
+ id: action.id || action.title,
76
+ title: action.title,
77
+ link: action.link,
78
+ icon: action.icon || undefined,
79
+ target: action.type === 'blank' ? 'blank' : undefined,
80
+ actions: action.subItems?.length ? describeNavigationActions(action.subItems) : undefined,
81
+ }));
82
+ }
83
+ function describeNavigationItem(item) {
84
+ return {
85
+ id: item.id,
86
+ title: item.title,
87
+ link: item.link,
88
+ icon: item.icon || undefined,
89
+ target: item.type === 'blank' ? 'blank' : undefined,
90
+ model: item.modelResourceName || undefined,
91
+ actions: item.actions?.length ? describeNavigationActions(item.actions) : undefined,
92
+ };
93
+ }
94
+ /** Field names of a model resource that this user may see or write. */
95
+ function fieldNames(adminizer, user, resource, action) {
96
+ return Object.keys(new DataAccessor(adminizer, user, resource, action).getFieldsConfig());
97
+ }
98
+ /**
99
+ * Skills every agent gets for free: who it is talking to, and read/edit access
100
+ * scoped to exactly the data that user may reach in the admin panel. Because
101
+ * every call re-checks the user's permissions, an agent built on them can be
102
+ * offered to non-administrator accounts as well.
103
+ */
104
+ export function buildBuiltinAgentSkills(adminizer) {
105
+ const currentUser = {
106
+ id: 'current_user',
107
+ description: 'Get the admin user this conversation belongs to: login, name, email, locale, groups and administrator flag.',
108
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
109
+ execute: (_input, { userIdentity }) => userIdentity,
110
+ };
111
+ const listModels = {
112
+ id: 'list_data_models',
113
+ description: 'List the data models the current user may read or edit, with their fields, allowed operations and admin URLs.',
114
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
115
+ execute: (_input, { user }) => ({
116
+ models: listPermittedModels(adminizer, user).map(({ resource, canRead, canUpdate, canCreate }) => ({
117
+ name: resource.name,
118
+ title: resource.config?.title ?? resource.name,
119
+ canRead,
120
+ canUpdate,
121
+ canCreate,
122
+ identifierField: identifierField(adminizer, resource),
123
+ listUrl: resource.uri,
124
+ recordUrlTemplate: canUpdate ? `${resource.uri}/edit/:id` : undefined,
125
+ fields: fieldNames(adminizer, user, resource, canUpdate ? 'edit' : 'list'),
126
+ })),
127
+ }),
128
+ };
129
+ const listNavigation = {
130
+ id: 'list_admin_navigation',
131
+ description: 'Show the admin panel navigation menu of the current user: sections, their links with sub-actions,'
132
+ + ' and the templates of parametrized pages such as a single record. Only entries this user may open are returned.',
133
+ inputSchema: {
134
+ type: 'object',
135
+ properties: {
136
+ query: {
137
+ type: 'string',
138
+ description: 'Optional words to filter menu items by title, section or path. Omit to get the whole menu.',
139
+ },
140
+ },
141
+ additionalProperties: false,
142
+ },
143
+ execute: (input, { user }) => {
144
+ const needle = slugify(input.query);
145
+ const matches = (...values) => !needle || slugify(values.filter(Boolean).join(' ')).includes(needle);
146
+ const sections = new Map();
147
+ for (const item of listAccessibleMenuItems(adminizer, user)) {
148
+ if (!matches(item.title, item.id, item.section, item.link, item.modelResourceName))
149
+ continue;
150
+ const section = item.section || 'Platform';
151
+ if (!sections.has(section))
152
+ sections.set(section, []);
153
+ sections.get(section).push(describeNavigationItem(item));
154
+ }
155
+ return {
156
+ sections: [...sections].map(([section, items]) => ({ section, items })),
157
+ // Record and other parametrized pages have no fixed URL, so the
158
+ // agent gets them as templates to fill in.
159
+ templates: adminizer.adminLinkHandler.listTemplates(user)
160
+ .filter((template) => matches(template.title, template.id, template.section, template.description, template.template))
161
+ .map((template) => ({
162
+ id: template.id,
163
+ title: template.title,
164
+ template: template.template,
165
+ description: template.description,
166
+ section: template.section,
167
+ params: template.params,
168
+ })),
169
+ };
170
+ },
171
+ };
172
+ const readRecords = {
173
+ id: 'read_model_records',
174
+ description: 'Read records of a data model the current user may read.',
175
+ inputSchema: {
176
+ type: 'object',
177
+ properties: {
178
+ model: { type: 'string', description: 'Model name as returned by list_data_models.' },
179
+ filter: { type: 'string', description: 'Optional JSON criteria object, e.g. {"title": "example"}.' },
180
+ fields: { type: 'array', items: { type: 'string' }, description: 'Optional subset of fields to return.' },
181
+ limit: {
182
+ type: 'integer', minimum: 1, maximum: MAX_RECORDS,
183
+ description: `Maximum number of records to return (default ${DEFAULT_RECORDS}).`,
184
+ },
185
+ },
186
+ required: ['model'],
187
+ additionalProperties: false,
188
+ },
189
+ requiresUser: true,
190
+ describe: (user) => {
191
+ const models = modelNames(adminizer, user, 'read');
192
+ return {
193
+ description: `Read records of a data model the current user may read. Readable models: ${models.join(', ') || 'none'}.`,
194
+ inputSchema: withModelEnum(readRecords.inputSchema, models),
195
+ };
196
+ },
197
+ execute: async (input, { user }) => {
198
+ const resource = requireModel(adminizer, user, String(input.model ?? ''), 'read');
199
+ const criteria = parseCriteria(input.filter);
200
+ const limit = Math.min(Math.max(Math.trunc(Number(input.limit) || DEFAULT_RECORDS), 1), MAX_RECORDS);
201
+ const fields = Array.isArray(input.fields) ? input.fields.map(String) : [];
202
+ const accessor = new DataAccessor(adminizer, user, resource, 'list');
203
+ const records = await resource.model.find(criteria, accessor);
204
+ const limited = records.slice(0, limit);
205
+ return toJsonSafe({
206
+ model: resource.name,
207
+ total: records.length,
208
+ count: limited.length,
209
+ records: fields.length ? limited.map((record) => pickFields(record, fields)) : limited,
210
+ });
211
+ },
212
+ };
213
+ const updateRecord = {
214
+ id: 'update_model_record',
215
+ description: 'Update one record of a data model the current user may edit.',
216
+ inputSchema: {
217
+ type: 'object',
218
+ properties: {
219
+ model: { type: 'string', description: 'Model name as returned by list_data_models.' },
220
+ id: { type: 'string', description: 'Identifier of the record to update.' },
221
+ values: {
222
+ type: 'object',
223
+ description: 'Field values to write, e.g. {"title": "new title"}. Fields this user may not edit are dropped by the panel.',
224
+ additionalProperties: true,
225
+ },
226
+ },
227
+ required: ['model', 'id', 'values'],
228
+ additionalProperties: false,
229
+ },
230
+ requiresUser: true,
231
+ describe: (user) => {
232
+ const models = modelNames(adminizer, user, 'update');
233
+ return {
234
+ description: `Update one record of a data model the current user may edit. Editable models: ${models.join(', ') || 'none'}.`,
235
+ inputSchema: withModelEnum(updateRecord.inputSchema, models),
236
+ };
237
+ },
238
+ execute: async (input, { user }) => {
239
+ const resource = requireModel(adminizer, user, String(input.model ?? ''), 'update');
240
+ const id = String(input.id ?? '').trim();
241
+ if (!id)
242
+ throw new Error('id of the record to update is required');
243
+ const values = input.values && typeof input.values === 'object' && !Array.isArray(input.values)
244
+ ? input.values
245
+ : null;
246
+ if (!values || !Object.keys(values).length)
247
+ throw new Error('values must be a non-empty object of field values');
248
+ const accessor = new DataAccessor(adminizer, user, resource, 'edit');
249
+ const record = await resource.model.updateOne({ [identifierField(adminizer, resource)]: id }, values, accessor);
250
+ if (!record)
251
+ throw new Error(`Record "${id}" was not found in "${resource.name}".`);
252
+ return toJsonSafe({ model: resource.name, id, record });
253
+ },
254
+ };
255
+ return [currentUser, listNavigation, listModels, readRecords, updateRecord];
256
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Converts arbitrary values (ORM records, Dates, Buffers, cyclic graphs) into
3
+ * plain JSON data.
4
+ *
5
+ * Tool results are stored verbatim in the agent's message history and are
6
+ * re-validated by the AI SDK against its `ModelMessage[]` schema on every
7
+ * subsequent turn. A single non-JSON value — a `Date` from a Sequelize record,
8
+ * for example — therefore does not fail the call that produced it: it poisons
9
+ * the session and every later turn dies with `Invalid prompt: The messages do
10
+ * not match the ModelMessage[] schema`. Passing tool output through this
11
+ * helper keeps that class of failure out of the history.
12
+ */
13
+ export declare function toJsonSafe<T>(value: T): unknown;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Converts arbitrary values (ORM records, Dates, Buffers, cyclic graphs) into
3
+ * plain JSON data.
4
+ *
5
+ * Tool results are stored verbatim in the agent's message history and are
6
+ * re-validated by the AI SDK against its `ModelMessage[]` schema on every
7
+ * subsequent turn. A single non-JSON value — a `Date` from a Sequelize record,
8
+ * for example — therefore does not fail the call that produced it: it poisons
9
+ * the session and every later turn dies with `Invalid prompt: The messages do
10
+ * not match the ModelMessage[] schema`. Passing tool output through this
11
+ * helper keeps that class of failure out of the history.
12
+ */
13
+ const MAX_DEPTH = 12;
14
+ export function toJsonSafe(value) {
15
+ return convert(value, new WeakSet(), 0);
16
+ }
17
+ function convert(value, seen, depth) {
18
+ if (value === null)
19
+ return null;
20
+ switch (typeof value) {
21
+ case 'string':
22
+ case 'boolean':
23
+ return value;
24
+ case 'number':
25
+ return Number.isFinite(value) ? value : null;
26
+ case 'bigint':
27
+ return value.toString();
28
+ case 'undefined':
29
+ case 'function':
30
+ case 'symbol':
31
+ return undefined;
32
+ }
33
+ const object = value;
34
+ if (value instanceof Date)
35
+ return Number.isNaN(value.getTime()) ? null : value.toISOString();
36
+ if (value instanceof Error)
37
+ return value.message;
38
+ if (value instanceof RegExp)
39
+ return value.toString();
40
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))
41
+ return `<binary ${value.length} bytes>`;
42
+ if (ArrayBuffer.isView(value))
43
+ return `<binary ${value.byteLength} bytes>`;
44
+ if (value instanceof ArrayBuffer)
45
+ return `<binary ${value.byteLength} bytes>`;
46
+ if (seen.has(object))
47
+ return '[Circular]';
48
+ if (depth >= MAX_DEPTH)
49
+ return '[MaxDepth]';
50
+ seen.add(object);
51
+ try {
52
+ if (Array.isArray(value)) {
53
+ // JSON.stringify turns array holes and `undefined` items into null.
54
+ return value.map((item) => {
55
+ const converted = convert(item, seen, depth + 1);
56
+ return converted === undefined ? null : converted;
57
+ });
58
+ }
59
+ if (value instanceof Set)
60
+ return convert([...value], seen, depth);
61
+ if (value instanceof Map) {
62
+ return convert(Object.fromEntries([...value].map(([key, item]) => [String(key), item])), seen, depth);
63
+ }
64
+ // Sequelize instances, Decimal, Luxon and friends: let them describe
65
+ // themselves first, then normalize whatever they produced.
66
+ const toJSON = value.toJSON;
67
+ if (typeof toJSON === 'function') {
68
+ return convert(toJSON.call(value), seen, depth + 1);
69
+ }
70
+ const result = {};
71
+ for (const [key, item] of Object.entries(value)) {
72
+ const converted = convert(item, seen, depth + 1);
73
+ if (converted !== undefined)
74
+ result[key] = converted;
75
+ }
76
+ return result;
77
+ }
78
+ finally {
79
+ seen.delete(object);
80
+ }
81
+ }
@@ -8,6 +8,9 @@ import type { Config, ControlType } from "../controls/Control.js";
8
8
  import type { User } from "../../models/User.js";
9
9
  import type { DataAccessor } from "../DataAccessor.js";
10
10
  import type { AbstractAiModelService } from "../ai-assistant/AbstractAiModelService.js";
11
+ import type { AiAssistantUiMethod } from '../ai-assistant/AiAssistantUiMethodHandler.js';
12
+ import type { AdminLink, AdminLinkTemplate } from '../admin-links/AdminLinkHandler.js';
13
+ import type { AiAssistantAgentSkill } from '../ai-assistant/AiAssistantAgentSkillHandler.js';
11
14
  export type AppDisposer = () => void | Promise<void>;
12
15
  export type AppEventName = string | symbol;
13
16
  export type AppEventHandler<TPayload = any> = (payload: TPayload, runtime: AppRuntime) => void | Promise<void>;
@@ -173,10 +176,26 @@ export interface AppAiAssistantContext {
173
176
  resolveModelResource(modelName: string): ModelResource | undefined;
174
177
  hasPermission(token: string, user: User): boolean;
175
178
  createDataAccessor(modelResource: ModelResource, user: User, action: ActionType): DataAccessor;
179
+ /** UI tools available to this user, including methods registered by apps. */
180
+ getUiMethods(user: User): AiAssistantUiMethod[];
176
181
  }
177
182
  export interface AppAiAssistantResource {
178
183
  models: Array<(context: AppAiAssistantContext) => AbstractAiModelService | Promise<AbstractAiModelService>>;
179
184
  }
185
+ export interface AppAiAssistantUiMethodResource extends AiAssistantUiMethod {
186
+ id: string;
187
+ }
188
+ export interface AppAiAssistantAgentSkillResource extends AiAssistantAgentSkill {
189
+ }
190
+ /** Skills contributed by an app. UI methods are browser skills; agent skills run on the server. */
191
+ export interface AppSkills {
192
+ uiMethod(method: AppAiAssistantUiMethodResource): void;
193
+ agent(skill: AppAiAssistantAgentSkillResource): void;
194
+ }
195
+ export interface AppAdminLinkResource extends AdminLink {
196
+ }
197
+ export interface AppAdminLinkTemplateResource extends AdminLinkTemplate {
198
+ }
180
199
  /**
181
200
  * Resource registration API provided while an app is being enabled.
182
201
  *
@@ -197,6 +216,11 @@ export interface AppSetupContext {
197
216
  model(model: AppModelResource): void;
198
217
  modelAccess(access: AppModelAccessResource): void;
199
218
  aiAssistant(resource: AppAiAssistantResource): void;
219
+ skills: AppSkills;
220
+ /** Register a standalone server page in the admin navigation and agent search. */
221
+ adminLink(link: AppAdminLinkResource): void;
222
+ /** Register a parametrized page (e.g. `/admin/orders/:id/invoice`) the assistant may open. */
223
+ adminLinkTemplate(template: AppAdminLinkTemplateResource): void;
200
224
  listener(event: AppEventName, handler: AppEventHandler): void;
201
225
  }
202
226
  /**
@@ -1,5 +1,6 @@
1
1
  import { Adminizer } from "../Adminizer.js";
2
2
  import { DataAccessor } from "../DataAccessor.js";
3
+ import { listModelResources, resolveModelResource } from "../../helpers/modelResourceHelper.js";
3
4
  class RuntimeAppSetupContext {
4
5
  adminizer;
5
6
  appName;
@@ -7,6 +8,8 @@ class RuntimeAppSetupContext {
7
8
  pendingModelRegistrations = [];
8
9
  pendingModelAccessRegistrations = [];
9
10
  pendingAiAssistantRegistrations = [];
11
+ pendingAiAssistantUiMethodRegistrations = [];
12
+ pendingAiAssistantAgentSkillRegistrations = [];
10
13
  pendingMediaManagerRegistrations = [];
11
14
  pendingCatalogRegistrations = [];
12
15
  configLayerIndex = 0;
@@ -14,7 +17,12 @@ class RuntimeAppSetupContext {
14
17
  constructor(adminizer, appName) {
15
18
  this.adminizer = adminizer;
16
19
  this.appName = appName;
20
+ this.skills = {
21
+ uiMethod: (method) => this.registerAiAssistantUiMethod(method),
22
+ agent: (skill) => this.registerAiAssistantAgentSkill(skill),
23
+ };
17
24
  }
25
+ skills;
18
26
  asset(asset) {
19
27
  const resourceId = `${this.appName}:${asset.id}`;
20
28
  const url = this.adminizer.assetHandler.register(this.appName, asset);
@@ -114,6 +122,32 @@ class RuntimeAppSetupContext {
114
122
  aiAssistant(resource) {
115
123
  this.pendingAiAssistantRegistrations.push(() => this.registerAiAssistant(resource));
116
124
  }
125
+ registerAiAssistantUiMethod(method) {
126
+ this.pendingAiAssistantUiMethodRegistrations.push(async () => {
127
+ this.adminizer.aiAssistantUiMethodHandler.register(method, this.appName);
128
+ this.disposers.push(() => this.adminizer.aiAssistantUiMethodHandler.unregister(method.id, this.appName));
129
+ });
130
+ }
131
+ registerAiAssistantAgentSkill(skill) {
132
+ this.pendingAiAssistantAgentSkillRegistrations.push(async () => {
133
+ this.adminizer.aiAssistantAgentSkillHandler.add(skill, this.appName);
134
+ this.disposers.push(() => {
135
+ this.adminizer.aiAssistantAgentSkillHandler.remove(skill.id, this.appName);
136
+ });
137
+ });
138
+ }
139
+ adminLink(link) {
140
+ const resourceId = this.adminizer.adminLinkHandler.add(link, this.appName);
141
+ this.disposers.push(() => {
142
+ this.adminizer.adminLinkHandler.remove(resourceId, this.appName);
143
+ });
144
+ }
145
+ adminLinkTemplate(template) {
146
+ const resourceId = this.adminizer.adminLinkHandler.addTemplate(template, this.appName);
147
+ this.disposers.push(() => {
148
+ this.adminizer.adminLinkHandler.removeTemplate(resourceId, this.appName);
149
+ });
150
+ }
117
151
  listener(event, handler) {
118
152
  const resourceId = `${this.appName}:${event.toString()}:${this.disposers.length + 1}`;
119
153
  const listener = (payload) => handler(payload, this.adminizer.appManager.createRuntime(this.appName));
@@ -136,6 +170,8 @@ class RuntimeAppSetupContext {
136
170
  await this.runRegistrationPhase(this.pendingModelRegistrations);
137
171
  await this.runRegistrationPhase(this.pendingModelAccessRegistrations);
138
172
  await this.runRegistrationPhase(this.pendingAiAssistantRegistrations);
173
+ await this.runRegistrationPhase(this.pendingAiAssistantUiMethodRegistrations);
174
+ await this.runRegistrationPhase(this.pendingAiAssistantAgentSkillRegistrations);
139
175
  await this.runRegistrationPhase(this.pendingMediaManagerRegistrations);
140
176
  await this.runRegistrationPhase(this.pendingCatalogRegistrations);
141
177
  }
@@ -350,52 +386,13 @@ class RuntimeAppSetupContext {
350
386
  return {
351
387
  runtime: this.adminizer.appManager.createRuntime(this.appName),
352
388
  routePrefix: this.adminizer.config.routePrefix,
353
- getModelResources: () => this.getModelResources(),
354
- resolveModelResource: (modelName) => this.resolveModelResource(modelName),
389
+ getModelResources: () => listModelResources(this.adminizer),
390
+ resolveModelResource: (modelName) => resolveModelResource(this.adminizer, modelName),
355
391
  hasPermission: (token, user) => this.adminizer.accessRightsHelper.hasPermission(token, user),
356
392
  createDataAccessor: (modelResource, user, action) => new DataAccessor(this.adminizer, user, modelResource, action),
393
+ getUiMethods: (user) => this.adminizer.aiAssistantUiMethodHandler.getAvailable(user),
357
394
  };
358
395
  }
359
- getModelResources() {
360
- const resources = [];
361
- for (const [configName, configValue] of Object.entries(this.adminizer.config.models ?? {})) {
362
- const normalizedConfig = this.normalizeModelConfig(configName, configValue);
363
- const model = this.adminizer.modelHandler.model.get(normalizedConfig.model.toLowerCase());
364
- if (!model) {
365
- continue;
366
- }
367
- resources.push({
368
- name: configName,
369
- uri: `${this.adminizer.config.routePrefix}/model/${configName}`,
370
- config: normalizedConfig,
371
- model,
372
- });
373
- }
374
- return resources;
375
- }
376
- resolveModelResource(modelName) {
377
- const loweredName = modelName.toLowerCase();
378
- return this.getModelResources().find((resource) => {
379
- const modelId = resource.config.model?.toLowerCase();
380
- return resource.name.toLowerCase() === loweredName || modelId === loweredName;
381
- });
382
- }
383
- normalizeModelConfig(name, config) {
384
- const baseConfig = {
385
- model: name,
386
- icon: "description",
387
- title: name,
388
- list: true,
389
- add: true,
390
- edit: true,
391
- remove: true,
392
- view: true,
393
- };
394
- if (typeof config === "boolean") {
395
- return config ? baseConfig : { ...baseConfig, list: false, add: false, edit: false, remove: false, view: false };
396
- }
397
- return { ...baseConfig, ...config };
398
- }
399
396
  }
400
397
  export class AppManager {
401
398
  adminizer;