pgkiln 0.69.0 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/136_vector_search.sql +252 -0
- package/db/migrations/137_create_page_types.sql +904 -0
- package/db/migrations/138_app_utilities.sql +153 -0
- package/docs/apex-feature-parity.md +10 -8
- package/docs/guide/03-builder.md +176 -4
- package/docs/guide/04-pages-and-regions.md +45 -3
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/06-processing.md +18 -0
- package/docs/guide/09-reference.md +34 -1
- package/docs/guide/12-development.md +53 -3
- package/docs/guide/15-extensions.md +1 -1
- package/docs/guide/16-files.md +1 -1
- package/examples/hr/README.md +1 -0
- package/examples/hr/hr_72_vector_search.sql +158 -0
- package/examples/hr/hr_73_configuration_options.sql +17 -0
- package/examples/hr/hr_74_app_utilities.sql +22 -0
- package/package.json +1 -1
- package/public/app.css +9 -0
- package/public/builder.css +48 -0
- package/src/ai/embeddings.ts +197 -0
- package/src/ai/service.ts +13 -9
- package/src/app.ts +13 -0
- package/src/builder/advisor.ts +43 -3
- package/src/builder/ai-agents.ts +13 -5
- package/src/builder/app-updates.ts +3 -0
- package/src/builder/components.ts +24 -4
- package/src/builder/create-page.ts +259 -0
- package/src/builder/dependencies.ts +311 -0
- package/src/builder/features.ts +313 -0
- package/src/builder/home.ts +3 -0
- package/src/builder/i18n/de.ts +38 -0
- package/src/builder/i18n/en.ts +38 -0
- package/src/builder/i18n/es.ts +38 -0
- package/src/builder/i18n/fr.ts +38 -0
- package/src/builder/i18n/it.ts +38 -0
- package/src/builder/i18n/ja.ts +38 -0
- package/src/builder/i18n/ko.ts +38 -0
- package/src/builder/i18n/nl.ts +38 -0
- package/src/builder/i18n/pt.ts +38 -0
- package/src/builder/i18n/zh-cn.ts +38 -0
- package/src/builder/i18n/zh-tw.ts +38 -0
- package/src/builder/login-page.ts +17 -14
- package/src/builder/newapp.ts +41 -23
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/routes.ts +13 -2
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +3 -0
- package/src/builder/table-editor.ts +1 -0
- package/src/builder/ui-defaults.ts +161 -0
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +154 -0
- package/src/builder/utilities.ts +181 -0
- package/src/builder/vector-column.ts +229 -0
- package/src/builder/vector-providers.ts +286 -0
- package/src/builder/wizards.ts +211 -26
- package/src/cli/replace.ts +11 -1
- package/src/history.ts +155 -0
- package/src/runtime/ai-agents.ts +53 -2
- package/src/runtime/builtin-components.ts +23 -2
- package/src/runtime/items.ts +4 -2
- package/src/runtime/search.ts +116 -12
- package/src/server.ts +3 -0
- package/src/vector.ts +288 -0
package/public/builder.css
CHANGED
|
@@ -1597,3 +1597,51 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
1597
1597
|
--font: "Inter", "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", "Noto Sans CJK KR", "Source Han Sans KR", sans-serif; }
|
|
1598
1598
|
:is(.ide-lang-form, .signin-langs) [lang] { font-family: var(--font); }
|
|
1599
1599
|
:is(.ide, .ide-login):lang(ko) { word-break: keep-all; overflow-wrap: break-word; }
|
|
1600
|
+
|
|
1601
|
+
/* (sprint 136) a button inside an alert (e.g. Add vector column without pgvector) wraps on a phone instead of overflowing */
|
|
1602
|
+
.alert .btn { white-space: normal; overflow-wrap: anywhere; max-width: 100%; }
|
|
1603
|
+
|
|
1604
|
+
/* (sprint 137, AP-3) Create page: the grid of page types with small previews, the features, the type's step */
|
|
1605
|
+
.cp-tabs { margin-bottom: 0.9rem; flex-wrap: wrap; }
|
|
1606
|
+
.cp-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); gap: 0.75rem; }
|
|
1607
|
+
.cp-grid > li { min-width: 0; }
|
|
1608
|
+
.cp-type { display: flex; flex-direction: column; gap: 0.35rem; height: 100%; padding: 0.6rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
|
|
1609
|
+
a.cp-type:hover, a.cp-type:focus-visible { border-color: var(--accent); }
|
|
1610
|
+
.cp-type b { font-size: 0.95rem; }
|
|
1611
|
+
.cp-type small { color: var(--muted); line-height: 1.35; }
|
|
1612
|
+
.cp-type .tag { align-self: flex-start; }
|
|
1613
|
+
.cp-done { opacity: 0.6; }
|
|
1614
|
+
.cp-preview { display: block; border-radius: var(--radius-sm); background: var(--surface-2); padding: 4px; }
|
|
1615
|
+
.cp-preview-lg { max-width: 16rem; }
|
|
1616
|
+
.cp-svg { display: block; width: 100%; height: auto; }
|
|
1617
|
+
.cp-svg .pv-line { fill: var(--border-strong); }
|
|
1618
|
+
.cp-svg .pv-soft { fill: color-mix(in srgb, var(--accent) 28%, var(--surface)); }
|
|
1619
|
+
.cp-svg .pv-acc { fill: var(--accent); }
|
|
1620
|
+
.cp-svg .pv-bg { fill: var(--surface); }
|
|
1621
|
+
.cp-svg .pv-frame { fill: var(--surface); stroke: var(--border-strong); stroke-width: 1; }
|
|
1622
|
+
.cp-svg .pv-stroke { fill: none; stroke: var(--border-strong); stroke-width: 1.5; }
|
|
1623
|
+
.cp-feature-icon { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 10px; background: var(--accent-soft, var(--surface-2)); color: var(--accent); }
|
|
1624
|
+
.cp-step { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; margin-bottom: 0.75rem; }
|
|
1625
|
+
.cp-step > .cp-preview { flex: 0 1 12rem; }
|
|
1626
|
+
.cp-step > p { flex: 1 1 14rem; margin: 0; }
|
|
1627
|
+
.cp-adds { padding-inline-start: 1.2rem; overflow-wrap: anywhere; }
|
|
1628
|
+
|
|
1629
|
+
/* (sprint 138, AP-5) an application's Utilities: filters, the dashboard figures, object dependencies, icons, UI defaults */
|
|
1630
|
+
.util-filters { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem 0.75rem; margin-bottom: 0.75rem; }
|
|
1631
|
+
.util-filters .field { margin: 0; min-width: 0; flex: 1 1 9rem; }
|
|
1632
|
+
.util-filters .buttons { margin: 0; }
|
|
1633
|
+
.util-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: 0.75rem; margin: 0; }
|
|
1634
|
+
.util-stats div { border: 1px solid var(--border); border-radius: var(--radius-sm, 6px); padding: 0.5rem 0.75rem; }
|
|
1635
|
+
.util-stats dt { color: var(--muted); font-size: 0.8rem; }
|
|
1636
|
+
.util-stats dd { margin: 0; font-size: 1.4rem; font-weight: 650; }
|
|
1637
|
+
.util-sum code, .util-history td, .dep-table td { overflow-wrap: anywhere; }
|
|
1638
|
+
.util-sum code { word-break: break-all; }
|
|
1639
|
+
.tag-success { background: var(--success-soft); color: var(--success); }
|
|
1640
|
+
.dep-problem td:first-child, .dep-missing { color: var(--danger); }
|
|
1641
|
+
.dep-list { margin: 0.25rem 0 0; padding-left: 1rem; }
|
|
1642
|
+
.dep-list a { display: inline-flex; gap: 0.3rem; align-items: center; }
|
|
1643
|
+
.util-icons { display: grid; grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr)); gap: 0.5rem; margin: 0 0 1rem; padding: 0; list-style: none; }
|
|
1644
|
+
.util-icons .icon-tile { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; padding: 0.6rem 0.25rem; border: 1px solid var(--border); border-radius: var(--radius-sm, 6px); text-align: center; }
|
|
1645
|
+
.util-icons code { font-size: 0.75rem; overflow-wrap: anywhere; }
|
|
1646
|
+
.ui-defaults :is(input, select, textarea) { width: 100%; min-width: 7rem; }
|
|
1647
|
+
.util-upgrade + .util-upgrade { margin-top: 1rem; }
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { openAiError } from './openai.ts';
|
|
4
|
+
import { baseUrlFor, checkDailyLimit, logUsage, serviceForApp, serviceKey, type CallInfo } from './service.ts';
|
|
5
|
+
import { AiError } from './types.ts';
|
|
6
|
+
|
|
7
|
+
// (sprint 136, AP-1; APEX: Vector Providers, APEX_AI.GET_VECTOR_EMBEDDINGS) Vector providers turn text into an
|
|
8
|
+
// embedding. They are a JSON list in meta.app (vector_providers, migration 136), exported with the application:
|
|
9
|
+
//
|
|
10
|
+
// - ai_service: an AI service of the installation (OpenAI, Gemini, or any OpenAI-compatible endpoint such as
|
|
11
|
+
// Ollama) and an embedding model (text-embedding-3-small, gemini-embedding-001, nomic-embed-text …), called with
|
|
12
|
+
// the official openai SDK's embeddings API. The key stays in meta.ai_service (encrypted, write-only, never
|
|
13
|
+
// exported); the application must be allowed to use the service and its daily limits apply; every call is
|
|
14
|
+
// logged in meta.ai_usage (source "embedding": tokens, model, duration, never the text).
|
|
15
|
+
// - function: a SQL or PL/pgSQL function (text → vector, real[] or float8[]), called by meta.embed() as the
|
|
16
|
+
// role that runs the query (security invoker), so its grants apply.
|
|
17
|
+
//
|
|
18
|
+
// The dimensions are part of the definition: every embedding is checked against them, and the builder checks
|
|
19
|
+
// them against the vector column (vector(n)) a search configuration or a vector column helper uses.
|
|
20
|
+
|
|
21
|
+
export type VectorProviderType = 'ai_service' | 'function';
|
|
22
|
+
export const VECTOR_PROVIDER_TYPES: VectorProviderType[] = ['ai_service', 'function'];
|
|
23
|
+
|
|
24
|
+
export interface VectorProvider {
|
|
25
|
+
name: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
type: VectorProviderType;
|
|
28
|
+
/** ai_service: the AI service's name */
|
|
29
|
+
service?: string;
|
|
30
|
+
/** ai_service: the embedding model, exactly as the provider names it */
|
|
31
|
+
model?: string;
|
|
32
|
+
dimensions: number;
|
|
33
|
+
/** function: schema.name */
|
|
34
|
+
function?: string;
|
|
35
|
+
/** ai_service: texts per request (background embedding; default 64) */
|
|
36
|
+
batch_size?: number;
|
|
37
|
+
/** ai_service: requests per minute (background embedding; default 60) */
|
|
38
|
+
per_minute?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const PROVIDER_NAME = /^[A-Z][A-Z0-9_]{0,59}$/;
|
|
42
|
+
const SERVICE_NAME = /^[A-Za-z][A-Za-z0-9_]{0,59}$/;
|
|
43
|
+
const MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/;
|
|
44
|
+
export const FUNCTION_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}\.[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
45
|
+
/** pgvector's vector type takes up to 16,000 dimensions (an HNSW index up to 2,000). */
|
|
46
|
+
export const MAX_DIMENSIONS = 16_000;
|
|
47
|
+
export const HNSW_MAX_DIMENSIONS = 2_000;
|
|
48
|
+
export const DEFAULT_BATCH = 64;
|
|
49
|
+
export const DEFAULT_PER_MINUTE = 60;
|
|
50
|
+
/** Characters of one text sent to a provider (longer texts are cut). */
|
|
51
|
+
export const MAX_TEXT_CHARS = 20_000;
|
|
52
|
+
|
|
53
|
+
const isObj = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
54
|
+
const intIn = (v: unknown, min: number, max: number) => Number.isInteger(v) && (v as number) >= min && (v as number) <= max;
|
|
55
|
+
|
|
56
|
+
/** Problems with one vector provider (builder and runtime). */
|
|
57
|
+
export function vectorProviderProblems(p: unknown): string[] {
|
|
58
|
+
if (!isObj(p)) return ['A vector provider is an object.'];
|
|
59
|
+
const what = `Vector provider ${typeof p.name === 'string' ? p.name : '?'}`;
|
|
60
|
+
const out: string[] = [];
|
|
61
|
+
if (typeof p.name !== 'string' || !PROVIDER_NAME.test(p.name)) out.push(`${what}: the name (static ID) is upper case letters, digits and _ (e.g. DOC_EMBEDDINGS).`);
|
|
62
|
+
if (!VECTOR_PROVIDER_TYPES.includes(p.type)) out.push(`${what}: the type is ai_service or function.`);
|
|
63
|
+
if (p.description !== undefined && p.description !== null && (typeof p.description !== 'string' || p.description.length > 500)) out.push(`${what}: the description is text (at most 500 characters).`);
|
|
64
|
+
if (!intIn(p.dimensions, 1, MAX_DIMENSIONS)) out.push(`${what}: the dimensions are a whole number from 1 to ${MAX_DIMENSIONS}.`);
|
|
65
|
+
if (p.type === 'ai_service') {
|
|
66
|
+
if (typeof p.service !== 'string' || !SERVICE_NAME.test(p.service)) out.push(`${what}: choose an AI service.`);
|
|
67
|
+
if (typeof p.model !== 'string' || !MODEL.test(p.model)) out.push(`${what}: the embedding model is its name, e.g. text-embedding-3-small or nomic-embed-text.`);
|
|
68
|
+
if (p.function !== undefined) out.push(`${what}: an AI service provider has no function.`);
|
|
69
|
+
}
|
|
70
|
+
if (p.type === 'function') {
|
|
71
|
+
if (typeof p.function !== 'string' || !FUNCTION_NAME.test(p.function)) out.push(`${what}: the function is schema.name, e.g. sales.embed.`);
|
|
72
|
+
if (p.service !== undefined || p.model !== undefined) out.push(`${what}: a function provider has no AI service or model.`);
|
|
73
|
+
}
|
|
74
|
+
if (p.batch_size !== undefined && !intIn(p.batch_size, 1, 2048)) out.push(`${what}: texts per request 1 to 2048.`);
|
|
75
|
+
if (p.per_minute !== undefined && !intIn(p.per_minute, 1, 10_000)) out.push(`${what}: requests per minute 1 to 10000.`);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Problems with an application's list: each entry, and names used twice. */
|
|
80
|
+
export function vectorProvidersProblems(list: unknown): string[] {
|
|
81
|
+
if (!Array.isArray(list)) return ['Vector providers are a list.'];
|
|
82
|
+
const out: string[] = [];
|
|
83
|
+
const names = new Set<string>();
|
|
84
|
+
for (const p of list) {
|
|
85
|
+
out.push(...vectorProviderProblems(p));
|
|
86
|
+
if (isObj(p) && typeof p.name === 'string') {
|
|
87
|
+
if (names.has(p.name)) out.push(`Vector provider ${p.name} is defined twice.`);
|
|
88
|
+
names.add(p.name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The application's vector providers (the owner reads them: the builder may have changed them a moment ago). */
|
|
95
|
+
export async function loadVectorProviders(appId: number): Promise<VectorProvider[]> {
|
|
96
|
+
const r = await owner.one<{ vector_providers: unknown }>('select vector_providers from meta.app where id = $1', [appId]);
|
|
97
|
+
return Array.isArray(r?.vector_providers) ? (r!.vector_providers as VectorProvider[]) : [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One valid provider of an application by its static ID, or an AiError. */
|
|
101
|
+
export async function vectorProviderFor(appId: number, name: string): Promise<VectorProvider> {
|
|
102
|
+
const p = (await loadVectorProviders(appId)).find((x) => x.name === String(name ?? '').toUpperCase());
|
|
103
|
+
if (!p) throw new AiError('config', `There is no vector provider ${String(name ?? '').toUpperCase()} (Shared Components → Vector providers).`);
|
|
104
|
+
const problems = vectorProviderProblems(p);
|
|
105
|
+
if (problems.length) throw new AiError('config', problems.join(' '));
|
|
106
|
+
return p;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A vector as pgvector's text input: [0.1,0.2,…] (numbers only). */
|
|
110
|
+
export function vectorLiteral(v: number[]): string {
|
|
111
|
+
if (!Array.isArray(v) || !v.length || v.some((x) => typeof x !== 'number' || !Number.isFinite(x))) throw new AiError('output', 'The embedding is not a list of numbers.');
|
|
112
|
+
return `[${v.join(',')}]`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A client, a pool or pgkiln's Db: something that runs a query with parameters. */
|
|
116
|
+
type Queryable = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> };
|
|
117
|
+
|
|
118
|
+
export interface EmbedInfo extends Pick<CallInfo, 'appId' | 'pageNo' | 'user' | 'debug'> {
|
|
119
|
+
appId: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Embed texts with a provider: one vector per text, each with the provider's dimensions. A function provider
|
|
124
|
+
* runs through meta.embed() on `client` (the caller's role and transaction); an AI service is called here.
|
|
125
|
+
*/
|
|
126
|
+
export async function embedTexts(p: VectorProvider, texts: string[], info: EmbedInfo, client?: Queryable): Promise<number[][]> {
|
|
127
|
+
if (!texts.length) return [];
|
|
128
|
+
const input = texts.map((t) => String(t ?? '').slice(0, MAX_TEXT_CHARS));
|
|
129
|
+
if (p.type === 'function') {
|
|
130
|
+
if (!client) throw new AiError('config', `Vector provider ${p.name}: a function provider needs a database connection.`);
|
|
131
|
+
const rows = (await client.query('select meta.embed(t, $2, $3) as v from unnest($1::text[]) with ordinality u(t, n) order by n', [input, p.name, info.appId])).rows;
|
|
132
|
+
return rows.map((r: { v: number[] | null }) => {
|
|
133
|
+
if (!Array.isArray(r.v)) throw new AiError('output', `Vector provider ${p.name}: the function returned no embedding.`);
|
|
134
|
+
return r.v.map(Number);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const { service: s, limits } = await serviceForApp(p.service!, info.appId);
|
|
138
|
+
if (s.provider === 'anthropic') throw new AiError('config', `AI service ${s.name} is a Claude service: Claude has no embeddings API (use an OpenAI, Gemini or OpenAI-compatible service).`);
|
|
139
|
+
const call: CallInfo = { ...info, source: 'embedding' };
|
|
140
|
+
await checkDailyLimit(s, limits, call, p.model!);
|
|
141
|
+
const client2 = new OpenAI({ apiKey: serviceKey(s), baseURL: baseUrlFor(s) || undefined, timeout: s.timeout_s * 1000, maxRetries: 1 });
|
|
142
|
+
const t0 = performance.now();
|
|
143
|
+
info.debug?.(6, `vector provider ${p.name}: ${input.length} text(s) to AI service ${s.name} (${s.provider}, ${p.model})`);
|
|
144
|
+
let tokens = 0;
|
|
145
|
+
try {
|
|
146
|
+
let res: OpenAI.CreateEmbeddingResponse;
|
|
147
|
+
try {
|
|
148
|
+
res = await client2.embeddings.create({
|
|
149
|
+
model: p.model!,
|
|
150
|
+
input,
|
|
151
|
+
encoding_format: 'float',
|
|
152
|
+
// OpenAI's text-embedding-3 models shorten their vectors to the dimensions asked for
|
|
153
|
+
...(s.provider === 'openai' && /^text-embedding-3/.test(p.model!) ? { dimensions: p.dimensions } : {}),
|
|
154
|
+
});
|
|
155
|
+
} catch (e) {
|
|
156
|
+
throw openAiError(e);
|
|
157
|
+
}
|
|
158
|
+
tokens = res.usage?.prompt_tokens ?? 0;
|
|
159
|
+
const data = [...(res.data ?? [])].sort((a, b) => a.index - b.index);
|
|
160
|
+
if (data.length !== input.length) throw new AiError('output', `The AI service sent ${data.length} embeddings for ${input.length} texts.`);
|
|
161
|
+
const out = data.map((d) => (Array.isArray(d.embedding) ? d.embedding.map(Number) : []));
|
|
162
|
+
for (const v of out) {
|
|
163
|
+
if (v.length !== p.dimensions)
|
|
164
|
+
throw new AiError('output', `Vector provider ${p.name}: the model returned ${v.length} dimensions, the provider says ${p.dimensions} (change the provider's dimensions).`);
|
|
165
|
+
if (v.some((x) => !Number.isFinite(x))) throw new AiError('output', 'The embedding is not a list of numbers.');
|
|
166
|
+
}
|
|
167
|
+
await logUsage(call, { service: s, model: res.model || p.model!, inputTokens: tokens, outputTokens: 0, ms: performance.now() - t0, status: 'ok', message: null });
|
|
168
|
+
return out;
|
|
169
|
+
} catch (e) {
|
|
170
|
+
const err = e instanceof AiError ? e : new AiError('server', 'The embedding request failed.');
|
|
171
|
+
await logUsage(call, { service: s, model: p.model!, inputTokens: tokens, outputTokens: 0, ms: performance.now() - t0, status: 'error', message: err.kind + (err.status ? ` ${err.status}` : '') });
|
|
172
|
+
info.debug?.(1, `vector provider ${p.name}: ${err.kind}: ${err.message}`);
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------- the search text's embedding
|
|
178
|
+
|
|
179
|
+
/** Search texts embedded recently (per server process, AI service providers only): a search as you type asks again. */
|
|
180
|
+
const recent = new Map<string, { at: number; v: number[] }>();
|
|
181
|
+
const RECENT_MAX = 500;
|
|
182
|
+
const RECENT_MS = 10 * 60_000;
|
|
183
|
+
|
|
184
|
+
/** The embedding of one search text (AI service providers: kept a few minutes in this process's memory). */
|
|
185
|
+
export async function embedQuery(p: VectorProvider, text: string, info: EmbedInfo, client?: Queryable): Promise<number[]> {
|
|
186
|
+
if (p.type === 'function') return (await embedTexts(p, [text], info, client))[0];
|
|
187
|
+
const key = JSON.stringify([info.appId, p.name, p.service, p.model, p.dimensions, text]);
|
|
188
|
+
const hit = recent.get(key);
|
|
189
|
+
if (hit && Date.now() - hit.at < RECENT_MS) return hit.v;
|
|
190
|
+
const [v] = await embedTexts(p, [text], info, client);
|
|
191
|
+
if (recent.size >= RECENT_MAX) recent.delete(recent.keys().next().value!);
|
|
192
|
+
recent.set(key, { at: Date.now(), v });
|
|
193
|
+
return v;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Forget the remembered search texts (tests; a provider that changed). */
|
|
197
|
+
export const forgetQueries = () => recent.clear();
|
package/src/ai/service.ts
CHANGED
|
@@ -99,7 +99,7 @@ export interface CallInfo {
|
|
|
99
99
|
appId: number | null;
|
|
100
100
|
pageNo?: number | null;
|
|
101
101
|
user?: string | null;
|
|
102
|
-
source: 'process' | 'dynamic_action' | 'sql' | 'builder' | 'assistant' | 'nl2ir';
|
|
102
|
+
source: 'process' | 'dynamic_action' | 'sql' | 'builder' | 'assistant' | 'nl2ir' | 'embedding';
|
|
103
103
|
/** debug messages of the request (level, text) */
|
|
104
104
|
debug?: (level: number, text: string) => void;
|
|
105
105
|
}
|
|
@@ -147,6 +147,17 @@ export async function serviceForApp(name: string, appId: number | null) {
|
|
|
147
147
|
return { service: s, limits };
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/** The application's daily request and token limits on a service (logged and refused when reached). */
|
|
151
|
+
export async function checkDailyLimit(s: AiService, limits: { max_requests: number | null; max_tokens: string | null } | null, info: CallInfo, model = s.model) {
|
|
152
|
+
if (!limits || info.appId === null || (limits.max_requests === null && limits.max_tokens === null)) return;
|
|
153
|
+
const today = await usageToday(info.appId, s.id);
|
|
154
|
+
const over = (limits.max_requests !== null && today.requests >= limits.max_requests) || (limits.max_tokens !== null && today.tokens >= Number(limits.max_tokens));
|
|
155
|
+
if (over) {
|
|
156
|
+
await logUsage(info, { service: s, model, inputTokens: 0, outputTokens: 0, ms: 0, status: 'limited', message: 'daily limit' });
|
|
157
|
+
throw new AiError('limit', `This application has reached its daily limit for AI service ${s.name}: try again tomorrow.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
150
161
|
/**
|
|
151
162
|
* One AI request through a service: checks, daily limits, the call, the
|
|
152
163
|
* usage log. A structured request returns the parsed JSON in `json`.
|
|
@@ -157,14 +168,7 @@ export async function generate(name: string, req: GenerateRequest, info: CallInf
|
|
|
157
168
|
if (req.prompt.length > MAX_PROMPT_CHARS || (req.system?.length ?? 0) > MAX_SYSTEM_CHARS)
|
|
158
169
|
throw new AiError('config', `The prompt is too long (at most ${MAX_PROMPT_CHARS} characters, the system prompt ${MAX_SYSTEM_CHARS}).`);
|
|
159
170
|
if (!req.prompt.trim()) throw new AiError('config', 'The prompt is empty.');
|
|
160
|
-
|
|
161
|
-
const today = await usageToday(info.appId, s.id);
|
|
162
|
-
const over = (limits.max_requests !== null && today.requests >= limits.max_requests) || (limits.max_tokens !== null && today.tokens >= Number(limits.max_tokens));
|
|
163
|
-
if (over) {
|
|
164
|
-
await logUsage(info, { service: s, model: s.model, inputTokens: 0, outputTokens: 0, ms: 0, status: 'limited', message: 'daily limit' });
|
|
165
|
-
throw new AiError('limit', `This application has reached its daily limit for AI service ${s.name}: try again tomorrow.`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
171
|
+
await checkDailyLimit(s, limits, info);
|
|
168
172
|
const provider = providerFor(s);
|
|
169
173
|
const t0 = performance.now();
|
|
170
174
|
info.debug?.(6, `AI service ${s.name} (${s.provider}, ${s.model})${req.schema ? ', structured output' : ''}`);
|
package/src/app.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { diagnosticsRoutes } from './builder/diagnostics.ts';
|
|
|
26
26
|
import { ldapRoutes } from './builder/ldap.ts';
|
|
27
27
|
import { aiRoutes } from './builder/ai.ts';
|
|
28
28
|
import { aiAgentRoutes } from './builder/ai-agents.ts';
|
|
29
|
+
import { vectorProviderRoutes } from './builder/vector-providers.ts';
|
|
29
30
|
import { mailRoutes } from './builder/mail.ts';
|
|
30
31
|
import { backupRoutes } from './builder/backups.ts';
|
|
31
32
|
import { availabilityRoutes } from './builder/availability.ts';
|
|
@@ -40,6 +41,10 @@ import { layoutRoutes } from './builder/layouts.ts';
|
|
|
40
41
|
import { dataLoadRoutes } from './builder/dataload.ts';
|
|
41
42
|
import { builderRoutes } from './builder/routes.ts';
|
|
42
43
|
import { appUpdateHooks } from './builder/app-updates.ts';
|
|
44
|
+
import { historyHooks, utilityRoutes } from './builder/utilities.ts';
|
|
45
|
+
import { dependencyRoutes } from './builder/dependencies.ts';
|
|
46
|
+
import { upgradeRoutes } from './builder/upgrade.ts';
|
|
47
|
+
import { uiDefaultRoutes } from './builder/ui-defaults.ts';
|
|
43
48
|
import { dbUserRoutes } from './builder/db-users.ts';
|
|
44
49
|
import { dataPumpRoutes } from './builder/datapump.ts';
|
|
45
50
|
import { schedulingRoutes } from './builder/scheduling.ts';
|
|
@@ -164,6 +169,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
164
169
|
});
|
|
165
170
|
builderErrorPages(app);
|
|
166
171
|
appUpdateHooks(app); // (116) who changed an application last
|
|
172
|
+
historyHooks(app); // (138) a snapshot before an application's first recorded change
|
|
167
173
|
await app.register(cookie);
|
|
168
174
|
await app.register(formbody, { bodyLimit: 5 * 1024 * 1024 });
|
|
169
175
|
// file upload items; oversized files are cut off and reported, not thrown
|
|
@@ -236,6 +242,8 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
236
242
|
await app.register(ldapRoutes);
|
|
237
243
|
await app.register(aiRoutes);
|
|
238
244
|
await app.register(aiAgentRoutes);
|
|
245
|
+
// (sprint 136, AP-1) vector providers: Shared Components and Workspace utilities
|
|
246
|
+
await app.register(vectorProviderRoutes);
|
|
239
247
|
await app.register(mailRoutes);
|
|
240
248
|
await app.register(backupRoutes);
|
|
241
249
|
await app.register(availabilityRoutes);
|
|
@@ -266,6 +274,11 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
266
274
|
await app.register(mailQueueRoutes);
|
|
267
275
|
await app.register(networkAccessRoutes);
|
|
268
276
|
await app.register(workspaceMailRoutes);
|
|
277
|
+
// (sprint 138, AP-5) an application's Utilities
|
|
278
|
+
await app.register(utilityRoutes);
|
|
279
|
+
await app.register(dependencyRoutes);
|
|
280
|
+
await app.register(upgradeRoutes);
|
|
281
|
+
await app.register(uiDefaultRoutes);
|
|
269
282
|
app.get('/', async (_req, reply) => reply.redirect('/builder/home'));
|
|
270
283
|
return app;
|
|
271
284
|
}
|
package/src/builder/advisor.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { processProblems } from '../runtime/processes.ts';
|
|
|
13
13
|
import { urlConfigProblem, urlTarget } from '../runtime/url-region.ts';
|
|
14
14
|
import { configColumns, searchConfigProblems, textSearchConfig, tsvectorSql } from '../runtime/search.ts';
|
|
15
15
|
import { COMPONENTS } from './components.ts';
|
|
16
|
+
import { DISTANCES, PGVECTOR_MISSING, vectorColumnDims, vectorSchema, type Distance } from '../vector.ts';
|
|
16
17
|
import { appEntries, type Entry } from './search.ts';
|
|
17
18
|
import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
|
|
18
19
|
|
|
@@ -43,7 +44,7 @@ export interface Finding {
|
|
|
43
44
|
export type SqlShape = 'select' | 'boolean' | 'statements' | 'regex';
|
|
44
45
|
|
|
45
46
|
/** Which fields hold SQL, and of what shape, per component kind (null: decided by the row). */
|
|
46
|
-
function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[] {
|
|
47
|
+
export function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[] {
|
|
47
48
|
switch (kind) {
|
|
48
49
|
case 'region':
|
|
49
50
|
return [
|
|
@@ -104,7 +105,7 @@ function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[]
|
|
|
104
105
|
const conditionSql = (row: any): { name: string; shape: SqlShape }[] =>
|
|
105
106
|
row.condition_type === 'sql' ? [{ name: 'condition_expr', shape: 'boolean' }] : row.condition_type === 'exists' || row.condition_type === 'not_exists' ? [{ name: 'condition_expr', shape: 'select' }] : [];
|
|
106
107
|
|
|
107
|
-
const EXPLAINABLE = /^\s*(\(|select|with|values|table|insert|update|delete|merge)\b/i;
|
|
108
|
+
export const EXPLAINABLE = /^\s*(\(|select|with|values|table|insert|update|delete|merge)\b/i;
|
|
108
109
|
|
|
109
110
|
/**
|
|
110
111
|
* The problem with one piece of SQL, or null. Runs inside the caller's
|
|
@@ -242,7 +243,10 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
242
243
|
}
|
|
243
244
|
let cols = configColumns(row).map((x) => `t.${pg.escapeIdentifier(x)}`).join(', ') || '*';
|
|
244
245
|
// (135) a full-text configuration: its tsvector matched with its text search configuration
|
|
245
|
-
const fulltext = row.search_type === 'fulltext';
|
|
246
|
+
const fulltext = row.search_type === 'fulltext' || row.search_type === 'hybrid';
|
|
247
|
+
// (136) a vector or hybrid configuration also reads its vector (and key) column
|
|
248
|
+
const vectorType = row.search_type === 'vector' || row.search_type === 'hybrid';
|
|
249
|
+
if (vectorType) cols = [cols === '*' ? 't.*' : cols, ...[row.vector_column, row.key_column].filter(Boolean).map((x: string) => `t.${pg.escapeIdentifier(x)}`)].join(', ');
|
|
246
250
|
if (fulltext) {
|
|
247
251
|
const cfg = await textSearchConfig(c, row.fts_config, app.language);
|
|
248
252
|
if (row.fts_config && cfg !== row.fts_config)
|
|
@@ -281,6 +285,42 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
281
285
|
});
|
|
282
286
|
}
|
|
283
287
|
}
|
|
288
|
+
// (136) vector and hybrid configurations: pgvector, the provider, the column's dimensions and its index
|
|
289
|
+
for (const { kind, row } of all.filter((x) => x.kind === 'search_config' && (x.row.search_type === 'vector' || x.row.search_type === 'hybrid') && !searchConfigProblems(x.row).length)) {
|
|
290
|
+
const entry = byKey.get(`${kind}-${row.id}`) ?? null;
|
|
291
|
+
const schema = await vectorSchema(c);
|
|
292
|
+
if (!schema) {
|
|
293
|
+
findings.push({ severity: 'error', entry, field: 'Search type', message: PGVECTOR_MISSING });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const provider = (Array.isArray(app.vector_providers) ? app.vector_providers : []).find((p: { name: string }) => p.name === row.vector_provider);
|
|
297
|
+
if (!provider) findings.push({ severity: 'error', entry, field: 'Vector provider', message: `There is no vector provider ${row.vector_provider} (Shared Components → Vector providers).` });
|
|
298
|
+
if (row.source_type !== 'table') continue;
|
|
299
|
+
await c.query('savepoint advisor_vec');
|
|
300
|
+
const info = await (async () => {
|
|
301
|
+
const dims = await vectorColumnDims(c, row.source.trim(), row.vector_column);
|
|
302
|
+
const ix = (await c.query<{ ok: boolean; ops: string | null }>(
|
|
303
|
+
`select exists (select from pg_catalog.pg_index i join pg_catalog.pg_class ic on ic.oid = i.indexrelid join pg_catalog.pg_am am on am.oid = ic.relam
|
|
304
|
+
where i.indrelid = $1::regclass and am.amname in ('hnsw', 'ivfflat')
|
|
305
|
+
and (select a.attnum from pg_catalog.pg_attribute a where a.attrelid = $1::regclass and a.attname = $2) = any (i.indkey::int2[])) as ok,
|
|
306
|
+
(select string_agg(o.opcname, ',') from pg_catalog.pg_index i join pg_catalog.pg_opclass o on o.oid = any (i.indclass::oid[])
|
|
307
|
+
where i.indrelid = $1::regclass and (select a.attnum from pg_catalog.pg_attribute a where a.attrelid = $1::regclass and a.attname = $2) = any (i.indkey::int2[])) as ops`,
|
|
308
|
+
[row.source.trim(), row.vector_column])).rows[0];
|
|
309
|
+
return { dims, ix };
|
|
310
|
+
})().catch(async () => {
|
|
311
|
+
await c.query('rollback to savepoint advisor_vec');
|
|
312
|
+
return null;
|
|
313
|
+
});
|
|
314
|
+
if (!info) continue;
|
|
315
|
+
if (info.dims === undefined) findings.push({ severity: 'error', entry, field: 'Vector column', message: `${row.source.trim()} has no column ${row.vector_column}.` });
|
|
316
|
+
else if (info.dims === null) findings.push({ severity: 'error', entry, field: 'Vector column', message: `${row.source.trim()}.${row.vector_column} is not a vector(n) column.` });
|
|
317
|
+
else if (provider && info.dims !== provider.dimensions)
|
|
318
|
+
findings.push({ severity: 'error', entry, field: 'Vector column', message: `${row.source.trim()}.${row.vector_column} is vector(${info.dims}), vector provider ${provider.name} gives ${provider.dimensions} dimensions.` });
|
|
319
|
+
if (info.dims && !info.ix?.ok)
|
|
320
|
+
findings.push({ severity: 'warning', entry, field: 'Vector column', message: `${row.source.trim()}.${row.vector_column} has no HNSW index: every search reads the whole table (SQL Workshop › Table Properties › Add vector column adds one).` });
|
|
321
|
+
else if (info.dims && info.ix?.ops && !info.ix.ops.split(',').includes(DISTANCES[(row.distance ?? 'cosine') as Distance].opclass))
|
|
322
|
+
findings.push({ severity: 'warning', entry, field: 'Distance', message: `The index on ${row.vector_column} is for ${info.ix.ops}, not ${DISTANCES[(row.distance ?? 'cosine') as Distance].opclass}: the ${row.distance ?? 'cosine'} search can't use it.` });
|
|
323
|
+
}
|
|
284
324
|
// the search regions name existing configurations
|
|
285
325
|
for (const { kind, row } of all.filter((x) => x.kind === 'region' && x.row.type === 'search')) {
|
|
286
326
|
const names: unknown[] = Array.isArray(row.config?.configs) ? row.config.configs : [];
|
package/src/builder/ai-agents.ts
CHANGED
|
@@ -24,6 +24,7 @@ const TOOL_TYPES: [string, string][] = [
|
|
|
24
24
|
['rest', 'REST data source'],
|
|
25
25
|
['javascript', 'JavaScript action in the browser'],
|
|
26
26
|
['navigate', 'Navigate: a link to a page'],
|
|
27
|
+
['retrieve', 'Retrieve (RAG): rows of a search configuration'],
|
|
27
28
|
];
|
|
28
29
|
|
|
29
30
|
const when = (d: Date | string) => new Date(d).toISOString().slice(0, 19).replace('T', ' ');
|
|
@@ -87,6 +88,7 @@ async function agentForm(a: any, s: Session, x: Partial<AiAgent>, original: stri
|
|
|
87
88
|
|
|
88
89
|
async function toolForm(a: any, s: Session, x: Partial<AiTool> & { parameters_text?: string }, original: string, problems: string[]): Promise<Raw> {
|
|
89
90
|
const sources = (await owner.query<{ name: string }>('select name from meta.rest_source where app_id = $1 order by name', [a.id])).rows.map((r) => r.name);
|
|
91
|
+
const configs = (await owner.query<{ name: string; search_type: string }>(`select name, search_type from meta.search_config where app_id = $1 and source_type <> 'list' order by name`, [a.id])).rows;
|
|
90
92
|
const params = x.parameters_text ?? (x.parameters ? JSON.stringify(x.parameters, null, 2) : '');
|
|
91
93
|
const usedBy = original ? ((a.ai_agents ?? []) as AiAgent[]).filter((g) => (g.tools ?? []).includes(original)).map((g) => g.name) : [];
|
|
92
94
|
return html`${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
|
|
@@ -100,7 +102,10 @@ async function toolForm(a: any, s: Session, x: Partial<AiTool> & { parameters_te
|
|
|
100
102
|
${select('source', 'REST data source (REST)', x.source ?? '', [['', '-'], ...sources.map((n): [string, string] => [n, n])], 'The parameters become the source\'s parameters.')}
|
|
101
103
|
${input('action', 'Action (JavaScript)', x.action ?? '', { placeholder: 'e.g. highlightOrder', help: 'After the answer the browser calls the function registered with pgkiln.actions.register(name) with {value: the arguments}, and the dynamic actions of the custom event of this name.' })}
|
|
102
104
|
${input('pages', 'Pages (Navigate)', (x.pages ?? []).join(', '), { placeholder: 'e.g. 3, 7', help: 'The page numbers the agent may link to. pgkiln offers only those the user may open and builds the URL (item values with their checksum).' })}
|
|
103
|
-
${
|
|
105
|
+
${select('search_config', 'Search configuration (Retrieve)', x.search_config ?? '', [['', '-'], ...configs.map((c): [string, string] => [c.name, `${c.name} (${c.search_type})`]),
|
|
106
|
+
...(x.search_config && !configs.some((c) => c.name === x.search_config) ? [[x.search_config, `${x.search_config} (does not exist)`] as [string, string]] : [])],
|
|
107
|
+
'The model gives a text; pgkiln runs this search configuration (best: vector or hybrid) as the application\'s role, so row level security applies, and returns the best rows\' title, text and score.')}
|
|
108
|
+
${area('parameters', 'Parameters (JSON; not for Navigate or Retrieve)', params, 6, '{"DNAME": {"type": "string", "description": "A department name"}, "LIMIT": {"type": "integer", "optional": true}}. Types: string, integer, number, boolean, date; "enum" for a list of texts.', true)}
|
|
104
109
|
${input('max_rows', 'Maximum rows', x.max_rows ?? '', { type: 'number', placeholder: '50', help: 'Rows the model gets back (1 – 500).' })}
|
|
105
110
|
${input('timeout_s', 'Time limit (seconds)', x.timeout_s ?? '', { type: 'number', placeholder: '15', help: 'SQL and function tools (1 – 60).' })}
|
|
106
111
|
${authzSelect(await schemes(a.id), x.authz, 'Only users who pass it are offered the tool.')}
|
|
@@ -151,8 +156,9 @@ function toolFrom(b: Body, errors: string[]): AiTool & { parameters_text?: strin
|
|
|
151
156
|
if (type === 'rest') x.source = one(b.source).trim().toUpperCase();
|
|
152
157
|
if (type === 'javascript') x.action = one(b.action).trim();
|
|
153
158
|
if (type === 'navigate') x.pages = one(b.pages).split(/[\s,;]+/).filter(Boolean).map(Number);
|
|
159
|
+
if (type === 'retrieve') x.search_config = one(b.search_config).trim().toUpperCase();
|
|
154
160
|
const params = one(b.parameters).trim();
|
|
155
|
-
if (params && type !== 'navigate') {
|
|
161
|
+
if (params && type !== 'navigate' && type !== 'retrieve') {
|
|
156
162
|
try {
|
|
157
163
|
x.parameters = JSON.parse(params);
|
|
158
164
|
} catch {
|
|
@@ -160,11 +166,11 @@ function toolFrom(b: Body, errors: string[]): AiTool & { parameters_text?: strin
|
|
|
160
166
|
x.parameters_text = params;
|
|
161
167
|
}
|
|
162
168
|
}
|
|
163
|
-
if (type === 'sql' || type === 'function' || type === 'rest') {
|
|
169
|
+
if (type === 'sql' || type === 'function' || type === 'rest' || type === 'retrieve') {
|
|
164
170
|
const rows = intOrUndef(one(b.max_rows), 'Maximum rows', errors);
|
|
165
171
|
if (rows !== undefined) x.max_rows = rows;
|
|
166
172
|
}
|
|
167
|
-
if (type === 'sql' || type === 'function') {
|
|
173
|
+
if (type === 'sql' || type === 'function' || type === 'retrieve') {
|
|
168
174
|
const t = intOrUndef(one(b.timeout_s), 'Time limit', errors);
|
|
169
175
|
if (t !== undefined) x.timeout_s = t;
|
|
170
176
|
}
|
|
@@ -225,7 +231,7 @@ export async function aiAgentRoutes(app: FastifyInstance) {
|
|
|
225
231
|
const problems = definitionProblems(agents, tools);
|
|
226
232
|
const main = html`${appHeader(a, 'shared', s)}
|
|
227
233
|
<div class="title-row"><h1>AI Agents</h1></div>
|
|
228
|
-
<p class="muted u-mt0">An agent answers in an AI assistant region and acts only through the tools you give it: SQL queries (read-only, as the application's database role, so row level security applies), PL/pgSQL functions, REST data sources, JavaScript actions in the browser, and links to pages (pgkiln builds the URL and its checksum, only for pages the user may open). What users type and the tools' results go to the agent's AI service. <a href="${BASE}/apps/${a.id}/ai">AI usage</a> lists every tool call.</p>
|
|
234
|
+
<p class="muted u-mt0">An agent answers in an AI assistant region and acts only through the tools you give it: SQL queries (read-only, as the application's database role, so row level security applies), PL/pgSQL functions, REST data sources, JavaScript actions in the browser, and links to pages (pgkiln builds the URL and its checksum, only for pages the user may open). A Retrieve tool gives the agent the rows of a search configuration that match a text by meaning (RAG), again as the application's role. What users type and the tools' results go to the agent's AI service. <a href="${BASE}/apps/${a.id}/ai">AI usage</a> lists every tool call.</p>
|
|
229
235
|
${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
|
|
230
236
|
<div class="columns">
|
|
231
237
|
${region('Agents', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Agent</th><th>AI service</th><th>Tools</th><th>Authorization</th></tr></thead>
|
|
@@ -322,6 +328,8 @@ export async function aiAgentRoutes(app: FastifyInstance) {
|
|
|
322
328
|
const out = aiToolProblems(tool);
|
|
323
329
|
if (tool.type === 'rest' && tool.source && !(await owner.one('select 1 from meta.rest_source where app_id = $1 and name = $2', [a.id, tool.source])))
|
|
324
330
|
out.push(`There is no REST data source ${tool.source}.`);
|
|
331
|
+
if (tool.type === 'retrieve' && tool.search_config && !(await owner.one(`select 1 from meta.search_config where app_id = $1 and name = $2 and source_type <> 'list'`, [a.id, tool.search_config])))
|
|
332
|
+
out.push(`There is no search configuration ${tool.search_config} with a table or SQL source.`);
|
|
325
333
|
if (tool.type === 'navigate') {
|
|
326
334
|
const known = new Set((await owner.query<{ page_no: number }>('select page_no from meta.page where app_id = $1', [a.id])).rows.map((p) => p.page_no));
|
|
327
335
|
for (const p of tool.pages ?? []) if (!known.has(p)) out.push(`There is no page ${p}.`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
|
2
2
|
import { owner } from '../db.ts';
|
|
3
|
+
import { recordHistory } from '../history.ts';
|
|
3
4
|
import type { Session } from '../session.ts';
|
|
4
5
|
import { BASE } from './ui.ts';
|
|
5
6
|
|
|
@@ -50,6 +51,8 @@ export function appUpdateHooks(app: FastifyInstance) {
|
|
|
50
51
|
appId = Number(m[1]);
|
|
51
52
|
}
|
|
52
53
|
await recordChange(appId, n.s.username).catch((err) => req.log.warn({ err }, 'app_change not recorded'));
|
|
54
|
+
// (sprint 138) what changed, per component (src/history.ts)
|
|
55
|
+
await recordHistory(appId, n.s.username).catch((err) => req.log.warn({ err }, 'change history not recorded'));
|
|
53
56
|
return payload;
|
|
54
57
|
});
|
|
55
58
|
}
|
|
@@ -545,11 +545,13 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
545
545
|
plural: 'Build options',
|
|
546
546
|
icon: 'settings',
|
|
547
547
|
summary: (o) => `${o.name} (${o.status})`,
|
|
548
|
-
defaults: { status: 'include' },
|
|
548
|
+
defaults: { status: 'include', configurable: false },
|
|
549
549
|
fields: [
|
|
550
550
|
{ name: 'name', label: 'Name', kind: 'upper', help: 'e.g. FEATURE_EXPORT; components name it in their Build option property.' },
|
|
551
551
|
{ name: 'status', label: 'Status', kind: 'select', options: ['include', 'exclude'], help: 'exclude: every component with this option is left out of the running application (and those with "Not" are included).' },
|
|
552
552
|
{ name: 'description', label: 'Description', kind: 'text', wide: true },
|
|
553
|
+
// (137) the Configuration Options feature
|
|
554
|
+
{ name: 'configurable', label: 'Configurable', kind: 'bool', help: 'The application\'s administrators may switch it on the Configuration page (Create page → Feature → Configuration options).' },
|
|
553
555
|
],
|
|
554
556
|
},
|
|
555
557
|
rest_module: {
|
|
@@ -722,12 +724,20 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
722
724
|
plural: 'Search configurations',
|
|
723
725
|
icon: 'search',
|
|
724
726
|
summary: (c) => `${c.name} (${c.source_type})`,
|
|
725
|
-
defaults: { source_type: 'table', search_type: 'standard', seq: 10, max_results: 10, searchable_columns: [], did_you_mean: false },
|
|
727
|
+
defaults: { source_type: 'table', search_type: 'standard', seq: 10, max_results: 10, searchable_columns: [], did_you_mean: false, distance: 'cosine' },
|
|
726
728
|
validate: (v) => searchConfigProblems(v as never).join(' ') || null,
|
|
727
729
|
// (135) empty means: built from the searchable columns / the application language's configuration
|
|
728
730
|
beforeSave: async (v) => {
|
|
729
731
|
for (const k of ['fts_vector', 'fts_config'] as const) if (typeof v[k] === 'string' && !(v[k] as string).trim()) v[k] = null;
|
|
730
732
|
if (typeof v.fts_config === 'string') v.fts_config = v.fts_config.trim().toLowerCase();
|
|
733
|
+
// (136) vector and hybrid search
|
|
734
|
+
for (const k of ['vector_column', 'key_column'] as const) if (typeof v[k] === 'string') v[k] = (v[k] as string).trim() || null;
|
|
735
|
+
if (typeof v.vector_provider === 'string') v.vector_provider = v.vector_provider.trim().toUpperCase() || null;
|
|
736
|
+
if (typeof v.min_similarity === 'string') {
|
|
737
|
+
const t = v.min_similarity.trim().replace(',', '.');
|
|
738
|
+
if (t && !Number.isFinite(Number(t))) throw new Error('Minimum similarity must be a number from -1 to 1.');
|
|
739
|
+
v.min_similarity = t ? Number(t) : null;
|
|
740
|
+
}
|
|
731
741
|
},
|
|
732
742
|
fields: [
|
|
733
743
|
{ name: 'name', label: 'Name (static ID)', kind: 'upper', group: 'Identification', help: 'e.g. CUSTOMERS; a search region names it in {"configs": ["CUSTOMERS"]}.' },
|
|
@@ -738,14 +748,24 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
738
748
|
{ name: 'source', label: 'Source', kind: 'code', wide: true, group: 'Source', help: 'table: sales.customers · sql: select id, name, city from sales.customers where active · list: the list\'s name.' },
|
|
739
749
|
{ name: 'searchable_columns', label: 'Searchable columns', kind: 'list', group: 'Source',
|
|
740
750
|
help: 'Comma-separated columns the search text is looked for in (each word must be found in one of them). Empty: the title, subtitle and description columns.' },
|
|
741
|
-
{ name: 'search_type', label: 'Search type', kind: 'select', options: ['standard', 'fulltext'], group: 'Search',
|
|
742
|
-
help: 'standard: each word anywhere in one of the searchable columns (case-insensitive) · fulltext: PostgreSQL full-text search: stemmed words ("running" finds "run"), "quoted phrases", or, -word; the best match first, the matched words highlighted (table, sql).' },
|
|
751
|
+
{ name: 'search_type', label: 'Search type', kind: 'select', options: ['standard', 'fulltext', 'vector', 'hybrid'], group: 'Search',
|
|
752
|
+
help: 'standard: each word anywhere in one of the searchable columns (case-insensitive) · fulltext: PostgreSQL full-text search: stemmed words ("running" finds "run"), "quoted phrases", or, -word; the best match first, the matched words highlighted (table, sql) · vector: by meaning: the rows whose embedding is nearest to the search text\'s (pgvector) · hybrid: full-text and vector together (reciprocal rank fusion).' },
|
|
743
753
|
{ name: 'fts_vector', label: 'tsvector column or expression', kind: 'code', wide: true, group: 'Search',
|
|
744
754
|
help: 'fulltext: a tsvector column of the source (e.g. search_vector, with a GIN index: SQL Workshop › Table Properties › Add full-text search), or an expression, e.g. to_tsvector(\'english\', title || \' \' || body). Empty: to_tsvector over the searchable columns (no index can help).' },
|
|
745
755
|
{ name: 'fts_config', label: 'Text search configuration', kind: 'text', group: 'Search',
|
|
746
756
|
help: 'fulltext: english, dutch, german, french, simple … (pg_ts_config); the same one the tsvector was made with. Empty: the application\'s primary language\'s, else simple.' },
|
|
747
757
|
{ name: 'did_you_mean', label: 'Did you mean', kind: 'bool', group: 'Search',
|
|
748
758
|
help: 'With the pg_trgm extension installed: when nothing is found, suggest the closest words from the searched columns (reads up to 10,000 rows). Without pg_trgm nothing is suggested.' },
|
|
759
|
+
{ name: 'vector_provider', label: 'Vector provider', kind: 'upper', group: 'Vector search',
|
|
760
|
+
help: 'vector, hybrid: the static ID of a vector provider (Shared Components → Vector providers) that embeds the search text, the same one that embedded the rows.' },
|
|
761
|
+
{ name: 'vector_column', label: 'Vector column', kind: 'text', group: 'Vector search',
|
|
762
|
+
help: 'vector, hybrid: the source\'s vector(n) column, e.g. embedding (SQL Workshop › Table Properties › Add vector column adds it, with an HNSW index, and keeps it current); n must be the provider\'s dimensions.' },
|
|
763
|
+
{ name: 'key_column', label: 'Key column', kind: 'text', group: 'Vector search',
|
|
764
|
+
help: 'hybrid: the primary key (e.g. id) that matches the full-text and the vector results. The full-text side uses the tsvector and text search configuration above.' },
|
|
765
|
+
{ name: 'distance', label: 'Distance', kind: 'select', options: ['cosine', 'l2', 'inner_product'], group: 'Vector search',
|
|
766
|
+
help: 'cosine (most embedding models), l2 (Euclidean) or inner_product; the HNSW index must use the same (vector_cosine_ops, vector_l2_ops, vector_ip_ops).' },
|
|
767
|
+
{ name: 'min_similarity', label: 'Minimum similarity', kind: 'text', group: 'Vector search',
|
|
768
|
+
help: 'vector, hybrid: leave out rows less similar than this (cosine: 1 − distance, e.g. 0.3; inner product: the product; l2: 1 / (1 + distance)). Empty: the nearest rows, however far.' },
|
|
749
769
|
{ name: 'title_column', label: 'Title column', kind: 'text', group: 'Result', help: 'Required (table, sql); results are ordered by it.' },
|
|
750
770
|
{ name: 'subtitle_column', label: 'Subtitle column', kind: 'text', group: 'Result' },
|
|
751
771
|
{ name: 'description_column', label: 'Description column', kind: 'text', group: 'Result' },
|