pgkiln 0.68.0 → 0.70.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 (65) hide show
  1. package/db/migrations/135_fulltext_search.sql +60 -0
  2. package/db/migrations/136_vector_search.sql +252 -0
  3. package/docs/apex-feature-parity.md +5 -4
  4. package/docs/guide/03-builder.md +76 -0
  5. package/docs/guide/04-pages-and-regions.md +86 -1
  6. package/docs/guide/09-reference.md +19 -0
  7. package/docs/guide/12-development.md +41 -2
  8. package/docs/guide/15-extensions.md +1 -1
  9. package/examples/hr/README.md +2 -0
  10. package/examples/hr/hr_71_fulltext_search.sql +43 -0
  11. package/examples/hr/hr_72_vector_search.sql +158 -0
  12. package/package.json +1 -1
  13. package/public/builder.css +3 -0
  14. package/src/ai/embeddings.ts +197 -0
  15. package/src/ai/service.ts +13 -9
  16. package/src/app.ts +3 -0
  17. package/src/builder/advisor.ts +79 -3
  18. package/src/builder/ai-agents.ts +13 -5
  19. package/src/builder/components.ts +32 -1
  20. package/src/builder/fulltext.ts +160 -0
  21. package/src/builder/home.ts +1 -0
  22. package/src/builder/i18n/de.ts +54 -0
  23. package/src/builder/i18n/en.ts +55 -0
  24. package/src/builder/i18n/es.ts +54 -0
  25. package/src/builder/i18n/fr.ts +54 -0
  26. package/src/builder/i18n/it.ts +54 -0
  27. package/src/builder/i18n/ja.ts +54 -0
  28. package/src/builder/i18n/ko.ts +54 -0
  29. package/src/builder/i18n/nl.ts +54 -0
  30. package/src/builder/i18n/pt.ts +54 -0
  31. package/src/builder/i18n/zh-cn.ts +54 -0
  32. package/src/builder/i18n/zh-tw.ts +54 -0
  33. package/src/builder/object-actions.ts +2 -0
  34. package/src/builder/shared.ts +1 -1
  35. package/src/builder/sql.ts +6 -0
  36. package/src/builder/table-editor.ts +2 -0
  37. package/src/builder/vector-column.ts +229 -0
  38. package/src/builder/vector-providers.ts +286 -0
  39. package/src/cli/replace.ts +3 -1
  40. package/src/i18n/ar.ts +1 -0
  41. package/src/i18n/cs.ts +1 -0
  42. package/src/i18n/da.ts +1 -0
  43. package/src/i18n/de.ts +1 -0
  44. package/src/i18n/el.ts +1 -0
  45. package/src/i18n/es.ts +1 -0
  46. package/src/i18n/fi.ts +1 -0
  47. package/src/i18n/fr.ts +1 -0
  48. package/src/i18n/he.ts +1 -0
  49. package/src/i18n/it.ts +1 -0
  50. package/src/i18n/ja.ts +1 -0
  51. package/src/i18n/ko.ts +1 -0
  52. package/src/i18n/nb.ts +1 -0
  53. package/src/i18n/pl.ts +1 -0
  54. package/src/i18n/pt.ts +1 -0
  55. package/src/i18n/ru.ts +1 -0
  56. package/src/i18n/sv.ts +1 -0
  57. package/src/i18n/tr.ts +1 -0
  58. package/src/i18n/uk.ts +1 -0
  59. package/src/i18n/zh.ts +1 -0
  60. package/src/i18n.ts +2 -0
  61. package/src/runtime/ai-agents.ts +53 -2
  62. package/src/runtime/report.ts +26 -2
  63. package/src/runtime/search.ts +290 -23
  64. package/src/server.ts +3 -0
  65. package/src/vector.ts +288 -0
@@ -0,0 +1,286 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { embedTexts, vectorProviderProblems, DEFAULT_BATCH, DEFAULT_PER_MINUTE, HNSW_MAX_DIMENSIONS, type VectorProvider } from '../ai/embeddings.ts';
3
+ import { providerLabel } from '../ai/service.ts';
4
+ import { isAiError } from '../ai/types.ts';
5
+ import { owner } from '../db.ts';
6
+ import { html, type Raw } from '../html.ts';
7
+ import { icon } from '../icons.ts';
8
+ import type { Session } from '../session.ts';
9
+ import { clientIp, logActivity } from '../session.ts';
10
+ import { pgvectorStatus, vectorTriggers, type PgvectorStatus } from '../vector.ts';
11
+ import { devDbForApp } from '../workspace-db.ts';
12
+ import { appOr404 } from './forms.ts';
13
+ import { isAdmin } from './locks.ts';
14
+ import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
15
+ import { currentWorkspace, inWorkspaceSql } from './workspaces.ts';
16
+
17
+ // (sprint 136, AP-1; APEX: Workspace Utilities and Shared Components → Vector Providers)
18
+ //
19
+ // Shared Components → Generative AI → Vector providers: the application's providers, a JSON list in meta.app
20
+ // (vector_providers, migration 136) exported with the application. A provider is an AI service with an embedding
21
+ // model (OpenAI text-embedding-3-*, Gemini, any OpenAI-compatible endpoint such as Ollama) or a SQL / PL/pgSQL
22
+ // function returning vector; its dimensions are shown and checked (Test embeds a sample text). The key of an AI
23
+ // service stays with the service (Workspace utilities → AI services).
24
+ //
25
+ // Workspace utilities → Vector providers: whether pgvector is installed (administrators may create it), every
26
+ // provider of the workspace's applications and the vector columns that providers keep current.
27
+
28
+ type Body = Record<string, string | string[] | undefined>;
29
+ const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? '';
30
+
31
+ 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?][]),
34
+ ];
35
+
36
+ /** pgvector's state as a short paragraph (with how to get it). */
37
+ 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>
41
+ ${st.available
42
+ ? 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>`}
46
+ </div>`;
47
+ }
48
+
49
+ async function servicesOf(appId: number) {
50
+ return (await owner.query<{ name: string; provider: string; model: string }>(
51
+ `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
52
+ where s.enabled and s.provider <> 'anthropic' order by s.name`, [appId])).rows;
53
+ }
54
+
55
+ async function providerForm(a: any, s: Session, x: Partial<VectorProvider>, original: string, problems: string[]): Promise<Raw> {
56
+ const services = await servicesOf(a.id);
57
+ const svc: [string, string][] = [['', '-'], ...services.map((v): [string, string] => [v.name, `${v.name} (${providerLabel(v.provider)})`])];
58
+ if (x.service && !services.some((v) => v.name === x.service)) svc.push([x.service, `${x.service} (not available to this application)`]);
59
+ const configs = original
60
+ ? (await owner.query<{ id: number; name: string; search_type: string }>(`select id, name, search_type from meta.search_config where app_id = $1 and vector_provider = $2 order by name`, [a.id, original])).rows
61
+ : [];
62
+ const columns = original ? (await vectorTriggers(await devDbForApp(a.id))).filter((t) => t.appId === a.id && t.provider === original) : [];
63
+ return html`${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
64
+ <form method="post" action="${BASE}/apps/${a.id}/vector-providers/provider">${csrf(s)}<input type="hidden" name="original" value="${original}">
65
+ <fieldset class="prop-group"><legend>Vector provider</legend><div class="form-grid">
66
+ ${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 providers).' })}
67
+ ${input('description', 'Description', x.description ?? '')}
68
+ ${select('type', 'Type', x.type ?? 'ai_service', [['ai_service', 'AI service with an embedding model'], ['function', 'SQL or PL/pgSQL function']], 'Where the embeddings come from.')}
69
+ ${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}.` })}
70
+ </div></fieldset>
71
+ <fieldset class="prop-group"><legend>AI service</legend><div class="form-grid">
72
+ ${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>`}
73
+ ${select('service', 'AI service', x.service ?? '', svc, 'Its key, base URL and daily limits apply; the texts to embed go to its provider.')}
74
+ ${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 …' })}
75
+ ${input('batch_size', 'Texts per request', x.batch_size ?? '', { type: 'number', placeholder: String(DEFAULT_BATCH), help: 'When the server embeds queued rows (vector columns).' })}
76
+ ${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.' })}
77
+ </div></fieldset>
78
+ <fieldset class="prop-group"><legend>Function</legend><div class="form-grid">
79
+ ${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.' })}
80
+ </div></fieldset>
81
+ <div class="buttons"><button class="btn btn-hot">${original ? 'Save' : 'Add vector provider'}</button></div>
82
+ </form>
83
+ ${original ? html`
84
+ <form method="post" action="${BASE}/apps/${a.id}/vector-providers/test" class="u-mt1">${csrf(s)}<input type="hidden" name="name" value="${original}">
85
+ <button class="btn">${icon('play')} Test: embed a sample text</button></form>
86
+ <h3>Used by</h3>
87
+ ${configs.length || columns.length ? html`<ul>
88
+ ${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>`)}
89
+ ${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>`)}
90
+ </ul>` : html`<p class="muted">No search configuration or vector column uses this provider yet.</p>`}
91
+ <form method="post" action="${BASE}/apps/${a.id}/vector-providers/provider/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="name" value="${original}">
92
+ <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>` : ''}`;
93
+ }
94
+
95
+ const intOrUndef = (v: string, what: string, errors: string[]) => {
96
+ const t = v.trim();
97
+ if (!t) return undefined;
98
+ const n = Number(t);
99
+ if (!Number.isInteger(n)) errors.push(`${what} is a whole number.`);
100
+ return n;
101
+ };
102
+
103
+ function providerFrom(b: Body, errors: string[]): VectorProvider {
104
+ const type = one(b.type) as VectorProvider['type'];
105
+ const x: VectorProvider = { name: one(b.name).trim().toUpperCase(), type, dimensions: intOrUndef(one(b.dimensions), 'Dimensions', errors) ?? 0 };
106
+ const description = one(b.description).trim().slice(0, 500);
107
+ if (description) x.description = description;
108
+ if (type === 'ai_service') {
109
+ x.service = one(b.service).trim().toUpperCase();
110
+ x.model = one(b.model).trim();
111
+ const batch = intOrUndef(one(b.batch_size), 'Texts per request', errors);
112
+ if (batch !== undefined) x.batch_size = batch;
113
+ const pm = intOrUndef(one(b.per_minute), 'Requests per minute', errors);
114
+ if (pm !== undefined) x.per_minute = pm;
115
+ }
116
+ if (type === 'function') x.function = one(b.function).trim();
117
+ return x;
118
+ }
119
+
120
+ /** Change the application's list in one transaction (row locked); `change` returns problems to refuse with. */
121
+ async function update(appId: number, change: (list: VectorProvider[]) => string[] | Promise<string[]>) {
122
+ return owner.tx(async (c) => {
123
+ const row = (await c.query<{ vector_providers: VectorProvider[] }>('select vector_providers from meta.app where id = $1 for update', [appId])).rows[0];
124
+ const list = [...(row?.vector_providers ?? [])];
125
+ const problems = await change(list);
126
+ if (problems.length) return problems;
127
+ await c.query('update meta.app set vector_providers = $2, updated_at = now() where id = $1', [appId, JSON.stringify(list)]);
128
+ return [];
129
+ });
130
+ }
131
+
132
+ export async function vectorProviderRoutes(app: FastifyInstance) {
133
+ const load = async (req: Req, reply: FastifyReply) => {
134
+ const s = await developer(req, reply);
135
+ if (!s) return null;
136
+ const a = await appOr404(req.params.id);
137
+ if (!a) {
138
+ reply.code(404).send('Not found');
139
+ return null;
140
+ }
141
+ return { s, a };
142
+ };
143
+
144
+ app.get(`${BASE}/apps/:id(^\\d+$)/vector-providers`, async (req: Req, reply) => {
145
+ const ok = await load(req, reply);
146
+ if (!ok) return;
147
+ const { s, a } = ok;
148
+ const list = (a.vector_providers ?? []) as VectorProvider[];
149
+ const st = await pgvectorStatus(await devDbForApp(a.id));
150
+ const main = html`${appHeader(a, 'shared', s)}
151
+ <div class="title-row"><h1>Vector providers</h1></div>
152
+ <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>
153
+ ${pgvectorNote(st, await isAdmin(s.username), s)}
154
+ ${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>
155
+ <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>
156
+ <td data-label="Type">${p.type === 'ai_service' ? html`AI service ${p.service}` : 'function'}</td><td data-label="Model or function"><code>${p.type === 'ai_service' ? p.model : p.function}</code></td>
157
+ <td class="num" data-label="Dimensions">${p.dimensions}</td></tr>`)
158
+ : html`<tr><td colspan="4" class="empty">No vector providers yet.</td></tr>`}</tbody></table></div>
159
+ <p><a class="btn" href="${BASE}/apps/${a.id}/vector-providers/provider">${icon('plus')} Add vector provider</a></p>`)}`;
160
+ return send(reply, s, shell(s, 'Vector providers', crumbs(a), main));
161
+ });
162
+
163
+ app.get(`${BASE}/apps/:id(^\\d+$)/vector-providers/provider`, async (req: Req, reply) => {
164
+ const ok = await load(req, reply);
165
+ if (!ok) return;
166
+ const { s, a } = ok;
167
+ const name = String((req.query as Record<string, unknown>)?.name ?? '').toUpperCase();
168
+ const x = name ? ((a.vector_providers ?? []) as VectorProvider[]).find((p) => p.name === name) : {};
169
+ if (!x) return reply.code(404).send('Not found');
170
+ const title = name || 'New vector provider';
171
+ 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, []))}`;
172
+ return send(reply, s, shell(s, title, crumbs(a, title), main));
173
+ });
174
+
175
+ app.post(`${BASE}/apps/:id(^\\d+$)/vector-providers/provider`, async (req: Req, reply) => {
176
+ const ok = await load(req, reply);
177
+ if (!ok) return;
178
+ const { s, a } = ok;
179
+ const b = (req.body ?? {}) as Body;
180
+ const original = one(b.original).toUpperCase();
181
+ const errors: string[] = [];
182
+ const x = providerFrom(b, errors);
183
+ const problems = errors.length ? errors : await update(a.id, (list) => {
184
+ const out = vectorProviderProblems(x);
185
+ const at = original ? list.findIndex((p) => p.name === original) : -1;
186
+ if (original && at < 0) out.push(`Vector provider ${original} no longer exists.`);
187
+ if (list.some((p, i) => p.name === x.name && i !== at)) out.push(`A vector provider ${x.name} exists already.`);
188
+ if (out.length) return out;
189
+ if (at >= 0) list[at] = x;
190
+ else list.push(x);
191
+ return [];
192
+ });
193
+ if (problems.length) {
194
+ 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))}`;
195
+ return send(reply.code(422), s, shell(s, original || 'New vector provider', crumbs(a, original || 'New vector provider'), main));
196
+ }
197
+ // a renamed provider keeps its search configurations
198
+ 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]);
199
+ flash(s, 'Vector provider saved.');
200
+ return back(reply, s, `${BASE}/apps/${a.id}/vector-providers/provider?name=${encodeURIComponent(x.name)}`);
201
+ });
202
+
203
+ app.post(`${BASE}/apps/:id(^\\d+$)/vector-providers/provider/delete`, async (req: Req, reply) => {
204
+ const ok = await load(req, reply);
205
+ if (!ok) return;
206
+ const { s, a } = ok;
207
+ const name = one((req.body as Body)?.name).toUpperCase();
208
+ await update(a.id, (list) => {
209
+ const at = list.findIndex((p) => p.name === name);
210
+ if (at >= 0) list.splice(at, 1);
211
+ return [];
212
+ });
213
+ flash(s, `Vector provider ${name} deleted.`);
214
+ return back(reply, s, `${BASE}/apps/${a.id}/vector-providers`);
215
+ });
216
+
217
+ // Test: embed a sample text (an AI service: one request, logged under AI usage; a function: as the developer connection)
218
+ app.post(`${BASE}/apps/:id(^\\d+$)/vector-providers/test`, async (req: Req, reply) => {
219
+ const ok = await load(req, reply);
220
+ if (!ok) return;
221
+ const { s, a } = ok;
222
+ const name = one((req.body as Body)?.name).toUpperCase();
223
+ const p = ((a.vector_providers ?? []) as VectorProvider[]).find((x) => x.name === name);
224
+ if (!p) return reply.code(404).send('Not found');
225
+ const problems = vectorProviderProblems(p);
226
+ const to = `${BASE}/apps/${a.id}/vector-providers/provider?name=${encodeURIComponent(name)}`;
227
+ if (problems.length) {
228
+ flash(s, problems.join(' '), 'error');
229
+ return back(reply, s, to);
230
+ }
231
+ const t0 = performance.now();
232
+ try {
233
+ 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));
234
+ 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(', ')}, …].`);
235
+ } catch (e) {
236
+ flash(s, `${name}: ${isAiError(e) ? e.message : (e as Error).message}`, 'error');
237
+ }
238
+ return back(reply, s, to);
239
+ });
240
+
241
+ // ---------------------------------------------------------------- Workspace utilities → Vector providers
242
+
243
+ app.get(`${BASE}/vector-providers`, async (req: Req, reply) => {
244
+ const s = await developer(req, reply);
245
+ if (!s) return;
246
+ const admin = await isAdmin(s.username);
247
+ const st = await pgvectorStatus(owner);
248
+ const apps = (await owner.query<{ id: number; name: string; alias: string; vector_providers: VectorProvider[] }>(
249
+ `select id, name, alias, vector_providers from meta.app a where ${inWorkspaceSql('a', 1)} and jsonb_array_length(vector_providers) > 0 order by name`, [currentWorkspace(s)])).rows;
250
+ const ids = new Set(apps.map((x) => x.id));
251
+ const columns = st.installed ? (await vectorTriggers(owner)).filter((t) => ids.has(t.appId)) : [];
252
+ const rows = apps.flatMap((x) => x.vector_providers.map((p) => ({ app: x, p })));
253
+ const main = html`<div class="title-row"><h1>Vector providers</h1></div>
254
+ <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>
255
+ ${pgvectorNote(st, admin, s)}
256
+ ${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>
257
+ <tbody>${rows.length ? rows.map(({ app: x, p }) => html`<tr><td data-label="Application">${x.name}</td>
258
+ <td data-label="Name"><a href="${BASE}/apps/${x.id}/vector-providers/provider?name=${encodeURIComponent(p.name)}">${p.name}</a></td>
259
+ <td data-label="Type">${p.type === 'ai_service' ? html`AI service ${p.service}` : 'function'}</td><td data-label="Model or function"><code>${p.type === 'ai_service' ? p.model : p.function}</code></td>
260
+ <td class="num" data-label="Dimensions">${p.dimensions}</td></tr>`)
261
+ : html`<tr><td colspan="5" class="empty">No vector providers yet.</td></tr>`}</tbody></table></div>`)}
262
+ ${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>
263
+ <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>
264
+ <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>`)
265
+ : html`<tr><td colspan="4" class="empty">No vector columns yet (SQL Workshop › Table Properties › Add vector column).</td></tr>`}</tbody></table></div>`)}`;
266
+ return send(reply, s, shell(s, 'Vector providers', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Vector providers']], main));
267
+ });
268
+
269
+ // administrators: create extension vector (pgvector is not a trusted extension: the owner connection must be a superuser)
270
+ app.post(`${BASE}/vector-providers/extension`, async (req: Req, reply) => {
271
+ const s = await developer(req, reply);
272
+ if (!s) return;
273
+ if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators create extensions.');
274
+ try {
275
+ await owner.query('create extension if not exists vector');
276
+ await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: 'create extension if not exists vector' });
277
+ flash(s, 'The pgvector extension is installed.');
278
+ } catch (e) {
279
+ flash(s, `create extension vector failed: ${(e as Error).message}`, 'error');
280
+ }
281
+ return back(reply, s, `${BASE}/vector-providers`);
282
+ });
283
+ }
284
+
285
+ export const vectorProviderOptions = async (appId: number) =>
286
+ (((await owner.one<{ vector_providers: VectorProvider[] }>('select vector_providers from meta.app where id = $1', [appId]))?.vector_providers ?? []) as VectorProvider[]);
@@ -84,7 +84,9 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
84
84
  // (125) who changed the application last in the builder (App Builder list: Updated by)
85
85
  'app_change',
86
86
  // (126) outgoing calls the network access lists refused (Administration → Network access; a log, 7 days)
87
- 'network_refusal'];
87
+ 'network_refusal',
88
+ // (136) rows queued for their embeddings (the vector providers travel in meta.app)
89
+ 'embedding_queue'];
88
90
  /** Children of pages (replaced with their page). */
89
91
  const PAGE_CHILDREN = ['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'];
90
92
  /** References into replaced tables from kept data, repointed below: "table.column". */
package/src/i18n/ar.ts CHANGED
@@ -424,6 +424,7 @@ export const ar: Record<MessageKey, string> = {
424
424
  'lov.loading': "جارٍ تحميل المزيد…",
425
425
  'da.wait': "يرجى الانتظار…",
426
426
  'lov.no_rows': "لم يُعثر على شيء.",
427
+ 'search.did_you_mean': "هل تقصد:",
427
428
  'error.lov_value': "{label}: اختر قيمة من القائمة.",
428
429
  'item.yes': "نعم",
429
430
  'item.no': "لا",
package/src/i18n/cs.ts CHANGED
@@ -424,6 +424,7 @@ export const cs: Record<MessageKey, string> = {
424
424
  'lov.loading': "Načítání dalších…",
425
425
  'da.wait': "Čekejte prosím…",
426
426
  'lov.no_rows': "Nic nenalezeno.",
427
+ 'search.did_you_mean': "Měli jste na mysli:",
427
428
  'error.lov_value': "{label}: vyberte hodnotu ze seznamu.",
428
429
  'item.yes': "Ano",
429
430
  'item.no': "Ne",
package/src/i18n/da.ts CHANGED
@@ -424,6 +424,7 @@ export const da: Record<MessageKey, string> = {
424
424
  'lov.loading': "Indlæser flere…",
425
425
  'da.wait': "Vent venligst…",
426
426
  'lov.no_rows': "Intet fundet.",
427
+ 'search.did_you_mean': "Mente du:",
427
428
  'error.lov_value': "{label}: vælg en værdi på listen.",
428
429
  'item.yes': "Ja",
429
430
  'item.no': "Nej",
package/src/i18n/de.ts CHANGED
@@ -421,6 +421,7 @@ export const de: Record<MessageKey, string> = {
421
421
  'lov.loading': 'Weitere werden geladen…',
422
422
  'da.wait': 'Bitte warten…',
423
423
  'lov.no_rows': 'Nichts gefunden.',
424
+ 'search.did_you_mean': "Meinten Sie:",
424
425
  'error.lov_value': '{label}: Wählen Sie einen Wert aus der Liste.',
425
426
  'item.yes': 'Ja',
426
427
  'item.no': 'Nein',
package/src/i18n/el.ts CHANGED
@@ -424,6 +424,7 @@ export const el: Record<MessageKey, string> = {
424
424
  'lov.loading': "Φόρτωση περισσότερων…",
425
425
  'da.wait': "Παρακαλώ περιμένετε…",
426
426
  'lov.no_rows': "Δεν βρέθηκε τίποτα.",
427
+ 'search.did_you_mean': "Μήπως εννοούσατε:",
427
428
  'error.lov_value': "{label}: επιλέξτε τιμή από τη λίστα.",
428
429
  'item.yes': "Ναι",
429
430
  'item.no': "Όχι",
package/src/i18n/es.ts CHANGED
@@ -421,6 +421,7 @@ export const es: Record<MessageKey, string> = {
421
421
  'lov.loading': 'Cargando más…',
422
422
  'da.wait': 'Espere, por favor…',
423
423
  'lov.no_rows': 'No se ha encontrado nada.',
424
+ 'search.did_you_mean': "Quizás quiso decir:",
424
425
  'error.lov_value': '{label}: elija un valor de la lista.',
425
426
  'item.yes': 'Sí',
426
427
  'item.no': 'No',
package/src/i18n/fi.ts CHANGED
@@ -424,6 +424,7 @@ export const fi: Record<MessageKey, string> = {
424
424
  'lov.loading': "Ladataan lisää…",
425
425
  'da.wait': "Odota hetki…",
426
426
  'lov.no_rows': "Mitään ei löytynyt.",
427
+ 'search.did_you_mean': "Tarkoititko:",
427
428
  'error.lov_value': "{label}: valitse arvo luettelosta.",
428
429
  'item.yes': "Kyllä",
429
430
  'item.no': "Ei",
package/src/i18n/fr.ts CHANGED
@@ -421,6 +421,7 @@ export const fr: Record<MessageKey, string> = {
421
421
  'lov.loading': 'Chargement de la suite…',
422
422
  'da.wait': 'Veuillez patienter…',
423
423
  'lov.no_rows': 'Aucun résultat.',
424
+ 'search.did_you_mean': "Vouliez-vous dire :",
424
425
  'error.lov_value': '{label} : choisissez une valeur dans la liste.',
425
426
  'item.yes': 'Oui',
426
427
  'item.no': 'Non',
package/src/i18n/he.ts CHANGED
@@ -424,6 +424,7 @@ export const he: Record<MessageKey, string> = {
424
424
  'lov.loading': "טוען עוד…",
425
425
  'da.wait': "נא להמתין…",
426
426
  'lov.no_rows': "לא נמצא דבר.",
427
+ 'search.did_you_mean': "האם התכוונת ל:",
427
428
  'error.lov_value': "{label}: בחרו ערך מהרשימה.",
428
429
  'item.yes': "כן",
429
430
  'item.no': "לא",
package/src/i18n/it.ts CHANGED
@@ -424,6 +424,7 @@ export const it: Record<MessageKey, string> = {
424
424
  'lov.loading': "Caricamento di altri…",
425
425
  'da.wait': "Attendere prego…",
426
426
  'lov.no_rows': "Nessun risultato.",
427
+ 'search.did_you_mean': "Forse cercavi:",
427
428
  'error.lov_value': "{label}: scegli un valore dall'elenco.",
428
429
  'item.yes': "Sì",
429
430
  'item.no': "No",
package/src/i18n/ja.ts CHANGED
@@ -424,6 +424,7 @@ export const ja: Record<MessageKey, string> = {
424
424
  'lov.loading': "さらに読み込み中…",
425
425
  'da.wait': "お待ちください…",
426
426
  'lov.no_rows': "見つかりません。",
427
+ 'search.did_you_mean': "もしかして:",
427
428
  'error.lov_value': "{label}: 一覧から値を選んでください。",
428
429
  'item.yes': "はい",
429
430
  'item.no': "いいえ",
package/src/i18n/ko.ts CHANGED
@@ -424,6 +424,7 @@ export const ko: Record<MessageKey, string> = {
424
424
  'lov.loading': "더 불러오는 중…",
425
425
  'da.wait': "잠시 기다려 주세요…",
426
426
  'lov.no_rows': "찾은 항목이 없습니다.",
427
+ 'search.did_you_mean': "이것을 찾으셨나요:",
427
428
  'error.lov_value': "{label}: 목록에서 값을 선택하세요.",
428
429
  'item.yes': "예",
429
430
  'item.no': "아니요",
package/src/i18n/nb.ts CHANGED
@@ -424,6 +424,7 @@ export const nb: Record<MessageKey, string> = {
424
424
  'lov.loading': "Laster inn flere…",
425
425
  'da.wait': "Vent litt…",
426
426
  'lov.no_rows': "Fant ingenting.",
427
+ 'search.did_you_mean': "Mente du:",
427
428
  'error.lov_value': "{label}: velg en verdi fra listen.",
428
429
  'item.yes': "Ja",
429
430
  'item.no': "Nei",
package/src/i18n/pl.ts CHANGED
@@ -424,6 +424,7 @@ export const pl: Record<MessageKey, string> = {
424
424
  'lov.loading': "Wczytywanie kolejnych…",
425
425
  'da.wait': "Proszę czekać…",
426
426
  'lov.no_rows': "Nic nie znaleziono.",
427
+ 'search.did_you_mean': "Czy chodziło o:",
427
428
  'error.lov_value': "{label}: wybierz wartość z listy.",
428
429
  'item.yes': "Tak",
429
430
  'item.no': "Nie",
package/src/i18n/pt.ts CHANGED
@@ -424,6 +424,7 @@ export const pt: Record<MessageKey, string> = {
424
424
  'lov.loading': "A carregar mais…",
425
425
  'da.wait': "Aguarde, por favor…",
426
426
  'lov.no_rows': "Nada encontrado.",
427
+ 'search.did_you_mean': "Queria dizer:",
427
428
  'error.lov_value': "{label}: escolha um valor da lista.",
428
429
  'item.yes': "Sim",
429
430
  'item.no': "Não",
package/src/i18n/ru.ts CHANGED
@@ -424,6 +424,7 @@ export const ru: Record<MessageKey, string> = {
424
424
  'lov.loading': "Загрузка…",
425
425
  'da.wait': "Подождите…",
426
426
  'lov.no_rows': "Ничего не найдено.",
427
+ 'search.did_you_mean': "Возможно, вы имели в виду:",
427
428
  'error.lov_value': "{label}: выберите значение из списка.",
428
429
  'item.yes': "Да",
429
430
  'item.no': "Нет",
package/src/i18n/sv.ts CHANGED
@@ -424,6 +424,7 @@ export const sv: Record<MessageKey, string> = {
424
424
  'lov.loading': "Läser in fler…",
425
425
  'da.wait': "Vänta…",
426
426
  'lov.no_rows': "Inget hittades.",
427
+ 'search.did_you_mean': "Menade du:",
427
428
  'error.lov_value': "{label}: välj ett värde i listan.",
428
429
  'item.yes': "Ja",
429
430
  'item.no': "Nej",
package/src/i18n/tr.ts CHANGED
@@ -424,6 +424,7 @@ export const tr: Record<MessageKey, string> = {
424
424
  'lov.loading': "Daha fazlası yükleniyor…",
425
425
  'da.wait': "Lütfen bekleyin…",
426
426
  'lov.no_rows': "Hiçbir şey bulunamadı.",
427
+ 'search.did_you_mean': "Bunu mu demek istediniz:",
427
428
  'error.lov_value': "{label}: listeden bir değer seçin.",
428
429
  'item.yes': "Evet",
429
430
  'item.no': "Hayır",
package/src/i18n/uk.ts CHANGED
@@ -424,6 +424,7 @@ export const uk: Record<MessageKey, string> = {
424
424
  'lov.loading': "Завантаження…",
425
425
  'da.wait': "Зачекайте…",
426
426
  'lov.no_rows': "Нічого не знайдено.",
427
+ 'search.did_you_mean': "Можливо, ви мали на увазі:",
427
428
  'error.lov_value': "{label}: виберіть значення зі списку.",
428
429
  'item.yes': "Так",
429
430
  'item.no': "Ні",
package/src/i18n/zh.ts CHANGED
@@ -424,6 +424,7 @@ export const zh: Record<MessageKey, string> = {
424
424
  'lov.loading': "正在加载更多…",
425
425
  'da.wait': "请稍候…",
426
426
  'lov.no_rows': "未找到任何内容。",
427
+ 'search.did_you_mean': "您是不是要找:",
427
428
  'error.lov_value': "{label}:请从列表中选择一个值。",
428
429
  'item.yes': "是",
429
430
  'item.no': "否",
package/src/i18n.ts CHANGED
@@ -451,6 +451,7 @@ const en = {
451
451
  'lov.loading': 'Loading more…',
452
452
  'da.wait': 'Please wait…',
453
453
  'lov.no_rows': 'Nothing found.',
454
+ 'search.did_you_mean': 'Did you mean:',
454
455
  'error.lov_value': '{label}: choose a value from the list.',
455
456
  'item.yes': 'Yes',
456
457
  'item.no': 'No',
@@ -1318,6 +1319,7 @@ const nl: Record<MessageKey, string> = {
1318
1319
  'lov.loading': 'Meer laden…',
1319
1320
  'da.wait': 'Even geduld…',
1320
1321
  'lov.no_rows': 'Niets gevonden.',
1322
+ 'search.did_you_mean': 'Bedoelde u:',
1321
1323
  'error.lov_value': '{label}: kies een waarde uit de lijst.',
1322
1324
  'item.yes': 'Ja',
1323
1325
  'item.no': 'Nee',
@@ -7,6 +7,7 @@ import { checkArgs, toolSchema, type ParamDef, type ToolDef } from './assistant.
7
7
  import { isAuthorized, pageAllowed } from './authz.ts';
8
8
  import { bindValues, dbg, publicError, stripSemicolon, type PageContext } from './context.ts';
9
9
  import { txContext } from './routes.ts';
10
+ import { retrieve } from './search.ts';
10
11
 
11
12
  // AI agents and AI tools (migration 121; APEX 26.1 / 26.2: Shared Components
12
13
  // → AI Agents). Both are lists in meta.app (ai_agents, ai_tools), exported
@@ -31,11 +32,18 @@ import { txContext } from './routes.ts';
31
32
  // values of that page; pgkiln refuses a page the user may not open and
32
33
  // builds the URL itself (item values with their checksum, bound to app,
33
34
  // page and user). The link is shown under the answer; nothing opens it.
35
+ // - retrieve (136): RAG. The model gives a text (QUERY); pgkiln runs the
36
+ // tool's search configuration (vector, hybrid, full-text or standard) as
37
+ // the application's role in a read-only transaction, so row level
38
+ // security decides which rows the model sees, and returns the best rows'
39
+ // title, text and score (max_rows, default 5).
34
40
  // Every tool call is logged with its arguments (meta.ai_tool_call, the
35
41
  // Activity → AI usage page) and in the page's debug messages.
36
42
 
37
- export type AiToolType = 'sql' | 'function' | 'rest' | 'javascript' | 'navigate';
38
- export const AI_TOOL_TYPES: AiToolType[] = ['sql', 'function', 'rest', 'javascript', 'navigate'];
43
+ export type AiToolType = 'sql' | 'function' | 'rest' | 'javascript' | 'navigate' | 'retrieve';
44
+ export const AI_TOOL_TYPES: AiToolType[] = ['sql', 'function', 'rest', 'javascript', 'navigate', 'retrieve'];
45
+ /** (136) Rows a Retrieve tool returns by default. */
46
+ const RETRIEVE_ROWS = 5;
39
47
  export const EFFORTS = ['low', 'medium', 'high'] as const;
40
48
 
41
49
  export interface AiTool {
@@ -47,6 +55,8 @@ export interface AiTool {
47
55
  source?: string;
48
56
  action?: string;
49
57
  pages?: number[];
58
+ /** (136) retrieve: the search configuration (static ID) */
59
+ search_config?: string;
50
60
  parameters?: Record<string, ParamDef>;
51
61
  max_rows?: number;
52
62
  timeout_s?: number;
@@ -116,6 +126,9 @@ export function aiToolProblems(t: unknown): string[] {
116
126
  if (!Array.isArray(t.pages) || !t.pages.length || t.pages.length > 50 || t.pages.some((p: unknown) => !intIn(p, 1, 2_147_483_647)))
117
127
  out.push(`${what}: "pages" lists the page numbers the agent may offer (1 to 50 pages).`);
118
128
  if (t.parameters !== undefined) out.push(`${what}: a navigate tool has no parameters (the model picks a page, a link text and item values).`);
129
+ } else if (t.type === 'retrieve') {
130
+ if (typeof t.search_config !== 'string' || !/^[A-Z][A-Z0-9_]{0,59}$/.test(t.search_config)) out.push(`${what}: "search_config" names a search configuration (e.g. POLICIES).`);
131
+ if (t.parameters !== undefined) out.push(`${what}: a retrieve tool has no parameters (the model gives the text to look for).`);
119
132
  } else out.push(...paramProblems(what, t.parameters));
120
133
  if (t.max_rows !== undefined && !intIn(t.max_rows, 1, 500)) out.push(`${what}: maximum rows 1 to 500.`);
121
134
  if (t.timeout_s !== undefined && !intIn(t.timeout_s, 1, 60)) out.push(`${what}: time limit 1 to 60 seconds.`);
@@ -201,6 +214,14 @@ export interface PreparedTool {
201
214
 
202
215
  const asToolDef = (t: AiTool): ToolDef => ({ name: t.name, description: t.description, parameters: t.parameters, max_rows: t.max_rows });
203
216
 
217
+ /** (136) The one parameter of a Retrieve tool. */
218
+ const RETRIEVE_SCHEMA = {
219
+ type: 'object',
220
+ properties: { QUERY: { type: 'string', description: 'What to look for, in your own words (a question or a few words); the search finds rows by meaning.' } },
221
+ required: ['QUERY'],
222
+ additionalProperties: false,
223
+ };
224
+
204
225
  /**
205
226
  * The agent's tools this user may use (their authorization schemes), with their schemas. Navigate tools keep
206
227
  * only the pages the user may open (checked here, in the caller's transaction). Needs ctx.client.
@@ -209,6 +230,10 @@ export async function prepareTools(ctx: PageContext, tools: AiTool[]): Promise<P
209
230
  const out: PreparedTool[] = [];
210
231
  for (const tool of tools) {
211
232
  if (!(await isAuthorized(ctx, tool.authz))) continue;
233
+ if (tool.type === 'retrieve') {
234
+ out.push({ tool, chat: { name: tool.name, description: `${tool.description} Returns the best matching rows (title, text, score) the user may read.`, parameters: RETRIEVE_SCHEMA } });
235
+ continue;
236
+ }
212
237
  if (tool.type !== 'navigate') {
213
238
  const extra = tool.type === 'javascript' ? ' It runs in the user\'s browser after your answer; it returns no data.' : '';
214
239
  out.push({ tool, chat: { name: tool.name, description: tool.description + extra, parameters: toolSchema(asToolDef(tool)) } });
@@ -309,6 +334,7 @@ export async function runAgentTool(ctx: PageContext, p: PreparedTool, input: Rec
309
334
  const { tool } = p;
310
335
  if (tool.type === 'navigate') return navigate(ctx, p, input, extras);
311
336
  if (tool.type === 'rest') return rest({ ...asToolDef(tool), type: 'rest', source: tool.source }, input);
337
+ if (tool.type === 'retrieve') return retrieveTool(ctx, tool, input);
312
338
  const checked = checkArgs(asToolDef(tool), input);
313
339
  if ('error' in checked) return { content: checked.error, isError: true };
314
340
  if (tool.type === 'javascript') {
@@ -336,6 +362,31 @@ export async function runAgentTool(ctx: PageContext, p: PreparedTool, input: Rec
336
362
  }
337
363
  }
338
364
 
365
+ /**
366
+ * (136) A Retrieve tool: the search configuration as the application's role in a read-only transaction with the
367
+ * tool's time limit (row level security applies), its best rows as JSON for the model.
368
+ */
369
+ async function retrieveTool(ctx: PageContext, tool: AiTool, input: Record<string, unknown>): Promise<AgentOutcome> {
370
+ for (const k of Object.keys(input)) if (k !== 'QUERY') return { content: `Unknown argument "${k}".`, isError: true };
371
+ const query = typeof input.QUERY === 'string' ? input.QUERY.replace(/\s+/g, ' ').trim().slice(0, 1000) : '';
372
+ if (!query) return { content: 'QUERY is the text to look for.', isError: true };
373
+ const max = tool.max_rows ?? RETRIEVE_ROWS;
374
+ const before = ctx.client;
375
+ try {
376
+ const found = await appTx(txContext(ctx), async (c) => {
377
+ await c.query(`select set_config('statement_timeout', $1, true)`, [`${tool.timeout_s ?? DEFAULT_TIMEOUT_S}s`]);
378
+ await c.query('set transaction read only');
379
+ ctx.client = c;
380
+ return retrieve(ctx, tool.search_config!, query, max);
381
+ });
382
+ return { content: JSON.stringify(found) };
383
+ } catch (e) {
384
+ return { content: await publicError(ctx, e, `AI tool ${tool.name}`), isError: true };
385
+ } finally {
386
+ ctx.client = before;
387
+ }
388
+ }
389
+
339
390
  // ---------------------------------------------------------------- the log
340
391
 
341
392
  const MAX_ARG_CHARS = 4000;