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.
Files changed (126) hide show
  1. package/db/migrations/154_notifications.sql +186 -0
  2. package/docs/apex-feature-parity.md +3 -2
  3. package/docs/guide/03-builder.md +16 -5
  4. package/docs/guide/04-pages-and-regions.md +42 -0
  5. package/docs/guide/06-processing.md +22 -1
  6. package/docs/guide/07-dynamic-actions.md +1 -0
  7. package/docs/guide/09-reference.md +15 -0
  8. package/docs/guide/11-from-apex.md +1 -0
  9. package/docs/guide/12-development.md +13 -1
  10. package/package.json +1 -1
  11. package/public/app.css +29 -0
  12. package/public/app.js +78 -0
  13. package/public/sw.js +5 -1
  14. package/src/app.ts +11 -0
  15. package/src/builder/ai-agents.ts +86 -85
  16. package/src/builder/ai-builder.ts +30 -30
  17. package/src/builder/ai.ts +93 -92
  18. package/src/builder/api.ts +47 -47
  19. package/src/builder/app-groups.ts +33 -32
  20. package/src/builder/appfromfile.ts +57 -58
  21. package/src/builder/appsheets.ts +33 -32
  22. package/src/builder/appwizard.ts +65 -65
  23. package/src/builder/arrange.ts +23 -22
  24. package/src/builder/assistant.ts +39 -38
  25. package/src/builder/automations.ts +32 -31
  26. package/src/builder/availability.ts +24 -24
  27. package/src/builder/backups.ts +30 -31
  28. package/src/builder/blueprints.ts +62 -61
  29. package/src/builder/code-editor.ts +1 -1
  30. package/src/builder/components.ts +10 -6
  31. package/src/builder/create-page.ts +52 -50
  32. package/src/builder/cross-app.ts +37 -35
  33. package/src/builder/dataload.ts +2 -2
  34. package/src/builder/datasets.ts +107 -106
  35. package/src/builder/dependencies.ts +20 -19
  36. package/src/builder/designer.ts +36 -36
  37. package/src/builder/diagnostics.ts +58 -57
  38. package/src/builder/features.ts +39 -35
  39. package/src/builder/feedback.ts +23 -20
  40. package/src/builder/find-workspace.ts +28 -24
  41. package/src/builder/form-designer.ts +36 -35
  42. package/src/builder/gallery.ts +25 -29
  43. package/src/builder/globalization.ts +50 -49
  44. package/src/builder/i18n/de.ts +3461 -0
  45. package/src/builder/i18n/en.ts +3461 -0
  46. package/src/builder/i18n/es.ts +3461 -0
  47. package/src/builder/i18n/fr.ts +3461 -0
  48. package/src/builder/i18n/it.ts +3461 -0
  49. package/src/builder/i18n/ja.ts +3461 -0
  50. package/src/builder/i18n/ko.ts +3461 -0
  51. package/src/builder/i18n/nl.ts +3461 -0
  52. package/src/builder/i18n/pt.ts +3461 -0
  53. package/src/builder/i18n/zh-cn.ts +3461 -0
  54. package/src/builder/i18n/zh-tw.ts +3461 -0
  55. package/src/builder/i18n.ts +17 -3
  56. package/src/builder/json-sources.ts +63 -62
  57. package/src/builder/layouts.ts +12 -11
  58. package/src/builder/locks.ts +29 -28
  59. package/src/builder/login-page.ts +26 -25
  60. package/src/builder/mail.ts +148 -147
  61. package/src/builder/newapp.ts +15 -12
  62. package/src/builder/notification-settings.ts +27 -0
  63. package/src/builder/plugins.ts +47 -44
  64. package/src/builder/process-jobs.ts +11 -10
  65. package/src/builder/pwa.ts +22 -21
  66. package/src/builder/remote-databases.ts +55 -56
  67. package/src/builder/report-settings.ts +1 -1
  68. package/src/builder/reporter.ts +43 -42
  69. package/src/builder/rest-catalogs.ts +87 -86
  70. package/src/builder/rest.ts +9 -8
  71. package/src/builder/routes.ts +30 -27
  72. package/src/builder/search.ts +14 -13
  73. package/src/builder/static-files.ts +31 -32
  74. package/src/builder/subscriptions.ts +47 -45
  75. package/src/builder/supporting.ts +23 -18
  76. package/src/builder/team.ts +179 -179
  77. package/src/builder/telemetry.ts +59 -62
  78. package/src/builder/templates.ts +66 -65
  79. package/src/builder/themeroller.ts +60 -63
  80. package/src/builder/ui-defaults.ts +21 -20
  81. package/src/builder/ui.ts +2 -1
  82. package/src/builder/upgrade.ts +25 -24
  83. package/src/builder/utilities.ts +48 -47
  84. package/src/builder/vector-providers.ts +67 -66
  85. package/src/builder/websources.ts +43 -42
  86. package/src/builder/wizards.ts +95 -89
  87. package/src/builder/workflow-migrate.ts +30 -31
  88. package/src/builder/workflows.ts +40 -39
  89. package/src/builder/workingcopies.ts +41 -49
  90. package/src/builder/workspace-backups.ts +21 -20
  91. package/src/builder/workspace-mail.ts +17 -16
  92. package/src/builder/workspace-resources.ts +104 -112
  93. package/src/builder/workspace-transfer.ts +34 -26
  94. package/src/cli/replace.ts +2 -0
  95. package/src/i18n/ar.ts +9 -0
  96. package/src/i18n/cs.ts +9 -0
  97. package/src/i18n/da.ts +9 -0
  98. package/src/i18n/de.ts +9 -0
  99. package/src/i18n/el.ts +9 -0
  100. package/src/i18n/es.ts +9 -0
  101. package/src/i18n/fi.ts +9 -0
  102. package/src/i18n/fr.ts +9 -0
  103. package/src/i18n/he.ts +9 -0
  104. package/src/i18n/it.ts +9 -0
  105. package/src/i18n/ja.ts +9 -0
  106. package/src/i18n/ko.ts +9 -0
  107. package/src/i18n/nb.ts +9 -0
  108. package/src/i18n/pl.ts +9 -0
  109. package/src/i18n/pt.ts +9 -0
  110. package/src/i18n/ru.ts +9 -0
  111. package/src/i18n/sv.ts +9 -0
  112. package/src/i18n/tr.ts +9 -0
  113. package/src/i18n/uk.ts +9 -0
  114. package/src/i18n/zh.ts +9 -0
  115. package/src/i18n.ts +18 -0
  116. package/src/metadata.ts +9 -3
  117. package/src/runtime/engine.ts +2 -1
  118. package/src/runtime/feedback.ts +1 -1
  119. package/src/runtime/lists.ts +43 -11
  120. package/src/runtime/notifications.ts +237 -0
  121. package/src/runtime/processes.ts +45 -1
  122. package/src/runtime/regions.ts +11 -15
  123. package/src/runtime/render.ts +12 -3
  124. package/src/runtime/routes.ts +12 -4
  125. package/src/runtime/signin.ts +2 -1
  126. 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(self.registration.showNotification(typeof m.title === 'string' && m.title ? m.title : 'Notification', options));
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);
@@ -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', 'SQL query (read-only, as the application\'s role)'],
23
- ['function', 'PL/pgSQL function (may change data)'],
24
- ['rest', 'REST data source'],
25
- ['javascript', 'JavaScript action in the browser'],
26
- ['navigate', 'Navigate: a link to a page'],
27
- ['retrieve', 'Retrieve (RAG): rows of a search configuration'],
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', 'Authorization scheme', cur ?? '', [['', '- none: everyone who may use the page -'], ['MUST_NOT_BE_PUBLIC_USER', 'Must not be the public user'], ...names.map((n): [string, string] => [n, n]),
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
- ['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`],
44
- ...(last ? [['AI Agents', `${BASE}/apps/${a.id}/ai-agents`], [last]] as [string, string?][] : [['AI Agents']] as [string, string?][]),
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][] = [['', '- choose -'], ...services.map((v): [string, string] => [v.name, `${v.name} (${providerLabel(v.provider)}, ${v.model})`])];
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">This application may not use any AI service yet: an administrator adds one under Workspace utilities → AI services and allows this application to use it.</p>`}
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>Agent</legend><div class="form-grid">
65
- ${input('name', 'Name', x.name ?? '', { required: true, placeholder: 'e.g. SALES_AGENT', help: 'Upper case letters, digits and _. AI assistant regions name the agent.' })}
66
- ${input('description', 'Description', x.description ?? '')}
67
- ${select('service', 'AI service', x.service ?? '', svc, 'The conversation, the tools\' results and the system prompt go to this service\'s provider.')}
68
- ${select('effort', 'Reasoning effort', x.effort ?? '', [['', 'The service\'s'], ...EFFORTS.map((e): [string, string] => [e, e])],
69
- 'Claude: the output effort (thinking depth). OpenAI, Gemini and OpenAI-compatible: reasoning_effort (leave empty for models without reasoning).')}
70
- ${input('max_steps', 'Maximum steps', x.max_steps ?? '', { type: 'number', placeholder: '5', help: 'Rounds of tool calls per question (1 – 20); then the model must answer.' })}
71
- ${input('max_tokens', 'Maximum output tokens', x.max_tokens ?? '', { type: 'number', placeholder: 'the service\'s', help: 'Per call to the model (never above the service\'s maximum).' })}
72
- ${authzSelect(await schemes(a.id), x.authz, 'Only users who pass it may use the agent (regions show a message to the others).')}
73
- ${area('system', 'System prompt', x.system ?? '', 6, 'Who the agent is, what it may answer and when to use which tool. &ITEM. is replaced by the item\'s value as delimited data.')}
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>Tools</legend>
76
+ <fieldset class="prop-group"><legend>${tx('agt.tools')}</legend>
76
77
  ${tools.length
77
- ? html`<p class="muted u-mt0">The agent acts only through the tools you tick.</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>`)}`
78
- : html`<p class="muted u-mt0">No AI tools yet: <a href="${BASE}/apps/${a.id}/ai-agents/tool">add one</a>.</p>`}
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 ? 'Save' : 'Add agent'}</button></div>
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>Used by</h3>${usedBy.length
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">No AI assistant region uses this agent yet (Page Designer → an AI assistant region → AI agent).</p>`}
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="Delete agent ${original}? Regions that use it show that the assistant is not set up.">Delete agent</button></form>` : ''}`;
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>Tool</legend><div class="form-grid">
97
- ${input('name', 'Name', x.name ?? '', { required: true, placeholder: 'e.g. top_earners', help: 'Letters, digits and _: the model calls the tool by this name.' })}
98
- ${select('type', 'Type', x.type ?? 'sql', TOOL_TYPES, 'What the tool does; each type uses the fields below that name it.')}
99
- ${area('description', 'Description', x.description ?? '', 2, 'Tells the model what the tool does and when to use it.')}
100
- ${area('sql', 'SQL query (SQL)', x.sql ?? '', 6, 'One SELECT with the parameters as :NAME bind values (and :APP_USER, items). Runs as the application\'s database role (grants and row level security apply) in a read-only transaction.', true)}
101
- ${input('function', 'Function (PL/pgSQL)', x.function ?? '', { placeholder: 'e.g. sales.give_discount', help: 'schema.name, called as the application\'s role with named arguments: each parameter name in lower case is an argument name (P_ORDER → p_order). It may change data.' })}
102
- ${select('source', 'REST data source (REST)', x.source ?? '', [['', '-'], ...sources.map((n): [string, string] => [n, n])], 'The parameters become the source\'s parameters.')}
103
- ${input('action', 'Action (JavaScript)', x.action ?? '', { placeholder: 'e.g. highlightOrder', help: 'After the answer the browser calls the function registered with pgkiln.actions.register(name) with {value: the arguments}, and the dynamic actions of the custom event of this name.' })}
104
- ${input('pages', 'Pages (Navigate)', (x.pages ?? []).join(', '), { placeholder: 'e.g. 3, 7', help: 'The page numbers the agent may link to. pgkiln offers only those the user may open and builds the URL (item values with their checksum).' })}
105
- ${select('search_config', 'Search configuration (Retrieve)', x.search_config ?? '', [['', '-'], ...configs.map((c): [string, string] => [c.name, `${c.name} (${c.search_type})`]),
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', 'Parameters (JSON; not for Navigate or Retrieve)', 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)}
109
- ${input('max_rows', 'Maximum rows', x.max_rows ?? '', { type: 'number', placeholder: '50', help: 'Rows the model gets back (1 – 500).' })}
110
- ${input('timeout_s', 'Time limit (seconds)', x.timeout_s ?? '', { type: 'number', placeholder: '15', help: 'SQL and function tools (1 – 60).' })}
111
- ${authzSelect(await schemes(a.id), x.authz, 'Only users who pass it are offered the tool.')}
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 ? 'Save' : 'Add tool'}</button></div>
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>Used by</h3>${usedBy.length ? html`<p>${usedBy.join(', ')}</p>` : html`<p class="muted">No agent uses this tool yet.</p>`}
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="Delete tool ${original}?">Delete tool</button></form>` : ''}`;
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(`${what} is a whole number.`);
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), 'Maximum steps', errors);
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), 'Maximum output tokens', errors);
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('Parameters: not valid JSON.');
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), 'Maximum rows', errors);
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), 'Time limit', errors);
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('Tool calls', html`<p class="muted u-mt0">Every tool call of the application's AI assistants and agents, with the arguments the model gave (kept 30 days).</p>
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>When (UTC)</th><th class="num">Page</th><th>User</th><th>Agent</th><th>Tool</th><th>Arguments</th><th class="num">ms</th><th>Status</th></tr></thead>
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="When">${when(r.at)}</td><td class="num" data-label="Page">${r.page_no ?? ''}</td><td data-label="User">${r.username ?? ''}</td>
205
- <td data-label="Agent">${r.agent ?? html`<span class="muted">region</span>`}</td><td data-label="Tool">${r.tool} <span class="muted">${r.type}</span></td>
206
- <td data-label="Arguments"><code class="ai-args">${JSON.stringify(r.arguments)}</code></td><td class="num" data-label="ms">${r.duration_ms}</td>
207
- <td data-label="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>`)
208
- : html`<tr><td colspan="8" class="empty">No tool calls yet.</td></tr>`}</tbody></table></div>`);
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>AI Agents</h1></div>
234
- <p class="muted u-mt0">An agent answers in an AI assistant region and acts only through the tools you give it: SQL queries (read-only, as the application's database role, so row level security applies), PL/pgSQL functions, REST data sources, JavaScript actions in the browser, and links to pages (pgkiln builds the URL and its checksum, only for pages the user may open). A Retrieve tool gives the agent the rows of a search configuration that match a text by meaning (RAG), again as the application's role. What users type and the tools' results go to the agent's AI service. <a href="${BASE}/apps/${a.id}/ai">AI usage</a> lists every tool call.</p>
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('Agents', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Agent</th><th>AI service</th><th>Tools</th><th>Authorization</th></tr></thead>
238
- <tbody>${agents.length ? agents.map((g) => html`<tr><td data-label="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>
239
- <td data-label="AI service">${g.service}${g.effort ? html` <span class="muted">(${g.effort})</span>` : ''}</td><td data-label="Tools">${(g.tools ?? []).join(', ')}</td><td data-label="Authorization">${g.authz ?? ''}</td></tr>`)
240
- : html`<tr><td colspan="4" class="empty">No AI agents yet.</td></tr>`}</tbody></table></div>
241
- <p><a class="btn" href="${BASE}/apps/${a.id}/ai-agents/agent">${icon('plus')} Add agent</a></p>`)}
242
- ${region('Tools', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Tool</th><th>Type</th><th>Authorization</th></tr></thead>
243
- <tbody>${tools.length ? tools.map((t) => html`<tr><td data-label="Tool"><a href="${BASE}/apps/${a.id}/ai-agents/tool?name=${encodeURIComponent(t.name)}">${t.name}</a> <span class="muted">${t.description}</span></td>
244
- <td data-label="Type">${t.type}</td><td data-label="Authorization">${t.authz ?? ''}</td></tr>`)
245
- : html`<tr><td colspan="3" class="empty">No AI tools yet.</td></tr>`}</tbody></table></div>
246
- <p><a class="btn" href="${BASE}/apps/${a.id}/ai-agents/tool">${icon('plus')} Add tool</a></p>`)}
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, 'AI Agents', crumbs(a), main));
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('AI agent', await agentForm(a, s, x, name, []))}`;
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(`Agent ${original} no longer exists.`);
275
- if (agents.some((g, i) => g.name === x.name && i !== at)) out.push(`An agent ${x.name} exists already.`);
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('AI agent', await agentForm(a, s, x, original, problems))}`;
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, 'AI agent saved.');
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('AI tool', await toolForm(a, s, x, name, []))}`;
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(`There is no REST data source ${tool.source}.`);
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(`There is no search configuration ${tool.search_config} with a table or SQL source.`);
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(`There is no page ${p}.`);
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(`Tool ${original} no longer exists.`);
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('AI tool', await toolForm(a, s, x, original, problems))}`;
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, 'AI tool saved.');
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 [`Tool ${name} is used by ${users.join(', ')}: take it out of those agents first.`];
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 [];