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