pgkiln 0.79.0 → 0.81.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/144_workflow_migrate_branches.sql +186 -0
- package/db/migrations/145_remote_databases.sql +41 -0
- package/db/migrations/146_database_model_providers.sql +53 -0
- package/docs/apex-feature-parity.md +7 -7
- package/docs/guide/03-builder.md +45 -3
- package/docs/guide/04-pages-and-regions.md +8 -2
- package/docs/guide/06-processing.md +13 -2
- package/docs/guide/15-extensions.md +1 -1
- package/package.json +1 -1
- package/public/app.css +2 -0
- package/public/app.js +83 -0
- package/src/ai/chat.ts +32 -5
- package/src/ai/embeddings.ts +20 -7
- package/src/app.ts +2 -0
- package/src/builder/db-users.ts +124 -118
- package/src/builder/home.ts +42 -39
- package/src/builder/i18n/de.ts +429 -0
- package/src/builder/i18n/en.ts +429 -0
- package/src/builder/i18n/es.ts +429 -0
- package/src/builder/i18n/fr.ts +429 -0
- package/src/builder/i18n/it.ts +429 -0
- package/src/builder/i18n/ja.ts +429 -0
- package/src/builder/i18n/ko.ts +429 -0
- package/src/builder/i18n/nl.ts +429 -0
- package/src/builder/i18n/pt.ts +429 -0
- package/src/builder/i18n/zh-cn.ts +429 -0
- package/src/builder/i18n/zh-tw.ts +429 -0
- package/src/builder/instance.ts +27 -23
- package/src/builder/ldap.ts +50 -47
- package/src/builder/mail-queue.ts +50 -45
- package/src/builder/network-access.ts +46 -43
- package/src/builder/remote-databases.ts +215 -0
- package/src/builder/vector-column.ts +3 -3
- package/src/builder/vector-providers.ts +16 -5
- package/src/builder/workflow-migrate.ts +19 -13
- package/src/remote-db.ts +145 -0
- package/src/runtime/assistant.ts +54 -17
- package/src/vector.ts +3 -3
package/public/app.js
CHANGED
|
@@ -4041,6 +4041,89 @@ function areaFilter(map, f) {
|
|
|
4041
4041
|
btn.addEventListener('click', (x) => x.preventDefault());
|
|
4042
4042
|
}
|
|
4043
4043
|
});
|
|
4044
|
+
// (sprint 148, WS-4) the answer as the model writes it: the message is posted with fetch asking for server-sent
|
|
4045
|
+
// events; "text" pieces are added as text (never as HTML), "tool" names the tool being used, and "done" loads the
|
|
4046
|
+
// page, which shows the saved answer formatted, its links and actions, or the error. Without fetch streams (or when
|
|
4047
|
+
// the server answers with a page, e.g. a refusal) the form is sent the ordinary way.
|
|
4048
|
+
const streams = typeof ReadableStream !== 'undefined' && typeof TextDecoder !== 'undefined' && typeof fetch === 'function';
|
|
4049
|
+
const go = (url) => {
|
|
4050
|
+
const u = new URL(url, location.href);
|
|
4051
|
+
if (u.pathname === location.pathname && u.search === location.search) {
|
|
4052
|
+
history.replaceState(history.state, '', u);
|
|
4053
|
+
location.reload();
|
|
4054
|
+
} else location.assign(u);
|
|
4055
|
+
};
|
|
4056
|
+
async function streamAnswer(f) {
|
|
4057
|
+
const box = document.querySelector(`textarea[form="${CSS.escape(f.id)}"]`);
|
|
4058
|
+
const wrap = box?.closest('.assistant');
|
|
4059
|
+
const log = wrap?.querySelector('.assistant-log');
|
|
4060
|
+
const entry = (role, label) => {
|
|
4061
|
+
const li = document.createElement('li');
|
|
4062
|
+
li.className = `assistant-msg assistant-msg-${role}`;
|
|
4063
|
+
const who = document.createElement('span');
|
|
4064
|
+
who.className = 'assistant-who';
|
|
4065
|
+
who.textContent = label || '';
|
|
4066
|
+
const text = document.createElement('div');
|
|
4067
|
+
text.className = 'assistant-text';
|
|
4068
|
+
li.append(who, text);
|
|
4069
|
+
log.append(li);
|
|
4070
|
+
return { li, text };
|
|
4071
|
+
};
|
|
4072
|
+
const mine = entry('user', wrap.dataset.you);
|
|
4073
|
+
const said = document.createElement('p');
|
|
4074
|
+
said.textContent = box.value;
|
|
4075
|
+
mine.text.append(said);
|
|
4076
|
+
const answer = entry('assistant', wrap.dataset.who);
|
|
4077
|
+
answer.text.setAttribute('aria-busy', 'true');
|
|
4078
|
+
const para = document.createElement('p');
|
|
4079
|
+
para.className = 'assistant-streaming';
|
|
4080
|
+
answer.text.append(para);
|
|
4081
|
+
const status = document.createElement('small');
|
|
4082
|
+
status.className = 'assistant-tools muted';
|
|
4083
|
+
answer.li.append(status);
|
|
4084
|
+
answer.li.scrollIntoView({ block: 'nearest' });
|
|
4085
|
+
let res;
|
|
4086
|
+
try {
|
|
4087
|
+
res = await fetch(f.action, { method: 'POST', body: new URLSearchParams(new FormData(f)), headers: { accept: 'text/event-stream' }, credentials: 'same-origin' });
|
|
4088
|
+
} catch {
|
|
4089
|
+
return HTMLFormElement.prototype.submit.call(f);
|
|
4090
|
+
}
|
|
4091
|
+
if (!res.ok || !(res.headers.get('content-type') || '').startsWith('text/event-stream') || !res.body) return HTMLFormElement.prototype.submit.call(f);
|
|
4092
|
+
const reader = res.body.getReader();
|
|
4093
|
+
const decoder = new TextDecoder();
|
|
4094
|
+
let buf = '';
|
|
4095
|
+
try {
|
|
4096
|
+
for (;;) {
|
|
4097
|
+
const { value, done } = await reader.read();
|
|
4098
|
+
if (done) break;
|
|
4099
|
+
buf += decoder.decode(value, { stream: true });
|
|
4100
|
+
let cut;
|
|
4101
|
+
while ((cut = buf.indexOf('\n\n')) >= 0) {
|
|
4102
|
+
const block = buf.slice(0, cut);
|
|
4103
|
+
buf = buf.slice(cut + 2);
|
|
4104
|
+
const event = /^event: (.*)$/m.exec(block)?.[1];
|
|
4105
|
+
const line = /^data: (.*)$/m.exec(block)?.[1];
|
|
4106
|
+
if (!event || line === undefined) continue;
|
|
4107
|
+
const data = JSON.parse(line);
|
|
4108
|
+
if (event === 'text' && typeof data === 'string') {
|
|
4109
|
+
para.textContent += data;
|
|
4110
|
+
answer.li.scrollIntoView({ block: 'nearest' });
|
|
4111
|
+
} else if (event === 'tool' && typeof data === 'string') status.textContent = (wrap.dataset.used || '{tools}').replace('{tools}', data);
|
|
4112
|
+
else if (event === 'done' && data && typeof data.url === 'string') return go(data.url);
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
} catch {
|
|
4116
|
+
// the connection broke: the server still saves the answer; the page shows it
|
|
4117
|
+
}
|
|
4118
|
+
location.reload();
|
|
4119
|
+
}
|
|
4120
|
+
document.addEventListener('submit', (e) => {
|
|
4121
|
+
const f = e.target;
|
|
4122
|
+
if (e.defaultPrevented || !streams || !f.matches || !f.matches('form.assistant-form') || !f.id) return;
|
|
4123
|
+
if (!document.querySelector(`textarea[form="${CSS.escape(f.id)}"]`)?.closest('.assistant')?.querySelector('.assistant-log')) return;
|
|
4124
|
+
e.preventDefault();
|
|
4125
|
+
streamAnswer(f);
|
|
4126
|
+
});
|
|
4044
4127
|
// Ctrl+Enter (or ⌘+Enter) sends the message
|
|
4045
4128
|
document.addEventListener('keydown', (e) => {
|
|
4046
4129
|
const box = e.target;
|
package/src/ai/chat.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface ChatRequest {
|
|
|
52
52
|
effort?: 'low' | 'medium' | 'high' | null;
|
|
53
53
|
/** (121) an agent's output limit per call (never above the service's maximum) */
|
|
54
54
|
maxTokens?: number | null;
|
|
55
|
+
/**
|
|
56
|
+
* (sprint 148, WS-4) streamed answers: the text as the model writes it, in pieces (also the text of a round that
|
|
57
|
+
* then calls tools), and each tool before it runs. Without them a call waits for the whole answer, as before.
|
|
58
|
+
*/
|
|
59
|
+
onText?: (delta: string) => void;
|
|
60
|
+
onTool?: (name: string) => void;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
export interface ChatResult {
|
|
@@ -122,7 +128,7 @@ async function claudeChat(s: AiService, limits: Parameters<typeof metered>[1], r
|
|
|
122
128
|
const msg = await metered(s, limits, info, async () => {
|
|
123
129
|
let m: Anthropic.Beta.BetaMessage;
|
|
124
130
|
try {
|
|
125
|
-
|
|
131
|
+
const stream = client.beta.messages.stream({
|
|
126
132
|
model: s.model,
|
|
127
133
|
max_tokens: outputLimit(s, req),
|
|
128
134
|
system: req.system,
|
|
@@ -130,7 +136,9 @@ async function claudeChat(s: AiService, limits: Parameters<typeof metered>[1], r
|
|
|
130
136
|
...(tools.length ? { tools } : {}),
|
|
131
137
|
...((req.effort ?? s.effort) ? { output_config: { effort: (req.effort ?? s.effort)! } } : {}),
|
|
132
138
|
...(s.refusal_fallback ? { betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default' as const } : {}),
|
|
133
|
-
})
|
|
139
|
+
});
|
|
140
|
+
if (req.onText) stream.on('text', (delta) => req.onText!(delta));
|
|
141
|
+
m = await stream.finalMessage();
|
|
134
142
|
} catch (e) {
|
|
135
143
|
throw claudeError(e);
|
|
136
144
|
}
|
|
@@ -150,6 +158,7 @@ async function claudeChat(s: AiService, limits: Parameters<typeof metered>[1], r
|
|
|
150
158
|
if (round >= req.maxRounds) throw new AiError('output', TOO_MANY_ROUNDS);
|
|
151
159
|
const results: Anthropic.Beta.BetaToolResultBlockParam[] = [];
|
|
152
160
|
for (const call of calls) {
|
|
161
|
+
req.onTool?.(call.name);
|
|
153
162
|
const out = await req.runTool(call.name, (call.input ?? {}) as Record<string, unknown>);
|
|
154
163
|
used.push({ name: call.name, ok: !out.isError });
|
|
155
164
|
results.push({ type: 'tool_result', tool_use_id: call.id, content: clip(out.content), ...(out.isError ? { is_error: true } : {}) });
|
|
@@ -169,15 +178,32 @@ async function openAiChat(s: AiService, limits: Parameters<typeof metered>[1], r
|
|
|
169
178
|
const res = await metered(s, limits, info, async () => {
|
|
170
179
|
let r: OpenAI.Chat.ChatCompletion;
|
|
171
180
|
try {
|
|
172
|
-
|
|
181
|
+
const params = {
|
|
173
182
|
model: s.model,
|
|
174
183
|
// the system prompt is sent with each request, not kept in the history
|
|
175
|
-
messages: [{ role: 'system', content: req.system }, ...messages],
|
|
184
|
+
messages: [{ role: 'system' as const, content: req.system }, ...messages],
|
|
176
185
|
max_completion_tokens: outputLimit(s, req),
|
|
177
186
|
...(tools.length ? { tools } : {}),
|
|
178
187
|
// (121) an agent's reasoning effort; models without reasoning refuse it, so none is sent without one
|
|
179
188
|
...(req.effort ? { reasoning_effort: req.effort } : {}),
|
|
180
|
-
}
|
|
189
|
+
};
|
|
190
|
+
if (req.onText) {
|
|
191
|
+
// (sprint 148) streamed: the content as it comes; the usage in the last chunk
|
|
192
|
+
// an OpenAI-compatible endpoint that refuses streaming (400) gets the plain request once
|
|
193
|
+
let streamed: OpenAI.Chat.ChatCompletion | null = null;
|
|
194
|
+
let sent = false;
|
|
195
|
+
try {
|
|
196
|
+
const stream = client.chat.completions.stream({ ...params, stream_options: { include_usage: true } });
|
|
197
|
+
stream.on('content', (delta) => {
|
|
198
|
+
sent = true;
|
|
199
|
+
req.onText!(delta);
|
|
200
|
+
});
|
|
201
|
+
streamed = await stream.finalChatCompletion();
|
|
202
|
+
} catch (e) {
|
|
203
|
+
if (sent || (e as { status?: number }).status !== 400) throw e;
|
|
204
|
+
}
|
|
205
|
+
r = streamed ?? (await client.chat.completions.create(params));
|
|
206
|
+
} else r = await client.chat.completions.create(params);
|
|
181
207
|
} catch (e) {
|
|
182
208
|
throw openAiError(e);
|
|
183
209
|
}
|
|
@@ -198,6 +224,7 @@ async function openAiChat(s: AiService, limits: Parameters<typeof metered>[1], r
|
|
|
198
224
|
for (const call of calls) {
|
|
199
225
|
let input: Record<string, unknown>;
|
|
200
226
|
let out: ToolOutcome;
|
|
227
|
+
req.onTool?.(call.function.name);
|
|
201
228
|
try {
|
|
202
229
|
input = JSON.parse(call.function.arguments || '{}');
|
|
203
230
|
out = input && typeof input === 'object' && !Array.isArray(input) ? await req.runTool(call.function.name, input) : { content: 'The arguments are not a JSON object.', isError: true };
|
package/src/ai/embeddings.ts
CHANGED
|
@@ -18,8 +18,13 @@ import { AiError } from './types.ts';
|
|
|
18
18
|
// The dimensions are part of the definition: every embedding is checked against them, and the builder checks
|
|
19
19
|
// them against the vector column (vector(n)) a search configuration or a vector column helper uses.
|
|
20
20
|
|
|
21
|
-
export type VectorProviderType = 'ai_service' | 'function';
|
|
22
|
-
export const VECTOR_PROVIDER_TYPES: VectorProviderType[] = ['ai_service', 'function'];
|
|
21
|
+
export type VectorProviderType = 'ai_service' | 'function' | 'database_model';
|
|
22
|
+
export const VECTOR_PROVIDER_TYPES: VectorProviderType[] = ['ai_service', 'function', 'database_model'];
|
|
23
|
+
/** (sprint 151, migration 146) the extensions that run a model in the database: pgml = PostgresML's pgml.embed(model, text) */
|
|
24
|
+
export const MODEL_ENGINES = ['pgml'] as const;
|
|
25
|
+
export type ModelEngine = (typeof MODEL_ENGINES)[number];
|
|
26
|
+
/** A provider that embeds in the database (meta.embed, as the caller): a function, or a model in the database. */
|
|
27
|
+
export const embedsInDatabase = (p: { type?: unknown }) => p.type === 'function' || p.type === 'database_model';
|
|
23
28
|
|
|
24
29
|
export interface VectorProvider {
|
|
25
30
|
name: string;
|
|
@@ -32,6 +37,8 @@ export interface VectorProvider {
|
|
|
32
37
|
dimensions: number;
|
|
33
38
|
/** function: schema.name */
|
|
34
39
|
function?: string;
|
|
40
|
+
/** (151) database_model: the extension that runs the model (model: its name, e.g. intfloat/e5-small-v2) */
|
|
41
|
+
engine?: ModelEngine;
|
|
35
42
|
/** ai_service: texts per request (background embedding; default 64) */
|
|
36
43
|
batch_size?: number;
|
|
37
44
|
/** ai_service: requests per minute (background embedding; default 60) */
|
|
@@ -59,7 +66,7 @@ export function vectorProviderProblems(p: unknown): string[] {
|
|
|
59
66
|
const what = `Vector provider ${typeof p.name === 'string' ? p.name : '?'}`;
|
|
60
67
|
const out: string[] = [];
|
|
61
68
|
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
|
|
69
|
+
if (!VECTOR_PROVIDER_TYPES.includes(p.type)) out.push(`${what}: the type is ai_service, function or database_model.`);
|
|
63
70
|
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
71
|
if (!intIn(p.dimensions, 1, MAX_DIMENSIONS)) out.push(`${what}: the dimensions are a whole number from 1 to ${MAX_DIMENSIONS}.`);
|
|
65
72
|
if (p.type === 'ai_service') {
|
|
@@ -67,6 +74,12 @@ export function vectorProviderProblems(p: unknown): string[] {
|
|
|
67
74
|
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
75
|
if (p.function !== undefined) out.push(`${what}: an AI service provider has no function.`);
|
|
69
76
|
}
|
|
77
|
+
if (p.type === 'database_model') {
|
|
78
|
+
if (!MODEL_ENGINES.includes(p.engine)) out.push(`${what}: the engine is ${MODEL_ENGINES.join(' or ')} (the extension that runs the model in the database).`);
|
|
79
|
+
if (typeof p.model !== 'string' || !MODEL.test(p.model)) out.push(`${what}: the model is its name, e.g. intfloat/e5-small-v2.`);
|
|
80
|
+
if (p.service !== undefined || p.function !== undefined) out.push(`${what}: a database model provider has no AI service or function.`);
|
|
81
|
+
}
|
|
82
|
+
if (p.type !== 'database_model' && p.engine !== undefined) out.push(`${what}: only a database model provider has an engine.`);
|
|
70
83
|
if (p.type === 'function') {
|
|
71
84
|
if (typeof p.function !== 'string' || !FUNCTION_NAME.test(p.function)) out.push(`${what}: the function is schema.name, e.g. sales.embed.`);
|
|
72
85
|
if (p.service !== undefined || p.model !== undefined) out.push(`${what}: a function provider has no AI service or model.`);
|
|
@@ -126,11 +139,11 @@ export interface EmbedInfo extends Pick<CallInfo, 'appId' | 'pageNo' | 'user' |
|
|
|
126
139
|
export async function embedTexts(p: VectorProvider, texts: string[], info: EmbedInfo, client?: Queryable): Promise<number[][]> {
|
|
127
140
|
if (!texts.length) return [];
|
|
128
141
|
const input = texts.map((t) => String(t ?? '').slice(0, MAX_TEXT_CHARS));
|
|
129
|
-
if (p
|
|
130
|
-
if (!client) throw new AiError('config', `Vector provider ${p.name}: a function provider needs a database connection.`);
|
|
142
|
+
if (embedsInDatabase(p)) {
|
|
143
|
+
if (!client) throw new AiError('config', `Vector provider ${p.name}: a ${p.type === 'function' ? 'function' : 'database model'} provider needs a database connection.`);
|
|
131
144
|
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
145
|
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.`);
|
|
146
|
+
if (!Array.isArray(r.v)) throw new AiError('output', `Vector provider ${p.name}: the ${p.type === 'function' ? 'function' : 'model'} returned no embedding.`);
|
|
134
147
|
return r.v.map(Number);
|
|
135
148
|
});
|
|
136
149
|
}
|
|
@@ -183,7 +196,7 @@ const RECENT_MS = 10 * 60_000;
|
|
|
183
196
|
|
|
184
197
|
/** The embedding of one search text (AI service providers: kept a few minutes in this process's memory). */
|
|
185
198
|
export async function embedQuery(p: VectorProvider, text: string, info: EmbedInfo, client?: Queryable): Promise<number[]> {
|
|
186
|
-
if (p
|
|
199
|
+
if (embedsInDatabase(p)) return (await embedTexts(p, [text], info, client))[0];
|
|
187
200
|
const key = JSON.stringify([info.appId, p.name, p.service, p.model, p.dimensions, text]);
|
|
188
201
|
const hit = recent.get(key);
|
|
189
202
|
if (hit && Date.now() - hit.at < RECENT_MS) return hit.v;
|
package/src/app.ts
CHANGED
|
@@ -99,6 +99,7 @@ import { mailQueueRoutes } from './builder/mail-queue.ts';
|
|
|
99
99
|
import { networkAccessRoutes } from './builder/network-access.ts';
|
|
100
100
|
import { workspaceMailRoutes } from './builder/workspace-mail.ts';
|
|
101
101
|
import { workspaceResourceRoutes } from './builder/workspace-resources.ts';
|
|
102
|
+
import { remoteDatabaseRoutes } from './builder/remote-databases.ts';
|
|
102
103
|
import { runtimeScope } from './db.ts';
|
|
103
104
|
// (sprint 139, AP-6) OpenTelemetry: a trace per request, /metrics, the browser relay
|
|
104
105
|
import { startTelemetry } from './telemetry/export.ts';
|
|
@@ -288,6 +289,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
288
289
|
await app.register(networkAccessRoutes);
|
|
289
290
|
await app.register(workspaceMailRoutes);
|
|
290
291
|
await app.register(workspaceResourceRoutes);
|
|
292
|
+
await app.register(remoteDatabaseRoutes);
|
|
291
293
|
// (sprint 138, AP-5) an application's Utilities
|
|
292
294
|
await app.register(utilityRoutes);
|
|
293
295
|
await app.register(dependencyRoutes);
|