pgkiln 0.83.0 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/154_notifications.sql +186 -0
- package/docs/apex-feature-parity.md +3 -2
- package/docs/guide/03-builder.md +16 -5
- package/docs/guide/04-pages-and-regions.md +42 -0
- package/docs/guide/06-processing.md +22 -1
- package/docs/guide/07-dynamic-actions.md +1 -0
- package/docs/guide/09-reference.md +15 -0
- package/docs/guide/11-from-apex.md +1 -0
- package/docs/guide/12-development.md +13 -1
- package/package.json +1 -1
- package/public/app.css +29 -0
- package/public/app.js +78 -0
- package/public/sw.js +5 -1
- package/src/app.ts +11 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +10 -6
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3461 -0
- package/src/builder/i18n/en.ts +3461 -0
- package/src/builder/i18n/es.ts +3461 -0
- package/src/builder/i18n/fr.ts +3461 -0
- package/src/builder/i18n/it.ts +3461 -0
- package/src/builder/i18n/ja.ts +3461 -0
- package/src/builder/i18n/ko.ts +3461 -0
- package/src/builder/i18n/nl.ts +3461 -0
- package/src/builder/i18n/pt.ts +3461 -0
- package/src/builder/i18n/zh-cn.ts +3461 -0
- package/src/builder/i18n/zh-tw.ts +3461 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/notification-settings.ts +27 -0
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +30 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
- package/src/cli/replace.ts +2 -0
- package/src/i18n/ar.ts +9 -0
- package/src/i18n/cs.ts +9 -0
- package/src/i18n/da.ts +9 -0
- package/src/i18n/de.ts +9 -0
- package/src/i18n/el.ts +9 -0
- package/src/i18n/es.ts +9 -0
- package/src/i18n/fi.ts +9 -0
- package/src/i18n/fr.ts +9 -0
- package/src/i18n/he.ts +9 -0
- package/src/i18n/it.ts +9 -0
- package/src/i18n/ja.ts +9 -0
- package/src/i18n/ko.ts +9 -0
- package/src/i18n/nb.ts +9 -0
- package/src/i18n/pl.ts +9 -0
- package/src/i18n/pt.ts +9 -0
- package/src/i18n/ru.ts +9 -0
- package/src/i18n/sv.ts +9 -0
- package/src/i18n/tr.ts +9 -0
- package/src/i18n/uk.ts +9 -0
- package/src/i18n/zh.ts +9 -0
- package/src/i18n.ts +18 -0
- package/src/metadata.ts +9 -3
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/feedback.ts +1 -1
- package/src/runtime/lists.ts +43 -11
- package/src/runtime/notifications.ts +237 -0
- package/src/runtime/processes.ts +45 -1
- package/src/runtime/regions.ts +11 -15
- package/src/runtime/render.ts +12 -3
- package/src/runtime/routes.ts +12 -4
- package/src/runtime/signin.ts +2 -1
- package/src/session.ts +13 -4
package/src/builder/utilities.ts
CHANGED
|
@@ -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', '
|
|
80
|
-
${card(`${u}/utilities/dependencies`, 'database', '
|
|
81
|
-
${card(`${u}/utilities/history`, 'history', '
|
|
82
|
-
${card(`${u}/utilities/upgrade`, 'refresh', '
|
|
83
|
-
${card(`${BASE}/ui-defaults`, 'sliders-horizontal', '
|
|
84
|
-
${card(`${u}/search`, 'search', '
|
|
85
|
-
${card(`${u}/debug`, 'list', '
|
|
86
|
-
${card(`${u}/activity`, 'activity', '
|
|
87
|
-
${card(`${u}/backups`, 'archive', '
|
|
88
|
-
${card(`${u}/working-copies`, 'layers', '
|
|
89
|
-
${card(`${u}/export`, 'download', '
|
|
90
|
-
${card(`${BASE}/icons`, 'image', '
|
|
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('
|
|
93
|
-
<div><dt
|
|
94
|
-
<div><dt
|
|
95
|
-
<div><dt
|
|
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('
|
|
98
|
-
? html`<div class="table-wrap"><table class="table report-reflow"><thead><tr><th scope="col"
|
|
99
|
-
${recent.map((r) => html`<tr><td data-label="
|
|
100
|
-
<td data-label="
|
|
101
|
-
: html`<p class="muted u-mt0"
|
|
102
|
-
${region('
|
|
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"
|
|
105
|
-
${[...pageSums].map(([p, h]) => html`<tr><td data-label="
|
|
106
|
-
return send(reply, s, shell(s,
|
|
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('
|
|
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', '
|
|
147
|
-
<div class="field"><label class="label" for="f_from"
|
|
148
|
-
<div class="field"><label class="label" for="f_to"
|
|
149
|
-
<div class="field"><label class="label" for="f_page"
|
|
150
|
-
<div class="field"><label class="label" for="f_q"
|
|
151
|
-
<div class="buttons"><button class="btn btn-sm">${icon('filter')}
|
|
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"
|
|
154
|
-
${rows.map((r) => html`<tr${r.id === shown ? raw(' aria-current="true"') : ''}><td data-label="
|
|
155
|
-
<td data-label="
|
|
156
|
-
<td data-label="
|
|
157
|
-
<td data-label="
|
|
158
|
-
: html`<p class="muted"
|
|
159
|
-
${detail ? region(
|
|
160
|
-
return send(reply, s, shell(s,
|
|
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"
|
|
173
|
-
${region('
|
|
174
|
-
<form method="get" role="search" class="util-filters"><div class="field"><label class="label" for="f_icon_q"
|
|
175
|
-
<div class="buttons"><button class="btn btn-sm">${icon('search')}
|
|
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"
|
|
178
|
-
: html`<p class="muted"
|
|
179
|
-
return send(reply, s, shell(s, '
|
|
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
|
-
['
|
|
33
|
-
...(last ? [['
|
|
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')}
|
|
39
|
-
return html`<div class="alert alert-info" role="status"><p><strong
|
|
40
|
-
|
|
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')}
|
|
44
|
-
: html`<p
|
|
45
|
-
: html`<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
|
|
68
|
-
${input('name', '
|
|
69
|
-
${input('description', '
|
|
70
|
-
${select('type', '
|
|
71
|
-
${input('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
|
|
74
|
-
${services.length ? '' : html`<p class="muted" data-wide
|
|
75
|
-
${select('service', '
|
|
76
|
-
${input('model', '
|
|
77
|
-
${input('batch_size', '
|
|
78
|
-
${input('per_minute', '
|
|
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
|
|
81
|
-
${pgml ? '' : html`<p class="muted" data-wide
|
|
82
|
-
${select('engine', '
|
|
83
|
-
${input('db_model', '
|
|
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
|
|
86
|
-
${input('function', '
|
|
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 ? '
|
|
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')}
|
|
93
|
-
<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
|
|
96
|
-
${columns.map((c) => html`<li
|
|
97
|
-
</ul>` : html`<p class="muted"
|
|
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="
|
|
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(
|
|
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), '
|
|
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), '
|
|
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), '
|
|
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
|
|
163
|
-
<p class="muted u-mt0"
|
|
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('
|
|
166
|
-
<tbody>${list.length ? list.map((p) => html`<tr><td data-label="
|
|
167
|
-
<td data-label="
|
|
168
|
-
<td class="num" data-label="
|
|
169
|
-
: html`<tr><td colspan="4" class="empty"
|
|
170
|
-
<p><a class="btn" href="${BASE}/apps/${a.id}/vector-providers/provider">${icon('plus')}
|
|
171
|
-
return send(reply, s, shell(s, '
|
|
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('
|
|
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(
|
|
198
|
-
if (list.some((p, i) => p.name === x.name && i !== at)) out.push(
|
|
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('
|
|
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, '
|
|
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,
|
|
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, ['
|
|
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
|
|
265
|
-
<p class="muted u-mt0"
|
|
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('
|
|
268
|
-
<tbody>${rows.length ? rows.map(({ app: x, p }) => html`<tr><td data-label="
|
|
269
|
-
<td data-label="
|
|
270
|
-
<td data-label="
|
|
271
|
-
<td class="num" data-label="
|
|
272
|
-
: html`<tr><td colspan="5" class="empty"
|
|
273
|
-
${region('
|
|
274
|
-
<tbody>${columns.length ? columns.map((c) => html`<tr><td data-label="
|
|
275
|
-
<td data-label="
|
|
276
|
-
: html`<tr><td colspan="4" class="empty"
|
|
277
|
-
return send(reply, s, shell(s, '
|
|
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, '
|
|
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
|
}
|