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
@@ -6,6 +6,7 @@ import { html, raw, type Raw } from '../html.ts';
6
6
  import { icon, ICONS, LUCIDE_VERSION, lucideNames, searchIcons } from '../icons.ts';
7
7
  import { appOfPath } from './locks.ts';
8
8
  import { appHeader, BASE, designerUrl, developer, region, select, send, shell, type Req } from './ui.ts';
9
+ import { scopedTx as tx } from './i18n.ts';
9
10
 
10
11
  // (sprint 138, AP-5) App Builder → an application's Utilities (APEX: Application
11
12
  // Utilities): one page for what an application's maintenance needs, the tools
@@ -76,34 +77,34 @@ export async function utilityRoutes(app: FastifyInstance) {
76
77
  const u = `${BASE}/apps/${a.id}`;
77
78
  const main = html`${appHeader(a, 'utilities', s)}
78
79
  <div class="ab-tiles ab-tiles-wrap util-tiles">
79
- ${card(`${u}/advisor`, 'check', 'Advisor', 'Plans every SQL statement as the application\'s role and checks every reference.')}
80
- ${card(`${u}/utilities/dependencies`, 'database', 'Database object dependencies', 'The tables, views, sequences and functions the application uses, missing ones and missing grants.')}
81
- ${card(`${u}/utilities/history`, 'history', 'Change history', 'Who changed which component and when, with the differences.')}
82
- ${card(`${u}/utilities/upgrade`, 'refresh', 'Upgrade application', 'Components that can use a newer default of this pgkiln version.')}
83
- ${card(`${BASE}/ui-defaults`, 'sliders-horizontal', 'UI defaults', 'Labels, format masks, help and lists of values per table column, used by the page wizards.')}
84
- ${card(`${u}/search`, 'search', 'Search', 'Names, SQL, conditions, settings and help texts of every component.')}
85
- ${card(`${u}/debug`, 'list', 'Debug messages', 'Timed steps of each request and messages from meta.debug().')}
86
- ${card(`${u}/activity`, 'activity', 'Activity', 'Page views, errors, sign-ins and the application\'s logs.')}
87
- ${card(`${u}/backups`, 'archive', 'Backups', 'Back up now, download and restore earlier versions.')}
88
- ${card(`${u}/working-copies`, 'layers', 'Working copies', 'Change a copy, compare it and merge it back.')}
89
- ${card(`${u}/export`, 'download', 'Export', `The application as a file (checksum ${shortSum(sum)}).`)}
90
- ${card(`${BASE}/icons`, 'image', 'Icons', 'Browse the icons the icon picker offers.')}
80
+ ${card(`${u}/advisor`, 'check', tx('util.c_advisor'), tx('util.plans_every_sql_statement_as'))}
81
+ ${card(`${u}/utilities/dependencies`, 'database', tx('util.c_deps'), tx('util.the_tables_views_sequences_and'))}
82
+ ${card(`${u}/utilities/history`, 'history', tx('util.c_history'), tx('util.who_changed_which_component_and'))}
83
+ ${card(`${u}/utilities/upgrade`, 'refresh', tx('util.c_upgrade'), tx('util.components_that_can_use_a'))}
84
+ ${card(`${BASE}/ui-defaults`, 'sliders-horizontal', tx('util.c_ui_defaults'), tx('util.labels_format_masks_help_and'))}
85
+ ${card(`${u}/search`, 'search', tx('util.c_search'), tx('util.names_sql_conditions_settings_and'))}
86
+ ${card(`${u}/debug`, 'list', tx('util.c_debug'), tx('util.timed_steps_of_each_request'))}
87
+ ${card(`${u}/activity`, 'activity', tx('util.c_activity'), tx('util.page_views_errors_sign_ins'))}
88
+ ${card(`${u}/backups`, 'archive', tx('util.c_backups'), tx('util.back_up_now_download_and'))}
89
+ ${card(`${u}/working-copies`, 'layers', tx('util.c_wc'), tx('util.change_a_copy_compare_it'))}
90
+ ${card(`${u}/export`, 'download', tx('util.c_export'), tx('util.export_text', { sum: shortSum(sum) }))}
91
+ ${card(`${BASE}/icons`, 'image', tx('util.c_icons'), tx('util.browse_the_icons_the_icon'))}
91
92
  </div>
92
- ${region('Application dashboard', html`<dl class="util-stats">
93
- <div><dt>Pages</dt><dd>${counts.pages}</dd></div><div><dt>Regions</dt><dd>${counts.regions}</dd></div>
94
- <div><dt>Items</dt><dd>${counts.items}</dd></div><div><dt>Processes</dt><dd>${counts.processes}</dd></div>
95
- <div><dt>Lists of values</dt><dd>${counts.lovs}</dd></div><div><dt>Changes in 7 days</dt><dd>${counts.week}</dd></div>
93
+ ${region(tx('util.application_dashboard'), html`<dl class="util-stats">
94
+ <div><dt>${tx('util.pages')}</dt><dd>${counts.pages}</dd></div><div><dt>${tx('util.regions')}</dt><dd>${counts.regions}</dd></div>
95
+ <div><dt>${tx('util.items')}</dt><dd>${counts.items}</dd></div><div><dt>${tx('util.processes')}</dt><dd>${counts.processes}</dd></div>
96
+ <div><dt>${tx('util.lists_of_values')}</dt><dd>${counts.lovs}</dd></div><div><dt>${tx('util.changes_in_7_days')}</dt><dd>${counts.week}</dd></div>
96
97
  </dl>`)}
97
- ${region('Recently updated pages', recent.length
98
- ? html`<div class="table-wrap"><table class="table report-reflow"><thead><tr><th scope="col">Page</th><th scope="col">Last change</th><th scope="col">By</th><th scope="col">Changes</th></tr></thead><tbody>
99
- ${recent.map((r) => html`<tr><td data-label="Page"><a href="${designerUrl(a.id, r.page_no)}">${r.page_no}${pageName.has(r.page_no) ? `: ${pageName.get(r.page_no)}` : ''}</a></td><td data-label="Last change">${when(r.changed_at)}</td>
100
- <td data-label="By">${r.changed_by ?? 'command line'}</td><td data-label="Changes"><a href="${u}/utilities/history?page=${r.page_no}">${r.n}</a></td></tr>`)}</tbody></table></div>`
101
- : html`<p class="muted u-mt0">No recorded changes yet: the change history starts with the first change after the upgrade to this version.</p>`)}
102
- ${region('Application checksum', html`<p class="u-mt0">The checksum of the application's definition (its export without the alias): the same definition gives the same checksum on any installation, so two installations, or an application and its working copy, compare at a glance.</p>
98
+ ${region(tx('util.recently_updated_pages'), recent.length
99
+ ? html`<div class="table-wrap"><table class="table report-reflow"><thead><tr><th scope="col">${tx('util.page')}</th><th scope="col">${tx('util.last_change')}</th><th scope="col">${tx('util.by')}</th><th scope="col">${tx('util.changes')}</th></tr></thead><tbody>
100
+ ${recent.map((r) => html`<tr><td data-label="${tx('util.page')}"><a href="${designerUrl(a.id, r.page_no)}">${r.page_no}${pageName.has(r.page_no) ? `: ${pageName.get(r.page_no)}` : ''}</a></td><td data-label="${tx('util.last_change')}">${when(r.changed_at)}</td>
101
+ <td data-label="${tx('util.by')}">${r.changed_by ?? 'command line'}</td><td data-label="${tx('util.changes')}"><a href="${u}/utilities/history?page=${r.page_no}">${r.n}</a></td></tr>`)}</tbody></table></div>`
102
+ : html`<p class="muted u-mt0">${tx('util.no_recorded_changes_yet_the')}</p>`)}
103
+ ${region(tx('util.application_checksum'), html`<p class="u-mt0">${tx('util.the_checksum_of_the_application')}</p>
103
104
  <p class="util-sum"><code>${sum}</code></p>
104
- <details><summary>Page checksums (${pageSums.size})</summary><div class="table-wrap"><table class="table report-reflow"><thead><tr><th scope="col">Page</th><th scope="col">Checksum</th></tr></thead><tbody>
105
- ${[...pageSums].map(([p, h]) => html`<tr><td data-label="Page">${p}${pageName.has(p) ? `: ${pageName.get(p)}` : ''}</td><td data-label="Checksum"><code>${shortSum(h)}</code></td></tr>`)}</tbody></table></div></details>`, '', 'checksum')}`;
106
- return send(reply, s, shell(s, `Utilities · ${a.name}`, [['App Builder', BASE], [a.name, u], ['Utilities']], main));
105
+ <details><summary>Page checksums (${pageSums.size})</summary><div class="table-wrap"><table class="table report-reflow"><thead><tr><th scope="col">${tx('util.page')}</th><th scope="col">${tx('util.checksum')}</th></tr></thead><tbody>
106
+ ${[...pageSums].map(([p, h]) => html`<tr><td data-label="${tx('util.page')}">${p}${pageName.has(p) ? `: ${pageName.get(p)}` : ''}</td><td data-label="${tx('util.checksum')}"><code>${shortSum(h)}</code></td></tr>`)}</tbody></table></div></details>`, '', 'checksum')}`;
107
+ return send(reply, s, shell(s, tx('util.title', { app: a.name }), [[tx('util.app_builder'), BASE], [a.name, u], [tx('util.utilities')]], main));
107
108
  });
108
109
 
109
110
  // ---------------------------------------------------------------- change history
@@ -141,23 +142,23 @@ export async function utilityRoutes(app: FastifyInstance) {
141
142
  const link = (id: string) => `?${new URLSearchParams([...keep, ['c', id]])}#change`;
142
143
  const OPS: Record<string, string> = { added: 'tag-info', changed: 'tag-warning', deleted: 'tag-error' };
143
144
  const main = html`${appHeader(a, 'utilities', s)}
144
- ${region('Change history', html`<p class="muted u-mt0">Every change made in the builder (and every import, restore or merge) is compared with the application before it: each component that was added, changed or deleted is listed here with who and when. Changes from the command line or the MCP server have no developer.</p>
145
+ ${region(tx('util.change_history'), html`<p class="muted u-mt0">${tx('util.every_change_made_in_the')}</p>
145
146
  <form method="get" class="util-filters">
146
- ${select('dev', 'Developer', dev, [['', 'All'], ...devs.map((d): [string, string] => (d === null ? ['(command line)', 'Command line / import'] : [d, d]))])}
147
- <div class="field"><label class="label" for="f_from">From</label><input id="f_from" name="from" type="date" value="${from}"></div>
148
- <div class="field"><label class="label" for="f_to">To</label><input id="f_to" name="to" type="date" value="${to}"></div>
149
- <div class="field"><label class="label" for="f_page">Page</label><input id="f_page" name="page" type="number" value="${page ?? ''}"></div>
150
- <div class="field"><label class="label" for="f_q">Component</label><input id="f_q" name="q" type="search" value="${text}" placeholder="e.g. P3_ID"></div>
151
- <div class="buttons"><button class="btn btn-sm">${icon('filter')} Show</button></div></form>
147
+ ${select('dev', tx('util.developer'), dev, [['', tx('util.all')], ...devs.map((d): [string, string] => (d === null ? ['(command line)', tx('util.cmd_line')] : [d, d]))])}
148
+ <div class="field"><label class="label" for="f_from">${tx('util.from')}</label><input id="f_from" name="from" type="date" value="${from}"></div>
149
+ <div class="field"><label class="label" for="f_to">${tx('util.to')}</label><input id="f_to" name="to" type="date" value="${to}"></div>
150
+ <div class="field"><label class="label" for="f_page">${tx('util.page')}</label><input id="f_page" name="page" type="number" value="${page ?? ''}"></div>
151
+ <div class="field"><label class="label" for="f_q">${tx('util.component')}</label><input id="f_q" name="q" type="search" value="${text}" placeholder="${tx('util.e_g_p3_id')}"></div>
152
+ <div class="buttons"><button class="btn btn-sm">${icon('filter')} ${tx('util.show')}</button></div></form>
152
153
  ${rows.length
153
- ? html`<div class="table-wrap"><table class="table util-history report-reflow"><thead><tr><th scope="col">When</th><th scope="col">Developer</th><th scope="col">Component</th><th scope="col">Change</th><th scope="col">Differences</th></tr></thead><tbody>
154
- ${rows.map((r) => html`<tr${r.id === shown ? raw(' aria-current="true"') : ''}><td data-label="When">${when(r.changed_at)}</td><td data-label="Developer">${r.changed_by ?? html`<span class="muted">${r.source === 'import' ? 'import' : 'command line'}</span>`}</td>
155
- <td data-label="Component"><a href="${componentUrl(a.id, r.page_no)}">${r.label}</a></td>
156
- <td data-label="Change"><span class="tag ${OPS[r.op] ?? ''}">${r.op}</span>${r.source !== 'builder' ? html` <span class="tag">${r.source}</span>` : ''}</td>
157
- <td data-label="Differences"><a class="btn btn-sm" href="${link(r.id)}">Show</a></td></tr>`)}</tbody></table></div>`
158
- : html`<p class="muted">No changes${where.length > 1 ? ' with these filters' : ' recorded yet'}.</p>`}`)}
159
- ${detail ? region(`Differences: ${detail.label}`, html`<p class="muted u-mt0">${detail.op} by ${detail.changed_by ?? 'the command line'}, ${when(detail.changed_at)}. Lines with <code>-</code> are before the change, lines with <code>+</code> after it.</p>${changeDiff(detail.before, detail.after)}`, '', 'change') : ''}`;
160
- return send(reply, s, shell(s, `Change history · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Utilities', `${BASE}/apps/${a.id}/utilities`], ['Change history']], main));
154
+ ? html`<div class="table-wrap"><table class="table util-history report-reflow"><thead><tr><th scope="col">${tx('util.when')}</th><th scope="col">${tx('util.developer')}</th><th scope="col">${tx('util.component')}</th><th scope="col">${tx('util.change')}</th><th scope="col">${tx('util.differences')}</th></tr></thead><tbody>
155
+ ${rows.map((r) => html`<tr${r.id === shown ? raw(' aria-current="true"') : ''}><td data-label="${tx('util.when')}">${when(r.changed_at)}</td><td data-label="${tx('util.developer')}">${r.changed_by ?? html`<span class="muted">${r.source === 'import' ? tx('util.import') : tx('util.command_line')}</span>`}</td>
156
+ <td data-label="${tx('util.component')}"><a href="${componentUrl(a.id, r.page_no)}">${r.label}</a></td>
157
+ <td data-label="${tx('util.change')}"><span class="tag ${OPS[r.op] ?? ''}">${tx.dyn(`util.op.${r.op}`, r.op)}</span>${r.source !== 'builder' ? html` <span class="tag">${r.source}</span>` : ''}</td>
158
+ <td data-label="${tx('util.differences')}"><a class="btn btn-sm" href="${link(r.id)}">${tx('util.show')}</a></td></tr>`)}</tbody></table></div>`
159
+ : html`<p class="muted">${where.length > 1 ? tx('util.no_changes_filtered') : tx('util.no_changes')}</p>`}`)}
160
+ ${detail ? region(tx('util.diff_title', { label: detail.label }), html`<p class="muted u-mt0">${tx.rich('util.diff_intro', { op: tx.dyn(`util.op.${detail.op}`, detail.op), who: detail.changed_by ?? tx('util.the_command_line'), when: when(detail.changed_at), minus: html`<code>-</code>`, plus: html`<code>+</code>` })}</p>${changeDiff(detail.before, detail.after)}`, '', 'change') : ''}`;
161
+ return send(reply, s, shell(s, tx('util.history_title', { app: a.name }), [[tx('util.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('util.utilities'), `${BASE}/apps/${a.id}/utilities`], [tx('util.change_history')]], main));
161
162
  });
162
163
 
163
164
  // ---------------------------------------------------------------- icons
@@ -169,13 +170,13 @@ export async function utilityRoutes(app: FastifyInstance) {
169
170
  const own = (ICONS as readonly string[]).filter((n) => !needle || n.includes(needle));
170
171
  const lucide = needle ? searchIcons(q, 300) : [];
171
172
  const tile = (n: string) => html`<li><span class="icon-tile">${icon(n, 'icon icon-2x')}<code>${n}</code></span></li>`;
172
- const main = html`<h1 class="u-mb1">Icons</h1>
173
- ${region('Icon browser', html`<p class="muted u-mt0">The icons of the icon picker: pgkiln's own set and ${String(lucideNames().size)} Lucide icons (${LUCIDE_VERSION}, ISC licence). Use the name in an icon field; modifiers such as <code>lg</code>, <code>spin</code> or <code>success</code> may follow it (e.g. <code>truck lg success</code>), and Font APEX names work where Lucide has the same icon (<code>fa-users</code>).</p>
174
- <form method="get" role="search" class="util-filters"><div class="field"><label class="label" for="f_icon_q">Search</label><input id="f_icon_q" name="q" type="search" value="${q}" placeholder="e.g. truck, money, arrow"></div>
175
- <div class="buttons"><button class="btn btn-sm">${icon('search')} Search</button></div></form>
173
+ const main = html`<h1 class="u-mb1">${tx('util.icons')}</h1>
174
+ ${region(tx('util.icon_browser'), html`<p class="muted u-mt0">${tx.rich('util.icons_intro', { n: String(lucideNames().size), version: LUCIDE_VERSION, lg: html`<code>lg</code>`, spin: html`<code>spin</code>`, success: html`<code>success</code>`, example: html`<code>truck lg success</code>`, fa: html`<code>fa-users</code>` })}</p>
175
+ <form method="get" role="search" class="util-filters"><div class="field"><label class="label" for="f_icon_q">${tx('util.search')}</label><input id="f_icon_q" name="q" type="search" value="${q}" placeholder="${tx('util.e_g_truck_money_arrow')}"></div>
176
+ <div class="buttons"><button class="btn btn-sm">${icon('search')} ${tx('util.search')}</button></div></form>
176
177
  <h3>pgkiln (${own.length})</h3><ul class="util-icons">${own.map(tile)}</ul>
177
- ${needle ? html`<h3>Lucide (${lucide.length}${lucide.length === 300 ? '+' : ''})</h3>${lucide.length ? html`<ul class="util-icons">${lucide.map(tile)}</ul>` : html`<p class="muted">No Lucide icon matches.</p>`}`
178
- : html`<p class="muted">Search to see the matching Lucide icons.</p>`}`)}`;
179
- return send(reply, s, shell(s, 'Icons', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Icons']], main));
178
+ ${needle ? html`<h3>Lucide (${lucide.length}${lucide.length === 300 ? '+' : ''})</h3>${lucide.length ? html`<ul class="util-icons">${lucide.map(tile)}</ul>` : html`<p class="muted">${tx('util.no_lucide_icon_matches')}</p>`}`
179
+ : html`<p class="muted">${tx('util.search_to_see_the_matching')}</p>`}`)}`;
180
+ return send(reply, s, shell(s, tx('util.icons'), [[tx('util.app_builder'), BASE], [tx('util.workspace_utilities'), `${BASE}/utilities`], [tx('util.icons')]], main));
180
181
  });
181
182
  }
@@ -13,6 +13,7 @@ import { appOr404 } from './forms.ts';
13
13
  import { isAdmin } from './locks.ts';
14
14
  import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
15
15
  import { currentWorkspace, inWorkspaceSql } from './workspaces.ts';
16
+ import { scopedTx as tx } from './i18n.ts';
16
17
 
17
18
  // (sprint 136, AP-1; APEX: Workspace Utilities and Shared Components → Vector Providers)
18
19
  //
@@ -29,20 +30,20 @@ type Body = Record<string, string | string[] | undefined>;
29
30
  const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? '';
30
31
 
31
32
  const crumbs = (a: { id: number; name: string }, last?: string): [string, string?][] => [
32
- ['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`],
33
- ...(last ? [['Vector providers', `${BASE}/apps/${a.id}/vector-providers`], [last]] as [string, string?][] : [['Vector providers']] as [string, string?][]),
33
+ [tx('vp.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('vp.shared_components'), `${BASE}/apps/${a.id}/shared`],
34
+ ...(last ? [[tx('vp.vector_providers'), `${BASE}/apps/${a.id}/vector-providers`], [last]] as [string, string?][] : [[tx('vp.vector_providers')]] as [string, string?][]),
34
35
  ];
35
36
 
36
37
  /** pgvector's state as a short paragraph (with how to get it). */
37
38
  export function pgvectorNote(st: PgvectorStatus, admin: boolean, s: Session): Raw {
38
- if (st.installed) return html`<p class="muted u-mt0">${icon('check')} pgvector ${st.version} is installed (schema <code>${st.schema}</code>).</p>`;
39
- return html`<div class="alert alert-info" role="status"><p><strong>pgvector is not installed in this database.</strong>
40
- Vector providers can be defined, but vector columns, vector search and Retrieve tools need the extension.</p>
39
+ if (st.installed) return html`<p class="muted u-mt0">${icon('check')} ${tx.rich('vp.installed', { version: st.version ?? '', schema: html`<code>${st.schema}</code>` })}</p>`;
40
+ return html`<div class="alert alert-info" role="status"><p><strong>${tx('vp.pgvector_is_not_installed_in')}</strong>
41
+ ${tx('vp.not_installed_detail')}</p>
41
42
  ${st.available
42
43
  ? admin && st.canCreate
43
- ? html`<form method="post" action="${BASE}/vector-providers/extension">${csrf(s)}<button class="btn btn-hot">${icon('plus')} Create extension vector ${st.availableVersion}</button></form>`
44
- : html`<p>The database server has pgvector ${st.availableVersion}: ${admin ? 'a superuser' : 'an administrator'} runs <code>create extension vector;</code> in this database.</p>`
45
- : html`<p>The database server doesn't have the pgvector package: install it (for example <code>apt install postgresql-17-pgvector</code>, the <code>pgvector/pgvector:pg17</code> Docker image, or your cloud provider's <i>vector</i> extension), then create the extension.</p>`}
44
+ ? html`<form method="post" action="${BASE}/vector-providers/extension">${csrf(s)}<button class="btn btn-hot">${icon('plus')} ${tx('vp.create_extension', { version: st.availableVersion ?? '' })}</button></form>`
45
+ : html`<p>${tx.rich('vp.has_pgvector', { version: st.availableVersion ?? '', who: admin ? tx('vp.a_superuser') : tx('vp.an_administrator'), sql: html`<code>create extension vector;</code>` })}</p>`
46
+ : html`<p>${tx.rich('vp.no_package', { apt: html`<code>apt install postgresql-17-pgvector</code>`, docker: html`<code>pgvector/pgvector:pg17</code>`, vector: html`<i>vector</i>` })}</p>`}
46
47
  </div>`;
47
48
  }
48
49
 
@@ -64,60 +65,60 @@ async function providerForm(a: any, s: Session, x: Partial<VectorProvider>, orig
64
65
  const columns = original ? (await vectorTriggers(await devDbForApp(a.id))).filter((t) => t.appId === a.id && t.provider === original) : [];
65
66
  return html`${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
66
67
  <form method="post" action="${BASE}/apps/${a.id}/vector-providers/provider">${csrf(s)}<input type="hidden" name="original" value="${original}">
67
- <fieldset class="prop-group"><legend>Vector provider</legend><div class="form-grid">
68
- ${input('name', 'Name (static ID)', x.name ?? '', { required: true, placeholder: 'e.g. DOC_EMBEDDINGS', help: 'Upper case letters, digits and _. Search configurations name it; in SQL: meta.embed(text, \'DOC_EMBEDDINGS\') (function and database model providers).' })}
69
- ${input('description', 'Description', x.description ?? '')}
70
- ${select('type', 'Type', x.type ?? 'ai_service', [['ai_service', 'AI service with an embedding model'], ['database_model', 'Model in the database (PostgresML)'], ['function', 'SQL or PL/pgSQL function']], 'Where the embeddings come from.')}
71
- ${input('dimensions', 'Dimensions', x.dimensions ?? '', { type: 'number', required: true, placeholder: 'e.g. 1536', help: `The length of each vector: text-embedding-3-small 1536 (or fewer: OpenAI shortens them), text-embedding-3-large 3072, nomic-embed-text 768, gemini-embedding-001 3072. A vector column is vector(n) with this n; an HNSW index takes up to ${HNSW_MAX_DIMENSIONS}.` })}
68
+ <fieldset class="prop-group"><legend>${tx('vp.vector_provider')}</legend><div class="form-grid">
69
+ ${input('name', tx('vp.name_static_id'), x.name ?? '', { required: true, placeholder: tx('vp.e_g_doc_embeddings'), help: tx('vp.upper_case_letters_digits_and') })}
70
+ ${input('description', tx('vp.description'), x.description ?? '')}
71
+ ${select('type', tx('vp.type'), x.type ?? 'ai_service', [['ai_service', tx('vp.ai_service_with_an_embedding')], ['database_model', tx('vp.model_in_the_database_postgresml')], ['function', tx('vp.sql_or_pl_pgsql_function')]], tx('vp.where_the_embeddings_come_from'))}
72
+ ${input('dimensions', tx('vp.dimensions'), x.dimensions ?? '', { type: 'number', required: true, placeholder: tx('vp.eg_1536'), help: tx('vp.dimensions_help', { max: HNSW_MAX_DIMENSIONS }) })}
72
73
  </div></fieldset>
73
- <fieldset class="prop-group"><legend>AI service</legend><div class="form-grid">
74
- ${services.length ? '' : html`<p class="muted" data-wide>This application may not use an OpenAI, Gemini or OpenAI-compatible AI service yet: an administrator adds one under <a href="${BASE}/ai">Workspace utilities → AI services</a> (Claude has no embeddings) and allows this application to use it.</p>`}
75
- ${select('service', 'AI service', x.service ?? '', svc, 'Its key, base URL and daily limits apply; the texts to embed go to its provider.')}
76
- ${input('model', 'Embedding model', x.model ?? '', { placeholder: 'e.g. text-embedding-3-small', help: 'Exactly as the provider names it: text-embedding-3-small, gemini-embedding-001, nomic-embed-text, mxbai-embed-large …' })}
77
- ${input('batch_size', 'Texts per request', x.batch_size ?? '', { type: 'number', placeholder: String(DEFAULT_BATCH), help: 'When the server embeds queued rows (vector columns).' })}
78
- ${input('per_minute', 'Requests per minute', x.per_minute ?? '', { type: 'number', placeholder: String(DEFAULT_PER_MINUTE), help: 'At most this many background requests per minute (per server), under the provider\'s rate limit.' })}
74
+ <fieldset class="prop-group"><legend>${tx('vp.ai_service')}</legend><div class="form-grid">
75
+ ${services.length ? '' : html`<p class="muted" data-wide>${tx.rich('vp.no_service', { link: html`<a href="${BASE}/ai">${tx('vp.workspace_utilities_ai_services')}</a>` })}</p>`}
76
+ ${select('service', tx('vp.ai_service'), x.service ?? '', svc, tx('vp.its_key_base_url_and'))}
77
+ ${input('model', tx('vp.embedding_model'), x.model ?? '', { placeholder: tx('vp.e_g_text_embedding_3'), help: tx('vp.exactly_as_the_provider_names') })}
78
+ ${input('batch_size', tx('vp.texts_per_request'), x.batch_size ?? '', { type: 'number', placeholder: String(DEFAULT_BATCH), help: tx('vp.when_the_server_embeds_queued') })}
79
+ ${input('per_minute', tx('vp.requests_per_minute'), x.per_minute ?? '', { type: 'number', placeholder: String(DEFAULT_PER_MINUTE), help: tx('vp.at_most_this_many_background') })}
79
80
  </div></fieldset>
80
- <fieldset class="prop-group"><legend>Model in the database</legend><div class="form-grid">
81
- ${pgml ? '' : html`<p class="muted" data-wide>PostgresML (<code>pgml</code>) is not installed in this database: install it on the database server, then <code>create extension pgml;</code>. It runs Hugging Face models (ONNX among them) inside PostgreSQL.</p>`}
82
- ${select('engine', 'Engine', x.engine ?? 'pgml', MODEL_ENGINES.map((e): [string, string] => [e, e === 'pgml' ? 'PostgresML: pgml.embed(model, text)' : e]), 'The extension that runs the model. Nothing leaves the database; grant usage on its schema (pgml) to the application\'s role.')}
83
- ${input('db_model', 'Model', x.type === 'database_model' ? (x.model ?? '') : '', { placeholder: 'e.g. intfloat/e5-small-v2', help: 'A model the engine loads (the first call downloads it): intfloat/e5-small-v2 (384), Alibaba-NLP/gte-base-en-v1.5 (768), mixedbread-ai/mxbai-embed-large-v1 (1024) …' })}
81
+ <fieldset class="prop-group"><legend>${tx('vp.model_in_the_database')}</legend><div class="form-grid">
82
+ ${pgml ? '' : html`<p class="muted" data-wide>${tx.rich('vp.no_pgml', { pgml: html`<code>pgml</code>`, sql: html`<code>create extension pgml;</code>` })}</p>`}
83
+ ${select('engine', tx('vp.engine'), x.engine ?? 'pgml', MODEL_ENGINES.map((e): [string, string] => [e, e === 'pgml' ? tx('vp.postgresml_pgml_embed_model_text') : e]), tx('vp.the_extension_that_runs_the'))}
84
+ ${input('db_model', tx('vp.model'), x.type === 'database_model' ? (x.model ?? '') : '', { placeholder: tx('vp.e_g_intfloat_e5_small'), help: tx('vp.a_model_the_engine_loads') })}
84
85
  </div></fieldset>
85
- <fieldset class="prop-group"><legend>Function</legend><div class="form-grid">
86
- ${input('function', 'Function', x.function ?? '', { placeholder: 'e.g. sales.embed', help: 'schema.name of a function (text) returning vector, real[] or float8[]. meta.embed calls it as the role that runs the query (its grants apply): grant execute on it to the application\'s role.' })}
86
+ <fieldset class="prop-group"><legend>${tx('vp.function')}</legend><div class="form-grid">
87
+ ${input('function', tx('vp.function'), x.function ?? '', { placeholder: tx('vp.e_g_sales_embed'), help: tx('vp.schema_name_of_a_function') })}
87
88
  </div></fieldset>
88
- <div class="buttons"><button class="btn btn-hot">${original ? 'Save' : 'Add vector provider'}</button></div>
89
+ <div class="buttons"><button class="btn btn-hot">${original ? tx('vp.save') : tx('vp.add_vector_provider')}</button></div>
89
90
  </form>
90
91
  ${original ? html`
91
92
  <form method="post" action="${BASE}/apps/${a.id}/vector-providers/test" class="u-mt1">${csrf(s)}<input type="hidden" name="name" value="${original}">
92
- <button class="btn">${icon('play')} Test: embed a sample text</button></form>
93
- <h3>Used by</h3>
93
+ <button class="btn">${icon('play')} ${tx('vp.test_embed_a_sample_text')}</button></form>
94
+ <h3>${tx('vp.used_by')}</h3>
94
95
  ${configs.length || columns.length ? html`<ul>
95
- ${configs.map((c) => html`<li>Search configuration <a href="${BASE}/apps/${a.id}/shared?c=search_config-${c.id}">${c.name}</a> (${c.search_type})</li>`)}
96
- ${columns.map((c) => html`<li>Vector column <a href="${BASE}/sql/objects/vector?${new URLSearchParams({ o: c.table })}"><code>${c.table}.${c.vectorColumn}</code></a> (${c.mode === 'set' ? 'set by a trigger' : 'queued for the server'})</li>`)}
97
- </ul>` : html`<p class="muted">No search configuration or vector column uses this provider yet.</p>`}
96
+ ${configs.map((c) => html`<li>${tx.rich('vp.used_config', { link: html`<a href="${BASE}/apps/${a.id}/shared?c=search_config-${c.id}">${c.name}</a>`, type: c.search_type })}</li>`)}
97
+ ${columns.map((c) => html`<li>${tx.rich('vp.used_column', { link: html`<a href="${BASE}/sql/objects/vector?${new URLSearchParams({ o: c.table })}"><code>${c.table}.${c.vectorColumn}</code></a>`, mode: c.mode === 'set' ? tx('vp.set_by_trigger') : tx('vp.queued_server') })}</li>`)}
98
+ </ul>` : html`<p class="muted">${tx('vp.no_search_configuration_or_vector')}</p>`}
98
99
  <form method="post" action="${BASE}/apps/${a.id}/vector-providers/provider/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="name" value="${original}">
99
- <button class="btn btn-danger" data-confirm="Delete vector provider ${original}? Search configurations and vector columns that use it stop working.">Delete vector provider</button></form>` : ''}`;
100
+ <button class="btn btn-danger" data-confirm="${tx('vp.delete_confirm', { name: original })}">${tx('vp.delete_vector_provider')}</button></form>` : ''}`;
100
101
  }
101
102
 
102
103
  const intOrUndef = (v: string, what: string, errors: string[]) => {
103
104
  const t = v.trim();
104
105
  if (!t) return undefined;
105
106
  const n = Number(t);
106
- if (!Number.isInteger(n)) errors.push(`${what} is a whole number.`);
107
+ if (!Number.isInteger(n)) errors.push(tx('vp.whole_number', { what }));
107
108
  return n;
108
109
  };
109
110
 
110
111
  function providerFrom(b: Body, errors: string[]): VectorProvider {
111
112
  const type = one(b.type) as VectorProvider['type'];
112
- const x: VectorProvider = { name: one(b.name).trim().toUpperCase(), type, dimensions: intOrUndef(one(b.dimensions), 'Dimensions', errors) ?? 0 };
113
+ const x: VectorProvider = { name: one(b.name).trim().toUpperCase(), type, dimensions: intOrUndef(one(b.dimensions), tx('vp.f_dimensions'), errors) ?? 0 };
113
114
  const description = one(b.description).trim().slice(0, 500);
114
115
  if (description) x.description = description;
115
116
  if (type === 'ai_service') {
116
117
  x.service = one(b.service).trim().toUpperCase();
117
118
  x.model = one(b.model).trim();
118
- const batch = intOrUndef(one(b.batch_size), 'Texts per request', errors);
119
+ const batch = intOrUndef(one(b.batch_size), tx('vp.f_batch'), errors);
119
120
  if (batch !== undefined) x.batch_size = batch;
120
- const pm = intOrUndef(one(b.per_minute), 'Requests per minute', errors);
121
+ const pm = intOrUndef(one(b.per_minute), tx('vp.f_per_minute'), errors);
121
122
  if (pm !== undefined) x.per_minute = pm;
122
123
  }
123
124
  if (type === 'function') x.function = one(b.function).trim();
@@ -159,16 +160,16 @@ export async function vectorProviderRoutes(app: FastifyInstance) {
159
160
  const list = (a.vector_providers ?? []) as VectorProvider[];
160
161
  const st = await pgvectorStatus(await devDbForApp(a.id));
161
162
  const main = html`${appHeader(a, 'shared', s)}
162
- <div class="title-row"><h1>Vector providers</h1></div>
163
- <p class="muted u-mt0">A vector provider turns text into an embedding: a list of numbers whose distance says how close two texts are in meaning. Vector columns keep the embeddings of a table's rows (SQL Workshop › Table Properties › Add vector column), and search configurations of type vector or hybrid, and the Retrieve tools of AI agents, find rows by meaning. The texts go to the provider's AI service.</p>
163
+ <div class="title-row"><h1>${tx('vp.vector_providers')}</h1></div>
164
+ <p class="muted u-mt0">${tx('vp.a_vector_provider_turns_text')}</p>
164
165
  ${pgvectorNote(st, await isAdmin(s.username), s)}
165
- ${region('Providers', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Name</th><th>Type</th><th>Model or function</th><th class="num">Dimensions</th></tr></thead>
166
- <tbody>${list.length ? list.map((p) => html`<tr><td data-label="Name"><a href="${BASE}/apps/${a.id}/vector-providers/provider?name=${encodeURIComponent(p.name)}">${p.name}</a>${p.description ? html` <span class="muted">${p.description}</span>` : ''}</td>
167
- <td data-label="Type">${p.type === 'ai_service' ? html`AI service ${p.service}` : p.type === 'database_model' ? `in the database (${p.engine})` : 'function'}</td><td data-label="Model or function"><code>${p.type === 'function' ? p.function : p.model}</code></td>
168
- <td class="num" data-label="Dimensions">${p.dimensions}</td></tr>`)
169
- : html`<tr><td colspan="4" class="empty">No vector providers yet.</td></tr>`}</tbody></table></div>
170
- <p><a class="btn" href="${BASE}/apps/${a.id}/vector-providers/provider">${icon('plus')} Add vector provider</a></p>`)}`;
171
- return send(reply, s, shell(s, 'Vector providers', crumbs(a), main));
166
+ ${region(tx('vp.providers'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('vp.name')}</th><th>${tx('vp.type')}</th><th>${tx('vp.model_or_function')}</th><th class="num">${tx('vp.dimensions')}</th></tr></thead>
167
+ <tbody>${list.length ? list.map((p) => html`<tr><td data-label="${tx('vp.name')}"><a href="${BASE}/apps/${a.id}/vector-providers/provider?name=${encodeURIComponent(p.name)}">${p.name}</a>${p.description ? html` <span class="muted">${p.description}</span>` : ''}</td>
168
+ <td data-label="${tx('vp.type')}">${p.type === 'ai_service' ? html`AI service ${p.service}` : p.type === 'database_model' ? `in the database (${p.engine})` : 'function'}</td><td data-label="${tx('vp.model_or_function')}"><code>${p.type === 'function' ? p.function : p.model}</code></td>
169
+ <td class="num" data-label="${tx('vp.dimensions')}">${p.dimensions}</td></tr>`)
170
+ : html`<tr><td colspan="4" class="empty">${tx('vp.no_vector_providers_yet')}</td></tr>`}</tbody></table></div>
171
+ <p><a class="btn" href="${BASE}/apps/${a.id}/vector-providers/provider">${icon('plus')} ${tx('vp.add_vector_provider')}</a></p>`)}`;
172
+ return send(reply, s, shell(s, tx('vp.vector_providers'), crumbs(a), main));
172
173
  });
173
174
 
174
175
  app.get(`${BASE}/apps/:id(^\\d+$)/vector-providers/provider`, async (req: Req, reply) => {
@@ -179,7 +180,7 @@ export async function vectorProviderRoutes(app: FastifyInstance) {
179
180
  const x = name ? ((a.vector_providers ?? []) as VectorProvider[]).find((p) => p.name === name) : {};
180
181
  if (!x) return reply.code(404).send('Not found');
181
182
  const title = name || 'New vector provider';
182
- const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${title}</h1></div>${region('Vector provider', await providerForm(a, s, x, name, []))}`;
183
+ const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${title}</h1></div>${region(tx('vp.vector_provider'), await providerForm(a, s, x, name, []))}`;
183
184
  return send(reply, s, shell(s, title, crumbs(a, title), main));
184
185
  });
185
186
 
@@ -194,20 +195,20 @@ export async function vectorProviderRoutes(app: FastifyInstance) {
194
195
  const problems = errors.length ? errors : await update(a.id, (list) => {
195
196
  const out = vectorProviderProblems(x);
196
197
  const at = original ? list.findIndex((p) => p.name === original) : -1;
197
- if (original && at < 0) out.push(`Vector provider ${original} no longer exists.`);
198
- if (list.some((p, i) => p.name === x.name && i !== at)) out.push(`A vector provider ${x.name} exists already.`);
198
+ if (original && at < 0) out.push(tx('vp.gone', { name: original }));
199
+ if (list.some((p, i) => p.name === x.name && i !== at)) out.push(tx('vp.exists', { name: x.name }));
199
200
  if (out.length) return out;
200
201
  if (at >= 0) list[at] = x;
201
202
  else list.push(x);
202
203
  return [];
203
204
  });
204
205
  if (problems.length) {
205
- const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${original || 'New vector provider'}</h1></div>${region('Vector provider', await providerForm(a, s, x, original, problems))}`;
206
+ const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${original || 'New vector provider'}</h1></div>${region(tx('vp.vector_provider'), await providerForm(a, s, x, original, problems))}`;
206
207
  return send(reply.code(422), s, shell(s, original || 'New vector provider', crumbs(a, original || 'New vector provider'), main));
207
208
  }
208
209
  // a renamed provider keeps its search configurations
209
210
  if (original && original !== x.name) await owner.query('update meta.search_config set vector_provider = $3 where app_id = $1 and vector_provider = $2', [a.id, original, x.name]);
210
- flash(s, 'Vector provider saved.');
211
+ flash(s, tx('vp.vector_provider_saved'));
211
212
  return back(reply, s, `${BASE}/apps/${a.id}/vector-providers/provider?name=${encodeURIComponent(x.name)}`);
212
213
  });
213
214
 
@@ -221,7 +222,7 @@ export async function vectorProviderRoutes(app: FastifyInstance) {
221
222
  if (at >= 0) list.splice(at, 1);
222
223
  return [];
223
224
  });
224
- flash(s, `Vector provider ${name} deleted.`);
225
+ flash(s, tx('vp.deleted', { name }));
225
226
  return back(reply, s, `${BASE}/apps/${a.id}/vector-providers`);
226
227
  });
227
228
 
@@ -241,7 +242,7 @@ export async function vectorProviderRoutes(app: FastifyInstance) {
241
242
  }
242
243
  const t0 = performance.now();
243
244
  try {
244
- const [v] = await embedTexts(p, ['The quick brown fox jumps over the lazy dog.'], { appId: a.id, user: s.username, pageNo: null }, await devDbForApp(a.id));
245
+ const [v] = await embedTexts(p, [tx('vp.the_quick_brown_fox_jumps')], { appId: a.id, user: s.username, pageNo: null }, await devDbForApp(a.id));
245
246
  flash(s, `${name} answered in ${Math.round(performance.now() - t0)} ms with ${v.length} dimensions, as the provider says: [${v.slice(0, 4).map((n) => n.toFixed(4)).join(', ')}, …].`);
246
247
  } catch (e) {
247
248
  flash(s, `${name}: ${isAiError(e) ? e.message : (e as Error).message}`, 'error');
@@ -261,20 +262,20 @@ export async function vectorProviderRoutes(app: FastifyInstance) {
261
262
  const ids = new Set(apps.map((x) => x.id));
262
263
  const columns = st.installed ? (await vectorTriggers(owner)).filter((t) => ids.has(t.appId)) : [];
263
264
  const rows = apps.flatMap((x) => x.vector_providers.map((p) => ({ app: x, p })));
264
- const main = html`<div class="title-row"><h1>Vector providers</h1></div>
265
- <p class="muted u-mt0">The vector providers of this workspace's applications (each application defines its own under Shared Components → Vector providers), and the vector columns they keep current.</p>
265
+ const main = html`<div class="title-row"><h1>${tx('vp.vector_providers')}</h1></div>
266
+ <p class="muted u-mt0">${tx('vp.the_vector_providers_of_this')}</p>
266
267
  ${pgvectorNote(st, admin, s)}
267
- ${region('Providers', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Application</th><th>Name</th><th>Type</th><th>Model or function</th><th class="num">Dimensions</th></tr></thead>
268
- <tbody>${rows.length ? rows.map(({ app: x, p }) => html`<tr><td data-label="Application">${x.name}</td>
269
- <td data-label="Name"><a href="${BASE}/apps/${x.id}/vector-providers/provider?name=${encodeURIComponent(p.name)}">${p.name}</a></td>
270
- <td data-label="Type">${p.type === 'ai_service' ? html`AI service ${p.service}` : p.type === 'database_model' ? `in the database (${p.engine})` : 'function'}</td><td data-label="Model or function"><code>${p.type === 'function' ? p.function : p.model}</code></td>
271
- <td class="num" data-label="Dimensions">${p.dimensions}</td></tr>`)
272
- : html`<tr><td colspan="5" class="empty">No vector providers yet.</td></tr>`}</tbody></table></div>`)}
273
- ${region('Vector columns', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Column</th><th>Provider</th><th>Source columns</th><th>Kept by</th></tr></thead>
274
- <tbody>${columns.length ? columns.map((c) => html`<tr><td data-label="Column"><a href="${BASE}/sql/objects/vector?${new URLSearchParams({ o: c.table })}"><code>${c.table}.${c.vectorColumn}</code></a></td>
275
- <td data-label="Provider">${c.provider}</td><td data-label="Source columns">${c.sourceColumns.join(', ')}</td><td data-label="Kept by">${c.mode === 'set' ? 'a trigger' : 'the server\'s queue'}</td></tr>`)
276
- : html`<tr><td colspan="4" class="empty">No vector columns yet (SQL Workshop › Table Properties › Add vector column).</td></tr>`}</tbody></table></div>`)}`;
277
- return send(reply, s, shell(s, 'Vector providers', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Vector providers']], main));
268
+ ${region(tx('vp.providers'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('vp.application')}</th><th>${tx('vp.name')}</th><th>${tx('vp.type')}</th><th>${tx('vp.model_or_function')}</th><th class="num">${tx('vp.dimensions')}</th></tr></thead>
269
+ <tbody>${rows.length ? rows.map(({ app: x, p }) => html`<tr><td data-label="${tx('vp.application')}">${x.name}</td>
270
+ <td data-label="${tx('vp.name')}"><a href="${BASE}/apps/${x.id}/vector-providers/provider?name=${encodeURIComponent(p.name)}">${p.name}</a></td>
271
+ <td data-label="${tx('vp.type')}">${p.type === 'ai_service' ? html`AI service ${p.service}` : p.type === 'database_model' ? `in the database (${p.engine})` : 'function'}</td><td data-label="${tx('vp.model_or_function')}"><code>${p.type === 'function' ? p.function : p.model}</code></td>
272
+ <td class="num" data-label="${tx('vp.dimensions')}">${p.dimensions}</td></tr>`)
273
+ : html`<tr><td colspan="5" class="empty">${tx('vp.no_vector_providers_yet')}</td></tr>`}</tbody></table></div>`)}
274
+ ${region(tx('vp.vector_columns'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('vp.column')}</th><th>${tx('vp.provider')}</th><th>${tx('vp.source_columns')}</th><th>${tx('vp.kept_by')}</th></tr></thead>
275
+ <tbody>${columns.length ? columns.map((c) => html`<tr><td data-label="${tx('vp.column')}"><a href="${BASE}/sql/objects/vector?${new URLSearchParams({ o: c.table })}"><code>${c.table}.${c.vectorColumn}</code></a></td>
276
+ <td data-label="${tx('vp.provider')}">${c.provider}</td><td data-label="${tx('vp.source_columns')}">${c.sourceColumns.join(', ')}</td><td data-label="${tx('vp.kept_by')}">${c.mode === 'set' ? 'a trigger' : 'the server\'s queue'}</td></tr>`)
277
+ : html`<tr><td colspan="4" class="empty">${tx('vp.no_vector_columns_yet_sql')}</td></tr>`}</tbody></table></div>`)}`;
278
+ return send(reply, s, shell(s, tx('vp.vector_providers'), [[tx('vp.app_builder'), BASE], [tx('vp.workspace_utilities'), `${BASE}/utilities`], [tx('vp.vector_providers')]], main));
278
279
  });
279
280
 
280
281
  // administrators: create extension vector (pgvector is not a trusted extension: the owner connection must be a superuser)
@@ -285,7 +286,7 @@ export async function vectorProviderRoutes(app: FastifyInstance) {
285
286
  try {
286
287
  await owner.query('create extension if not exists vector');
287
288
  await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: 'create extension if not exists vector' });
288
- flash(s, 'The pgvector extension is installed.');
289
+ flash(s, tx('vp.the_pgvector_extension_is_installed'));
289
290
  } catch (e) {
290
291
  flash(s, `create extension vector failed: ${(e as Error).message}`, 'error');
291
292
  }