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.
- package/assets/{add-DQE53mIb.js → add-B_hn__Ba.js} +1 -1
- package/assets/{add-group-BsizwU4R.js → add-group-0SdYgiXZ.js} +1 -1
- package/assets/{add-user-BkSrCzw4.js → add-user-Bh1iqSem.js} +1 -1
- package/assets/ai-assistant/agent.es.js +5036 -4931
- package/assets/app.js +32 -32
- package/assets/{catalog-DPbsKvEY.js → catalog-DjK8r0Mn.js} +1 -1
- package/assets/controls/handsontable.es.js +11637 -11632
- package/assets/controls/jsoneditor.es.js +6308 -6297
- package/assets/controls/toast-ui.es.js +18 -17
- package/assets/{dashboard-DDbX6YWf.js → dashboard-CbJIK7ka.js} +1 -1
- package/assets/{history-DzHm8RJs.js → history-CZAJgVXP.js} +1 -1
- package/assets/{list-CMymFRqO.js → list-C6w_xDK2.js} +1 -1
- package/assets/manifest.json +24 -24
- package/assets/{module-DkFrKFpO.js → module-DvPjKyF_.js} +1 -1
- package/assets/{notification-ChERJE5c.js → notification-N_b-N0Zu.js} +1 -1
- package/assets/{user-filters-list-BytQFcmQ.js → user-filters-list-xbJoY_5_.js} +1 -1
- package/assets/{welcome-Daw8Hukl.js → welcome-BUkdkKa5.js} +1 -1
- package/assets/{with-app-layout-0iXcLqi5.js → with-app-layout-WhRyO5WK.js} +1 -1
- package/controllers/ai/AiAgentController.js +4 -1
- package/controllers/view.js +1 -1
- package/helpers/configHelper.js +12 -17
- package/helpers/controllerHelper.d.ts +1 -0
- package/helpers/controllerHelper.js +25 -15
- package/helpers/fieldsHelper.js +3 -3
- package/helpers/inertiaAddHelper.js +1 -1
- package/helpers/inertiaMenuHelper.d.ts +0 -1
- package/helpers/inertiaMenuHelper.js +3 -26
- package/helpers/modelResourceHelper.d.ts +9 -0
- package/helpers/modelResourceHelper.js +43 -0
- package/helpers/navigationAccessHelper.d.ts +13 -0
- package/helpers/navigationAccessHelper.js +40 -0
- package/index.d.ts +6 -0
- package/index.js +6 -0
- package/interfaces/adminpanelConfig.d.ts +6 -1
- package/interfaces/types.d.ts +40 -3
- package/lib/Adminizer.d.ts +6 -0
- package/lib/Adminizer.js +9 -0
- package/lib/DataAccessor.d.ts +1 -0
- package/lib/DataAccessor.js +45 -47
- package/lib/admin-links/AdminLinkHandler.d.ts +72 -0
- package/lib/admin-links/AdminLinkHandler.js +175 -0
- package/lib/ai-assistant/AbstractAiModelService.d.ts +44 -3
- package/lib/ai-assistant/AbstractAiModelService.js +61 -2
- package/lib/ai-assistant/AiAssistantAgentSkillHandler.d.ts +58 -0
- package/lib/ai-assistant/AiAssistantAgentSkillHandler.js +87 -0
- package/lib/ai-assistant/AiAssistantUiMethodHandler.d.ts +60 -0
- package/lib/ai-assistant/AiAssistantUiMethodHandler.js +138 -0
- package/lib/ai-assistant/builtinAgentSkills.d.ts +9 -0
- package/lib/ai-assistant/builtinAgentSkills.js +256 -0
- package/lib/ai-assistant/jsonSafe.d.ts +13 -0
- package/lib/ai-assistant/jsonSafe.js +81 -0
- package/lib/app-manager/AdminizerApp.d.ts +24 -0
- package/lib/app-manager/AppManager.js +39 -42
- package/lib/filters/FilterService.js +4 -3
- package/lib/history-actions/AbstractHistoryAdapter.js +11 -9
- package/lib/model/AbstractModel.d.ts +2 -0
- package/lib/model/ModelHandler.d.ts +47 -2
- package/lib/model/ModelHandler.js +107 -8
- package/package.json +1 -1
- package/system/bindAccessRights.js +2 -3
- package/system/buildInternalModelAccess.js +5 -2
- 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
|
|
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
|
+
}
|