pgkiln 0.50.0 → 0.52.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 (104) hide show
  1. package/SECURITY.md +7 -0
  2. package/db/migrations/119_workspace_home.sql +30 -0
  3. package/db/migrations/120_datasets.sql +87 -0
  4. package/db/migrations/121_ai_agents.sql +94 -0
  5. package/db/migrations/122_team_development.sql +186 -0
  6. package/db/migrations/123_workflow_outcomes.sql +407 -0
  7. package/db/migrations/124_component_details.sql +51 -0
  8. package/docs/apex-feature-parity.md +15 -12
  9. package/docs/guide/01-installation.md +9 -3
  10. package/docs/guide/03-builder.md +211 -11
  11. package/docs/guide/04-pages-and-regions.md +96 -22
  12. package/docs/guide/05-items.md +7 -1
  13. package/docs/guide/06-processing.md +123 -8
  14. package/docs/guide/07-dynamic-actions.md +34 -0
  15. package/docs/guide/09-reference.md +2 -1
  16. package/docs/guide/11-from-apex.md +2 -1
  17. package/docs/guide/12-development.md +70 -11
  18. package/docs/guide/20-ai-agents.md +4 -2
  19. package/examples/hr/README.md +2 -0
  20. package/examples/hr/hr_63_datasets.sql +29 -0
  21. package/examples/hr/hr_64_ai_agent.sql +97 -0
  22. package/examples/hr/hr_65_ask_reports.sql +21 -0
  23. package/examples/hr/hr_66_team_development.sql +58 -0
  24. package/examples/hr/hr_67_workflow_outcomes.sql +113 -0
  25. package/examples/hr/hr_68_component_details.sql +96 -0
  26. package/package.json +1 -1
  27. package/public/app.css +46 -0
  28. package/public/app.js +134 -20
  29. package/public/builder.css +116 -0
  30. package/public/builder.js +47 -0
  31. package/src/ai/chat.ts +18 -18
  32. package/src/ai/service.ts +35 -11
  33. package/src/ai/types.ts +7 -2
  34. package/src/app.ts +11 -1
  35. package/src/builder/advisor.ts +6 -3
  36. package/src/builder/ai-agents.ts +372 -0
  37. package/src/builder/ai-builder.ts +3 -3
  38. package/src/builder/ai.ts +13 -9
  39. package/src/builder/assistant.ts +22 -7
  40. package/src/builder/components.ts +38 -9
  41. package/src/builder/datasets.ts +472 -0
  42. package/src/builder/designer.ts +8 -3
  43. package/src/builder/feedback.ts +6 -1
  44. package/src/builder/home.ts +12 -7
  45. package/src/builder/instance.ts +2 -0
  46. package/src/builder/locks.ts +7 -1
  47. package/src/builder/region-help.ts +2 -2
  48. package/src/builder/reporter.ts +52 -3
  49. package/src/builder/routes.ts +7 -4
  50. package/src/builder/shared.ts +1 -1
  51. package/src/builder/subscriptions.ts +1 -0
  52. package/src/builder/team.ts +991 -0
  53. package/src/builder/ui.ts +8 -5
  54. package/src/builder/workflow-migrate.ts +210 -0
  55. package/src/builder/workflows.ts +29 -6
  56. package/src/builder/workspace-home.ts +285 -0
  57. package/src/builder/workspace-transfer.ts +1 -1
  58. package/src/builder/workspaces.ts +7 -4
  59. package/src/cli/replace.ts +7 -1
  60. package/src/datasets.ts +367 -0
  61. package/src/i18n/ar.ts +33 -0
  62. package/src/i18n/cs.ts +33 -0
  63. package/src/i18n/da.ts +33 -0
  64. package/src/i18n/de.ts +33 -0
  65. package/src/i18n/el.ts +33 -0
  66. package/src/i18n/es.ts +33 -0
  67. package/src/i18n/fi.ts +33 -0
  68. package/src/i18n/fr.ts +33 -0
  69. package/src/i18n/he.ts +33 -0
  70. package/src/i18n/it.ts +33 -0
  71. package/src/i18n/ja.ts +33 -0
  72. package/src/i18n/ko.ts +33 -0
  73. package/src/i18n/nb.ts +33 -0
  74. package/src/i18n/pl.ts +33 -0
  75. package/src/i18n/pt.ts +33 -0
  76. package/src/i18n/ru.ts +33 -0
  77. package/src/i18n/sv.ts +33 -0
  78. package/src/i18n/tr.ts +33 -0
  79. package/src/i18n/uk.ts +33 -0
  80. package/src/i18n/zh.ts +33 -0
  81. package/src/i18n.ts +66 -0
  82. package/src/instance.ts +3 -0
  83. package/src/metadata.ts +9 -3
  84. package/src/release-check.ts +139 -0
  85. package/src/reporting-app.ts +101 -0
  86. package/src/runtime/ai-agents.ts +365 -0
  87. package/src/runtime/ai-filter.ts +385 -107
  88. package/src/runtime/assistant.ts +64 -11
  89. package/src/runtime/context.ts +2 -0
  90. package/src/runtime/data-reporter.ts +199 -32
  91. package/src/runtime/ir-phrases.ts +812 -0
  92. package/src/runtime/ir-plan.ts +449 -0
  93. package/src/runtime/items.ts +1 -1
  94. package/src/runtime/regions.ts +6 -2
  95. package/src/runtime/render.ts +1 -1
  96. package/src/runtime/report.ts +1 -1
  97. package/src/runtime/tasks.ts +63 -12
  98. package/src/session.ts +2 -0
  99. package/src/subscriptions.ts +2 -1
  100. package/src/task-events.ts +232 -0
  101. package/src/team-samples.ts +74 -0
  102. package/src/team.ts +342 -0
  103. package/src/workflow.ts +135 -18
  104. package/src/workspace-export.ts +39 -4
@@ -0,0 +1,372 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { providerLabel } from '../ai/service.ts';
3
+ import { owner } from '../db.ts';
4
+ import { html, raw, type Raw } from '../html.ts';
5
+ import { icon } from '../icons.ts';
6
+ import { agentProblems, aiToolProblems, definitionProblems, EFFORTS, type AiAgent, type AiTool } from '../runtime/ai-agents.ts';
7
+ import type { Session } from '../session.ts';
8
+ import { appOr404 } from './forms.ts';
9
+ import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
10
+
11
+ // Shared Components → Generative AI → AI Agents (migration 121): the
12
+ // application's AI tools and AI agents, kept as two JSON lists in meta.app
13
+ // (ai_tools, ai_agents) and exported with the application. Each form checks
14
+ // its entry (src/runtime/ai-agents.ts) before it saves; the runtime checks
15
+ // again. Also the "Tool calls" region of Activity → AI usage.
16
+
17
+ type Body = Record<string, string | string[] | undefined>;
18
+ const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? '';
19
+ const all = (v: string | string[] | undefined) => (v === undefined ? [] : [v].flat());
20
+
21
+ const TOOL_TYPES: [string, string][] = [
22
+ ['sql', 'SQL query (read-only, as the application\'s role)'],
23
+ ['function', 'PL/pgSQL function (may change data)'],
24
+ ['rest', 'REST data source'],
25
+ ['javascript', 'JavaScript action in the browser'],
26
+ ['navigate', 'Navigate: a link to a page'],
27
+ ];
28
+
29
+ const when = (d: Date | string) => new Date(d).toISOString().slice(0, 19).replace('T', ' ');
30
+ const area = (name: string, label: string, value: unknown, rows: number, help: string, code = false) => html`<div class="field" data-wide><label class="label" for="f_${name}">${label}</label>
31
+ <textarea id="f_${name}" name="${name}" rows="${rows}"${code ? raw(' class="code" spellcheck="false"') : ''}>${value ?? ''}</textarea><small class="help">${help}</small></div>`;
32
+
33
+ async function schemes(appId: number) {
34
+ return (await owner.query<{ name: string }>('select name from meta.authz_scheme where app_id = $1 order by name', [appId])).rows.map((x) => x.name);
35
+ }
36
+
37
+ const authzSelect = (names: string[], cur: unknown, help: string) =>
38
+ select('authz', 'Authorization scheme', cur ?? '', [['', '- none: everyone who may use the page -'], ['MUST_NOT_BE_PUBLIC_USER', 'Must not be the public user'], ...names.map((n): [string, string] => [n, n]),
39
+ ...(typeof cur === 'string' && cur && cur !== 'MUST_NOT_BE_PUBLIC_USER' && !names.includes(cur) ? [[cur, `${cur} (does not exist)`] as [string, string]] : [])], help);
40
+
41
+ const crumbs = (a: { id: number; name: string }, last?: string): [string, string?][] => [
42
+ ['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`],
43
+ ...(last ? [['AI Agents', `${BASE}/apps/${a.id}/ai-agents`], [last]] as [string, string?][] : [['AI Agents']] as [string, string?][]),
44
+ ];
45
+
46
+ // ---------------------------------------------------------------- forms
47
+
48
+ async function agentForm(a: any, s: Session, x: Partial<AiAgent>, original: string, problems: string[]): Promise<Raw> {
49
+ const services = (await owner.query<{ name: string; provider: string; model: string }>(
50
+ `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 where s.enabled order by s.name`, [a.id])).rows;
51
+ const tools = (a.ai_tools ?? []) as AiTool[];
52
+ const chosen = new Set(x.tools ?? []);
53
+ const svc: [string, string][] = [['', '- choose -'], ...services.map((v): [string, string] => [v.name, `${v.name} (${providerLabel(v.provider)}, ${v.model})`])];
54
+ if (x.service && !services.some((v) => v.name === x.service)) svc.push([x.service, `${x.service} (not available to this application)`]);
55
+ const usedBy = original
56
+ ? (await owner.query<{ page_no: number; title: string | null; page_id: number; id: number }>(
57
+ `select p.page_no, r.title, p.id as page_id, r.id from meta.region r join meta.page p on p.id = r.page_id
58
+ where p.app_id = $1 and r.type = 'ai_assistant' and upper(r.config->>'agent') = $2 order by p.page_no, r.seq`, [a.id, original])).rows
59
+ : [];
60
+ return html`${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
61
+ ${services.length ? '' : html`<p class="muted">This application may not use any AI service yet: an administrator adds one under Workspace utilities → AI services and allows this application to use it.</p>`}
62
+ <form method="post" action="${BASE}/apps/${a.id}/ai-agents/agent">${csrf(s)}<input type="hidden" name="original" value="${original}">
63
+ <fieldset class="prop-group"><legend>Agent</legend><div class="form-grid">
64
+ ${input('name', 'Name', x.name ?? '', { required: true, placeholder: 'e.g. SALES_AGENT', help: 'Upper case letters, digits and _. AI assistant regions name the agent.' })}
65
+ ${input('description', 'Description', x.description ?? '')}
66
+ ${select('service', 'AI service', x.service ?? '', svc, 'The conversation, the tools\' results and the system prompt go to this service\'s provider.')}
67
+ ${select('effort', 'Reasoning effort', x.effort ?? '', [['', 'The service\'s'], ...EFFORTS.map((e): [string, string] => [e, e])],
68
+ 'Claude: the output effort (thinking depth). OpenAI, Gemini and OpenAI-compatible: reasoning_effort (leave empty for models without reasoning).')}
69
+ ${input('max_steps', 'Maximum steps', x.max_steps ?? '', { type: 'number', placeholder: '5', help: 'Rounds of tool calls per question (1 – 20); then the model must answer.' })}
70
+ ${input('max_tokens', 'Maximum output tokens', x.max_tokens ?? '', { type: 'number', placeholder: 'the service\'s', help: 'Per call to the model (never above the service\'s maximum).' })}
71
+ ${authzSelect(await schemes(a.id), x.authz, 'Only users who pass it may use the agent (regions show a message to the others).')}
72
+ ${area('system', 'System prompt', x.system ?? '', 6, 'Who the agent is, what it may answer and when to use which tool. &ITEM. is replaced by the item\'s value as delimited data.')}
73
+ </div></fieldset>
74
+ <fieldset class="prop-group"><legend>Tools</legend>
75
+ ${tools.length
76
+ ? html`<p class="muted u-mt0">The agent acts only through the tools you tick.</p>${tools.map((t) => html`<div class="field"><label class="check"><input type="checkbox" name="tools" value="${t.name}"${chosen.has(t.name) ? raw(' checked') : ''}> <b>${t.name}</b> <span class="muted">(${t.type}) ${t.description}</span></label></div>`)}`
77
+ : html`<p class="muted u-mt0">No AI tools yet: <a href="${BASE}/apps/${a.id}/ai-agents/tool">add one</a>.</p>`}
78
+ </fieldset>
79
+ <div class="buttons"><button class="btn btn-hot">${original ? 'Save' : 'Add agent'}</button></div>
80
+ </form>
81
+ ${original ? html`<h3>Used by</h3>${usedBy.length
82
+ ? html`<ul>${usedBy.map((r) => html`<li><a href="${BASE}/pages/${r.page_id}?c=region-${r.id}">Page ${r.page_no}: ${r.title ?? 'AI assistant'}</a></li>`)}</ul>`
83
+ : html`<p class="muted">No AI assistant region uses this agent yet (Page Designer → an AI assistant region → AI agent).</p>`}
84
+ <form method="post" action="${BASE}/apps/${a.id}/ai-agents/agent/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="name" value="${original}">
85
+ <button class="btn btn-danger" data-confirm="Delete agent ${original}? Regions that use it show that the assistant is not set up.">Delete agent</button></form>` : ''}`;
86
+ }
87
+
88
+ async function toolForm(a: any, s: Session, x: Partial<AiTool> & { parameters_text?: string }, original: string, problems: string[]): Promise<Raw> {
89
+ 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);
90
+ const params = x.parameters_text ?? (x.parameters ? JSON.stringify(x.parameters, null, 2) : '');
91
+ const usedBy = original ? ((a.ai_agents ?? []) as AiAgent[]).filter((g) => (g.tools ?? []).includes(original)).map((g) => g.name) : [];
92
+ return html`${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
93
+ <form method="post" action="${BASE}/apps/${a.id}/ai-agents/tool">${csrf(s)}<input type="hidden" name="original" value="${original}">
94
+ <fieldset class="prop-group"><legend>Tool</legend><div class="form-grid">
95
+ ${input('name', 'Name', x.name ?? '', { required: true, placeholder: 'e.g. top_earners', help: 'Letters, digits and _: the model calls the tool by this name.' })}
96
+ ${select('type', 'Type', x.type ?? 'sql', TOOL_TYPES, 'What the tool does; each type uses the fields below that name it.')}
97
+ ${area('description', 'Description', x.description ?? '', 2, 'Tells the model what the tool does and when to use it.')}
98
+ ${area('sql', 'SQL query (SQL)', x.sql ?? '', 6, 'One SELECT with the parameters as :NAME bind values (and :APP_USER, items). Runs as the application\'s database role (grants and row level security apply) in a read-only transaction.', true)}
99
+ ${input('function', 'Function (PL/pgSQL)', x.function ?? '', { placeholder: 'e.g. sales.give_discount', help: 'schema.name, called as the application\'s role with named arguments: each parameter name in lower case is an argument name (P_ORDER → p_order). It may change data.' })}
100
+ ${select('source', 'REST data source (REST)', x.source ?? '', [['', '-'], ...sources.map((n): [string, string] => [n, n])], 'The parameters become the source\'s parameters.')}
101
+ ${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
+ ${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
+ ${area('parameters', 'Parameters (JSON; not for Navigate)', 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
+ ${input('max_rows', 'Maximum rows', x.max_rows ?? '', { type: 'number', placeholder: '50', help: 'Rows the model gets back (1 – 500).' })}
105
+ ${input('timeout_s', 'Time limit (seconds)', x.timeout_s ?? '', { type: 'number', placeholder: '15', help: 'SQL and function tools (1 – 60).' })}
106
+ ${authzSelect(await schemes(a.id), x.authz, 'Only users who pass it are offered the tool.')}
107
+ </div></fieldset>
108
+ <div class="buttons"><button class="btn btn-hot">${original ? 'Save' : 'Add tool'}</button></div>
109
+ </form>
110
+ ${original ? html`<h3>Used by</h3>${usedBy.length ? html`<p>${usedBy.join(', ')}</p>` : html`<p class="muted">No agent uses this tool yet.</p>`}
111
+ <form method="post" action="${BASE}/apps/${a.id}/ai-agents/tool/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="name" value="${original}">
112
+ <button class="btn btn-danger" data-confirm="Delete tool ${original}?">Delete tool</button></form>` : ''}`;
113
+ }
114
+
115
+ // ---------------------------------------------------------------- reading the forms
116
+
117
+ const intOrUndef = (v: string, what: string, errors: string[]) => {
118
+ const t = v.trim();
119
+ if (!t) return undefined;
120
+ const n = Number(t);
121
+ if (!Number.isInteger(n)) errors.push(`${what} is a whole number.`);
122
+ return n;
123
+ };
124
+
125
+ function agentFrom(b: Body, errors: string[]): AiAgent {
126
+ const x: AiAgent = { name: one(b.name).trim().toUpperCase(), service: one(b.service).trim().toUpperCase() };
127
+ const text = (k: 'description' | 'system', max: number) => {
128
+ const v = one(b[k]).replace(/\r\n?/g, '\n').trim().slice(0, max);
129
+ if (v) x[k] = v;
130
+ };
131
+ text('description', 500);
132
+ text('system', 20_000);
133
+ const effort = one(b.effort);
134
+ if (effort) x.effort = effort as AiAgent['effort'];
135
+ const tools = all(b.tools).map((t) => t.trim()).filter(Boolean);
136
+ if (tools.length) x.tools = [...new Set(tools)];
137
+ const steps = intOrUndef(one(b.max_steps), 'Maximum steps', errors);
138
+ if (steps !== undefined) x.max_steps = steps;
139
+ const tokens = intOrUndef(one(b.max_tokens), 'Maximum output tokens', errors);
140
+ if (tokens !== undefined) x.max_tokens = tokens;
141
+ const authz = one(b.authz).trim();
142
+ if (authz) x.authz = authz;
143
+ return x;
144
+ }
145
+
146
+ function toolFrom(b: Body, errors: string[]): AiTool & { parameters_text?: string } {
147
+ const type = one(b.type) as AiTool['type'];
148
+ const x: AiTool & { parameters_text?: string } = { name: one(b.name).trim(), type, description: one(b.description).replace(/\r\n?/g, '\n').trim() };
149
+ if (type === 'sql') x.sql = one(b.sql).replace(/\r\n?/g, '\n').trim();
150
+ if (type === 'function') x.function = one(b.function).trim();
151
+ if (type === 'rest') x.source = one(b.source).trim().toUpperCase();
152
+ if (type === 'javascript') x.action = one(b.action).trim();
153
+ if (type === 'navigate') x.pages = one(b.pages).split(/[\s,;]+/).filter(Boolean).map(Number);
154
+ const params = one(b.parameters).trim();
155
+ if (params && type !== 'navigate') {
156
+ try {
157
+ x.parameters = JSON.parse(params);
158
+ } catch {
159
+ errors.push('Parameters: not valid JSON.');
160
+ x.parameters_text = params;
161
+ }
162
+ }
163
+ if (type === 'sql' || type === 'function' || type === 'rest') {
164
+ const rows = intOrUndef(one(b.max_rows), 'Maximum rows', errors);
165
+ if (rows !== undefined) x.max_rows = rows;
166
+ }
167
+ if (type === 'sql' || type === 'function') {
168
+ const t = intOrUndef(one(b.timeout_s), 'Time limit', errors);
169
+ if (t !== undefined) x.timeout_s = t;
170
+ }
171
+ const authz = one(b.authz).trim();
172
+ if (authz) x.authz = authz;
173
+ return x;
174
+ }
175
+
176
+ /** Change the application's lists in one transaction (row locked); `change` returns problems to refuse with. */
177
+ async function update(appId: number, change: (agents: AiAgent[], tools: AiTool[]) => string[] | Promise<string[]>) {
178
+ return owner.tx(async (c) => {
179
+ const row = (await c.query<{ ai_agents: AiAgent[]; ai_tools: AiTool[] }>('select ai_agents, ai_tools from meta.app where id = $1 for update', [appId])).rows[0];
180
+ const agents = [...(row?.ai_agents ?? [])];
181
+ const tools = [...(row?.ai_tools ?? [])];
182
+ const problems = await change(agents, tools);
183
+ if (problems.length) return problems;
184
+ await c.query('update meta.app set ai_agents = $2, ai_tools = $3, updated_at = now() where id = $1', [appId, JSON.stringify(agents), JSON.stringify(tools)]);
185
+ return [];
186
+ });
187
+ }
188
+
189
+ // ---------------------------------------------------------------- AI usage: tool calls
190
+
191
+ /** The "Tool calls" region of Activity → AI usage: the latest calls with their arguments. */
192
+ export async function toolCallTable(appId: number): Promise<Raw> {
193
+ const rows = (await owner.query('select * from meta.ai_tool_call where app_id = $1 order by id desc limit 100', [appId])).rows;
194
+ return region('Tool calls', html`<p class="muted u-mt0">Every tool call of the application's AI assistants and agents, with the arguments the model gave (kept 30 days).</p>
195
+ <div class="table-wrap"><table class="report report-reflow">
196
+ <thead><tr><th>When (UTC)</th><th class="num">Page</th><th>User</th><th>Agent</th><th>Tool</th><th>Arguments</th><th class="num">ms</th><th>Status</th></tr></thead>
197
+ <tbody>${rows.length ? rows.map((r) => html`<tr>
198
+ <td data-label="When">${when(r.at)}</td><td class="num" data-label="Page">${r.page_no ?? ''}</td><td data-label="User">${r.username ?? ''}</td>
199
+ <td data-label="Agent">${r.agent ?? html`<span class="muted">region</span>`}</td><td data-label="Tool">${r.tool} <span class="muted">${r.type}</span></td>
200
+ <td data-label="Arguments"><code class="ai-args">${JSON.stringify(r.arguments)}</code></td><td class="num" data-label="ms">${r.duration_ms}</td>
201
+ <td data-label="Status"><span class="tag${r.status === 'ok' ? ' tag-ok' : ' tag-error'}">${r.status}</span>${r.message ? html` <span class="muted">${r.message}</span>` : ''}</td></tr>`)
202
+ : html`<tr><td colspan="8" class="empty">No tool calls yet.</td></tr>`}</tbody></table></div>`);
203
+ }
204
+
205
+ // ---------------------------------------------------------------- routes
206
+
207
+ export async function aiAgentRoutes(app: FastifyInstance) {
208
+ const load = async (req: Req, reply: FastifyReply) => {
209
+ const s = await developer(req, reply);
210
+ if (!s) return null;
211
+ const a = await appOr404(req.params.id);
212
+ if (!a) {
213
+ reply.code(404).send('Not found');
214
+ return null;
215
+ }
216
+ return { s, a };
217
+ };
218
+
219
+ app.get(`${BASE}/apps/:id(^\\d+$)/ai-agents`, async (req: Req, reply) => {
220
+ const ok = await load(req, reply);
221
+ if (!ok) return;
222
+ const { s, a } = ok;
223
+ const agents = (a.ai_agents ?? []) as AiAgent[];
224
+ const tools = (a.ai_tools ?? []) as AiTool[];
225
+ const problems = definitionProblems(agents, tools);
226
+ const main = html`${appHeader(a, 'shared')}
227
+ <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>
229
+ ${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
230
+ <div class="columns">
231
+ ${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>
232
+ <tbody>${agents.length ? agents.map((g) => html`<tr><td data-label="Agent"><a href="${BASE}/apps/${a.id}/ai-agents/agent?name=${encodeURIComponent(g.name)}">${g.name}</a>${g.description ? html` <span class="muted">${g.description}</span>` : ''}</td>
233
+ <td data-label="AI service">${g.service}${g.effort ? html` <span class="muted">(${g.effort})</span>` : ''}</td><td data-label="Tools">${(g.tools ?? []).join(', ')}</td><td data-label="Authorization">${g.authz ?? ''}</td></tr>`)
234
+ : html`<tr><td colspan="4" class="empty">No AI agents yet.</td></tr>`}</tbody></table></div>
235
+ <p><a class="btn" href="${BASE}/apps/${a.id}/ai-agents/agent">${icon('plus')} Add agent</a></p>`)}
236
+ ${region('Tools', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Tool</th><th>Type</th><th>Authorization</th></tr></thead>
237
+ <tbody>${tools.length ? tools.map((t) => html`<tr><td data-label="Tool"><a href="${BASE}/apps/${a.id}/ai-agents/tool?name=${encodeURIComponent(t.name)}">${t.name}</a> <span class="muted">${t.description}</span></td>
238
+ <td data-label="Type">${t.type}</td><td data-label="Authorization">${t.authz ?? ''}</td></tr>`)
239
+ : html`<tr><td colspan="3" class="empty">No AI tools yet.</td></tr>`}</tbody></table></div>
240
+ <p><a class="btn" href="${BASE}/apps/${a.id}/ai-agents/tool">${icon('plus')} Add tool</a></p>`)}
241
+ </div>`;
242
+ return send(reply, s, shell(s, 'AI Agents', crumbs(a), main));
243
+ });
244
+
245
+ app.get(`${BASE}/apps/:id(^\\d+$)/ai-agents/agent`, async (req: Req, reply) => {
246
+ const ok = await load(req, reply);
247
+ if (!ok) return;
248
+ const { s, a } = ok;
249
+ const name = String((req.query as Record<string, unknown>)?.name ?? '').toUpperCase();
250
+ const x = name ? ((a.ai_agents ?? []) as AiAgent[]).find((g) => g.name === name) : {};
251
+ if (!x) return reply.code(404).send('Not found');
252
+ const title = name || 'New agent';
253
+ const main = html`${appHeader(a, 'shared')}<div class="title-row"><h1 class="ai-name">${title}</h1></div>${region('AI agent', await agentForm(a, s, x, name, []))}`;
254
+ return send(reply, s, shell(s, title, crumbs(a, title), main));
255
+ });
256
+
257
+ app.post(`${BASE}/apps/:id(^\\d+$)/ai-agents/agent`, async (req: Req, reply) => {
258
+ const ok = await load(req, reply);
259
+ if (!ok) return;
260
+ const { s, a } = ok;
261
+ const b = (req.body ?? {}) as Body;
262
+ const original = one(b.original).toUpperCase();
263
+ const errors: string[] = [];
264
+ const x = agentFrom(b, errors);
265
+ const problems = errors.length ? errors : await update(a.id, (agents, tools) => {
266
+ const out = agentProblems(x, new Set(tools.map((t) => t.name)));
267
+ const at = original ? agents.findIndex((g) => g.name === original) : -1;
268
+ if (original && at < 0) out.push(`Agent ${original} no longer exists.`);
269
+ if (agents.some((g, i) => g.name === x.name && i !== at)) out.push(`An agent ${x.name} exists already.`);
270
+ if (out.length) return out;
271
+ if (at >= 0) agents[at] = x;
272
+ else agents.push(x);
273
+ return [];
274
+ });
275
+ if (problems.length) {
276
+ const main = html`${appHeader(a, 'shared')}<div class="title-row"><h1 class="ai-name">${original || 'New agent'}</h1></div>${region('AI agent', await agentForm(a, s, x, original, problems))}`;
277
+ return send(reply.code(422), s, shell(s, original || 'New agent', crumbs(a, original || 'New agent'), main));
278
+ }
279
+ if (original && original !== x.name)
280
+ await owner.query(`update meta.region r set config = jsonb_set(r.config, '{agent}', to_jsonb($3::text)) from meta.page p
281
+ where p.id = r.page_id and p.app_id = $1 and r.type = 'ai_assistant' and upper(r.config->>'agent') = $2`, [a.id, original, x.name]);
282
+ flash(s, 'AI agent saved.');
283
+ return back(reply, s, `${BASE}/apps/${a.id}/ai-agents/agent?name=${encodeURIComponent(x.name)}`);
284
+ });
285
+
286
+ app.post(`${BASE}/apps/:id(^\\d+$)/ai-agents/agent/delete`, async (req: Req, reply) => {
287
+ const ok = await load(req, reply);
288
+ if (!ok) return;
289
+ const { s, a } = ok;
290
+ const name = one((req.body as Body)?.name).toUpperCase();
291
+ await update(a.id, (agents) => {
292
+ const at = agents.findIndex((g) => g.name === name);
293
+ if (at >= 0) agents.splice(at, 1);
294
+ return [];
295
+ });
296
+ flash(s, `AI agent ${name} deleted.`);
297
+ return back(reply, s, `${BASE}/apps/${a.id}/ai-agents`);
298
+ });
299
+
300
+ app.get(`${BASE}/apps/:id(^\\d+$)/ai-agents/tool`, async (req: Req, reply) => {
301
+ const ok = await load(req, reply);
302
+ if (!ok) return;
303
+ const { s, a } = ok;
304
+ const name = String((req.query as Record<string, unknown>)?.name ?? '');
305
+ const x = name ? ((a.ai_tools ?? []) as AiTool[]).find((t) => t.name === name) : {};
306
+ if (!x) return reply.code(404).send('Not found');
307
+ const title = name || 'New tool';
308
+ const main = html`${appHeader(a, 'shared')}<div class="title-row"><h1 class="ai-name">${title}</h1></div>${region('AI tool', await toolForm(a, s, x, name, []))}`;
309
+ return send(reply, s, shell(s, title, crumbs(a, title), main));
310
+ });
311
+
312
+ app.post(`${BASE}/apps/:id(^\\d+$)/ai-agents/tool`, async (req: Req, reply) => {
313
+ const ok = await load(req, reply);
314
+ if (!ok) return;
315
+ const { s, a } = ok;
316
+ const b = (req.body ?? {}) as Body;
317
+ const original = one(b.original);
318
+ const errors: string[] = [];
319
+ const x = toolFrom(b, errors);
320
+ const { parameters_text: _, ...tool } = x;
321
+ const problems = errors.length ? errors : await update(a.id, async (agents, tools) => {
322
+ const out = aiToolProblems(tool);
323
+ 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
+ out.push(`There is no REST data source ${tool.source}.`);
325
+ if (tool.type === 'navigate') {
326
+ 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
+ for (const p of tool.pages ?? []) if (!known.has(p)) out.push(`There is no page ${p}.`);
328
+ }
329
+ const at = original ? tools.findIndex((t) => t.name === original) : -1;
330
+ if (original && at < 0) out.push(`Tool ${original} no longer exists.`);
331
+ if (tools.some((t, i) => t.name === tool.name && i !== at)) out.push(`A tool ${tool.name} exists already.`);
332
+ if (out.length) return out;
333
+ if (at >= 0) tools[at] = tool;
334
+ else tools.push(tool);
335
+ // a renamed tool keeps its agents
336
+ if (original && original !== tool.name) for (const g of agents) if (g.tools) g.tools = g.tools.map((n) => (n === original ? tool.name : n));
337
+ return [];
338
+ });
339
+ if (problems.length) {
340
+ const main = html`${appHeader(a, 'shared')}<div class="title-row"><h1 class="ai-name">${original || 'New tool'}</h1></div>${region('AI tool', await toolForm(a, s, x, original, problems))}`;
341
+ return send(reply.code(422), s, shell(s, original || 'New tool', crumbs(a, original || 'New tool'), main));
342
+ }
343
+ flash(s, 'AI tool saved.');
344
+ return back(reply, s, `${BASE}/apps/${a.id}/ai-agents/tool?name=${encodeURIComponent(tool.name)}`);
345
+ });
346
+
347
+ app.post(`${BASE}/apps/:id(^\\d+$)/ai-agents/tool/delete`, async (req: Req, reply) => {
348
+ const ok = await load(req, reply);
349
+ if (!ok) return;
350
+ const { s, a } = ok;
351
+ const name = one((req.body as Body)?.name);
352
+ const problems = await update(a.id, (agents, tools) => {
353
+ const users = agents.filter((g) => (g.tools ?? []).includes(name)).map((g) => g.name);
354
+ if (users.length) return [`Tool ${name} is used by ${users.join(', ')}: take it out of those agents first.`];
355
+ const at = tools.findIndex((t) => t.name === name);
356
+ if (at >= 0) tools.splice(at, 1);
357
+ return [];
358
+ });
359
+ if (problems.length) {
360
+ flash(s, problems.join(' '), 'error');
361
+ return back(reply, s, `${BASE}/apps/${a.id}/ai-agents/tool?name=${encodeURIComponent(name)}`);
362
+ }
363
+ flash(s, `AI tool ${name} deleted.`);
364
+ return back(reply, s, `${BASE}/apps/${a.id}/ai-agents`);
365
+ });
366
+ }
367
+
368
+ /** The agents an AI assistant region may name (the region settings form). */
369
+ export async function agentOptions(appId: number) {
370
+ return ((await owner.one<{ ai_agents: AiAgent[] }>('select ai_agents from meta.app where id = $1', [appId]))?.ai_agents ?? []).map((g) => g.name);
371
+ }
372
+
@@ -1,6 +1,6 @@
1
1
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
2
  import pg from 'pg';
3
- import { generate } from '../ai/service.ts';
3
+ import { generate, providerLabel } from '../ai/service.ts';
4
4
  import { AiError } from '../ai/types.ts';
5
5
  import { owner } from '../db.ts';
6
6
  import { html, raw, type Raw } from '../html.ts';
@@ -116,12 +116,12 @@ async function serviceBox(s: Session) {
116
116
  const admin = await isAdmin(s.username);
117
117
  if (!admin)
118
118
  return svc
119
- ? html`<p class="muted">AI service: <b>${svc.name}</b> (${svc.provider === 'anthropic' ? 'Claude' : 'OpenAI'}, ${svc.model}). Table and column names, types and descriptions, and what you type, are sent to it.</p>`
119
+ ? html`<p class="muted">AI service: <b>${svc.name}</b> (${providerLabel(svc.provider)}, ${svc.model}). Table and column names, types and descriptions, and what you type, are sent to it.</p>`
120
120
  : html`<div class="alert alert-info">The App Builder has no AI service yet: ask an administrator to choose one here.</div>`;
121
121
  const all = (await owner.query<{ name: string; provider: string; model: string }>('select name, provider, model from meta.ai_service where enabled order by name')).rows;
122
122
  return html`<form method="post" action="${BASE}/sql/ai/service" class="component-form">${csrf(s)}
123
123
  <div class="form-grid"><div class="field"><label class="label" for="f_ai_builder_service">AI service of the App Builder (administrators)</label>
124
- <select id="f_ai_builder_service" name="service">${opt('', '- none: no AI in the App Builder -', svc?.name ?? '')}${all.map((x) => opt(x.name, `${x.name} (${x.provider === 'anthropic' ? 'Claude' : 'OpenAI'}, ${x.model})`, svc?.name ?? ''))}</select>
124
+ <select id="f_ai_builder_service" name="service">${opt('', '- none: no AI in the App Builder -', svc?.name ?? '')}${all.map((x) => opt(x.name, `${x.name} (${providerLabel(x.provider)}, ${x.model})`, svc?.name ?? ''))}</select>
125
125
  <small class="help">Table and column names, types and descriptions (never rows), and what developers type, are sent to it. Its calls are logged without an application.</small></div></div>
126
126
  <div class="buttons"><button class="btn">Save</button></div></form>`;
127
127
  }
package/src/builder/ai.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
- import { DEFAULT_CLAUDE_MODEL, envKeyName, generate, keySource, usageToday, type AiService } from '../ai/service.ts';
2
+ import { DEFAULT_CLAUDE_MODEL, envKeyName, GEMINI_BASE_URL, generate, keySource, providerLabel, usageToday, type AiService } from '../ai/service.ts';
3
+ import type { ProviderName } from '../ai/types.ts';
4
+ import { toolCallTable } from './ai-agents.ts';
3
5
  import { AiError } from '../ai/types.ts';
4
6
  import { owner } from '../db.ts';
5
7
  import { html, raw } from '../html.ts';
@@ -16,7 +18,7 @@ import { back, BASE, csrf, developer, flash, input, region, select, send, shell,
16
18
  // AI usage (developers): what an application may use and what it used.
17
19
  // API keys are encrypted (src/secrets.ts) and never sent back to the browser.
18
20
 
19
- const PROVIDERS: [string, string][] = [['anthropic', 'Claude (Anthropic)'], ['openai', 'OpenAI']];
21
+ const PROVIDERS: [string, string][] = [['anthropic', 'Claude (Anthropic)'], ['openai', 'OpenAI'], ['gemini', 'Gemini (Google, OpenAI-compatible endpoint)'], ['openai_compatible', 'OpenAI-compatible (Ollama, vLLM, Azure OpenAI, a gateway)']];
20
22
  const EFFORTS: [string, string][] = [['', 'Model default'], ['low', 'low'], ['medium', 'medium'], ['high', 'high'], ['xhigh', 'xhigh'], ['max', 'max']];
21
23
  const TEST_RESULT = '__AITEST';
22
24
 
@@ -33,18 +35,18 @@ function serviceForm(s: Partial<AiService>, action: string, csrfField: ReturnTyp
33
35
  <fieldset class="prop-group"><legend>Service</legend><div class="form-grid">
34
36
  ${isNew ? input('name', 'Name', '', { required: true, placeholder: 'e.g. CLAUDE', help: 'Upper case letters, digits and _. Processes refer to the service by this name.' }) : ''}
35
37
  ${input('description', 'Description', s.description)}
36
- ${select('provider', 'Provider', s.provider ?? 'anthropic', PROVIDERS, 'Prompts, and the item values they contain, are sent to this provider.')}
38
+ ${select('provider', 'Provider', s.provider ?? 'anthropic', PROVIDERS, 'Prompts, and the item values they contain, are sent to this provider. Gemini and OpenAI-compatible endpoints speak OpenAI\'s Chat Completions.')}
37
39
  ${input('model', 'Model', s.model ?? (isNew ? DEFAULT_CLAUDE_MODEL : ''), { required: true, help: `Exactly as the provider names it, e.g. ${DEFAULT_CLAUDE_MODEL} (Claude) or the OpenAI model your account uses. pgkiln never changes it.` })}
38
40
  ${select('effort', 'Effort (Claude)', isNew ? 'medium' : s.effort ?? '', EFFORTS, 'How deeply Claude thinks (thinking is always on for current Claude models): lower is faster and cheaper; high or more for hard tasks. Model default: medium for Claude Opus 5.5.')}
39
41
  ${input('max_tokens', 'Maximum output tokens', s.max_tokens ?? 4000, { type: 'number', help: 'Per request (1 – 128000); a process may ask for fewer.' })}
40
42
  ${input('timeout_s', 'Time limit (seconds)', s.timeout_s ?? 120, { type: 'number', help: '5 – 600. Pages wait for the answer.' })}
41
- ${input('base_url', 'Base URL', s.base_url, { placeholder: 'empty: the provider\'s own API', help: 'A gateway or proxy that speaks the provider\'s API. Administrators only.' })}
43
+ ${input('base_url', 'Base URL', s.base_url, { placeholder: 'empty: the provider\'s own API', help: `A gateway or proxy that speaks the provider's API. Administrators only. Gemini: empty is ${GEMINI_BASE_URL}. OpenAI-compatible: required, e.g. http://localhost:11434/v1 (Ollama), https://<resource>.openai.azure.com/openai/v1/ (Azure OpenAI).` })}
42
44
  </div>
43
45
  ${check('refusal_fallback', 'Refusal fallbacks (Claude)', s.refusal_fallback !== false, 'When Claude declines a request for safety reasons, Anthropic re-runs it on its recommended fallback model. The usage log shows the model that answered.')}
44
46
  ${check('enabled', 'Enabled', s.enabled !== false)}
45
47
  </fieldset>
46
48
  <fieldset class="prop-group"><legend>API key</legend>
47
- ${input('api_key', 'API key', '', { type: 'password', auto: 'new-password', help: source === 'stored' ? 'A key is stored (encrypted). It is never shown; type a new one to replace it.' : `Empty: the server's ${envKeyName((s.provider as 'anthropic') ?? 'anthropic')} is used (when set).` })}
49
+ ${input('api_key', 'API key', '', { type: 'password', auto: 'new-password', help: source === 'stored' ? 'A key is stored (encrypted). It is never shown; type a new one to replace it.' : `Empty: the server's ${envKeyName((s.provider as ProviderName) ?? 'anthropic')} is used (when set)${s.provider === 'openai_compatible' ? '; an endpoint without keys (Ollama) needs none' : ''}.` })}
48
50
  ${source === 'stored' ? check('remove_key', 'Remove the stored key (use the server default)', false) : ''}
49
51
  ${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">The server has no <code>PGKILN_SECRET_KEY</code>: API keys can't be stored until it is set (the server's environment variables still work).</div>`}
50
52
  </fieldset>
@@ -54,7 +56,7 @@ function serviceForm(s: Partial<AiService>, action: string, csrfField: ReturnTyp
54
56
 
55
57
  /** The form's values, checked; throws with a message. */
56
58
  function formValues(b: Record<string, string | undefined>) {
57
- const provider = b.provider === 'openai' ? 'openai' : 'anthropic';
59
+ const provider: ProviderName = (['openai', 'gemini', 'openai_compatible'] as const).find((p) => p === b.provider) ?? 'anthropic';
58
60
  const model = (b.model ?? '').trim();
59
61
  if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/.test(model)) throw new Error('The model is a name like claude-opus-5-5 (letters, digits and . _ : / @ -).');
60
62
  const maxTokens = Number(b.max_tokens || 4000);
@@ -63,6 +65,7 @@ function formValues(b: Record<string, string | undefined>) {
63
65
  if (!Number.isInteger(timeout) || timeout < 5 || timeout > 600) throw new Error('Time limit: 5 to 600 seconds.');
64
66
  const baseUrl = (b.base_url ?? '').trim() || null;
65
67
  if (baseUrl && (!/^https?:\/\/[^\s]+$/i.test(baseUrl) || baseUrl.length > 500)) throw new Error('The base URL starts with http:// or https://.');
68
+ if (!baseUrl && provider === 'openai_compatible') throw new Error('An OpenAI-compatible service needs a base URL (e.g. http://localhost:11434/v1 for Ollama).');
66
69
  const effort = ['low', 'medium', 'high', 'xhigh', 'max'].includes(b.effort ?? '') ? b.effort! : null;
67
70
  return [(b.description ?? '').trim() || null, provider, model, provider === 'anthropic' ? effort : null, b.refusal_fallback === 'true', maxTokens, timeout, baseUrl, b.enabled === 'true'];
68
71
  }
@@ -104,7 +107,7 @@ export async function aiRoutes(app: FastifyInstance) {
104
107
  <thead><tr><th>Service</th><th>Provider</th><th>Model</th><th>API key</th><th>Applications</th><th>Status</th></tr></thead>
105
108
  <tbody>${services.length ? services.map((x) => html`<tr>
106
109
  <td data-label="Service"><a href="${BASE}/ai/${x.id}">${x.name}</a>${x.description ? html` <span class="muted">${x.description}</span>` : ''}</td>
107
- <td data-label="Provider">${x.provider === 'anthropic' ? 'Claude' : 'OpenAI'}</td>
110
+ <td data-label="Provider">${providerLabel(x.provider)}</td>
108
111
  <td data-label="Model">${x.model}${x.effort ? html` <span class="muted">(${x.effort})</span>` : ''}</td>
109
112
  <td data-label="API key">${KEY_TEXT[keySource(x)]}</td>
110
113
  <td data-label="Applications">${x.apps ?? html`<span class="muted">none</span>`}</td>
@@ -271,12 +274,13 @@ export async function aiRoutes(app: FastifyInstance) {
271
274
  <div class="title-row"><h1 class="ai-name">AI usage · ${a.name}</h1></div>
272
275
  <p class="muted u-mt0">The AI services this application may use (an administrator allows them${admin ? html` under <a href="${BASE}/ai">AI services</a>` : ''}), today's use against their limits, and the recent requests.</p>
273
276
  ${region('Services', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Service</th><th>Model</th><th class="num">Requests today</th><th class="num">Tokens today</th><th>Status</th></tr></thead>
274
- <tbody>${allowed.length ? allowed.map((x, i) => html`<tr><td data-label="Service">${x.name}</td><td data-label="Model">${x.provider === 'anthropic' ? 'Claude' : 'OpenAI'} ${x.model}</td>
277
+ <tbody>${allowed.length ? allowed.map((x, i) => html`<tr><td data-label="Service">${x.name}</td><td data-label="Model">${providerLabel(x.provider)} ${x.model}</td>
275
278
  <td class="num" data-label="Requests today">${today[i].requests}${x.max_requests !== null ? ` / ${x.max_requests}` : ''}</td>
276
279
  <td class="num" data-label="Tokens today">${today[i].tokens}${x.max_tokens !== null ? ` / ${x.max_tokens}` : ''}</td>
277
280
  <td data-label="Status">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
278
281
  : html`<tr><td colspan="5" class="empty">This application may not use any AI service yet.</td></tr>`}</tbody></table></div>`)}
279
- ${region('Recent requests', usageTable(recent, false))}`;
282
+ ${region('Recent requests', usageTable(recent, false))}
283
+ ${await toolCallTable(a.id)}`;
280
284
  return send(reply, s, shell(s, `AI usage · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity', `${BASE}/apps/${a.id}/activity`], ['AI usage']], main));
281
285
  });
282
286
  }
@@ -1,9 +1,11 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
+ import { providerLabel } from '../ai/service.ts';
2
3
  import { owner } from '../db.ts';
3
4
  import { html, raw, type Raw } from '../html.ts';
4
5
  import { aiFilterProblems } from '../runtime/ai-filter.ts';
5
6
  import { assistantProblems, type AssistantConfig } from '../runtime/assistant.ts';
6
7
  import type { Session } from '../session.ts';
8
+ import { agentOptions } from './ai-agents.ts';
7
9
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
8
10
 
9
11
  // Page designer → an AI assistant region → Settings (service, prompts,
@@ -23,7 +25,7 @@ async function serviceOptions(appId: number, current: unknown) {
23
25
  const cur = typeof current === 'string' ? current.toUpperCase() : '';
24
26
  return {
25
27
  allowed,
26
- options: html`${allowed.map((x) => opt(x.name, `${x.name} (${x.provider === 'anthropic' ? 'Claude' : 'OpenAI'}, ${x.model})`, cur))}${cur && !allowed.some((x) => x.name === cur) ? opt(cur, `${cur} (not available to this application)`, cur) : ''}`,
28
+ options: html`${allowed.map((x) => opt(x.name, `${x.name} (${providerLabel(x.provider)}, ${x.model})`, cur))}${cur && !allowed.some((x) => x.name === cur) ? opt(cur, `${cur} (not available to this application)`, cur) : ''}`,
27
29
  };
28
30
  }
29
31
 
@@ -36,14 +38,19 @@ export async function assistantSettingsForm(pageId: number, appId: number, r: {
36
38
  const id = (n: string) => `ra_${r.id}_${n}`;
37
39
  const { allowed, options } = await serviceOptions(appId, cfg.service);
38
40
  const problems = assistantProblems(cfg);
41
+ const agents = await agentOptions(appId);
42
+ const agent = typeof cfg.agent === 'string' ? cfg.agent.toUpperCase() : '';
39
43
  return html`<h3 class="u-mt15">AI assistant settings</h3>
40
44
  <p class="muted u-mt0">A chat with an AI service. What users type, the context rows and the tools' results go to the service's provider (Claude or OpenAI). Context queries and SQL tools run as the application's database role; SQL tools are rolled back unless they may change data.</p>
41
45
  ${allowed.length ? '' : NO_SERVICE}
42
46
  ${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
43
47
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/assistant" class="component-form">${csrf(s)}
44
48
  <fieldset class="prop-group"><legend>Assistant</legend><div class="form-grid">
49
+ <div class="field"><label class="label" for="${id('agent')}">AI agent</label>
50
+ <select id="${id('agent')}" name="agent">${opt('', '- none: the service, prompt and tools below -', agent)}${agents.map((n) => opt(n, n, agent))}${agent && !agents.includes(agent) ? opt(agent, `${agent} (does not exist)`, agent) : ''}</select>
51
+ <small class="help">An agent of <a href="${BASE}/apps/${appId}/ai-agents">Shared Components → AI Agents</a> sets the service, system prompt, reasoning effort, tools and steps; saving with an agent removes the region's own service, system prompt, tools and tool rounds.</small></div>
45
52
  <div class="field"><label class="label" for="${id('service')}">AI service</label>
46
- <select id="${id('service')}" name="service" required>${opt('', '- choose -', cfg.service ?? '')}${options}</select></div>
53
+ <select id="${id('service')}" name="service">${opt('', '- choose -', cfg.service ?? '')}${options}</select></div>
47
54
  <div class="field"><label class="label" for="${id('max_rounds')}">Tool rounds per question</label>
48
55
  <input id="${id('max_rounds')}" name="max_rounds" type="number" min="1" max="10" value="${cfg.max_rounds ?? ''}" placeholder="5"></div>
49
56
  <div class="field"><label class="label" for="${id('max_turns')}">Questions per conversation</label>
@@ -95,6 +102,10 @@ export async function mergeAssistantSettings(appId: number, config: Config, b: B
95
102
  };
96
103
  out.service = (b.service ?? '').trim().toUpperCase();
97
104
  if (!out.service) delete out.service;
105
+ // (121) an agent sets the service, system prompt, tools and steps itself
106
+ const agent = (b.agent ?? '').trim().toUpperCase();
107
+ if (agent) out.agent = agent;
108
+ else delete out.agent;
98
109
  text('system', 20_000);
99
110
  text('welcome', 2000);
100
111
  text('placeholder', 200);
@@ -116,6 +127,10 @@ export async function mergeAssistantSettings(appId: number, config: Config, b: B
116
127
  errors.push(`${k === 'tools' ? 'Tools' : 'Context queries'}: not valid JSON (kept as it was).`);
117
128
  }
118
129
  }
130
+ if (out.agent) {
131
+ for (const k of ['service', 'system', 'tools', 'max_rounds']) delete out[k];
132
+ if (!(await agentOptions(appId)).includes(out.agent)) errors.push(`There is no AI agent ${out.agent}.`);
133
+ }
119
134
  errors.push(...assistantProblems(out), ...(await appProblems(appId, out as AssistantConfig)));
120
135
  return { config: out, errors };
121
136
  }
@@ -124,14 +139,13 @@ export async function mergeAssistantSettings(appId: number, config: Config, b: B
124
139
  export async function aiFilterSettingsForm(pageId: number, appId: number, r: { id: number; config: Config }, s: Session): Promise<Raw> {
125
140
  const cfg = (r.config?.ai_filter ?? {}) as Config;
126
141
  const id = (n: string) => `raf_${r.id}_${n}`;
127
- const { allowed, options } = await serviceOptions(appId, cfg.service);
142
+ const { options } = await serviceOptions(appId, cfg.service);
128
143
  return html`<h3 class="u-mt15">Ask in your own words (AI)</h3>
129
- <p class="muted u-mt0">Users type a question; an AI service turns it into the report's filters, search and sort (only columns the report shows, only its operators). The question and the column names go to the service's provider.</p>
130
- ${allowed.length || cfg.service ? '' : NO_SERVICE}
144
+ <p class="muted u-mt0">Users type a question; it becomes the report's own settings (filters, highlights, sort, control break, totals, group by, pivot, chart, computed columns; only columns the report shows, only its operators), shown as chips they can edit, remove or undo. With an AI service the question and the column names go to the service's provider; without one (<i>built-in phrases</i>) pgkiln reads common phrases itself, in English, Dutch and German.</p>
131
145
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/ai-filter" class="component-form">${csrf(s)}
132
146
  <div class="form-grid">
133
147
  <div class="field"><label class="label" for="${id('service')}">AI service</label>
134
- <select id="${id('service')}" name="service">${opt('', '- off -', cfg.service ?? '')}${options}</select></div>
148
+ <select id="${id('service')}" name="service">${opt('', '- off -', r.config?.ai_filter ? '-' : '')}${opt('-', 'Built-in phrases (no AI)', r.config?.ai_filter && !cfg.service ? '-' : '')}${options}</select></div>
135
149
  <div class="field" data-wide><label class="label" for="${id('placeholder')}">Placeholder</label>
136
150
  <input id="${id('placeholder')}" name="placeholder" maxlength="200" value="${cfg.placeholder ?? ''}"></div>
137
151
  </div>
@@ -171,8 +185,9 @@ export async function assistantBuilderRoutes(app: FastifyInstance) {
171
185
  const service = (b.service ?? '').trim().toUpperCase();
172
186
  if (!service) delete config.ai_filter;
173
187
  else {
188
+ // "-": the built-in phrases, no AI service (sprint 112)
174
189
  const placeholder = (b.placeholder ?? '').trim().slice(0, 200);
175
- config.ai_filter = { service, ...(placeholder ? { placeholder } : {}), ...(b.public === 'true' ? { public: true } : {}) };
190
+ config.ai_filter = { ...(service === '-' ? {} : { service }), ...(placeholder ? { placeholder } : {}), ...(b.public === 'true' ? { public: true } : {}) };
176
191
  }
177
192
  const errors = aiFilterProblems(config.ai_filter);
178
193
  if (errors.length) flash(s, errors.join(' '), 'error');