pgkiln 0.69.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.
- package/db/migrations/136_vector_search.sql +252 -0
- package/docs/apex-feature-parity.md +5 -4
- package/docs/guide/03-builder.md +69 -0
- package/docs/guide/04-pages-and-regions.md +42 -2
- package/docs/guide/09-reference.md +19 -0
- package/docs/guide/12-development.md +31 -2
- package/docs/guide/15-extensions.md +1 -1
- package/examples/hr/README.md +1 -0
- package/examples/hr/hr_72_vector_search.sql +158 -0
- package/package.json +1 -1
- package/public/builder.css +3 -0
- package/src/ai/embeddings.ts +197 -0
- package/src/ai/service.ts +13 -9
- package/src/app.ts +3 -0
- package/src/builder/advisor.ts +41 -1
- package/src/builder/ai-agents.ts +13 -5
- package/src/builder/components.ts +21 -3
- package/src/builder/home.ts +1 -0
- package/src/builder/i18n/de.ts +34 -0
- package/src/builder/i18n/en.ts +34 -0
- package/src/builder/i18n/es.ts +34 -0
- package/src/builder/i18n/fr.ts +34 -0
- package/src/builder/i18n/it.ts +34 -0
- package/src/builder/i18n/ja.ts +34 -0
- package/src/builder/i18n/ko.ts +34 -0
- package/src/builder/i18n/nl.ts +34 -0
- package/src/builder/i18n/pt.ts +34 -0
- package/src/builder/i18n/zh-cn.ts +34 -0
- package/src/builder/i18n/zh-tw.ts +34 -0
- package/src/builder/object-actions.ts +1 -0
- 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/vector-column.ts +229 -0
- package/src/builder/vector-providers.ts +286 -0
- package/src/cli/replace.ts +3 -1
- package/src/runtime/ai-agents.ts +53 -2
- package/src/runtime/search.ts +116 -12
- package/src/server.ts +3 -0
- package/src/vector.ts +288 -0
|
@@ -1065,6 +1065,40 @@ export const zhTW: BuilderMessages = {
|
|
|
1065
1065
|
'fts.gin_yes': "GIN 索引",
|
|
1066
1066
|
'fts.gin_no': "無 GIN 索引",
|
|
1067
1067
|
'fts.added': "已為 {table} 新增全文檢索:欄位 {column},索引 {index}。",
|
|
1068
|
+
// (sprint 136, AP-1) Table Properties › Add vector column
|
|
1069
|
+
'vec.add': "新增向量欄位",
|
|
1070
|
+
'vec.title': "{table} 的向量欄位",
|
|
1071
|
+
'vec.intro': "新增一個嵌入欄位 vector(n)(n:向量提供者的維度數)、一個用於該距離的 HNSW 索引,以及在來源欄位變更時重新嵌入該列的觸發程序。之後,搜尋類型為 vector 或 hybrid 的搜尋組態,或 AI 代理的 Retrieve 工具,就能依含意找到資料列。現有的資料列會立即進入嵌入佇列。",
|
|
1072
|
+
'vec.existing': "目前的向量欄位",
|
|
1073
|
+
'vec.provider': "向量提供者",
|
|
1074
|
+
'vec.provider_help': "此工作區應用程式的共用元件 › 向量提供者。搜尋時使用同一個提供者。",
|
|
1075
|
+
'vec.keep': "保持最新",
|
|
1076
|
+
'vec.keep_help': "函式提供者可以在觸發程序中立即設定嵌入。AI 服務由伺服器呼叫:觸發程序將資料列放入佇列,伺服器在提供者的每分鐘請求數與應用程式的每日 AI 上限內,批次嵌入佇列中的資料列。",
|
|
1077
|
+
'vec.mode_set': "由觸發程序立即設定",
|
|
1078
|
+
'vec.mode_queue': "放入伺服器的佇列",
|
|
1079
|
+
'vec.sources': "來源欄位",
|
|
1080
|
+
'vec.sources_help': "要嵌入的文字:這些欄位,每行一個。其中任一欄位變更時會重新嵌入該列。",
|
|
1081
|
+
'vec.distance': "距離",
|
|
1082
|
+
'vec.distance_help': "大多數嵌入模型使用 cosine;搜尋組態使用相同的距離。",
|
|
1083
|
+
'vec.column': "向量欄位",
|
|
1084
|
+
'vec.no_key': "此表格需要單一欄位的主索引鍵:佇列以此找出資料列。",
|
|
1085
|
+
'vec.pick_provider': "請選擇一個向量提供者。",
|
|
1086
|
+
'vec.no_providers': "此工作區的應用程式還沒有向量提供者:請在共用元件 › 向量提供者中新增。",
|
|
1087
|
+
'vec.missing': "此資料庫未安裝 pgvector 擴充功能,因此暫時無法新增向量欄位。",
|
|
1088
|
+
'vec.missing_create': "資料庫伺服器有 pgvector {version}:請在工作區公用程式 › 向量提供者中建立該擴充功能。",
|
|
1089
|
+
'vec.missing_admin': "資料庫伺服器有 pgvector {version}:由管理員建立該擴充功能(工作區公用程式 › 向量提供者)。",
|
|
1090
|
+
'vec.missing_package': "資料庫伺服器沒有 pgvector 套件:請先安裝(例如 apt install postgresql-17-pgvector,或 Docker 映像檔 pgvector/pgvector),再建立該擴充功能。",
|
|
1091
|
+
'vec.utilities': "工作區公用程式 › 向量提供者",
|
|
1092
|
+
'vec.kept_by': "由 {provider} 依 {columns} 維護",
|
|
1093
|
+
'vec.not_kept': "沒有觸發程序讓它保持最新",
|
|
1094
|
+
'vec.progress': "{column} 已嵌入的資料列",
|
|
1095
|
+
'vec.counts': "{total} 列中已嵌入 {done} 列,{queued} 列在佇列中",
|
|
1096
|
+
'vec.failing': "{n} 列失敗",
|
|
1097
|
+
'vec.reembed_all': "所有資料列,而不只是沒有嵌入的資料列",
|
|
1098
|
+
'vec.backfill': "回填",
|
|
1099
|
+
'vec.refresh': "重新整理",
|
|
1100
|
+
'vec.backfill_queued': "已將 {column} 的 {n} 列放入嵌入佇列。",
|
|
1101
|
+
'vec.added': "已為 {table} 新增向量欄位 {column};{n} 列已進入嵌入佇列。",
|
|
1068
1102
|
// ------------------------------------------------------------ Shared Components (sprint 124)
|
|
1069
1103
|
'comp.nav_entry': '導覽項目',
|
|
1070
1104
|
'comp.nav_entry.plural': '導覽選單',
|
|
@@ -33,6 +33,7 @@ export function objectMenu(schema: string, o: MenuObj): Raw {
|
|
|
33
33
|
${table ? item(`${BASE}/sql/objects/security?${new URLSearchParams({ o: o.ref })}`, 'shield', 'Security…') : ''}
|
|
34
34
|
${table ? item(`${BASE}/sql/load`, 'upload', 'Load data') : ''}
|
|
35
35
|
${table ? item(`${BASE}/sql/objects/fulltext?${new URLSearchParams({ o: o.ref })}`, 'search', 'Add full-text search…') : ''}
|
|
36
|
+
${table ? item(`${BASE}/sql/objects/vector?${new URLSearchParams({ o: o.ref })}`, 'search', 'Add vector column…') : ''}
|
|
36
37
|
${table ? html`${item(q('index'), 'list', 'Add index')}${item(q('trigger'), 'bolt', 'Add trigger')}${item(q('policy'), 'shield', 'Add policy')}` : ''}
|
|
37
38
|
${RELATIONS.has(o.type) ? item(`${BASE}/sql/rest/object?${new URLSearchParams({ schema, name: o.name })}`, 'globe', 'REST › Enable / Disable') : ''}
|
|
38
39
|
${item(q('ddl'), 'code', 'Quick DDL to the worksheet')}
|
package/src/builder/shared.ts
CHANGED
|
@@ -76,7 +76,7 @@ export function sharedGroups(appId: number): { key: string; title: string; icon:
|
|
|
76
76
|
{ key: 'data', title: 'Data Sources', icon: 'database', items: [t('rest_source', 'REST Data Sources'), t('web_credential', 'Web Credentials'), t('rest_module', 'RESTful Services'), t('data_load_def', 'Data Load Definitions')] },
|
|
77
77
|
{ key: 'workflows', title: 'Workflows and Automations', icon: 'activity', items: [t('workflow_definition', 'Workflows'), t('task_definition', 'Task Definitions'), t('automation', 'Automations')] },
|
|
78
78
|
{ key: 'globalization', title: 'Globalization', icon: 'globe', items: [page('globalization', 'Translations and Text Messages'), t('dynamic_translation', 'Dynamic Translations'), settings('globalization', 'Globalization Attributes')] },
|
|
79
|
-
{ key: 'ai', title: 'Generative AI', icon: 'lightbulb', items: [page('ai', 'AI Services'), page('ai-agents', 'AI Agents')] },
|
|
79
|
+
{ key: 'ai', title: 'Generative AI', icon: 'lightbulb', items: [page('ai', 'AI Services'), page('ai-agents', 'AI Agents'), page('vector-providers', 'Vector Providers')] },
|
|
80
80
|
];
|
|
81
81
|
}
|
|
82
82
|
|
package/src/builder/sql.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { createTemplate, objectBrowserRoutes } from './object-browser.ts';
|
|
|
20
20
|
import { objectActionRoutes } from './object-actions.ts';
|
|
21
21
|
import { tableEditorRoutes } from './table-editor.ts';
|
|
22
22
|
import { fulltextRoutes } from './fulltext.ts';
|
|
23
|
+
import { vectorColumnRoutes } from './vector-column.ts';
|
|
23
24
|
import { dataSecurityRoutes } from './data-security.ts';
|
|
24
25
|
import { dataModelerRoutes } from './data-modeler.ts';
|
|
25
26
|
import { autoRestBuilderRoutes } from './autorest.ts';
|
|
@@ -137,6 +138,8 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
137
138
|
await app.register(tableEditorRoutes);
|
|
138
139
|
// (sprint 135, AP-2) Table Properties › Add full-text search
|
|
139
140
|
await app.register(fulltextRoutes);
|
|
141
|
+
// (sprint 136, AP-1) Table Properties › Add vector column
|
|
142
|
+
await app.register(vectorColumnRoutes);
|
|
140
143
|
await app.register(dataSecurityRoutes);
|
|
141
144
|
// (sprint 98) the Data Modeler: relational diagrams, their DDL and data dictionary
|
|
142
145
|
await app.register(dataModelerRoutes);
|
|
@@ -143,6 +143,7 @@ function form(s: Session, def: TableDef, opts: { editing: TableDef | null; schem
|
|
|
143
143
|
<button class="btn btn-hot" name="mode" value="apply">${icon('check')} ${tx('te.apply')}</button>
|
|
144
144
|
<button class="btn" name="mode" value="worksheet">${icon('code')} ${tx('te.open_ws')}</button>
|
|
145
145
|
${editing ? html`<a class="btn" href="${BASE}/sql/objects/fulltext?${new URLSearchParams({ o: opts.ref })}">${icon('search')} ${tx('fts.add')}…</a>` : ''}
|
|
146
|
+
${editing ? html`<a class="btn" href="${BASE}/sql/objects/vector?${new URLSearchParams({ o: opts.ref })}">${icon('search')} ${tx('vec.add')}…</a>` : ''}
|
|
146
147
|
<a class="btn" href="${editing ? `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema, k: 'table', o: opts.ref })}` : `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema })}`}">${tx('common.cancel')}</a>
|
|
147
148
|
</div></form>`;
|
|
148
149
|
}
|
|
@@ -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[]);
|