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,72 @@
1
+ import type { User } from '../../models/User.js';
2
+ import type { Adminizer } from '../Adminizer.js';
3
+ export interface AdminLink {
4
+ id: string;
5
+ type: string;
6
+ name: string;
7
+ link: string;
8
+ title?: string;
9
+ section?: string;
10
+ accessRightsToken?: string;
11
+ }
12
+ export interface ResolvedAdminLink extends AdminLink {
13
+ owner: string;
14
+ title: string;
15
+ }
16
+ /** A placeholder of an admin link template, e.g. `id` in `/model/Test/edit/:id`. */
17
+ export interface AdminLinkTemplateParam {
18
+ name: string;
19
+ description?: string;
20
+ }
21
+ /**
22
+ * A parametrized admin page, e.g. `/admin/model/Test/edit/:id`. Templates are
23
+ * what makes a record page reachable: the concrete URL only exists once the
24
+ * caller knows the record id.
25
+ */
26
+ export interface AdminLinkTemplate {
27
+ id: string;
28
+ title: string;
29
+ /** Absolute admin path with `:param` placeholders. */
30
+ template: string;
31
+ description?: string;
32
+ section?: string;
33
+ accessRightsToken?: string;
34
+ /** Placeholder descriptions; placeholders absent here are still required. */
35
+ params?: AdminLinkTemplateParam[];
36
+ }
37
+ export interface ResolvedAdminLinkTemplate extends AdminLinkTemplate {
38
+ owner: string;
39
+ params: AdminLinkTemplateParam[];
40
+ }
41
+ /** Server-side registry of standalone admin pages contributed by applications. */
42
+ export declare class AdminLinkHandler {
43
+ private readonly adminizer;
44
+ private readonly links;
45
+ private readonly templates;
46
+ constructor(adminizer: Adminizer);
47
+ add(link: AdminLink, owner?: string): string;
48
+ remove(id: string, owner?: string): boolean;
49
+ list(user: User, type?: string): ResolvedAdminLink[];
50
+ resolve(user: User, type: string, name: string): ResolvedAdminLink | undefined;
51
+ addTemplate(template: AdminLinkTemplate, owner?: string): string;
52
+ removeTemplate(id: string, owner?: string): boolean;
53
+ /**
54
+ * Every parametrized page this user may open: model record pages, catalog
55
+ * items, templates registered by apps and any navigation link that carries
56
+ * placeholders of its own.
57
+ */
58
+ listTemplates(user: User): ResolvedAdminLinkTemplate[];
59
+ /**
60
+ * Turns a template id (or its title) plus placeholder values into a real
61
+ * admin URL, rejecting templates this user may not open.
62
+ */
63
+ resolveTemplate(user: User, id: string, params?: Record<string, unknown>): string;
64
+ /** Fills the placeholders of a template with url-encoded values. */
65
+ buildHref(template: ResolvedAdminLinkTemplate, params?: Record<string, unknown>): string;
66
+ /** True for paths that change data and therefore must never be navigated to. */
67
+ isDestructivePath(path: string): boolean;
68
+ /** Templates derived from the current configuration, catalogs and navigation. */
69
+ private builtinTemplates;
70
+ private describeParams;
71
+ private slug;
72
+ }
@@ -0,0 +1,175 @@
1
+ /** Segments no template may point at: opening one mutates data. */
2
+ const DESTRUCTIVE_PATH = /\/model\/[^/]+\/remove(\/|$)/i;
3
+ /** Server-side registry of standalone admin pages contributed by applications. */
4
+ export class AdminLinkHandler {
5
+ adminizer;
6
+ links = new Map();
7
+ templates = new Map();
8
+ constructor(adminizer) {
9
+ this.adminizer = adminizer;
10
+ }
11
+ add(link, owner = 'host') {
12
+ const type = link.type.trim();
13
+ const name = link.name.trim();
14
+ const href = link.link.trim();
15
+ if (!type || !name || !href)
16
+ throw new Error('Admin link requires type, name and link');
17
+ if (!href.startsWith('/'))
18
+ throw new Error(`Admin link "${name}" must use an absolute admin path`);
19
+ const id = `${this.slug(type)}:${this.slug(name)}`;
20
+ if (this.links.has(id))
21
+ throw new Error(`Admin link "${id}" is already registered`);
22
+ this.links.set(id, {
23
+ ...link, id, type, name, link: href, owner,
24
+ title: link.title?.trim() || name,
25
+ section: link.section?.trim() || undefined,
26
+ });
27
+ return id;
28
+ }
29
+ remove(id, owner) {
30
+ const link = this.links.get(id);
31
+ if (!link || (owner && link.owner !== owner))
32
+ return false;
33
+ return this.links.delete(id);
34
+ }
35
+ list(user, type) {
36
+ const wanted = type ? this.slug(type) : null;
37
+ return [...this.links.values()].filter((link) => (!wanted || this.slug(link.type) === wanted)
38
+ && (!link.accessRightsToken || this.adminizer.accessRightsHelper.hasPermission(link.accessRightsToken, user)));
39
+ }
40
+ resolve(user, type, name) {
41
+ const wanted = this.slug(name);
42
+ return this.list(user, type).find((link) => this.slug(link.name) === wanted || this.slug(link.title) === wanted);
43
+ }
44
+ addTemplate(template, owner = 'host') {
45
+ const id = template.id.trim();
46
+ const title = template.title.trim();
47
+ const path = template.template.trim();
48
+ if (!id || !title || !path)
49
+ throw new Error('Admin link template requires id, title and template');
50
+ if (!path.startsWith('/'))
51
+ throw new Error(`Admin link template "${id}" must use an absolute admin path`);
52
+ if (this.templates.has(id))
53
+ throw new Error(`Admin link template "${id}" is already registered`);
54
+ this.templates.set(id, {
55
+ ...template, id, title, template: path, owner,
56
+ description: template.description?.trim() || undefined,
57
+ section: template.section?.trim() || undefined,
58
+ params: this.describeParams(path, template.params),
59
+ });
60
+ return id;
61
+ }
62
+ removeTemplate(id, owner) {
63
+ const template = this.templates.get(id);
64
+ if (!template || (owner && template.owner !== owner))
65
+ return false;
66
+ return this.templates.delete(id);
67
+ }
68
+ /**
69
+ * Every parametrized page this user may open: model record pages, catalog
70
+ * items, templates registered by apps and any navigation link that carries
71
+ * placeholders of its own.
72
+ */
73
+ listTemplates(user) {
74
+ const templates = [];
75
+ const push = (template) => {
76
+ if (DESTRUCTIVE_PATH.test(template.template))
77
+ return;
78
+ if (template.accessRightsToken && !this.adminizer.accessRightsHelper.hasPermission(template.accessRightsToken, user))
79
+ return;
80
+ if (templates.some((existing) => existing.template === template.template || existing.id === template.id))
81
+ return;
82
+ templates.push(template);
83
+ };
84
+ for (const template of this.builtinTemplates(user))
85
+ push(template);
86
+ for (const template of this.templates.values())
87
+ push(template);
88
+ return templates;
89
+ }
90
+ /**
91
+ * Turns a template id (or its title) plus placeholder values into a real
92
+ * admin URL, rejecting templates this user may not open.
93
+ */
94
+ resolveTemplate(user, id, params = {}) {
95
+ const wanted = this.slug(id);
96
+ const available = this.listTemplates(user);
97
+ const template = available.find((candidate) => this.slug(candidate.id) === wanted)
98
+ ?? available.find((candidate) => this.slug(candidate.title) === wanted || this.slug(candidate.template) === wanted);
99
+ if (!template)
100
+ throw new Error(`Admin link template "${id}" is not available`);
101
+ return this.buildHref(template, params);
102
+ }
103
+ /** Fills the placeholders of a template with url-encoded values. */
104
+ buildHref(template, params = {}) {
105
+ return template.template.replace(/:([A-Za-z0-9_]+)/g, (_match, name) => {
106
+ const value = params[name];
107
+ if (value === undefined || value === null || value === '') {
108
+ throw new Error(`Admin link template "${template.id}" requires the "${name}" parameter`);
109
+ }
110
+ return encodeURIComponent(String(value));
111
+ });
112
+ }
113
+ /** True for paths that change data and therefore must never be navigated to. */
114
+ isDestructivePath(path) {
115
+ return DESTRUCTIVE_PATH.test(path);
116
+ }
117
+ /** Templates derived from the current configuration, catalogs and navigation. */
118
+ builtinTemplates(user) {
119
+ const prefix = (this.adminizer.config.routePrefix || '').replace(/\/+$/, '');
120
+ const templates = [];
121
+ const builtin = (id, title, path, accessRightsToken, description, section, params = []) => {
122
+ templates.push({
123
+ id, title, template: path, accessRightsToken, description, section, owner: 'host',
124
+ params: this.describeParams(path, params),
125
+ });
126
+ };
127
+ const recordId = [
128
+ { name: 'id', description: 'Identifier of the record, as returned when reading its data.' },
129
+ ];
130
+ for (const [model, config] of Object.entries(this.adminizer.config.models ?? {})) {
131
+ const title = typeof config === 'object' && config?.title ? config.title : model;
132
+ const section = typeof config === 'object' ? config?.navbar?.section : undefined;
133
+ builtin(`model-${model}-edit`, `${title}: open record`, `${prefix}/model/${model}/edit/:id`, `update-${model}-model`, `Open the edit page of a single ${title} record by its id.`, section, recordId);
134
+ builtin(`model-${model}-add`, `${title}: create record`, `${prefix}/model/${model}/add`, `create-${model}-model`, `Open the create form of ${title}.`, section);
135
+ }
136
+ for (const catalog of this.adminizer.catalogHandler.getAll()) {
137
+ if (!catalog?.slug)
138
+ continue;
139
+ builtin(`catalog-${catalog.slug}-item`, `${catalog.name || catalog.slug}: open item`, `${prefix}/catalog/${catalog.slug}/:id`, `catalog-${catalog.slug}`, `Open a single item of the ${catalog.name || catalog.slug} catalog by its id.`, undefined, recordId);
140
+ }
141
+ // Navigation links may carry placeholders themselves (app pages, custom
142
+ // tools). Such a link cannot be opened as-is, so it is a template.
143
+ const fromNavigation = (item, section) => {
144
+ const path = typeof item?.link === 'string' ? item.link : '';
145
+ if (path.startsWith('/') && /:[A-Za-z0-9_]+/.test(path) && item.title) {
146
+ templates.push({
147
+ id: `link-${this.slug(String(item.id || item.title))}`,
148
+ title: String(item.title),
149
+ template: path,
150
+ section: item.section || section,
151
+ accessRightsToken: item.accessRightsToken || undefined,
152
+ owner: 'host',
153
+ params: this.describeParams(path),
154
+ });
155
+ }
156
+ for (const child of item?.actions ?? item?.subItems ?? [])
157
+ fromNavigation(child, item?.section || section);
158
+ };
159
+ for (const item of this.adminizer.menuHelper.getMenuItems(user))
160
+ fromNavigation(item, item.section);
161
+ for (const link of this.links.values())
162
+ fromNavigation(link, link.section);
163
+ return templates;
164
+ }
165
+ describeParams(path, described = []) {
166
+ const names = [...path.matchAll(/:([A-Za-z0-9_]+)/g)].map((match) => match[1]);
167
+ return [...new Set(names)].map((name) => ({
168
+ name,
169
+ description: described.find((param) => param.name === name)?.description,
170
+ }));
171
+ }
172
+ slug(value) {
173
+ return String(value).trim().toLowerCase().replace(/[\s_-]+/g, '-');
174
+ }
175
+ }
@@ -2,6 +2,8 @@ import { AiAgentConnectionStatus, AiAgentLimits, AiAgentModelOption, AiAgentPubl
2
2
  import { User } from '../../models/User.js';
3
3
  import type { Adminizer } from '../Adminizer.js';
4
4
  import { AbstractAiConversationHistoryService } from './AbstractAiConversationHistoryService.js';
5
+ import type { AiAssistantAdminLink, AiAssistantNavigationTarget } from './AiAssistantUiMethodHandler.js';
6
+ import type { AiAssistantAgentSkillDescriptor, AiAssistantSkillUser } from './AiAssistantAgentSkillHandler.js';
5
7
  export interface AiAgentChatCommand {
6
8
  /** Command name without the leading slash, e.g. `summarize`. */
7
9
  id: string;
@@ -61,7 +63,45 @@ export declare abstract class AbstractAiModelService {
61
63
  /** Commands available for autocomplete in the panel. */
62
64
  getChatCommands(): AiAgentChatCommand[];
63
65
  /** One declarative contract for the shared agent UI. */
64
- getUiSchema(): AiAgentUiSchema;
66
+ getUiSchema(locale?: string): AiAgentUiSchema;
67
+ /**
68
+ * Browser capabilities available to a particular user. Tool-capable
69
+ * services can turn these descriptors into their provider's tool format.
70
+ */
71
+ protected getUiMethods(user: User): import("./AiAssistantUiMethodHandler").AiAssistantUiMethod[];
72
+ /**
73
+ * Server-side implementation of the built-in `search-admin-links` skill.
74
+ * An OpenHarness/AI SDK adapter can return this value directly from its
75
+ * tool's `execute` callback.
76
+ */
77
+ protected searchAdminLinks(user: User, query?: string): AiAssistantAdminLink[];
78
+ /**
79
+ * Built-in and app-contributed server skills this user may call. Data
80
+ * skills are already scoped to that user's model permissions, so an agent
81
+ * exposing them can be offered to non-administrator accounts.
82
+ */
83
+ protected getAgentSkills(user: User): AiAssistantAgentSkillDescriptor[];
84
+ /**
85
+ * Executes an app-owned server skill after its permission check. The result
86
+ * is normalized to plain JSON: it becomes a tool result inside the agent's
87
+ * message history, which every later turn re-validates.
88
+ */
89
+ protected executeAgentSkill(id: string, input: Record<string, unknown>, user: User): Promise<unknown>;
90
+ /** Identity of the user an agent turn is running for. */
91
+ protected describeUser(user: User): AiAssistantSkillUser;
92
+ /**
93
+ * Opens an admin page for the user: either a concrete `href` or a link
94
+ * template plus `params` (a record page, a catalog item…). The target is
95
+ * validated against that user's permissions and the resolved URL is
96
+ * returned, so it can be reported back as the tool result.
97
+ */
98
+ protected openAdminLink(target: AiAssistantNavigationTarget, user: User, publish: AiAgentPublish): string;
99
+ /**
100
+ * Ask the shared panel to execute a registered browser method. The
101
+ * registry and permission check stay server-side, so an LLM cannot invoke
102
+ * an arbitrary client event.
103
+ */
104
+ protected publishUiMethod(id: string, input: Record<string, unknown>, user: User, publish: AiAgentPublish): void;
65
105
  /**
66
106
  * Invoked by the agent transport for a registered `/command`. Returning
67
107
  * null intentionally lets an unhandled command flow into `streamReply`
@@ -104,7 +144,8 @@ export declare abstract class AbstractAiModelService {
104
144
  getLimits?(forceRefresh: boolean): Promise<AiAgentLimits | null>;
105
145
  /**
106
146
  * Panel copy for this service: title, welcome hint, composer placeholder,
107
- * starter prompts and the setup hint of the connection loader.
147
+ * starter prompts and connection screens. The locale is resolved from the
148
+ * current admin user before this method is called.
108
149
  */
109
- getUiHints?(): AiAgentUiHints;
150
+ getUiHints?(locale?: string): AiAgentUiHints;
110
151
  }
@@ -1,4 +1,5 @@
1
1
  import { InMemoryAiConversationHistoryService } from './AbstractAiConversationHistoryService.js';
2
+ import { toJsonSafe } from './jsonSafe.js';
2
3
  /**
3
4
  * Base class for AI assistant models. App modules register access rights
4
5
  * and expose model metadata through this service.
@@ -70,9 +71,9 @@ export class AbstractAiModelService {
70
71
  return commands;
71
72
  }
72
73
  /** One declarative contract for the shared agent UI. */
73
- getUiSchema() {
74
+ getUiSchema(locale) {
74
75
  return {
75
- ...(this.getUiHints?.() ?? {}),
76
+ ...(this.getUiHints?.(locale) ?? {}),
76
77
  commands: this.getChatCommands(),
77
78
  panels: {
78
79
  history: true,
@@ -81,6 +82,64 @@ export class AbstractAiModelService {
81
82
  },
82
83
  };
83
84
  }
85
+ /**
86
+ * Browser capabilities available to a particular user. Tool-capable
87
+ * services can turn these descriptors into their provider's tool format.
88
+ */
89
+ getUiMethods(user) {
90
+ return this.adminizer.aiAssistantUiMethodHandler.getAvailable(user);
91
+ }
92
+ /**
93
+ * Server-side implementation of the built-in `search-admin-links` skill.
94
+ * An OpenHarness/AI SDK adapter can return this value directly from its
95
+ * tool's `execute` callback.
96
+ */
97
+ searchAdminLinks(user, query) {
98
+ return this.adminizer.aiAssistantUiMethodHandler.searchAdminLinks(user, query);
99
+ }
100
+ /**
101
+ * Built-in and app-contributed server skills this user may call. Data
102
+ * skills are already scoped to that user's model permissions, so an agent
103
+ * exposing them can be offered to non-administrator accounts.
104
+ */
105
+ getAgentSkills(user) {
106
+ return this.adminizer.aiAssistantAgentSkillHandler.getAvailable(user);
107
+ }
108
+ /**
109
+ * Executes an app-owned server skill after its permission check. The result
110
+ * is normalized to plain JSON: it becomes a tool result inside the agent's
111
+ * message history, which every later turn re-validates.
112
+ */
113
+ async executeAgentSkill(id, input, user) {
114
+ const result = await this.adminizer.aiAssistantAgentSkillHandler.execute(id, input, user);
115
+ return toJsonSafe(result) ?? null;
116
+ }
117
+ /** Identity of the user an agent turn is running for. */
118
+ describeUser(user) {
119
+ return this.adminizer.aiAssistantAgentSkillHandler.describeUser(user);
120
+ }
121
+ /**
122
+ * Opens an admin page for the user: either a concrete `href` or a link
123
+ * template plus `params` (a record page, a catalog item…). The target is
124
+ * validated against that user's permissions and the resolved URL is
125
+ * returned, so it can be reported back as the tool result.
126
+ */
127
+ openAdminLink(target, user, publish) {
128
+ const href = this.adminizer.aiAssistantUiMethodHandler.resolveNavigation(user, target);
129
+ this.publishUiMethod('navigate', { href }, user, publish);
130
+ return href;
131
+ }
132
+ /**
133
+ * Ask the shared panel to execute a registered browser method. The
134
+ * registry and permission check stay server-side, so an LLM cannot invoke
135
+ * an arbitrary client event.
136
+ */
137
+ publishUiMethod(id, input, user, publish) {
138
+ const method = this.getUiMethods(user).find((candidate) => candidate.id === id);
139
+ if (!method)
140
+ throw new Error(`AI assistant UI method "${id}" is not available`);
141
+ publish({ type: 'ui.method', method: method.id, action: method.action, input });
142
+ }
84
143
  /**
85
144
  * Invoked by the agent transport for a registered `/command`. Returning
86
145
  * null intentionally lets an unhandled command flow into `streamReply`
@@ -0,0 +1,58 @@
1
+ import type { User } from '../../models/User.js';
2
+ import type { Adminizer } from '../Adminizer.js';
3
+ import type { AppRuntime } from '../app-manager/AdminizerApp.js';
4
+ /** Identity of the admin user on whose behalf a skill runs. */
5
+ export interface AiAssistantSkillUser {
6
+ id?: number;
7
+ login: string;
8
+ fullName?: string;
9
+ email?: string;
10
+ locale?: string;
11
+ isAdministrator: boolean;
12
+ groups: string[];
13
+ }
14
+ export interface AiAssistantAgentSkillContext {
15
+ user: User;
16
+ /** The same user as a plain descriptor, safe to return in a tool result. */
17
+ userIdentity: AiAssistantSkillUser;
18
+ runtime: AppRuntime;
19
+ }
20
+ /** A server-side tool made available to every compatible registered agent. */
21
+ export interface AiAssistantAgentSkill {
22
+ id: string;
23
+ description: string;
24
+ inputSchema: Record<string, unknown>;
25
+ accessRightsToken?: string;
26
+ /**
27
+ * Advertise the calling user inside the input schema. The agent then sees
28
+ * who it acts for and echoes it back; the value is always overwritten with
29
+ * the authenticated identity, so it cannot be used to impersonate.
30
+ */
31
+ requiresUser?: boolean;
32
+ /**
33
+ * Per-user refinement of what the agent is told about this skill, e.g. an
34
+ * enum of the models this particular user may read.
35
+ */
36
+ describe?(user: User, adminizer: Adminizer): {
37
+ description?: string;
38
+ inputSchema?: Record<string, unknown>;
39
+ } | undefined;
40
+ execute(input: Record<string, unknown>, context: AiAssistantAgentSkillContext): unknown | Promise<unknown>;
41
+ }
42
+ export type AiAssistantAgentSkillDescriptor = Omit<AiAssistantAgentSkill, 'execute' | 'describe'>;
43
+ /** Input property that carries the calling user for `requiresUser` skills. */
44
+ export declare const CURRENT_USER_PARAM = "currentUser";
45
+ export declare class AiAssistantAgentSkillHandler {
46
+ private readonly adminizer;
47
+ private readonly skills;
48
+ constructor(adminizer: Adminizer);
49
+ add(skill: AiAssistantAgentSkill, owner?: string): void;
50
+ remove(id: string, owner?: string): boolean;
51
+ /** Skills this user may call, described with their permissions applied. */
52
+ getAvailable(user: User): AiAssistantAgentSkillDescriptor[];
53
+ execute(id: string, input: Record<string, unknown>, user: User): Promise<unknown>;
54
+ /** Plain, JSON-safe identity of an admin user. */
55
+ describeUser(user: User): AiAssistantSkillUser;
56
+ private isPermitted;
57
+ private describe;
58
+ }
@@ -0,0 +1,87 @@
1
+ import { buildBuiltinAgentSkills } from './builtinAgentSkills.js';
2
+ /** Input property that carries the calling user for `requiresUser` skills. */
3
+ export const CURRENT_USER_PARAM = 'currentUser';
4
+ export class AiAssistantAgentSkillHandler {
5
+ adminizer;
6
+ skills = new Map();
7
+ constructor(adminizer) {
8
+ this.adminizer = adminizer;
9
+ // Built-in skills only read `adminizer` when they run, so registering
10
+ // them from the constructor is safe while Adminizer is still wiring up.
11
+ for (const skill of buildBuiltinAgentSkills(adminizer))
12
+ this.add(skill);
13
+ }
14
+ add(skill, owner = 'host') {
15
+ const id = skill.id.trim().toLowerCase();
16
+ if (!/^[a-z][a-z0-9_-]*$/.test(id))
17
+ throw new Error(`Invalid AI assistant skill id: ${skill.id}`);
18
+ if (!skill.description.trim())
19
+ throw new Error(`AI assistant skill "${id}" requires a description`);
20
+ if (this.skills.has(id))
21
+ throw new Error(`AI assistant skill "${id}" is already registered`);
22
+ this.skills.set(id, { ...skill, id, owner });
23
+ }
24
+ remove(id, owner) {
25
+ const skill = this.skills.get(id);
26
+ if (!skill || (owner && skill.owner !== owner))
27
+ return false;
28
+ return this.skills.delete(id);
29
+ }
30
+ /** Skills this user may call, described with their permissions applied. */
31
+ getAvailable(user) {
32
+ return [...this.skills.values()]
33
+ .filter((skill) => this.isPermitted(skill, user))
34
+ .map((skill) => this.describe(skill, user));
35
+ }
36
+ async execute(id, input, user) {
37
+ const skill = this.skills.get(id.trim().toLowerCase());
38
+ if (!skill || !this.isPermitted(skill, user)) {
39
+ throw new Error(`AI assistant skill "${id}" is not available`);
40
+ }
41
+ const userIdentity = this.describeUser(user);
42
+ // Whatever the model passed as the user is discarded: a skill acts for
43
+ // the authenticated admin user only.
44
+ const payload = skill.requiresUser
45
+ ? { ...input, [CURRENT_USER_PARAM]: userIdentity.login }
46
+ : input;
47
+ return skill.execute(payload, {
48
+ user,
49
+ userIdentity,
50
+ runtime: this.adminizer.appManager.createRuntime(skill.owner),
51
+ });
52
+ }
53
+ /** Plain, JSON-safe identity of an admin user. */
54
+ describeUser(user) {
55
+ return {
56
+ id: user.id,
57
+ login: user.login,
58
+ fullName: user.fullName || undefined,
59
+ email: user.email || undefined,
60
+ locale: user.locale || undefined,
61
+ isAdministrator: Boolean(user.isAdministrator),
62
+ groups: (user.groups ?? []).map((group) => group?.name).filter(Boolean),
63
+ };
64
+ }
65
+ isPermitted(skill, user) {
66
+ return !skill.accessRightsToken || this.adminizer.accessRightsHelper.hasPermission(skill.accessRightsToken, user);
67
+ }
68
+ describe(skill, user) {
69
+ const refinement = skill.describe?.(user, this.adminizer) ?? {};
70
+ const { execute: _execute, describe: _describe, owner: _owner, ...rest } = skill;
71
+ const inputSchema = { ...(refinement.inputSchema ?? skill.inputSchema) };
72
+ if (skill.requiresUser) {
73
+ const identity = this.describeUser(user);
74
+ const properties = { ...(inputSchema.properties ?? {}) };
75
+ properties[CURRENT_USER_PARAM] = {
76
+ type: 'string',
77
+ enum: [identity.login],
78
+ description: `Login of the admin user this conversation belongs to (id ${identity.id}`
79
+ + `${identity.fullName ? `, ${identity.fullName}` : ''}). Always pass "${identity.login}".`,
80
+ };
81
+ const required = new Set([...(inputSchema.required ?? []), CURRENT_USER_PARAM]);
82
+ inputSchema.properties = properties;
83
+ inputSchema.required = [...required];
84
+ }
85
+ return { ...rest, description: refinement.description ?? skill.description, inputSchema };
86
+ }
87
+ }
@@ -0,0 +1,60 @@
1
+ import type { User } from '../../models/User.js';
2
+ import type { Adminizer } from '../Adminizer.js';
3
+ import type { AdminLinkTemplateParam } from '../admin-links/AdminLinkHandler.js';
4
+ export interface AiAssistantAdminLink {
5
+ id: string;
6
+ title: string;
7
+ /** Concrete page, ready for `navigate`. Absent for templates. */
8
+ link?: string;
9
+ /** Parametrized page: pass `template` plus `params` to `navigate`. */
10
+ template?: string;
11
+ /** Placeholders that must be filled to open a template. */
12
+ params?: AdminLinkTemplateParam[];
13
+ description?: string;
14
+ section?: string;
15
+ }
16
+ /**
17
+ * A browser capability an AI service may expose as a tool. The agent emits
18
+ * `ui.method` with this id; the assistant panel performs the browser action.
19
+ */
20
+ export interface AiAssistantUiMethod {
21
+ id: string;
22
+ title: string;
23
+ description: string;
24
+ /** JSON Schema consumed by tool-capable model services. */
25
+ inputSchema: Record<string, unknown>;
26
+ /** Built-in browser executor understood by the shared assistant panel. */
27
+ action: 'navigate' | 'search-admin-links' | string;
28
+ accessRightsToken?: string;
29
+ }
30
+ /** What the agent may ask to open: a concrete link or a template plus values. */
31
+ export interface AiAssistantNavigationTarget {
32
+ href?: string;
33
+ template?: string;
34
+ params?: Record<string, unknown>;
35
+ }
36
+ export declare class AiAssistantUiMethodHandler {
37
+ private readonly adminizer;
38
+ private readonly methods;
39
+ private readonly owners;
40
+ constructor(adminizer: Adminizer);
41
+ register(method: AiAssistantUiMethod, owner?: string): void;
42
+ unregister(id: string, owner?: string): void;
43
+ getAvailable(user: User): AiAssistantUiMethod[];
44
+ /**
45
+ * Search exactly the navigation a user can open. This intentionally uses
46
+ * MenuHelper rather than client-side markup, therefore a tool result is
47
+ * permission-safe and works when the assistant panel is not visible.
48
+ *
49
+ * Parametrized pages are returned as templates, so a record page is
50
+ * reachable even though its URL only exists once the id is known.
51
+ */
52
+ searchAdminLinks(user: User, query?: string): AiAssistantAdminLink[];
53
+ /**
54
+ * Validates what an agent asked to open and returns the concrete admin URL.
55
+ * Templates are resolved against this user's own permissions, so the LLM
56
+ * can only reach pages that user could reach by clicking.
57
+ */
58
+ resolveNavigation(user: User, target: AiAssistantNavigationTarget): string;
59
+ private slug;
60
+ }