pgkiln 0.84.0 → 0.86.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 (105) hide show
  1. package/db/migrations/155_mail_available.sql +23 -0
  2. package/docs/apex-feature-parity.md +2 -2
  3. package/docs/guide/03-builder.md +14 -5
  4. package/docs/guide/04-pages-and-regions.md +1 -1
  5. package/docs/guide/06-processing.md +7 -3
  6. package/docs/guide/07-dynamic-actions.md +3 -3
  7. package/docs/guide/09-reference.md +1 -0
  8. package/docs/guide/12-development.md +10 -1
  9. package/examples/gallery/README.md +23 -6
  10. package/examples/gallery/gallery.json +4 -0
  11. package/examples/gallery/samples/sample-approvals.sql +102 -11
  12. package/examples/gallery/samples/sample-calendar.sql +40 -5
  13. package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
  14. package/examples/gallery/samples/sample-grids.sql +35 -3
  15. package/examples/gallery/samples/sample-maps.sql +65 -7
  16. package/examples/gallery/samples/sample-processes.sql +466 -0
  17. package/examples/gallery/samples/sample-validations.sql +407 -0
  18. package/examples/gallery/samples/ux-patterns.sql +154 -14
  19. package/examples/hr/README.md +2 -0
  20. package/examples/hr/hr_80_notifications.sql +125 -0
  21. package/examples/hr/hr_81_vector_search_type.sql +33 -0
  22. package/package.json +1 -1
  23. package/public/app.css +8 -1
  24. package/public/app.js +28 -9
  25. package/src/app.ts +9 -0
  26. package/src/builder/ai-agents.ts +86 -85
  27. package/src/builder/ai-builder.ts +30 -30
  28. package/src/builder/ai.ts +93 -92
  29. package/src/builder/api.ts +47 -47
  30. package/src/builder/app-groups.ts +33 -32
  31. package/src/builder/appfromfile.ts +57 -58
  32. package/src/builder/appsheets.ts +33 -32
  33. package/src/builder/appwizard.ts +65 -65
  34. package/src/builder/arrange.ts +23 -22
  35. package/src/builder/assistant.ts +39 -38
  36. package/src/builder/automations.ts +32 -31
  37. package/src/builder/availability.ts +24 -24
  38. package/src/builder/backups.ts +30 -31
  39. package/src/builder/blueprints.ts +62 -61
  40. package/src/builder/create-page.ts +52 -50
  41. package/src/builder/cross-app.ts +37 -35
  42. package/src/builder/dataload.ts +2 -2
  43. package/src/builder/datasets.ts +107 -106
  44. package/src/builder/dependencies.ts +20 -19
  45. package/src/builder/designer.ts +36 -36
  46. package/src/builder/diagnostics.ts +58 -57
  47. package/src/builder/features.ts +39 -35
  48. package/src/builder/feedback.ts +23 -20
  49. package/src/builder/find-workspace.ts +28 -24
  50. package/src/builder/form-designer.ts +36 -35
  51. package/src/builder/gallery.ts +25 -29
  52. package/src/builder/globalization.ts +50 -49
  53. package/src/builder/i18n/de.ts +3453 -0
  54. package/src/builder/i18n/en.ts +3453 -0
  55. package/src/builder/i18n/es.ts +3453 -0
  56. package/src/builder/i18n/fr.ts +3453 -0
  57. package/src/builder/i18n/it.ts +3453 -0
  58. package/src/builder/i18n/ja.ts +3453 -0
  59. package/src/builder/i18n/ko.ts +3453 -0
  60. package/src/builder/i18n/nl.ts +3453 -0
  61. package/src/builder/i18n/pt.ts +3453 -0
  62. package/src/builder/i18n/zh-cn.ts +3453 -0
  63. package/src/builder/i18n/zh-tw.ts +3453 -0
  64. package/src/builder/i18n.ts +17 -3
  65. package/src/builder/json-sources.ts +63 -62
  66. package/src/builder/layouts.ts +12 -11
  67. package/src/builder/locks.ts +29 -28
  68. package/src/builder/login-page.ts +26 -25
  69. package/src/builder/mail.ts +148 -147
  70. package/src/builder/newapp.ts +15 -12
  71. package/src/builder/plugins.ts +47 -44
  72. package/src/builder/process-jobs.ts +11 -10
  73. package/src/builder/pwa.ts +22 -21
  74. package/src/builder/remote-databases.ts +55 -56
  75. package/src/builder/report-settings.ts +1 -1
  76. package/src/builder/reporter.ts +43 -42
  77. package/src/builder/rest-catalogs.ts +87 -86
  78. package/src/builder/rest.ts +9 -8
  79. package/src/builder/routes.ts +27 -27
  80. package/src/builder/search.ts +14 -13
  81. package/src/builder/static-files.ts +31 -32
  82. package/src/builder/subscriptions.ts +47 -45
  83. package/src/builder/supporting.ts +23 -18
  84. package/src/builder/team.ts +179 -179
  85. package/src/builder/telemetry.ts +59 -62
  86. package/src/builder/templates.ts +66 -65
  87. package/src/builder/themeroller.ts +60 -63
  88. package/src/builder/ui-defaults.ts +21 -20
  89. package/src/builder/ui.ts +2 -1
  90. package/src/builder/upgrade.ts +25 -24
  91. package/src/builder/utilities.ts +48 -47
  92. package/src/builder/vector-providers.ts +67 -66
  93. package/src/builder/websources.ts +43 -42
  94. package/src/builder/wizards.ts +95 -89
  95. package/src/builder/workflow-migrate.ts +30 -31
  96. package/src/builder/workflows.ts +40 -39
  97. package/src/builder/workingcopies.ts +41 -49
  98. package/src/builder/workspace-backups.ts +21 -20
  99. package/src/builder/workspace-mail.ts +17 -16
  100. package/src/builder/workspace-resources.ts +104 -112
  101. package/src/builder/workspace-transfer.ts +34 -26
  102. package/src/runtime/engine.ts +6 -3
  103. package/src/runtime/lists.ts +2 -2
  104. package/src/runtime/push.ts +7 -1
  105. package/src/runtime/regions.ts +9 -4
@@ -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 [];
@@ -10,8 +10,8 @@ import { appOr404 } from './forms.ts';
10
10
  import { isAdmin } from './locks.ts';
11
11
  import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, workshopTabs, type Req } from './ui.ts';
12
12
  import { workshopDb } from './workspaces.ts';
13
- import { WIZARD_KINDS, wizardTables } from './wizards.ts';
14
- import { tr } from './i18n.ts';
13
+ import { kindLabel, WIZARD_KINDS, wizardTables } from './wizards.ts';
14
+ import { scopedTx as tx, tr } from './i18n.ts';
15
15
 
16
16
  // App Builder AI (migration 062), with the AI service an administrator
17
17
  // chooses for the builder (meta.builder_ai):
@@ -41,7 +41,7 @@ export async function builderService() {
41
41
 
42
42
  async function ask(s: Session, req: Parameters<typeof generate>[1]) {
43
43
  const svc = await builderService();
44
- if (!svc) throw new AiError('config', 'The App Builder has no AI service yet (an administrator chooses one under SQL Workshop → AI).');
44
+ if (!svc) throw new AiError('config', tx('aib.the_app_builder_has_no'));
45
45
  return generate(svc.name, req, { appId: null, user: s.username, source: 'builder' });
46
46
  }
47
47
 
@@ -128,7 +128,7 @@ async function serviceBox(s: Session) {
128
128
  <div class="buttons"><button class="btn">${tx('scr.save')}</button></div></form>`;
129
129
  }
130
130
 
131
- const errorBox = (e: unknown) => html`<div class="alert alert-error" role="alert">${e instanceof AiError || e instanceof Error ? e.message : 'The AI request failed.'}</div>`;
131
+ const errorBox = (e: unknown) => html`<div class="alert alert-error" role="alert">${e instanceof AiError || e instanceof Error ? e.message : tx('aib.the_ai_request_failed')}</div>`;
132
132
 
133
133
  // ---------------------------------------------------------------- SQL Workshop → AI
134
134
 
@@ -228,33 +228,33 @@ async function freePages(appId: number) {
228
228
  async function pagesPage(s: Session, reply: FastifyReply, a: any, description: string, proposals: Proposal[] | null, message?: Raw) {
229
229
  const tables = (await wizardTables(a.db_role)).filter((t) => t.access).map((t) => t.t);
230
230
  const svc = await builderService();
231
- const kindOptions = WIZARD_KINDS.map(([k, label]): [string, string] => [k, label]);
231
+ const kindOptions = WIZARD_KINDS.map(([k, label]): [string, string] => [k, kindLabel(k, label)]);
232
232
  const main = html`${appHeader(a, 'pages', s)}
233
233
  ${message ?? ''}
234
- ${region('Create pages with AI', html`
235
- <p class="muted u-mt0">Describe the pages you want. The App Builder's AI service proposes pages of the create page wizards for tables and views the application's role (${a.db_role ?? 'the owner'}) can read; you check the proposals and create the ones you tick. Table and column names, types and <a href="${BASE}/sql/ai/describe">descriptions</a> are sent to the service, never rows.</p>
236
- ${svc ? '' : html`<div class="alert alert-info">The App Builder has no AI service yet: an administrator chooses one under <a href="${BASE}/sql/ai">SQL Workshop → AI</a>.</div>`}
234
+ ${region(tx('aib.create_pages_with_ai'), html`
235
+ <p class="muted u-mt0">${tx.rich('aib.intro', { role: a.db_role ?? tx('aib.the_owner'), descriptions: html`<a href="${BASE}/sql/ai/describe">${tx('aib.descriptions')}</a>` })}</p>
236
+ ${svc ? '' : html`<div class="alert alert-info">${tx.rich('aib.no_service', { link: html`<a href="${BASE}/sql/ai">${tx('aib.sql_workshop_ai')}</a>` })}</div>`}
237
237
  <form method="post" action="${BASE}/apps/${a.id}/ai-pages">${csrf(s)}
238
- <div class="field"><label class="label" for="f_description">Description</label>
239
- <textarea id="f_description" name="description" rows="4" maxlength="4000" required placeholder="e.g. A page to manage employees, a calendar of leave, and a chart of salaries per department">${description}</textarea></div>
240
- <div class="buttons"><button class="btn btn-hot" data-busy="Thinking…"${svc ? '' : raw(' disabled')}>Propose pages</button></div>
238
+ <div class="field"><label class="label" for="f_description">${tx('aib.description')}</label>
239
+ <textarea id="f_description" name="description" rows="4" maxlength="4000" required placeholder="${tx('aib.e_g_a_page_to')}">${description}</textarea></div>
240
+ <div class="buttons"><button class="btn btn-hot" data-busy="${tx('aib.thinking')}"${svc ? '' : raw(' disabled')}>${tx('aib.propose_pages')}</button></div>
241
241
  </form>`)}
242
- ${proposals ? region('Proposed pages', proposals.length ? html`
242
+ ${proposals ? region(tx('aib.proposed_pages'), proposals.length ? html`
243
243
  <form method="post" action="${BASE}/apps/${a.id}/ai-pages/create">${csrf(s)}
244
244
  <input type="hidden" name="count" value="${proposals.length}">
245
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">Create</th><th scope="col">Page type</th><th scope="col">Table or view</th><th scope="col">Page</th><th scope="col">Form page</th><th scope="col">Label</th><th scope="col">Why</th></tr></thead><tbody>
245
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">${tx('aib.create')}</th><th scope="col">${tx('aib.page_type')}</th><th scope="col">${tx('aib.table_or_view')}</th><th scope="col">${tx('aib.page')}</th><th scope="col">${tx('aib.form_page')}</th><th scope="col">${tx('aib.label')}</th><th scope="col">${tx('aib.why')}</th></tr></thead><tbody>
246
246
  ${proposals.map((p, i) => html`<tr>
247
- <td data-label="Create"><input type="checkbox" name="on_${i}" value="true" checked aria-label="Create proposal ${i + 1}"></td>
248
- <td data-label="Page type"><select name="kind_${i}" aria-label="Page type of proposal ${i + 1}">${kindOptions.map(([k, l]) => opt(k, l, p.kind))}</select></td>
249
- <td data-label="Table or view"><select name="table_${i}" aria-label="Table of proposal ${i + 1}">${tables.map((t) => opt(t, t, p.table))}</select></td>
250
- <td data-label="Page"><input name="page_${i}" type="number" min="1" max="99999" value="${p.page}" aria-label="Page number of proposal ${i + 1}"></td>
251
- <td data-label="Form page"><input name="form_${i}" type="number" min="1" max="99999" value="${p.form_page ?? ''}" aria-label="Form page of proposal ${i + 1} (report and form)"></td>
252
- <td data-label="Label"><input name="label_${i}" maxlength="80" value="${p.label}" aria-label="Label of proposal ${i + 1}"></td>
253
- <td data-label="Why" class="muted">${p.reason ?? ''}</td></tr>`)}
247
+ <td data-label="${tx('aib.create')}"><input type="checkbox" name="on_${i}" value="true" checked aria-label="${tx('aib.p_create', { n: i + 1 })}"></td>
248
+ <td data-label="${tx('aib.page_type')}"><select name="kind_${i}" aria-label="${tx('aib.p_kind', { n: i + 1 })}">${kindOptions.map(([k, l]) => opt(k, l, p.kind))}</select></td>
249
+ <td data-label="${tx('aib.table_or_view')}"><select name="table_${i}" aria-label="${tx('aib.p_table', { n: i + 1 })}">${tables.map((t) => opt(t, t, p.table))}</select></td>
250
+ <td data-label="${tx('aib.page')}"><input name="page_${i}" type="number" min="1" max="99999" value="${p.page}" aria-label="${tx('aib.p_page', { n: i + 1 })}"></td>
251
+ <td data-label="${tx('aib.form_page')}"><input name="form_${i}" type="number" min="1" max="99999" value="${p.form_page ?? ''}" aria-label="${tx('aib.p_form', { n: i + 1 })}"></td>
252
+ <td data-label="${tx('aib.label')}"><input name="label_${i}" maxlength="80" value="${p.label}" aria-label="${tx('aib.p_label', { n: i + 1 })}"></td>
253
+ <td data-label="${tx('aib.why')}" class="muted">${p.reason ?? ''}</td></tr>`)}
254
254
  </tbody></table></div>
255
- <div class="buttons"><button class="btn btn-hot">Create the ticked pages</button></div>
256
- </form>` : html`<p class="muted">No pages were proposed: describe them differently, or check that the application's role may read the tables.</p>`) : ''}`;
257
- return send(reply, s, shell(s, a.name, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create pages with AI']], main));
255
+ <div class="buttons"><button class="btn btn-hot">${tx('aib.create_the_ticked_pages')}</button></div>
256
+ </form>` : html`<p class="muted">${tx('aib.no_pages_were_proposed_describe')}</p>`) : ''}`;
257
+ return send(reply, s, shell(s, a.name, [[tx('aib.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('aib.create_pages_with_ai')]], main));
258
258
  }
259
259
 
260
260
  /** The model's proposals checked: known page types and tables, free page numbers (others moved to the next free ones). */
@@ -293,7 +293,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
293
293
  app.post(`${BASE}/sql/ai/service`, async (req: Req, reply) => {
294
294
  const s = await developer(req, reply);
295
295
  if (!s) return;
296
- if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators choose the App Builder\'s AI service.');
296
+ if (!(await isAdmin(s.username))) return reply.code(403).send(tx('aib.admins_only'));
297
297
  const name = String(req.body?.service ?? '').toUpperCase();
298
298
  const svc = name ? await owner.one<{ id: number }>('select id from meta.ai_service where name = $1', [name]) : null;
299
299
  if (name && !svc) {
@@ -452,9 +452,9 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
452
452
  if (!a) return reply.code(404).send('Not found');
453
453
  const description = String(req.body?.description ?? '').trim().slice(0, 4000);
454
454
  try {
455
- if (!description) throw new AiError('config', 'Describe the pages you want.');
455
+ if (!description) throw new AiError('config', tx('aib.describe_the_pages_you_want'));
456
456
  const tables = (await wizardTables(a.db_role)).filter((t) => t.access).map((t) => t.t).slice(0, 300);
457
- if (!tables.length) throw new AiError('config', `The application's role ${a.db_role} can't read any table or view yet.`);
457
+ if (!tables.length) throw new AiError('config', tx('aib.no_tables', { role: a.db_role }));
458
458
  const used = await freePages(a.id);
459
459
  const firstFree = (() => {
460
460
  let n = 1;
@@ -517,13 +517,13 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
517
517
  const form = num(b[`form_${i}`]);
518
518
  const label = String(b[`label_${i}`] ?? '').trim().slice(0, 80);
519
519
  if (!KINDS.has(kind) || !tables.has(table) || page === null || (kind === 'report_form' && form === null)) {
520
- errors.push(`Proposal ${i + 1}: choose a page type, a table the application can read and free page numbers.`);
520
+ errors.push(tx('aib.p_invalid', { n: i + 1 }));
521
521
  continue;
522
522
  }
523
523
  chosen.push({ kind, table, page, form_page: kind === 'report_form' ? form : null, label: label || table.split('.').pop()! });
524
524
  }
525
525
  if (errors.length || !chosen.length) {
526
- flash(s, errors.length ? errors.join(' ') : 'Tick the pages to create.', 'error');
526
+ flash(s, errors.length ? errors.join(' ') : tx('aib.tick_the_pages_to_create'), 'error');
527
527
  return back(reply, s, `${BASE}/apps/${a.id}/ai-pages`);
528
528
  }
529
529
  try {
@@ -538,10 +538,10 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
538
538
  }
539
539
  return out;
540
540
  });
541
- flash(s, `Pages ${created.join(', ')} created. Make sure the application's database role has the privileges they need.`);
541
+ flash(s, tx('aib.created', { list: created.join(', ') }));
542
542
  return back(reply, s, `${BASE}/apps/${a.id}`);
543
543
  } catch (e) {
544
- flash(s, `No pages were created: ${(e as Error).message}`, 'error');
544
+ flash(s, tx('aib.not_created', { error: (e as Error).message }), 'error');
545
545
  return back(reply, s, `${BASE}/apps/${a.id}/ai-pages`);
546
546
  }
547
547
  });