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.
Files changed (38) hide show
  1. package/db/migrations/144_workflow_migrate_branches.sql +186 -0
  2. package/db/migrations/145_remote_databases.sql +41 -0
  3. package/db/migrations/146_database_model_providers.sql +53 -0
  4. package/docs/apex-feature-parity.md +7 -7
  5. package/docs/guide/03-builder.md +45 -3
  6. package/docs/guide/04-pages-and-regions.md +8 -2
  7. package/docs/guide/06-processing.md +13 -2
  8. package/docs/guide/15-extensions.md +1 -1
  9. package/package.json +1 -1
  10. package/public/app.css +2 -0
  11. package/public/app.js +83 -0
  12. package/src/ai/chat.ts +32 -5
  13. package/src/ai/embeddings.ts +20 -7
  14. package/src/app.ts +2 -0
  15. package/src/builder/db-users.ts +124 -118
  16. package/src/builder/home.ts +42 -39
  17. package/src/builder/i18n/de.ts +429 -0
  18. package/src/builder/i18n/en.ts +429 -0
  19. package/src/builder/i18n/es.ts +429 -0
  20. package/src/builder/i18n/fr.ts +429 -0
  21. package/src/builder/i18n/it.ts +429 -0
  22. package/src/builder/i18n/ja.ts +429 -0
  23. package/src/builder/i18n/ko.ts +429 -0
  24. package/src/builder/i18n/nl.ts +429 -0
  25. package/src/builder/i18n/pt.ts +429 -0
  26. package/src/builder/i18n/zh-cn.ts +429 -0
  27. package/src/builder/i18n/zh-tw.ts +429 -0
  28. package/src/builder/instance.ts +27 -23
  29. package/src/builder/ldap.ts +50 -47
  30. package/src/builder/mail-queue.ts +50 -45
  31. package/src/builder/network-access.ts +46 -43
  32. package/src/builder/remote-databases.ts +215 -0
  33. package/src/builder/vector-column.ts +3 -3
  34. package/src/builder/vector-providers.ts +16 -5
  35. package/src/builder/workflow-migrate.ts +19 -13
  36. package/src/remote-db.ts +145 -0
  37. package/src/runtime/assistant.ts +54 -17
  38. 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
- m = await client.beta.messages.stream({
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
- }).finalMessage();
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
- r = await client.chat.completions.create({
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 };
@@ -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 function.`);
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.type === 'function') {
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.type === 'function') return (await embedTexts(p, [text], info, client))[0];
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);