pgkiln 0.69.0 → 0.71.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/136_vector_search.sql +252 -0
- package/db/migrations/137_create_page_types.sql +904 -0
- package/db/migrations/138_app_utilities.sql +153 -0
- package/docs/apex-feature-parity.md +10 -8
- package/docs/guide/03-builder.md +176 -4
- package/docs/guide/04-pages-and-regions.md +45 -3
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/06-processing.md +18 -0
- package/docs/guide/09-reference.md +34 -1
- package/docs/guide/12-development.md +53 -3
- package/docs/guide/15-extensions.md +1 -1
- package/docs/guide/16-files.md +1 -1
- package/examples/hr/README.md +1 -0
- package/examples/hr/hr_72_vector_search.sql +158 -0
- package/examples/hr/hr_73_configuration_options.sql +17 -0
- package/examples/hr/hr_74_app_utilities.sql +22 -0
- package/package.json +1 -1
- package/public/app.css +9 -0
- package/public/builder.css +48 -0
- package/src/ai/embeddings.ts +197 -0
- package/src/ai/service.ts +13 -9
- package/src/app.ts +13 -0
- package/src/builder/advisor.ts +43 -3
- package/src/builder/ai-agents.ts +13 -5
- package/src/builder/app-updates.ts +3 -0
- package/src/builder/components.ts +24 -4
- package/src/builder/create-page.ts +259 -0
- package/src/builder/dependencies.ts +311 -0
- package/src/builder/features.ts +313 -0
- package/src/builder/home.ts +3 -0
- package/src/builder/i18n/de.ts +38 -0
- package/src/builder/i18n/en.ts +38 -0
- package/src/builder/i18n/es.ts +38 -0
- package/src/builder/i18n/fr.ts +38 -0
- package/src/builder/i18n/it.ts +38 -0
- package/src/builder/i18n/ja.ts +38 -0
- package/src/builder/i18n/ko.ts +38 -0
- package/src/builder/i18n/nl.ts +38 -0
- package/src/builder/i18n/pt.ts +38 -0
- package/src/builder/i18n/zh-cn.ts +38 -0
- package/src/builder/i18n/zh-tw.ts +38 -0
- package/src/builder/login-page.ts +17 -14
- package/src/builder/newapp.ts +41 -23
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/routes.ts +13 -2
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +3 -0
- package/src/builder/table-editor.ts +1 -0
- package/src/builder/ui-defaults.ts +161 -0
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +154 -0
- package/src/builder/utilities.ts +181 -0
- package/src/builder/vector-column.ts +229 -0
- package/src/builder/vector-providers.ts +286 -0
- package/src/builder/wizards.ts +211 -26
- package/src/cli/replace.ts +11 -1
- package/src/history.ts +155 -0
- package/src/runtime/ai-agents.ts +53 -2
- package/src/runtime/builtin-components.ts +23 -2
- package/src/runtime/items.ts +4 -2
- package/src/runtime/search.ts +116 -12
- package/src/server.ts +3 -0
- package/src/vector.ts +288 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { HNSW_MAX_DIMENSIONS, vectorProviderProblems, type VectorProvider } from '../ai/embeddings.ts';
|
|
4
|
+
import { joinStatements, recordChanges } from '../changelog.ts';
|
|
5
|
+
import { owner } from '../db.ts';
|
|
6
|
+
import { html, type Raw } from '../html.ts';
|
|
7
|
+
import { icon } from '../icons.ts';
|
|
8
|
+
import { clientIp, logActivity } from '../session.ts';
|
|
9
|
+
import { readTable } from '../tabledef.ts';
|
|
10
|
+
import { backfill, DISTANCES, isDistance, kickEmbeddings, pgvectorStatus, queueState, vectorTriggers, type Distance } from '../vector.ts';
|
|
11
|
+
import { clearCompletions } from './code-editor.ts';
|
|
12
|
+
import { tr } from './i18n.ts';
|
|
13
|
+
import { isAdmin } from './locks.ts';
|
|
14
|
+
import { SYSTEM_SCHEMAS } from './object-browser.ts';
|
|
15
|
+
import { back, BASE, csrf, developer, flash, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
16
|
+
import { appAllowed, currentWorkspace, inWorkspaceSql, usableSchemas, workshopDb } from './workspaces.ts';
|
|
17
|
+
|
|
18
|
+
// (sprint 136, AP-1) SQL Workshop › Table Properties › Add vector column: an embedding column vector(n) (n: the
|
|
19
|
+
// chosen vector provider's dimensions), an HNSW index for the distance (vector_cosine_ops …), and a trigger that
|
|
20
|
+
// keeps it current when the source columns change:
|
|
21
|
+
// - a function provider: BEFORE INSERT OR UPDATE OF … → meta.embedding_set (the embedding at once);
|
|
22
|
+
// - an AI service (or a function, if chosen): AFTER INSERT OR UPDATE OF … → meta.embedding_enqueue (the row's key
|
|
23
|
+
// queued; the server embeds queued rows in batches, src/vector.ts).
|
|
24
|
+
// Preview DDL shows the statements; Apply runs them in one transaction with the workshop's connection and records
|
|
25
|
+
// them in the Change Log; Open in the worksheet puts them there. The page also lists the table's vector columns
|
|
26
|
+
// with their progress (rows embedded, queued, failing) and a Backfill button that queues the rows without an
|
|
27
|
+
// embedding (or all rows). Names come from the catalog or are checked as plain identifiers before they reach DDL.
|
|
28
|
+
|
|
29
|
+
type Db = Awaited<ReturnType<typeof workshopDb>>;
|
|
30
|
+
const NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
31
|
+
const ident = (n: string) => (/^[a-z_][a-z0-9_$]*$/.test(n) ? n : pg.escapeIdentifier(n));
|
|
32
|
+
|
|
33
|
+
interface Col { name: string; type: string; texty: boolean; dims: number | null; vector: boolean }
|
|
34
|
+
|
|
35
|
+
async function columnsOf(db: Db, oid: number): Promise<Col[]> {
|
|
36
|
+
return (await db.query<Col>(
|
|
37
|
+
`select a.attname as name, format_type(a.atttypid, a.atttypmod) as type, t.typcategory = 'S' and a.attgenerated = '' as texty,
|
|
38
|
+
t.typname = 'vector' as vector, case when t.typname = 'vector' and a.atttypmod > 0 then a.atttypmod end as dims
|
|
39
|
+
from pg_attribute a join pg_type t on t.oid = a.atttypid
|
|
40
|
+
where a.attrelid = $1 and a.attnum > 0 and not a.attisdropped order by a.attnum`, [oid])).rows;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The single-column primary key of a table (the queue keys rows by it), or null. */
|
|
44
|
+
async function keyOf(db: Db, oid: number) {
|
|
45
|
+
return (await db.one<{ k: string }>(
|
|
46
|
+
`select a.attname as k from pg_index i join pg_attribute a on a.attrelid = i.indrelid and a.attnum = i.indkey[0]
|
|
47
|
+
where i.indrelid = $1 and i.indisprimary and i.indnatts = 1`, [oid]))?.k ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The workspace's applications' vector providers, as options "appId:NAME". */
|
|
51
|
+
async function providerChoices(s: Parameters<typeof currentWorkspace>[0]) {
|
|
52
|
+
const apps = (await owner.query<{ id: number; alias: string; vector_providers: VectorProvider[] }>(
|
|
53
|
+
`select id, alias, vector_providers from meta.app a where ${inWorkspaceSql('a', 1)} and jsonb_array_length(vector_providers) > 0 order by alias`, [currentWorkspace(s)])).rows;
|
|
54
|
+
return apps.flatMap((a) => a.vector_providers.filter((p) => !vectorProviderProblems(p).length).map((p) => ({ key: `${a.id}:${p.name}`, appId: a.id, alias: a.alias, p })));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The statements: the column, its HNSW index (up to 2,000 dimensions) and the trigger that keeps it. */
|
|
58
|
+
export function vectorDdl(o: { table: string; vectorType: string; column: string; index: string; trigger: string; dims: number; distance: Distance; mode: 'set' | 'queue'; appId: number; provider: string; key: string; sources: string[] }) {
|
|
59
|
+
const args = [String(o.appId), o.provider, o.column, o.key, ...o.sources].map((x) => pg.escapeLiteral(x)).join(', ');
|
|
60
|
+
return [
|
|
61
|
+
`alter table ${o.table}\n add column ${ident(o.column)} ${o.vectorType}(${o.dims})`,
|
|
62
|
+
...(o.dims <= HNSW_MAX_DIMENSIONS ? [`create index ${ident(o.index)} on ${o.table} using hnsw (${ident(o.column)} ${DISTANCES[o.distance].opclass})`] : []),
|
|
63
|
+
o.mode === 'set'
|
|
64
|
+
? `create trigger ${ident(o.trigger)}\n before insert or update of ${o.sources.map(ident).join(', ')} on ${o.table}\n for each row execute function meta.embedding_set(${args})`
|
|
65
|
+
: `create trigger ${ident(o.trigger)}\n after insert or update of ${o.sources.map(ident).join(', ')} on ${o.table}\n for each row execute function meta.embedding_enqueue(${args})`,
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function page(req: Req, reply: FastifyReply) {
|
|
70
|
+
const s = await developer(req, reply);
|
|
71
|
+
if (!s) return;
|
|
72
|
+
const tx = tr(s);
|
|
73
|
+
const db = await workshopDb(s);
|
|
74
|
+
const isPost = req.method === 'POST';
|
|
75
|
+
const b: Body = (isPost ? req.body : req.query) ?? {};
|
|
76
|
+
const ref = String(b.o ?? '');
|
|
77
|
+
const t = ref ? await readTable(db, ref) : null;
|
|
78
|
+
const schemas = (await usableSchemas(db)).filter((n) => !SYSTEM_SCHEMAS.includes(n));
|
|
79
|
+
if (!t || !schemas.includes(t.schema)) return reply.code(404).send('Not found');
|
|
80
|
+
const table = `${ident(t.schema)}.${ident(t.name)}`;
|
|
81
|
+
const self = `${BASE}/sql/objects/vector?${new URLSearchParams({ o: table })}`;
|
|
82
|
+
const oid = (await db.one<{ oid: number }>('select $1::regclass::oid as oid', [table]))!.oid;
|
|
83
|
+
const st = await pgvectorStatus(db);
|
|
84
|
+
const cols = await columnsOf(db, oid);
|
|
85
|
+
const texty = cols.filter((c) => c.texty);
|
|
86
|
+
const key = await keyOf(db, oid);
|
|
87
|
+
const choices = await providerChoices(s);
|
|
88
|
+
const triggers = st.installed ? await vectorTriggers(db, table) : [];
|
|
89
|
+
|
|
90
|
+
// Backfill: queue the rows of a vector column that have no embedding (or all of them)
|
|
91
|
+
if (isPost && b.mode === 'backfill') {
|
|
92
|
+
const trg = triggers.find((x) => x.vectorColumn === String(b.column ?? ''));
|
|
93
|
+
if (!trg || !(await appAllowed(s, trg.appId))) return reply.code(404).send('Not found');
|
|
94
|
+
const n = await backfill(db, trg, b.all === 'on');
|
|
95
|
+
kickEmbeddings();
|
|
96
|
+
flash(s, tx('vec.backfill_queued', { n: String(n), column: trg.vectorColumn }));
|
|
97
|
+
return back(reply, s, self);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const pick = choices.find((c) => c.key === String(b.provider ?? '')) ?? (isPost ? undefined : choices[0]);
|
|
101
|
+
const column = isPost ? String(b.column ?? '').trim() : cols.some((c) => c.name === 'embedding') ? '' : 'embedding';
|
|
102
|
+
const base = t.name.replace(/[^a-z0-9_]+/gi, '_').toLowerCase().slice(0, 40);
|
|
103
|
+
const index = isPost ? String(b.index ?? '').trim() : `${base}_${column || 'embedding'}_idx`;
|
|
104
|
+
const distance: Distance = isDistance(b.distance) ? b.distance : 'cosine';
|
|
105
|
+
const mode: 'set' | 'queue' = pick?.p.type === 'function' && (isPost ? b.keep === 'set' : true) ? 'set' : 'queue';
|
|
106
|
+
const picked = new Set<string>();
|
|
107
|
+
if (isPost) texty.forEach((c, i) => b[`c.${i}.use`] === 'on' && b[`c.${i}.name`] === c.name && picked.add(c.name));
|
|
108
|
+
else texty.forEach((c) => picked.add(c.name));
|
|
109
|
+
|
|
110
|
+
const errors: string[] = [];
|
|
111
|
+
let ddl: string[] = [];
|
|
112
|
+
let output: Raw | '' = '';
|
|
113
|
+
if (isPost && st.installed) {
|
|
114
|
+
if (!pick) errors.push(tx('vec.pick_provider'));
|
|
115
|
+
if (!picked.size) errors.push(tx('fts.pick'));
|
|
116
|
+
if (!key) errors.push(tx('vec.no_key'));
|
|
117
|
+
for (const n of [column, index]) if (!NAME.test(n)) errors.push(tx('fts.bad_name', { name: n }));
|
|
118
|
+
if (NAME.test(column) && cols.some((c) => c.name === column)) errors.push(tx('fts.exists', { name: column }));
|
|
119
|
+
if (NAME.test(index) && (await db.one('select 1 as x from pg_class where relnamespace = $1::regnamespace and relname = $2', [ident(t.schema), index]))) errors.push(tx('fts.exists', { name: index }));
|
|
120
|
+
const trigger = `${column}_embed`.slice(0, 63);
|
|
121
|
+
if (!errors.length && pick && key) {
|
|
122
|
+
ddl = vectorDdl({ table, vectorType: `${ident(st.schema!)}.vector`, column, index, trigger, dims: pick.p.dimensions, distance, mode, appId: pick.appId, provider: pick.p.name, key, sources: [...picked] });
|
|
123
|
+
}
|
|
124
|
+
if (ddl.length && b.mode === 'worksheet') {
|
|
125
|
+
s.state.__SQLDRAFT = `${ddl.join(';\n\n')};`;
|
|
126
|
+
flash(s, tx('te.review'));
|
|
127
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
128
|
+
}
|
|
129
|
+
if (ddl.length && b.mode === 'apply') {
|
|
130
|
+
const c = await db.pool.connect();
|
|
131
|
+
let failed = '';
|
|
132
|
+
let at = '';
|
|
133
|
+
try {
|
|
134
|
+
await c.query('begin');
|
|
135
|
+
for (const x of ddl) {
|
|
136
|
+
at = x;
|
|
137
|
+
await c.query(x);
|
|
138
|
+
}
|
|
139
|
+
await c.query('commit');
|
|
140
|
+
} catch (e) {
|
|
141
|
+
failed = (e as Error).message;
|
|
142
|
+
await c.query('rollback').catch(() => {});
|
|
143
|
+
} finally {
|
|
144
|
+
c.release(true);
|
|
145
|
+
}
|
|
146
|
+
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: ddl.join(';\n').slice(0, 2000) });
|
|
147
|
+
clearCompletions();
|
|
148
|
+
if (!failed) {
|
|
149
|
+
await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'table', defaultSchema: t.schema, statements: [joinStatements(ddl)] });
|
|
150
|
+
// the rows there are now: queued at once (the server embeds them; a function provider too)
|
|
151
|
+
const trg = (await vectorTriggers(db, table)).find((x) => x.vectorColumn === column);
|
|
152
|
+
const n = trg ? await backfill(db, trg, true) : 0;
|
|
153
|
+
kickEmbeddings();
|
|
154
|
+
flash(s, tx('vec.added', { table, column, n: String(n) }));
|
|
155
|
+
return back(reply, s, self);
|
|
156
|
+
}
|
|
157
|
+
output = html`<div class="alert alert-error" role="alert"><p><strong>${tx('te.nothing_changed')}</strong>: ${tx('te.rolled_back')}</p><p>${failed}</p><pre class="source" data-code-view="sql" tabindex="0" aria-label="${tx('te.failed_stmt')}">${at}</pre></div>`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// the table's vector columns now, with their progress
|
|
162
|
+
const existing: Raw[] = [];
|
|
163
|
+
for (const c of cols.filter((x) => x.vector)) {
|
|
164
|
+
const trg = triggers.find((x) => x.vectorColumn === c.name);
|
|
165
|
+
const counts = (await db.one<{ total: number; done: number }>(`select count(*)::int as total, count(${ident(c.name)})::int as done from ${table}`))!;
|
|
166
|
+
const q = await queueState(oid, c.name);
|
|
167
|
+
const pct = counts.total ? Math.round((counts.done / counts.total) * 100) : 100;
|
|
168
|
+
existing.push(html`<li class="vec-column"><p><code>${c.name}</code> <code>${c.type}</code>
|
|
169
|
+
${trg ? html`· ${tx('vec.kept_by', { provider: trg.provider, columns: trg.sourceColumns.join(', ') })} (${trg.mode === 'set' ? tx('vec.mode_set') : tx('vec.mode_queue')})` : html`· <span class="muted">${tx('vec.not_kept')}</span>`}</p>
|
|
170
|
+
<p><progress max="100" value="${pct}" aria-label="${tx('vec.progress', { column: c.name })}">${pct}%</progress>
|
|
171
|
+
${tx('vec.counts', { done: String(counts.done), total: String(counts.total), queued: String(q.queued) })}${q.failing ? html` · <span class="tag tag-error">${tx('vec.failing', { n: String(q.failing) })}</span>` : ''}</p>
|
|
172
|
+
${q.message ? html`<p class="muted">${q.message}</p>` : ''}
|
|
173
|
+
${trg ? html`<form method="post" action="${BASE}/sql/objects/vector" class="buttons">${csrf(s)}<input type="hidden" name="o" value="${table}"><input type="hidden" name="column" value="${c.name}">
|
|
174
|
+
<label class="check"><input type="checkbox" name="all"> ${tx('vec.reembed_all')}</label>
|
|
175
|
+
<button class="btn" name="mode" value="backfill">${icon('refresh')} ${tx('vec.backfill')}</button>
|
|
176
|
+
<a class="btn" href="${self}">${tx('vec.refresh')}</a></form>` : ''}</li>`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const title = tx('vec.title', { table });
|
|
180
|
+
const sel = (a: string, v: string) => (a === v ? ' selected' : '');
|
|
181
|
+
const admin = await isAdmin(s.username);
|
|
182
|
+
const form = !st.installed
|
|
183
|
+
? html`<div class="alert alert-info" role="status"><p>${tx('vec.missing')}</p>${st.available ? html`<p>${tx(admin && st.canCreate ? 'vec.missing_create' : 'vec.missing_admin', { version: st.availableVersion ?? '' })}</p>` : html`<p>${tx('vec.missing_package')}</p>`}
|
|
184
|
+
<p><a class="btn" href="${BASE}/vector-providers">${icon('search')} ${tx('vec.utilities')}</a></p></div>`
|
|
185
|
+
: !choices.length
|
|
186
|
+
? html`<p class="alert alert-info">${tx('vec.no_providers')}</p>`
|
|
187
|
+
: !texty.length
|
|
188
|
+
? html`<p class="alert alert-info">${tx('fts.no_text')}</p>`
|
|
189
|
+
: html`<form method="post" action="${BASE}/sql/objects/vector" class="fts-form">${csrf(s)}
|
|
190
|
+
<input type="hidden" name="o" value="${table}">
|
|
191
|
+
<div class="form-grid">
|
|
192
|
+
<div class="field"><label class="label" for="vec-provider">${tx('vec.provider')}</label><select id="vec-provider" name="provider">${choices.map((c) => html`<option value="${c.key}"${sel(c.key, pick?.key ?? '')}>${c.alias} · ${c.p.name} (${c.p.type === 'ai_service' ? c.p.model : c.p.function}, ${c.p.dimensions})</option>`)}</select>
|
|
193
|
+
<small class="help">${tx('vec.provider_help')}</small></div>
|
|
194
|
+
<div class="field"><label class="label" for="vec-keep">${tx('vec.keep')}</label><select id="vec-keep" name="keep"><option value="set"${sel(mode, 'set')}>${tx('vec.mode_set')}</option><option value="queue"${sel(mode, 'queue')}>${tx('vec.mode_queue')}</option></select>
|
|
195
|
+
<small class="help">${tx('vec.keep_help')}</small></div>
|
|
196
|
+
</div>
|
|
197
|
+
<fieldset class="prop-group"><legend>${tx('vec.sources')}</legend>
|
|
198
|
+
<div class="table-wrap"><table class="report"><thead><tr><th>${tx('fts.include')}</th><th>${tx('te.name')}</th><th>${tx('te.type')}</th></tr></thead><tbody>
|
|
199
|
+
${texty.map((c, i) => html`<tr><td class="te-c"><input type="hidden" name="c.${i}.name" value="${c.name}"><input type="checkbox" id="vec-c${i}" name="c.${i}.use"${picked.has(c.name) ? ' checked' : ''}></td>
|
|
200
|
+
<td><label for="vec-c${i}">${c.name}</label></td><td><code>${c.type}</code></td></tr>`)}
|
|
201
|
+
</tbody></table></div>
|
|
202
|
+
<small class="help">${tx('vec.sources_help')}</small></fieldset>
|
|
203
|
+
<div class="form-grid u-mt1">
|
|
204
|
+
<div class="field"><label class="label" for="vec-distance">${tx('vec.distance')}</label><select id="vec-distance" name="distance">${(Object.keys(DISTANCES) as Distance[]).map((d) => html`<option${sel(d, distance)}>${d}</option>`)}</select><small class="help">${tx('vec.distance_help')}</small></div>
|
|
205
|
+
<div class="field"><label class="label" for="vec-column">${tx('vec.column')}</label><input id="vec-column" name="column" value="${column}" required autocomplete="off" spellcheck="false" class="code"></div>
|
|
206
|
+
<div class="field"><label class="label" for="vec-index">${tx('fts.index')}</label><input id="vec-index" name="index" value="${index}" required autocomplete="off" spellcheck="false" class="code"></div>
|
|
207
|
+
</div>
|
|
208
|
+
${key ? '' : html`<p class="alert alert-error" role="alert">${tx('vec.no_key')}</p>`}
|
|
209
|
+
${ddl.length ? html`<h2>DDL</h2><pre class="source te-ddl" data-code-view="sql" tabindex="0" aria-label="DDL">${ddl.map((x) => `${x};`).join('\n\n')}</pre>` : ''}
|
|
210
|
+
<div class="buttons te-buttons">
|
|
211
|
+
<button class="btn" name="mode" value="preview">${icon('eye')} ${tx('te.preview')}</button>
|
|
212
|
+
<button class="btn btn-hot" name="mode" value="apply">${icon('check')} ${tx('te.apply')}</button>
|
|
213
|
+
<button class="btn" name="mode" value="worksheet">${icon('code')} ${tx('te.open_ws')}</button>
|
|
214
|
+
<a class="btn" href="${BASE}/sql/objects/table?${new URLSearchParams({ o: table })}">${tx('common.cancel')}</a>
|
|
215
|
+
</div></form>`;
|
|
216
|
+
const main = html`<h1 class="u-mb1">${title}</h1>${workshopTabs('objects', s)}
|
|
217
|
+
<p class="muted">${tx('vec.intro')}</p>
|
|
218
|
+
${existing.length ? html`<h2>${tx('vec.existing')}</h2><ul class="vec-columns">${existing}</ul>` : ''}
|
|
219
|
+
${errors.length ? html`<div class="alert alert-error" role="alert"><ul>${errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : ''}
|
|
220
|
+
${output}
|
|
221
|
+
<h2>${tx('vec.add')}</h2>
|
|
222
|
+
${form}`;
|
|
223
|
+
return send(reply, s, shell(s, title, [['SQL Workshop', `${BASE}/sql`], [tx('ob.title'), `${BASE}/sql/objects`], [tx('te.title_table', { table }), `${BASE}/sql/objects/table?${new URLSearchParams({ o: table })}`], [tx('vec.add')]], main, 'sql'));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function vectorColumnRoutes(app: FastifyInstance) {
|
|
227
|
+
app.get(`${BASE}/sql/objects/vector`, page);
|
|
228
|
+
app.post(`${BASE}/sql/objects/vector`, page);
|
|
229
|
+
}
|
|
@@ -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[]);
|