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.
- package/SECURITY.md +7 -0
- package/db/migrations/119_workspace_home.sql +30 -0
- package/db/migrations/120_datasets.sql +87 -0
- package/db/migrations/121_ai_agents.sql +94 -0
- package/db/migrations/122_team_development.sql +186 -0
- package/db/migrations/123_workflow_outcomes.sql +407 -0
- package/db/migrations/124_component_details.sql +51 -0
- package/docs/apex-feature-parity.md +15 -12
- package/docs/guide/01-installation.md +9 -3
- package/docs/guide/03-builder.md +211 -11
- package/docs/guide/04-pages-and-regions.md +96 -22
- package/docs/guide/05-items.md +7 -1
- package/docs/guide/06-processing.md +123 -8
- package/docs/guide/07-dynamic-actions.md +34 -0
- package/docs/guide/09-reference.md +2 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +70 -11
- package/docs/guide/20-ai-agents.md +4 -2
- package/examples/hr/README.md +2 -0
- package/examples/hr/hr_63_datasets.sql +29 -0
- package/examples/hr/hr_64_ai_agent.sql +97 -0
- package/examples/hr/hr_65_ask_reports.sql +21 -0
- package/examples/hr/hr_66_team_development.sql +58 -0
- package/examples/hr/hr_67_workflow_outcomes.sql +113 -0
- package/examples/hr/hr_68_component_details.sql +96 -0
- package/package.json +1 -1
- package/public/app.css +46 -0
- package/public/app.js +134 -20
- package/public/builder.css +116 -0
- package/public/builder.js +47 -0
- package/src/ai/chat.ts +18 -18
- package/src/ai/service.ts +35 -11
- package/src/ai/types.ts +7 -2
- package/src/app.ts +11 -1
- package/src/builder/advisor.ts +6 -3
- package/src/builder/ai-agents.ts +372 -0
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/ai.ts +13 -9
- package/src/builder/assistant.ts +22 -7
- package/src/builder/components.ts +38 -9
- package/src/builder/datasets.ts +472 -0
- package/src/builder/designer.ts +8 -3
- package/src/builder/feedback.ts +6 -1
- package/src/builder/home.ts +12 -7
- package/src/builder/instance.ts +2 -0
- package/src/builder/locks.ts +7 -1
- package/src/builder/region-help.ts +2 -2
- package/src/builder/reporter.ts +52 -3
- package/src/builder/routes.ts +7 -4
- package/src/builder/shared.ts +1 -1
- package/src/builder/subscriptions.ts +1 -0
- package/src/builder/team.ts +991 -0
- package/src/builder/ui.ts +8 -5
- package/src/builder/workflow-migrate.ts +210 -0
- package/src/builder/workflows.ts +29 -6
- package/src/builder/workspace-home.ts +285 -0
- package/src/builder/workspace-transfer.ts +1 -1
- package/src/builder/workspaces.ts +7 -4
- package/src/cli/replace.ts +7 -1
- package/src/datasets.ts +367 -0
- package/src/i18n/ar.ts +33 -0
- package/src/i18n/cs.ts +33 -0
- package/src/i18n/da.ts +33 -0
- package/src/i18n/de.ts +33 -0
- package/src/i18n/el.ts +33 -0
- package/src/i18n/es.ts +33 -0
- package/src/i18n/fi.ts +33 -0
- package/src/i18n/fr.ts +33 -0
- package/src/i18n/he.ts +33 -0
- package/src/i18n/it.ts +33 -0
- package/src/i18n/ja.ts +33 -0
- package/src/i18n/ko.ts +33 -0
- package/src/i18n/nb.ts +33 -0
- package/src/i18n/pl.ts +33 -0
- package/src/i18n/pt.ts +33 -0
- package/src/i18n/ru.ts +33 -0
- package/src/i18n/sv.ts +33 -0
- package/src/i18n/tr.ts +33 -0
- package/src/i18n/uk.ts +33 -0
- package/src/i18n/zh.ts +33 -0
- package/src/i18n.ts +66 -0
- package/src/instance.ts +3 -0
- package/src/metadata.ts +9 -3
- package/src/release-check.ts +139 -0
- package/src/reporting-app.ts +101 -0
- package/src/runtime/ai-agents.ts +365 -0
- package/src/runtime/ai-filter.ts +385 -107
- package/src/runtime/assistant.ts +64 -11
- package/src/runtime/context.ts +2 -0
- package/src/runtime/data-reporter.ts +199 -32
- package/src/runtime/ir-phrases.ts +812 -0
- package/src/runtime/ir-plan.ts +449 -0
- package/src/runtime/items.ts +1 -1
- package/src/runtime/regions.ts +6 -2
- package/src/runtime/render.ts +1 -1
- package/src/runtime/report.ts +1 -1
- package/src/runtime/tasks.ts +63 -12
- package/src/session.ts +2 -0
- package/src/subscriptions.ts +2 -1
- package/src/task-events.ts +232 -0
- package/src/team-samples.ts +74 -0
- package/src/team.ts +342 -0
- package/src/workflow.ts +135 -18
- 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
|
|
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
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
}
|
package/src/builder/assistant.ts
CHANGED
|
@@ -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
|
|
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"
|
|
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 {
|
|
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;
|
|
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
|
|
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');
|