adminizer 5.0.0-build.11 → 5.0.0-build.12

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 (42) hide show
  1. package/assets/{add-DQE53mIb.js → add-BhTWhSNx.js} +1 -1
  2. package/assets/{add-group-BsizwU4R.js → add-group-BEOEkMGK.js} +1 -1
  3. package/assets/{add-user-BkSrCzw4.js → add-user-MW-fSILU.js} +1 -1
  4. package/assets/ai-assistant/agent.es.js +4898 -4852
  5. package/assets/app.js +2 -2
  6. package/assets/{catalog-DPbsKvEY.js → catalog-D-lFVV9U.js} +1 -1
  7. package/assets/{dashboard-DDbX6YWf.js → dashboard-Cmj4Df_C.js} +1 -1
  8. package/assets/{history-DzHm8RJs.js → history-BXfeZHTD.js} +1 -1
  9. package/assets/{list-CMymFRqO.js → list-Dp8F_hor.js} +1 -1
  10. package/assets/manifest.json +24 -24
  11. package/assets/{module-DkFrKFpO.js → module-Q-1AzZH9.js} +1 -1
  12. package/assets/{notification-ChERJE5c.js → notification-CSeMEsPn.js} +1 -1
  13. package/assets/{user-filters-list-BytQFcmQ.js → user-filters-list-Do6Dis5D.js} +1 -1
  14. package/assets/{welcome-Daw8Hukl.js → welcome-D3dg746Y.js} +1 -1
  15. package/assets/{with-app-layout-0iXcLqi5.js → with-app-layout-Bjo8B9zO.js} +1 -1
  16. package/controllers/ai/AiAgentController.js +4 -1
  17. package/helpers/inertiaMenuHelper.d.ts +0 -1
  18. package/helpers/inertiaMenuHelper.js +3 -26
  19. package/helpers/modelResourceHelper.d.ts +9 -0
  20. package/helpers/modelResourceHelper.js +39 -0
  21. package/helpers/navigationAccessHelper.d.ts +13 -0
  22. package/helpers/navigationAccessHelper.js +40 -0
  23. package/index.d.ts +6 -0
  24. package/index.js +6 -0
  25. package/interfaces/types.d.ts +40 -3
  26. package/lib/Adminizer.d.ts +6 -0
  27. package/lib/Adminizer.js +9 -0
  28. package/lib/admin-links/AdminLinkHandler.d.ts +72 -0
  29. package/lib/admin-links/AdminLinkHandler.js +175 -0
  30. package/lib/ai-assistant/AbstractAiModelService.d.ts +44 -3
  31. package/lib/ai-assistant/AbstractAiModelService.js +61 -2
  32. package/lib/ai-assistant/AiAssistantAgentSkillHandler.d.ts +58 -0
  33. package/lib/ai-assistant/AiAssistantAgentSkillHandler.js +87 -0
  34. package/lib/ai-assistant/AiAssistantUiMethodHandler.d.ts +60 -0
  35. package/lib/ai-assistant/AiAssistantUiMethodHandler.js +138 -0
  36. package/lib/ai-assistant/builtinAgentSkills.d.ts +9 -0
  37. package/lib/ai-assistant/builtinAgentSkills.js +256 -0
  38. package/lib/ai-assistant/jsonSafe.d.ts +13 -0
  39. package/lib/ai-assistant/jsonSafe.js +81 -0
  40. package/lib/app-manager/AdminizerApp.d.ts +24 -0
  41. package/lib/app-manager/AppManager.js +39 -42
  42. package/package.json +1 -1
@@ -0,0 +1,13 @@
1
+ import type { HrefConfig } from '../interfaces/adminpanelConfig.js';
2
+ import type { Adminizer } from '../lib/Adminizer.js';
3
+ import type { User } from '../models/User.js';
4
+ import type { MenuItem } from './menuHelper.js';
5
+ /** Drops the sub-items this user may not open, recursively. */
6
+ export declare function filterAccessibleHrefItems(adminizer: Adminizer, user: User, items: HrefConfig[]): HrefConfig[];
7
+ /**
8
+ * The navigation menu a user may actually open: configured models, additional
9
+ * links and app-contributed pages, filtered with the very same rules the
10
+ * sidebar uses. Titles are left untranslated, so callers that need i18n (the
11
+ * Inertia page props) translate on top of this.
12
+ */
13
+ export declare function listAccessibleMenuItems(adminizer: Adminizer, user: User): MenuItem[];
@@ -0,0 +1,40 @@
1
+ /** Drops the sub-items this user may not open, recursively. */
2
+ export function filterAccessibleHrefItems(adminizer, user, items) {
3
+ return items
4
+ .filter((item) => !item.accessRightsToken || adminizer.accessRightsHelper.hasPermission(item.accessRightsToken, user))
5
+ .map((item) => ({
6
+ ...item,
7
+ subItems: item.subItems ? filterAccessibleHrefItems(adminizer, user, item.subItems) : item.subItems,
8
+ }));
9
+ }
10
+ /**
11
+ * The navigation menu a user may actually open: configured models, additional
12
+ * links and app-contributed pages, filtered with the very same rules the
13
+ * sidebar uses. Titles are left untranslated, so callers that need i18n (the
14
+ * Inertia page props) translate on top of this.
15
+ */
16
+ export function listAccessibleMenuItems(adminizer, user) {
17
+ const menu = [];
18
+ for (const menuItem of adminizer.menuHelper.getMenuItems(user)) {
19
+ const actions = filterAccessibleHrefItems(adminizer, user, menuItem.actions ?? []);
20
+ const tokens = actions.map((item) => item.accessRightsToken).filter(Boolean);
21
+ if (menuItem.accessRightsToken)
22
+ tokens.push(menuItem.accessRightsToken);
23
+ if (adminizer.accessRightsHelper.enoughPermissions(tokens, user)) {
24
+ menu.push({ ...menuItem, actions });
25
+ }
26
+ }
27
+ for (const link of adminizer.adminLinkHandler.list(user)) {
28
+ menu.push({
29
+ id: link.id,
30
+ link: link.link,
31
+ title: link.title,
32
+ type: link.type === 'blank' ? 'blank' : 'self',
33
+ actions: null,
34
+ icon: null,
35
+ accessRightsToken: link.accessRightsToken ?? null,
36
+ section: link.section || 'Platform',
37
+ });
38
+ }
39
+ return menu;
40
+ }
package/index.d.ts CHANGED
@@ -46,6 +46,12 @@ export * from "./lib/ai-assistant/AbstractAiModelService.js";
46
46
  export * from "./lib/ai-assistant/AbstractAiConversationHistoryService.js";
47
47
  export * from "./lib/ai-assistant/AiAssistantHandler.js";
48
48
  export * from "./lib/ai-assistant/AiAgentRunManager.js";
49
+ export * from "./lib/ai-assistant/jsonSafe.js";
50
+ export * from "./lib/ai-assistant/AiAssistantUiMethodHandler.js";
51
+ export * from "./lib/ai-assistant/AiAssistantAgentSkillHandler.js";
52
+ export * from "./lib/admin-links/AdminLinkHandler.js";
53
+ export * from "./helpers/modelResourceHelper.js";
54
+ export * from "./helpers/navigationAccessHelper.js";
49
55
  export * from "./lib/DataAccessor.js";
50
56
  export * from "./lib/history-actions/AbstractHistoryAdapter.js";
51
57
  export * from "./lib/filters/CustomFilterHandler.js";
package/index.js CHANGED
@@ -46,6 +46,12 @@ export * from "./lib/ai-assistant/AbstractAiModelService.js";
46
46
  export * from "./lib/ai-assistant/AbstractAiConversationHistoryService.js";
47
47
  export * from "./lib/ai-assistant/AiAssistantHandler.js";
48
48
  export * from "./lib/ai-assistant/AiAgentRunManager.js";
49
+ export * from "./lib/ai-assistant/jsonSafe.js";
50
+ export * from "./lib/ai-assistant/AiAssistantUiMethodHandler.js";
51
+ export * from "./lib/ai-assistant/AiAssistantAgentSkillHandler.js";
52
+ export * from "./lib/admin-links/AdminLinkHandler.js";
53
+ export * from "./helpers/modelResourceHelper.js";
54
+ export * from "./helpers/navigationAccessHelper.js";
49
55
  export * from "./lib/DataAccessor.js";
50
56
  export * from "./lib/history-actions/AbstractHistoryAdapter.js";
51
57
  export * from "./lib/filters/CustomFilterHandler.js";
@@ -123,6 +123,29 @@ export interface AiAgentConnectionStatus {
123
123
  expiresAt?: string | null;
124
124
  [key: string]: unknown;
125
125
  }
126
+ /** States for which an agent can describe its connection screen. */
127
+ export type AiAgentConnectionState = Exclude<AiAgentConnectionStatus['state'], 'ready'>;
128
+ /**
129
+ * Declarative connection screen supplied by an agent service. The shared panel
130
+ * owns rendering, while the agent owns its copy, CTA and which status details
131
+ * are useful to expose.
132
+ */
133
+ export interface AiAgentConnectionScreen {
134
+ title?: string;
135
+ description?: string;
136
+ icon?: 'spinner' | 'bot' | 'error';
137
+ action?: {
138
+ label: string;
139
+ href: string;
140
+ };
141
+ /** Status values the agent wants to render on this screen. */
142
+ details?: Array<{
143
+ field: string;
144
+ label?: string;
145
+ format?: 'text' | 'local-time';
146
+ tone?: 'muted' | 'error';
147
+ }>;
148
+ }
126
149
  /** Normalized budget / rate limits of the provider key, when it exposes them. */
127
150
  export interface AiAgentLimits {
128
151
  provider: string;
@@ -144,9 +167,8 @@ export interface AiAgentLimits {
144
167
  [key: string]: unknown;
145
168
  }
146
169
  /**
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.
170
+ * Copy the panel shows instead of its neutral defaults. `getUiHints(locale)`
171
+ * receives the admin user's locale, so agents can return localized copy.
150
172
  */
151
173
  export interface AiAgentUiHints {
152
174
  title?: string;
@@ -157,14 +179,29 @@ export interface AiAgentUiHints {
157
179
  setupSetting?: string;
158
180
  /** Where to fill it in; rendered as a button on the connection loader. */
159
181
  setupUrl?: string;
182
+ /**
183
+ * Per-state pages rendered while this agent is not ready. `default` is
184
+ * used for any state without its own screen.
185
+ */
186
+ connectionScreens?: Partial<Record<AiAgentConnectionState | 'default', AiAgentConnectionScreen>>;
160
187
  [key: string]: unknown;
161
188
  }
189
+ /** A browser-side capability which an agent may call through `ui.method`. */
190
+ export interface AiAgentUiMethod {
191
+ id: string;
192
+ title: string;
193
+ description: string;
194
+ inputSchema: Record<string, unknown>;
195
+ action: string;
196
+ accessRightsToken?: string;
197
+ }
162
198
  /** Declarative UI contract consumed by the shared assistant panel. */
163
199
  export interface AiAgentUiSchema extends AiAgentUiHints {
164
200
  commands: Array<{
165
201
  id: string;
166
202
  description?: string;
167
203
  }>;
204
+ uiMethods?: AiAgentUiMethod[];
168
205
  panels: {
169
206
  history: boolean;
170
207
  models: boolean;
@@ -27,6 +27,9 @@ import { ControllerHandler } from "./app-manager/ControllerHandler.js";
27
27
  import { AssetHandler } from "./app-manager/AssetHandler.js";
28
28
  import { ConfigLayerHandler } from "./app-manager/ConfigLayerHandler.js";
29
29
  import { AccessRightsHandler } from "./access-rights/AccessRightsHandler.js";
30
+ import { AiAssistantUiMethodHandler } from './ai-assistant/AiAssistantUiMethodHandler.js';
31
+ import { AdminLinkHandler } from './admin-links/AdminLinkHandler.js';
32
+ import { AiAssistantAgentSkillHandler } from './ai-assistant/AiAssistantAgentSkillHandler.js';
30
33
  export declare function createAdminizerLogger(): winston.Logger;
31
34
  export interface EmitAsyncOptions {
32
35
  /**
@@ -54,6 +57,9 @@ export declare class Adminizer {
54
57
  notificationHandler: NotificationHandler;
55
58
  historyHandler: HistoryHandler;
56
59
  private _aiAssistantHandler?;
60
+ aiAssistantUiMethodHandler: AiAssistantUiMethodHandler;
61
+ adminLinkHandler: AdminLinkHandler;
62
+ aiAssistantAgentSkillHandler: AiAssistantAgentSkillHandler;
57
63
  modelHandler: ModelHandler;
58
64
  widgetHandler: WidgetHandler;
59
65
  customFilterHandler: CustomFilterHandler;
package/lib/Adminizer.js CHANGED
@@ -43,6 +43,9 @@ import { ControllerHandler } from "./app-manager/ControllerHandler.js";
43
43
  import { AssetHandler } from "./app-manager/AssetHandler.js";
44
44
  import { ConfigLayerHandler } from "./app-manager/ConfigLayerHandler.js";
45
45
  import { AccessRightsHandler } from "./access-rights/AccessRightsHandler.js";
46
+ import { AiAssistantUiMethodHandler } from './ai-assistant/AiAssistantUiMethodHandler.js';
47
+ import { AdminLinkHandler } from './admin-links/AdminLinkHandler.js';
48
+ import { AiAssistantAgentSkillHandler } from './ai-assistant/AiAssistantAgentSkillHandler.js';
46
49
  const logFilePath = "logs/app.log";
47
50
  function ensureLogDirectory(filePath) {
48
51
  const logDirectory = path.dirname(filePath);
@@ -109,6 +112,9 @@ export class Adminizer {
109
112
  notificationHandler;
110
113
  historyHandler;
111
114
  _aiAssistantHandler;
115
+ aiAssistantUiMethodHandler;
116
+ adminLinkHandler;
117
+ aiAssistantAgentSkillHandler;
112
118
  modelHandler;
113
119
  widgetHandler;
114
120
  customFilterHandler;
@@ -141,6 +147,9 @@ export class Adminizer {
141
147
  this.catalogTemplateComponentHandler = new CatalogTemplateComponentHandler();
142
148
  this.mediaManagerHandler = new MediaManagerHandler();
143
149
  this.feedbackHandler = new FeedbackHandler(this);
150
+ this.aiAssistantUiMethodHandler = new AiAssistantUiMethodHandler(this);
151
+ this.adminLinkHandler = new AdminLinkHandler(this);
152
+ this.aiAssistantAgentSkillHandler = new AiAssistantAgentSkillHandler(this);
144
153
  this.controllerHandler = new ControllerHandler(this);
145
154
  this.assetHandler = new AssetHandler(this);
146
155
  this.configLayerHandler = new ConfigLayerHandler(this);
@@ -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
+ }