pgkiln 0.83.0 → 0.85.0
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/db/migrations/154_notifications.sql +186 -0
- package/docs/apex-feature-parity.md +3 -2
- package/docs/guide/03-builder.md +16 -5
- package/docs/guide/04-pages-and-regions.md +42 -0
- package/docs/guide/06-processing.md +22 -1
- package/docs/guide/07-dynamic-actions.md +1 -0
- package/docs/guide/09-reference.md +15 -0
- package/docs/guide/11-from-apex.md +1 -0
- package/docs/guide/12-development.md +13 -1
- package/package.json +1 -1
- package/public/app.css +29 -0
- package/public/app.js +78 -0
- package/public/sw.js +5 -1
- package/src/app.ts +11 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +10 -6
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3461 -0
- package/src/builder/i18n/en.ts +3461 -0
- package/src/builder/i18n/es.ts +3461 -0
- package/src/builder/i18n/fr.ts +3461 -0
- package/src/builder/i18n/it.ts +3461 -0
- package/src/builder/i18n/ja.ts +3461 -0
- package/src/builder/i18n/ko.ts +3461 -0
- package/src/builder/i18n/nl.ts +3461 -0
- package/src/builder/i18n/pt.ts +3461 -0
- package/src/builder/i18n/zh-cn.ts +3461 -0
- package/src/builder/i18n/zh-tw.ts +3461 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/notification-settings.ts +27 -0
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +30 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
- package/src/cli/replace.ts +2 -0
- package/src/i18n/ar.ts +9 -0
- package/src/i18n/cs.ts +9 -0
- package/src/i18n/da.ts +9 -0
- package/src/i18n/de.ts +9 -0
- package/src/i18n/el.ts +9 -0
- package/src/i18n/es.ts +9 -0
- package/src/i18n/fi.ts +9 -0
- package/src/i18n/fr.ts +9 -0
- package/src/i18n/he.ts +9 -0
- package/src/i18n/it.ts +9 -0
- package/src/i18n/ja.ts +9 -0
- package/src/i18n/ko.ts +9 -0
- package/src/i18n/nb.ts +9 -0
- package/src/i18n/pl.ts +9 -0
- package/src/i18n/pt.ts +9 -0
- package/src/i18n/ru.ts +9 -0
- package/src/i18n/sv.ts +9 -0
- package/src/i18n/tr.ts +9 -0
- package/src/i18n/uk.ts +9 -0
- package/src/i18n/zh.ts +9 -0
- package/src/i18n.ts +18 -0
- package/src/metadata.ts +9 -3
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/feedback.ts +1 -1
- package/src/runtime/lists.ts +43 -11
- package/src/runtime/notifications.ts +237 -0
- package/src/runtime/processes.ts +45 -1
- package/src/runtime/regions.ts +11 -15
- package/src/runtime/render.ts +12 -3
- package/src/runtime/routes.ts +12 -4
- package/src/runtime/signin.ts +2 -1
- package/src/session.ts +13 -4
package/public/app.js
CHANGED
|
@@ -915,6 +915,16 @@ const pgkilnUnsaved = (() => {
|
|
|
915
915
|
requestAnimationFrame(() => { queued = false; runChain(run); });
|
|
916
916
|
}, { passive: true });
|
|
917
917
|
}
|
|
918
|
+
// (165) timer: every so many seconds (the trigger), while the page is visible
|
|
919
|
+
{
|
|
920
|
+
const bySeconds = new Map();
|
|
921
|
+
for (const da of das.filter((d) => d.event === 'timer')) {
|
|
922
|
+
const seconds = Number(da.trigger[0]);
|
|
923
|
+
if (!(seconds >= 5 && seconds <= 3600)) continue;
|
|
924
|
+
bySeconds.set(seconds, [...(bySeconds.get(seconds) || []), da]);
|
|
925
|
+
}
|
|
926
|
+
for (const [seconds, run] of bySeconds) setInterval(() => { if (!document.hidden) runChain(run); }, seconds * 1000);
|
|
927
|
+
}
|
|
918
928
|
// page_unload: the page is left (only quick, local actions finish)
|
|
919
929
|
window.addEventListener('pagehide', () => runChain(das.filter((da) => da.event === 'page_unload')));
|
|
920
930
|
// facets_change: a faceted search region's facets were applied (the page came back from its form)
|
|
@@ -2851,6 +2861,66 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
2851
2861
|
for (const form of document.querySelectorAll('form[data-autosubmit]')) form.submit();
|
|
2852
2862
|
});
|
|
2853
2863
|
|
|
2864
|
+
// (165) Live badges and the notification bell (body data-live: seconds; GET <base>/<page>/live).
|
|
2865
|
+
const liveBadges = (() => {
|
|
2866
|
+
let seconds = 0;
|
|
2867
|
+
let timer = 0;
|
|
2868
|
+
let last = 0;
|
|
2869
|
+
let busy = false;
|
|
2870
|
+
let stopped = false;
|
|
2871
|
+
const apply = (state) => {
|
|
2872
|
+
for (const el of document.querySelectorAll('[data-badge]')) {
|
|
2873
|
+
const key = el.dataset.badge;
|
|
2874
|
+
if (key === 'bell' || !(key in state.badges)) continue;
|
|
2875
|
+
const v = state.badges[key];
|
|
2876
|
+
el.textContent = v ?? '';
|
|
2877
|
+
el.hidden = v === null || v === '';
|
|
2878
|
+
}
|
|
2879
|
+
const bell = state.bell && document.querySelector('[data-bell]');
|
|
2880
|
+
if (!bell) return;
|
|
2881
|
+
const count = bell.querySelector('[data-badge="bell"]');
|
|
2882
|
+
if (count) {
|
|
2883
|
+
count.textContent = state.bell.unread ? (state.bell.unread > 99 ? '99+' : String(state.bell.unread)) : '';
|
|
2884
|
+
count.hidden = !state.bell.unread;
|
|
2885
|
+
}
|
|
2886
|
+
const summary = bell.querySelector('summary');
|
|
2887
|
+
if (summary) {
|
|
2888
|
+
summary.title = state.bell.label;
|
|
2889
|
+
summary.setAttribute('aria-label', state.bell.label);
|
|
2890
|
+
}
|
|
2891
|
+
// the list is replaced while the menu is closed only (not under the user's pointer or focus)
|
|
2892
|
+
const panel = bell.querySelector('[data-bell-panel]');
|
|
2893
|
+
if (panel && !bell.open && panel.innerHTML !== state.bell.panel) panel.innerHTML = state.bell.panel;
|
|
2894
|
+
};
|
|
2895
|
+
const poll = () => {
|
|
2896
|
+
const base = document.body.dataset.base;
|
|
2897
|
+
const page = document.body.dataset.page;
|
|
2898
|
+
if (stopped || busy || !base || !page) return;
|
|
2899
|
+
busy = true;
|
|
2900
|
+
last = Date.now();
|
|
2901
|
+
fetch(`${base}/${page}/live`, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
|
|
2902
|
+
.then((r) => {
|
|
2903
|
+
if (r.status === 401 || r.status === 403 || r.status === 404) stopped = true;
|
|
2904
|
+
return r.ok ? r.json() : null;
|
|
2905
|
+
})
|
|
2906
|
+
.then((state) => state && state.badges && apply(state))
|
|
2907
|
+
.catch(() => {})
|
|
2908
|
+
.finally(() => { busy = false; });
|
|
2909
|
+
};
|
|
2910
|
+
return {
|
|
2911
|
+
start(s) {
|
|
2912
|
+
if (!(s >= 10) || timer) return;
|
|
2913
|
+
seconds = s;
|
|
2914
|
+
timer = setInterval(() => { if (!document.hidden) poll(); }, seconds * 1000);
|
|
2915
|
+
// back to a tab that was hidden longer than the interval: at once
|
|
2916
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && Date.now() - last > seconds * 1000) poll(); });
|
|
2917
|
+
},
|
|
2918
|
+
now() {
|
|
2919
|
+
if (seconds) poll();
|
|
2920
|
+
},
|
|
2921
|
+
};
|
|
2922
|
+
})();
|
|
2923
|
+
|
|
2854
2924
|
// ------------------------------------------------------------------ Progressive Web App and field work
|
|
2855
2925
|
(() => {
|
|
2856
2926
|
const metaEl = document.getElementById('pgkiln-meta');
|
|
@@ -2879,10 +2949,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
2879
2949
|
}
|
|
2880
2950
|
navigator.serviceWorker.addEventListener('message', (e) => {
|
|
2881
2951
|
if (e.data && e.data.type === 'pgkiln:queue') showQueue(e.data.items, post);
|
|
2952
|
+
// (165) a push notification arrived: the badges and the bell now, not at the next refresh
|
|
2953
|
+
if (e.data && e.data.type === 'pgkiln:pushed') liveBadges.now();
|
|
2882
2954
|
});
|
|
2883
2955
|
if (new URLSearchParams(location.search).get('queued') === '1') note(t('pwa.queued'));
|
|
2884
2956
|
}
|
|
2885
2957
|
|
|
2958
|
+
// (165) live badges: every data-live seconds the page asks for its badges (navigation menu and bar,
|
|
2959
|
+
// buttons with a badge query) and the bell, and updates them in place; not while the tab is hidden.
|
|
2960
|
+
// A signed-out session (401) or a page without them (404, 403) stops it.
|
|
2961
|
+
liveBadges.start(Number(body.dataset.live) || 0);
|
|
2962
|
+
|
|
2886
2963
|
// offline: say so (the page may come from the device)
|
|
2887
2964
|
const banner = () => {
|
|
2888
2965
|
let b = document.querySelector('.offline-banner');
|
|
@@ -5241,3 +5318,4 @@ document.addEventListener('change', (e) => {
|
|
|
5241
5318
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') send(); });
|
|
5242
5319
|
window.addEventListener('pagehide', send);
|
|
5243
5320
|
})();
|
|
5321
|
+
|
package/public/sw.js
CHANGED
|
@@ -210,7 +210,11 @@ self.addEventListener('push', (event) => {
|
|
|
210
210
|
data: { url: appUrl(m.url) || appUrl(`${PGKILN.base}/`) },
|
|
211
211
|
};
|
|
212
212
|
if (typeof m.tag === 'string' && m.tag) Object.assign(options, { tag: m.tag, renotify: true });
|
|
213
|
-
event.waitUntil(
|
|
213
|
+
event.waitUntil(Promise.all([
|
|
214
|
+
self.registration.showNotification(typeof m.title === 'string' && m.title ? m.title : 'Notification', options),
|
|
215
|
+
// (165) open pages refresh their badges and the bell now
|
|
216
|
+
self.clients.matchAll({ type: 'window' }).then((wins) => wins.forEach((w) => w.postMessage({ type: 'pgkiln:pushed' }))),
|
|
217
|
+
]));
|
|
214
218
|
});
|
|
215
219
|
|
|
216
220
|
self.addEventListener('notificationclick', (event) => {
|
package/src/app.ts
CHANGED
|
@@ -60,6 +60,7 @@ import { assistantBuilderRoutes } from './builder/assistant.ts';
|
|
|
60
60
|
import { aiBuilderRoutes } from './builder/ai-builder.ts';
|
|
61
61
|
import { blueprintRoutes } from './builder/blueprints.ts';
|
|
62
62
|
import { accountRoutes } from './runtime/account.ts';
|
|
63
|
+
import { notificationRoutes } from './runtime/notifications.ts';
|
|
63
64
|
import { taskRoutes } from './runtime/tasks.ts';
|
|
64
65
|
import { dataReporterRoutes } from './runtime/data-reporter.ts';
|
|
65
66
|
import { datasetRoutes } from './builder/datasets.ts';
|
|
@@ -105,6 +106,7 @@ import { runtimeScope } from './db.ts';
|
|
|
105
106
|
import { startTelemetry } from './telemetry/export.ts';
|
|
106
107
|
import { telemetryHooks, telemetryRoutes } from './telemetry/http.ts';
|
|
107
108
|
import { telemetryBuilderRoutes } from './builder/telemetry.ts';
|
|
109
|
+
import { inRequestScope } from './builder/i18n.ts';
|
|
108
110
|
|
|
109
111
|
export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
110
112
|
// a database older than the code: apply the migrations (MIGRATE_ON_START=true)
|
|
@@ -208,8 +210,17 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
208
210
|
// the page's URL carries the package version, so the file never changes under it
|
|
209
211
|
return reply.header('cache-control', 'public, max-age=31536000, immutable').type('image/svg+xml').send(svg);
|
|
210
212
|
});
|
|
213
|
+
// (sprint 164) builder handlers run in a request scope: scopedTx (src/builder/i18n.ts) takes the developer's language from it
|
|
214
|
+
app.addHook('onRoute', (opts) => {
|
|
215
|
+
if (!opts.url.startsWith('/builder')) return;
|
|
216
|
+
const handler = opts.handler;
|
|
217
|
+
opts.handler = function (this: unknown, req, reply) {
|
|
218
|
+
return inRequestScope(req, () => (handler as (this: unknown, ...a: unknown[]) => unknown).call(this, req, reply));
|
|
219
|
+
} as typeof opts.handler;
|
|
220
|
+
});
|
|
211
221
|
await app.register(runtimeRoutes);
|
|
212
222
|
await app.register(accountRoutes);
|
|
223
|
+
await app.register(notificationRoutes);
|
|
213
224
|
await app.register(taskRoutes);
|
|
214
225
|
await app.register(workflowRoutes);
|
|
215
226
|
await app.register(pwaRoutes);
|
package/src/builder/ai-agents.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { agentProblems, aiToolProblems, definitionProblems, EFFORTS, type AiAgen
|
|
|
7
7
|
import type { Session } from '../session.ts';
|
|
8
8
|
import { appOr404 } from './forms.ts';
|
|
9
9
|
import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
10
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
10
11
|
|
|
11
12
|
// Shared Components → Generative AI → AI Agents (migration 121): the
|
|
12
13
|
// application's AI tools and AI agents, kept as two JSON lists in meta.app
|
|
@@ -18,13 +19,13 @@ type Body = Record<string, string | string[] | undefined>;
|
|
|
18
19
|
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? '';
|
|
19
20
|
const all = (v: string | string[] | undefined) => (v === undefined ? [] : [v].flat());
|
|
20
21
|
|
|
21
|
-
const TOOL_TYPES: [string, string][]
|
|
22
|
-
['sql',
|
|
23
|
-
['function', '
|
|
24
|
-
['rest', '
|
|
25
|
-
['javascript', '
|
|
26
|
-
['navigate', '
|
|
27
|
-
['retrieve', '
|
|
22
|
+
const TOOL_TYPES = (): [string, string][] => [
|
|
23
|
+
['sql', tx('agt.type.sql')],
|
|
24
|
+
['function', tx('agt.type.function')],
|
|
25
|
+
['rest', tx('agt.type.rest')],
|
|
26
|
+
['javascript', tx('agt.type.javascript')],
|
|
27
|
+
['navigate', tx('agt.type.navigate')],
|
|
28
|
+
['retrieve', tx('agt.type.retrieve')],
|
|
28
29
|
];
|
|
29
30
|
|
|
30
31
|
const when = (d: Date | string) => new Date(d).toISOString().slice(0, 19).replace('T', ' ');
|
|
@@ -36,12 +37,12 @@ async function schemes(appId: number) {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
const authzSelect = (names: string[], cur: unknown, help: string) =>
|
|
39
|
-
select('authz', '
|
|
40
|
+
select('authz', tx('agt.authorization_scheme'), cur ?? '', [['', tx('agt.none_everyone_who_may_use')], ['MUST_NOT_BE_PUBLIC_USER', tx('agt.must_not_be_the_public')], ...names.map((n): [string, string] => [n, n]),
|
|
40
41
|
...(typeof cur === 'string' && cur && cur !== 'MUST_NOT_BE_PUBLIC_USER' && !names.includes(cur) ? [[cur, `${cur} (does not exist)`] as [string, string]] : [])], help);
|
|
41
42
|
|
|
42
43
|
const crumbs = (a: { id: number; name: string }, last?: string): [string, string?][] => [
|
|
43
|
-
['
|
|
44
|
-
...(last ? [['
|
|
44
|
+
[tx('agt.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('agt.shared_components'), `${BASE}/apps/${a.id}/shared`],
|
|
45
|
+
...(last ? [[tx('agt.ai_agents'), `${BASE}/apps/${a.id}/ai-agents`], [last]] as [string, string?][] : [[tx('agt.ai_agents')]] as [string, string?][]),
|
|
45
46
|
];
|
|
46
47
|
|
|
47
48
|
// ---------------------------------------------------------------- forms
|
|
@@ -51,7 +52,7 @@ async function agentForm(a: any, s: Session, x: Partial<AiAgent>, original: stri
|
|
|
51
52
|
`select s.name, s.provider, s.model from meta.ai_service s join meta.app_ai_service x on x.service_id = s.id and x.app_id = $1 where s.enabled order by s.name`, [a.id])).rows;
|
|
52
53
|
const tools = (a.ai_tools ?? []) as AiTool[];
|
|
53
54
|
const chosen = new Set(x.tools ?? []);
|
|
54
|
-
const svc: [string, string][] = [['', '
|
|
55
|
+
const svc: [string, string][] = [['', tx('agt.choose')], ...services.map((v): [string, string] => [v.name, `${v.name} (${providerLabel(v.provider)}, ${v.model})`])];
|
|
55
56
|
if (x.service && !services.some((v) => v.name === x.service)) svc.push([x.service, `${x.service} (not available to this application)`]);
|
|
56
57
|
const usedBy = original
|
|
57
58
|
? (await owner.query<{ page_no: number; title: string | null; page_id: number; id: number }>(
|
|
@@ -59,31 +60,31 @@ async function agentForm(a: any, s: Session, x: Partial<AiAgent>, original: stri
|
|
|
59
60
|
where p.app_id = $1 and r.type = 'ai_assistant' and upper(r.config->>'agent') = $2 order by p.page_no, r.seq`, [a.id, original])).rows
|
|
60
61
|
: [];
|
|
61
62
|
return html`${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
|
|
62
|
-
${services.length ? '' : html`<p class="muted"
|
|
63
|
+
${services.length ? '' : html`<p class="muted">${tx('agt.this_application_may_not_use')}</p>`}
|
|
63
64
|
<form method="post" action="${BASE}/apps/${a.id}/ai-agents/agent">${csrf(s)}<input type="hidden" name="original" value="${original}">
|
|
64
|
-
<fieldset class="prop-group"><legend
|
|
65
|
-
${input('name', '
|
|
66
|
-
${input('description', '
|
|
67
|
-
${select('service', '
|
|
68
|
-
${select('effort', '
|
|
69
|
-
'
|
|
70
|
-
${input('max_steps', '
|
|
71
|
-
${input('max_tokens', '
|
|
72
|
-
${authzSelect(await schemes(a.id), x.authz, '
|
|
73
|
-
${area('system', '
|
|
65
|
+
<fieldset class="prop-group"><legend>${tx('agt.agent')}</legend><div class="form-grid">
|
|
66
|
+
${input('name', tx('agt.name'), x.name ?? '', { required: true, placeholder: tx('agt.e_g_sales_agent'), help: tx('agt.upper_case_letters_digits_and') })}
|
|
67
|
+
${input('description', tx('agt.description'), x.description ?? '')}
|
|
68
|
+
${select('service', tx('agt.ai_service'), x.service ?? '', svc, tx('agt.the_conversation_the_tools_results'))}
|
|
69
|
+
${select('effort', tx('agt.reasoning_effort'), x.effort ?? '', [['', tx('agt.the_service_s_2')], ...EFFORTS.map((e): [string, string] => [e, e])],
|
|
70
|
+
tx('agt.effort_help'))}
|
|
71
|
+
${input('max_steps', tx('agt.maximum_steps'), x.max_steps ?? '', { type: 'number', placeholder: '5', help: tx('agt.rounds_of_tool_calls_per') })}
|
|
72
|
+
${input('max_tokens', tx('agt.maximum_output_tokens'), x.max_tokens ?? '', { type: 'number', placeholder: tx('agt.the_service_s'), help: tx('agt.per_call_to_the_model') })}
|
|
73
|
+
${authzSelect(await schemes(a.id), x.authz, tx('agt.only_users_who_pass_it'))}
|
|
74
|
+
${area('system', tx('agt.system_prompt'), x.system ?? '', 6, tx('agt.system_help'))}
|
|
74
75
|
</div></fieldset>
|
|
75
|
-
<fieldset class="prop-group"><legend
|
|
76
|
+
<fieldset class="prop-group"><legend>${tx('agt.tools')}</legend>
|
|
76
77
|
${tools.length
|
|
77
|
-
? html`<p class="muted u-mt0"
|
|
78
|
-
: html`<p class="muted u-mt0"
|
|
78
|
+
? html`<p class="muted u-mt0">${tx('agt.the_agent_acts_only_through')}</p>${tools.map((t) => html`<div class="field"><label class="check"><input type="checkbox" name="tools" value="${t.name}"${chosen.has(t.name) ? raw(' checked') : ''}> <b>${t.name}</b> <span class="muted">(${t.type}) ${t.description}</span></label></div>`)}`
|
|
79
|
+
: html`<p class="muted u-mt0">${tx.rich('agt.no_tools', { link: html`<a href="${BASE}/apps/${a.id}/ai-agents/tool">${tx('agt.add_one')}</a>` })}</p>`}
|
|
79
80
|
</fieldset>
|
|
80
|
-
<div class="buttons"><button class="btn btn-hot">${original ? '
|
|
81
|
+
<div class="buttons"><button class="btn btn-hot">${original ? tx('agt.save') : tx('agt.add_agent')}</button></div>
|
|
81
82
|
</form>
|
|
82
|
-
${original ? html`<h3
|
|
83
|
+
${original ? html`<h3>${tx('agt.used_by')}</h3>${usedBy.length
|
|
83
84
|
? html`<ul>${usedBy.map((r) => html`<li><a href="${BASE}/pages/${r.page_id}?c=region-${r.id}">Page ${r.page_no}: ${r.title ?? 'AI assistant'}</a></li>`)}</ul>`
|
|
84
|
-
: html`<p class="muted"
|
|
85
|
+
: html`<p class="muted">${tx('agt.no_ai_assistant_region_uses')}</p>`}
|
|
85
86
|
<form method="post" action="${BASE}/apps/${a.id}/ai-agents/agent/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="name" value="${original}">
|
|
86
|
-
<button class="btn btn-danger" data-confirm="
|
|
87
|
+
<button class="btn btn-danger" data-confirm="${tx('agt.delete_agent_confirm', { name: original })}">${tx('agt.delete_agent')}</button></form>` : ''}`;
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
async function toolForm(a: any, s: Session, x: Partial<AiTool> & { parameters_text?: string }, original: string, problems: string[]): Promise<Raw> {
|
|
@@ -93,28 +94,28 @@ async function toolForm(a: any, s: Session, x: Partial<AiTool> & { parameters_te
|
|
|
93
94
|
const usedBy = original ? ((a.ai_agents ?? []) as AiAgent[]).filter((g) => (g.tools ?? []).includes(original)).map((g) => g.name) : [];
|
|
94
95
|
return html`${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
|
|
95
96
|
<form method="post" action="${BASE}/apps/${a.id}/ai-agents/tool">${csrf(s)}<input type="hidden" name="original" value="${original}">
|
|
96
|
-
<fieldset class="prop-group"><legend
|
|
97
|
-
${input('name', '
|
|
98
|
-
${select('type', '
|
|
99
|
-
${area('description', '
|
|
100
|
-
${area('sql', '
|
|
101
|
-
${input('function', '
|
|
102
|
-
${select('source', '
|
|
103
|
-
${input('action', '
|
|
104
|
-
${input('pages', '
|
|
105
|
-
${select('search_config', '
|
|
97
|
+
<fieldset class="prop-group"><legend>${tx('agt.tool')}</legend><div class="form-grid">
|
|
98
|
+
${input('name', tx('agt.name'), x.name ?? '', { required: true, placeholder: tx('agt.e_g_top_earners'), help: tx('agt.letters_digits_and_the_model') })}
|
|
99
|
+
${select('type', tx('agt.type'), x.type ?? 'sql', TOOL_TYPES(), tx('agt.what_the_tool_does_each'))}
|
|
100
|
+
${area('description', tx('agt.description'), x.description ?? '', 2, tx('agt.tells_the_model_what_the'))}
|
|
101
|
+
${area('sql', tx('agt.sql_query_sql'), x.sql ?? '', 6, tx('agt.sql_help'), true)}
|
|
102
|
+
${input('function', tx('agt.function_pl_pgsql'), x.function ?? '', { placeholder: tx('agt.e_g_sales_give_discount'), help: tx('agt.schema_name_called_as_the') })}
|
|
103
|
+
${select('source', tx('agt.rest_data_source_rest'), x.source ?? '', [['', '-'], ...sources.map((n): [string, string] => [n, n])], tx('agt.the_parameters_become_the_source'))}
|
|
104
|
+
${input('action', tx('agt.action_javascript'), x.action ?? '', { placeholder: tx('agt.e_g_highlightorder'), help: tx('agt.after_the_answer_the_browser') })}
|
|
105
|
+
${input('pages', tx('agt.pages_navigate'), (x.pages ?? []).join(', '), { placeholder: 'e.g. 3, 7', help: tx('agt.the_page_numbers_the_agent') })}
|
|
106
|
+
${select('search_config', tx('agt.search_configuration_retrieve'), x.search_config ?? '', [['', '-'], ...configs.map((c): [string, string] => [c.name, `${c.name} (${c.search_type})`]),
|
|
106
107
|
...(x.search_config && !configs.some((c) => c.name === x.search_config) ? [[x.search_config, `${x.search_config} (does not exist)`] as [string, string]] : [])],
|
|
107
108
|
'The model gives a text; pgkiln runs this search configuration (best: vector or hybrid) as the application\'s role, so row level security applies, and returns the best rows\' title, text and score.')}
|
|
108
|
-
${area('parameters', '
|
|
109
|
-
${input('max_rows', '
|
|
110
|
-
${input('timeout_s', '
|
|
111
|
-
${authzSelect(await schemes(a.id), x.authz, '
|
|
109
|
+
${area('parameters', tx('agt.parameters_json_not_for_navigate'), params, 6, '{"DNAME": {"type": "string", "description": "A department name"}, "LIMIT": {"type": "integer", "optional": true}}. Types: string, integer, number, boolean, date; "enum" for a list of texts.', true)}
|
|
110
|
+
${input('max_rows', tx('agt.maximum_rows'), x.max_rows ?? '', { type: 'number', placeholder: '50', help: tx('agt.rows_the_model_gets_back') })}
|
|
111
|
+
${input('timeout_s', tx('agt.time_limit_seconds'), x.timeout_s ?? '', { type: 'number', placeholder: '15', help: tx('agt.sql_and_function_tools_1') })}
|
|
112
|
+
${authzSelect(await schemes(a.id), x.authz, tx('agt.only_users_who_pass_it_2'))}
|
|
112
113
|
</div></fieldset>
|
|
113
|
-
<div class="buttons"><button class="btn btn-hot">${original ? '
|
|
114
|
+
<div class="buttons"><button class="btn btn-hot">${original ? tx('agt.save') : tx('agt.add_tool')}</button></div>
|
|
114
115
|
</form>
|
|
115
|
-
${original ? html`<h3
|
|
116
|
+
${original ? html`<h3>${tx('agt.used_by')}</h3>${usedBy.length ? html`<p>${usedBy.join(', ')}</p>` : html`<p class="muted">${tx('agt.no_agent_uses_this_tool')}</p>`}
|
|
116
117
|
<form method="post" action="${BASE}/apps/${a.id}/ai-agents/tool/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="name" value="${original}">
|
|
117
|
-
<button class="btn btn-danger" data-confirm="
|
|
118
|
+
<button class="btn btn-danger" data-confirm="${tx('agt.delete_tool_confirm', { name: original })}">${tx('agt.delete_tool')}</button></form>` : ''}`;
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
// ---------------------------------------------------------------- reading the forms
|
|
@@ -123,7 +124,7 @@ const intOrUndef = (v: string, what: string, errors: string[]) => {
|
|
|
123
124
|
const t = v.trim();
|
|
124
125
|
if (!t) return undefined;
|
|
125
126
|
const n = Number(t);
|
|
126
|
-
if (!Number.isInteger(n)) errors.push(
|
|
127
|
+
if (!Number.isInteger(n)) errors.push(tx('agt.whole_number', { what }));
|
|
127
128
|
return n;
|
|
128
129
|
};
|
|
129
130
|
|
|
@@ -139,9 +140,9 @@ function agentFrom(b: Body, errors: string[]): AiAgent {
|
|
|
139
140
|
if (effort) x.effort = effort as AiAgent['effort'];
|
|
140
141
|
const tools = all(b.tools).map((t) => t.trim()).filter(Boolean);
|
|
141
142
|
if (tools.length) x.tools = [...new Set(tools)];
|
|
142
|
-
const steps = intOrUndef(one(b.max_steps), '
|
|
143
|
+
const steps = intOrUndef(one(b.max_steps), tx('agt.f_steps'), errors);
|
|
143
144
|
if (steps !== undefined) x.max_steps = steps;
|
|
144
|
-
const tokens = intOrUndef(one(b.max_tokens), '
|
|
145
|
+
const tokens = intOrUndef(one(b.max_tokens), tx('agt.f_tokens'), errors);
|
|
145
146
|
if (tokens !== undefined) x.max_tokens = tokens;
|
|
146
147
|
const authz = one(b.authz).trim();
|
|
147
148
|
if (authz) x.authz = authz;
|
|
@@ -162,16 +163,16 @@ function toolFrom(b: Body, errors: string[]): AiTool & { parameters_text?: strin
|
|
|
162
163
|
try {
|
|
163
164
|
x.parameters = JSON.parse(params);
|
|
164
165
|
} catch {
|
|
165
|
-
errors.push('
|
|
166
|
+
errors.push(tx('agt.parameters_not_valid_json'));
|
|
166
167
|
x.parameters_text = params;
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
if (type === 'sql' || type === 'function' || type === 'rest' || type === 'retrieve') {
|
|
170
|
-
const rows = intOrUndef(one(b.max_rows), '
|
|
171
|
+
const rows = intOrUndef(one(b.max_rows), tx('agt.f_rows'), errors);
|
|
171
172
|
if (rows !== undefined) x.max_rows = rows;
|
|
172
173
|
}
|
|
173
174
|
if (type === 'sql' || type === 'function' || type === 'retrieve') {
|
|
174
|
-
const t = intOrUndef(one(b.timeout_s), '
|
|
175
|
+
const t = intOrUndef(one(b.timeout_s), tx('agt.f_timeout'), errors);
|
|
175
176
|
if (t !== undefined) x.timeout_s = t;
|
|
176
177
|
}
|
|
177
178
|
const authz = one(b.authz).trim();
|
|
@@ -197,15 +198,15 @@ async function update(appId: number, change: (agents: AiAgent[], tools: AiTool[]
|
|
|
197
198
|
/** The "Tool calls" region of Activity → AI usage: the latest calls with their arguments. */
|
|
198
199
|
export async function toolCallTable(appId: number): Promise<Raw> {
|
|
199
200
|
const rows = (await owner.query('select * from meta.ai_tool_call where app_id = $1 order by id desc limit 100', [appId])).rows;
|
|
200
|
-
return region('
|
|
201
|
+
return region(tx('agt.tool_calls'), html`<p class="muted u-mt0">${tx('agt.every_tool_call_of_the')}</p>
|
|
201
202
|
<div class="table-wrap"><table class="report report-reflow">
|
|
202
|
-
<thead><tr><th
|
|
203
|
+
<thead><tr><th>${tx('agt.when_utc')}</th><th class="num">${tx('agt.page')}</th><th>${tx('agt.user')}</th><th>${tx('agt.agent')}</th><th>${tx('agt.tool')}</th><th>${tx('agt.arguments')}</th><th class="num">ms</th><th>${tx('agt.status')}</th></tr></thead>
|
|
203
204
|
<tbody>${rows.length ? rows.map((r) => html`<tr>
|
|
204
|
-
<td data-label="
|
|
205
|
-
<td data-label="
|
|
206
|
-
<td data-label="
|
|
207
|
-
<td data-label="
|
|
208
|
-
: html`<tr><td colspan="8" class="empty"
|
|
205
|
+
<td data-label="${tx('agt.when')}">${when(r.at)}</td><td class="num" data-label="${tx('agt.page')}">${r.page_no ?? ''}</td><td data-label="${tx('agt.user')}">${r.username ?? ''}</td>
|
|
206
|
+
<td data-label="${tx('agt.agent')}">${r.agent ?? html`<span class="muted">region</span>`}</td><td data-label="${tx('agt.tool')}">${r.tool} <span class="muted">${r.type}</span></td>
|
|
207
|
+
<td data-label="${tx('agt.arguments')}"><code class="ai-args">${JSON.stringify(r.arguments)}</code></td><td class="num" data-label="ms">${r.duration_ms}</td>
|
|
208
|
+
<td data-label="${tx('agt.status')}"><span class="tag${r.status === 'ok' ? ' tag-ok' : ' tag-error'}">${r.status}</span>${r.message ? html` <span class="muted">${r.message}</span>` : ''}</td></tr>`)
|
|
209
|
+
: html`<tr><td colspan="8" class="empty">${tx('agt.no_tool_calls_yet')}</td></tr>`}</tbody></table></div>`);
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
// ---------------------------------------------------------------- routes
|
|
@@ -230,22 +231,22 @@ export async function aiAgentRoutes(app: FastifyInstance) {
|
|
|
230
231
|
const tools = (a.ai_tools ?? []) as AiTool[];
|
|
231
232
|
const problems = definitionProblems(agents, tools);
|
|
232
233
|
const main = html`${appHeader(a, 'shared', s)}
|
|
233
|
-
<div class="title-row"><h1
|
|
234
|
-
<p class="muted u-mt0"
|
|
234
|
+
<div class="title-row"><h1>${tx('agt.ai_agents')}</h1></div>
|
|
235
|
+
<p class="muted u-mt0">${tx.rich('agt.intro', { usage: html`<a href="${BASE}/apps/${a.id}/ai">${tx('agt.ai_usage')}</a>` })}</p>
|
|
235
236
|
${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
|
|
236
237
|
<div class="columns">
|
|
237
|
-
${region('
|
|
238
|
-
<tbody>${agents.length ? agents.map((g) => html`<tr><td data-label="
|
|
239
|
-
<td data-label="
|
|
240
|
-
: html`<tr><td colspan="4" class="empty"
|
|
241
|
-
<p><a class="btn" href="${BASE}/apps/${a.id}/ai-agents/agent">${icon('plus')}
|
|
242
|
-
${region('
|
|
243
|
-
<tbody>${tools.length ? tools.map((t) => html`<tr><td data-label="
|
|
244
|
-
<td data-label="
|
|
245
|
-
: html`<tr><td colspan="3" class="empty"
|
|
246
|
-
<p><a class="btn" href="${BASE}/apps/${a.id}/ai-agents/tool">${icon('plus')}
|
|
238
|
+
${region(tx('agt.agents'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('agt.agent')}</th><th>${tx('agt.ai_service')}</th><th>${tx('agt.tools')}</th><th>${tx('agt.authorization')}</th></tr></thead>
|
|
239
|
+
<tbody>${agents.length ? agents.map((g) => html`<tr><td data-label="${tx('agt.agent')}"><a href="${BASE}/apps/${a.id}/ai-agents/agent?name=${encodeURIComponent(g.name)}">${g.name}</a>${g.description ? html` <span class="muted">${g.description}</span>` : ''}</td>
|
|
240
|
+
<td data-label="${tx('agt.ai_service')}">${g.service}${g.effort ? html` <span class="muted">(${g.effort})</span>` : ''}</td><td data-label="${tx('agt.tools')}">${(g.tools ?? []).join(', ')}</td><td data-label="${tx('agt.authorization')}">${g.authz ?? ''}</td></tr>`)
|
|
241
|
+
: html`<tr><td colspan="4" class="empty">${tx('agt.no_ai_agents_yet')}</td></tr>`}</tbody></table></div>
|
|
242
|
+
<p><a class="btn" href="${BASE}/apps/${a.id}/ai-agents/agent">${icon('plus')} ${tx('agt.add_agent')}</a></p>`)}
|
|
243
|
+
${region(tx('agt.tools'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('agt.tool')}</th><th>${tx('agt.type')}</th><th>${tx('agt.authorization')}</th></tr></thead>
|
|
244
|
+
<tbody>${tools.length ? tools.map((t) => html`<tr><td data-label="${tx('agt.tool')}"><a href="${BASE}/apps/${a.id}/ai-agents/tool?name=${encodeURIComponent(t.name)}">${t.name}</a> <span class="muted">${t.description}</span></td>
|
|
245
|
+
<td data-label="${tx('agt.type')}">${t.type}</td><td data-label="${tx('agt.authorization')}">${t.authz ?? ''}</td></tr>`)
|
|
246
|
+
: html`<tr><td colspan="3" class="empty">${tx('agt.no_ai_tools_yet')}</td></tr>`}</tbody></table></div>
|
|
247
|
+
<p><a class="btn" href="${BASE}/apps/${a.id}/ai-agents/tool">${icon('plus')} ${tx('agt.add_tool')}</a></p>`)}
|
|
247
248
|
</div>`;
|
|
248
|
-
return send(reply, s, shell(s, '
|
|
249
|
+
return send(reply, s, shell(s, tx('agt.ai_agents'), crumbs(a), main));
|
|
249
250
|
});
|
|
250
251
|
|
|
251
252
|
app.get(`${BASE}/apps/:id(^\\d+$)/ai-agents/agent`, async (req: Req, reply) => {
|
|
@@ -256,7 +257,7 @@ export async function aiAgentRoutes(app: FastifyInstance) {
|
|
|
256
257
|
const x = name ? ((a.ai_agents ?? []) as AiAgent[]).find((g) => g.name === name) : {};
|
|
257
258
|
if (!x) return reply.code(404).send('Not found');
|
|
258
259
|
const title = name || 'New agent';
|
|
259
|
-
const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${title}</h1></div>${region('
|
|
260
|
+
const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${title}</h1></div>${region(tx('agt.ai_agent'), await agentForm(a, s, x, name, []))}`;
|
|
260
261
|
return send(reply, s, shell(s, title, crumbs(a, title), main));
|
|
261
262
|
});
|
|
262
263
|
|
|
@@ -271,21 +272,21 @@ export async function aiAgentRoutes(app: FastifyInstance) {
|
|
|
271
272
|
const problems = errors.length ? errors : await update(a.id, (agents, tools) => {
|
|
272
273
|
const out = agentProblems(x, new Set(tools.map((t) => t.name)));
|
|
273
274
|
const at = original ? agents.findIndex((g) => g.name === original) : -1;
|
|
274
|
-
if (original && at < 0) out.push(
|
|
275
|
-
if (agents.some((g, i) => g.name === x.name && i !== at)) out.push(
|
|
275
|
+
if (original && at < 0) out.push(tx('agt.agent_gone', { name: original }));
|
|
276
|
+
if (agents.some((g, i) => g.name === x.name && i !== at)) out.push(tx('agt.agent_exists', { name: x.name }));
|
|
276
277
|
if (out.length) return out;
|
|
277
278
|
if (at >= 0) agents[at] = x;
|
|
278
279
|
else agents.push(x);
|
|
279
280
|
return [];
|
|
280
281
|
});
|
|
281
282
|
if (problems.length) {
|
|
282
|
-
const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${original || 'New agent'}</h1></div>${region('
|
|
283
|
+
const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${original || 'New agent'}</h1></div>${region(tx('agt.ai_agent'), await agentForm(a, s, x, original, problems))}`;
|
|
283
284
|
return send(reply.code(422), s, shell(s, original || 'New agent', crumbs(a, original || 'New agent'), main));
|
|
284
285
|
}
|
|
285
286
|
if (original && original !== x.name)
|
|
286
287
|
await owner.query(`update meta.region r set config = jsonb_set(r.config, '{agent}', to_jsonb($3::text)) from meta.page p
|
|
287
288
|
where p.id = r.page_id and p.app_id = $1 and r.type = 'ai_assistant' and upper(r.config->>'agent') = $2`, [a.id, original, x.name]);
|
|
288
|
-
flash(s, '
|
|
289
|
+
flash(s, tx('agt.ai_agent_saved'));
|
|
289
290
|
return back(reply, s, `${BASE}/apps/${a.id}/ai-agents/agent?name=${encodeURIComponent(x.name)}`);
|
|
290
291
|
});
|
|
291
292
|
|
|
@@ -311,7 +312,7 @@ export async function aiAgentRoutes(app: FastifyInstance) {
|
|
|
311
312
|
const x = name ? ((a.ai_tools ?? []) as AiTool[]).find((t) => t.name === name) : {};
|
|
312
313
|
if (!x) return reply.code(404).send('Not found');
|
|
313
314
|
const title = name || 'New tool';
|
|
314
|
-
const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${title}</h1></div>${region('
|
|
315
|
+
const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${title}</h1></div>${region(tx('agt.ai_tool'), await toolForm(a, s, x, name, []))}`;
|
|
315
316
|
return send(reply, s, shell(s, title, crumbs(a, title), main));
|
|
316
317
|
});
|
|
317
318
|
|
|
@@ -327,15 +328,15 @@ export async function aiAgentRoutes(app: FastifyInstance) {
|
|
|
327
328
|
const problems = errors.length ? errors : await update(a.id, async (agents, tools) => {
|
|
328
329
|
const out = aiToolProblems(tool);
|
|
329
330
|
if (tool.type === 'rest' && tool.source && !(await owner.one('select 1 from meta.rest_source where app_id = $1 and name = $2', [a.id, tool.source])))
|
|
330
|
-
out.push(
|
|
331
|
+
out.push(tx('agt.no_rest_source', { name: tool.source }));
|
|
331
332
|
if (tool.type === 'retrieve' && tool.search_config && !(await owner.one(`select 1 from meta.search_config where app_id = $1 and name = $2 and source_type <> 'list'`, [a.id, tool.search_config])))
|
|
332
|
-
out.push(
|
|
333
|
+
out.push(tx('agt.no_search_config', { name: tool.search_config }));
|
|
333
334
|
if (tool.type === 'navigate') {
|
|
334
335
|
const known = new Set((await owner.query<{ page_no: number }>('select page_no from meta.page where app_id = $1', [a.id])).rows.map((p) => p.page_no));
|
|
335
|
-
for (const p of tool.pages ?? []) if (!known.has(p)) out.push(
|
|
336
|
+
for (const p of tool.pages ?? []) if (!known.has(p)) out.push(tx('agt.no_page', { n: p }));
|
|
336
337
|
}
|
|
337
338
|
const at = original ? tools.findIndex((t) => t.name === original) : -1;
|
|
338
|
-
if (original && at < 0) out.push(
|
|
339
|
+
if (original && at < 0) out.push(tx('agt.tool_gone', { name: original }));
|
|
339
340
|
if (tools.some((t, i) => t.name === tool.name && i !== at)) out.push(`A tool ${tool.name} exists already.`);
|
|
340
341
|
if (out.length) return out;
|
|
341
342
|
if (at >= 0) tools[at] = tool;
|
|
@@ -345,10 +346,10 @@ export async function aiAgentRoutes(app: FastifyInstance) {
|
|
|
345
346
|
return [];
|
|
346
347
|
});
|
|
347
348
|
if (problems.length) {
|
|
348
|
-
const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${original || 'New tool'}</h1></div>${region('
|
|
349
|
+
const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${original || 'New tool'}</h1></div>${region(tx('agt.ai_tool'), await toolForm(a, s, x, original, problems))}`;
|
|
349
350
|
return send(reply.code(422), s, shell(s, original || 'New tool', crumbs(a, original || 'New tool'), main));
|
|
350
351
|
}
|
|
351
|
-
flash(s, '
|
|
352
|
+
flash(s, tx('agt.ai_tool_saved'));
|
|
352
353
|
return back(reply, s, `${BASE}/apps/${a.id}/ai-agents/tool?name=${encodeURIComponent(tool.name)}`);
|
|
353
354
|
});
|
|
354
355
|
|
|
@@ -359,7 +360,7 @@ export async function aiAgentRoutes(app: FastifyInstance) {
|
|
|
359
360
|
const name = one((req.body as Body)?.name);
|
|
360
361
|
const problems = await update(a.id, (agents, tools) => {
|
|
361
362
|
const users = agents.filter((g) => (g.tools ?? []).includes(name)).map((g) => g.name);
|
|
362
|
-
if (users.length) return [
|
|
363
|
+
if (users.length) return [tx('agt.tool_used', { name, agents: users.join(', ') })];
|
|
363
364
|
const at = tools.findIndex((t) => t.name === name);
|
|
364
365
|
if (at >= 0) tools.splice(at, 1);
|
|
365
366
|
return [];
|