pgkiln 0.83.0 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/154_notifications.sql +186 -0
- package/docs/apex-feature-parity.md +3 -2
- package/docs/guide/03-builder.md +16 -5
- package/docs/guide/04-pages-and-regions.md +42 -0
- package/docs/guide/06-processing.md +22 -1
- package/docs/guide/07-dynamic-actions.md +1 -0
- package/docs/guide/09-reference.md +15 -0
- package/docs/guide/11-from-apex.md +1 -0
- package/docs/guide/12-development.md +13 -1
- package/package.json +1 -1
- package/public/app.css +29 -0
- package/public/app.js +78 -0
- package/public/sw.js +5 -1
- package/src/app.ts +11 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +10 -6
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3461 -0
- package/src/builder/i18n/en.ts +3461 -0
- package/src/builder/i18n/es.ts +3461 -0
- package/src/builder/i18n/fr.ts +3461 -0
- package/src/builder/i18n/it.ts +3461 -0
- package/src/builder/i18n/ja.ts +3461 -0
- package/src/builder/i18n/ko.ts +3461 -0
- package/src/builder/i18n/nl.ts +3461 -0
- package/src/builder/i18n/pt.ts +3461 -0
- package/src/builder/i18n/zh-cn.ts +3461 -0
- package/src/builder/i18n/zh-tw.ts +3461 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/notification-settings.ts +27 -0
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +30 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
- package/src/cli/replace.ts +2 -0
- package/src/i18n/ar.ts +9 -0
- package/src/i18n/cs.ts +9 -0
- package/src/i18n/da.ts +9 -0
- package/src/i18n/de.ts +9 -0
- package/src/i18n/el.ts +9 -0
- package/src/i18n/es.ts +9 -0
- package/src/i18n/fi.ts +9 -0
- package/src/i18n/fr.ts +9 -0
- package/src/i18n/he.ts +9 -0
- package/src/i18n/it.ts +9 -0
- package/src/i18n/ja.ts +9 -0
- package/src/i18n/ko.ts +9 -0
- package/src/i18n/nb.ts +9 -0
- package/src/i18n/pl.ts +9 -0
- package/src/i18n/pt.ts +9 -0
- package/src/i18n/ru.ts +9 -0
- package/src/i18n/sv.ts +9 -0
- package/src/i18n/tr.ts +9 -0
- package/src/i18n/uk.ts +9 -0
- package/src/i18n/zh.ts +9 -0
- package/src/i18n.ts +18 -0
- package/src/metadata.ts +9 -3
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/feedback.ts +1 -1
- package/src/runtime/lists.ts +43 -11
- package/src/runtime/notifications.ts +237 -0
- package/src/runtime/processes.ts +45 -1
- package/src/runtime/regions.ts +11 -15
- package/src/runtime/render.ts +12 -3
- package/src/runtime/routes.ts +12 -4
- package/src/runtime/signin.ts +2 -1
- package/src/session.ts +13 -4
|
@@ -10,8 +10,8 @@ import { appOr404 } from './forms.ts';
|
|
|
10
10
|
import { isAdmin } from './locks.ts';
|
|
11
11
|
import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
12
12
|
import { workshopDb } from './workspaces.ts';
|
|
13
|
-
import { WIZARD_KINDS, wizardTables } from './wizards.ts';
|
|
14
|
-
import { tr } from './i18n.ts';
|
|
13
|
+
import { kindLabel, WIZARD_KINDS, wizardTables } from './wizards.ts';
|
|
14
|
+
import { scopedTx as tx, tr } from './i18n.ts';
|
|
15
15
|
|
|
16
16
|
// App Builder AI (migration 062), with the AI service an administrator
|
|
17
17
|
// chooses for the builder (meta.builder_ai):
|
|
@@ -41,7 +41,7 @@ export async function builderService() {
|
|
|
41
41
|
|
|
42
42
|
async function ask(s: Session, req: Parameters<typeof generate>[1]) {
|
|
43
43
|
const svc = await builderService();
|
|
44
|
-
if (!svc) throw new AiError('config', '
|
|
44
|
+
if (!svc) throw new AiError('config', tx('aib.the_app_builder_has_no'));
|
|
45
45
|
return generate(svc.name, req, { appId: null, user: s.username, source: 'builder' });
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -128,7 +128,7 @@ async function serviceBox(s: Session) {
|
|
|
128
128
|
<div class="buttons"><button class="btn">${tx('scr.save')}</button></div></form>`;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
const errorBox = (e: unknown) => html`<div class="alert alert-error" role="alert">${e instanceof AiError || e instanceof Error ? e.message : '
|
|
131
|
+
const errorBox = (e: unknown) => html`<div class="alert alert-error" role="alert">${e instanceof AiError || e instanceof Error ? e.message : tx('aib.the_ai_request_failed')}</div>`;
|
|
132
132
|
|
|
133
133
|
// ---------------------------------------------------------------- SQL Workshop → AI
|
|
134
134
|
|
|
@@ -228,33 +228,33 @@ async function freePages(appId: number) {
|
|
|
228
228
|
async function pagesPage(s: Session, reply: FastifyReply, a: any, description: string, proposals: Proposal[] | null, message?: Raw) {
|
|
229
229
|
const tables = (await wizardTables(a.db_role)).filter((t) => t.access).map((t) => t.t);
|
|
230
230
|
const svc = await builderService();
|
|
231
|
-
const kindOptions = WIZARD_KINDS.map(([k, label]): [string, string] => [k, label]);
|
|
231
|
+
const kindOptions = WIZARD_KINDS.map(([k, label]): [string, string] => [k, kindLabel(k, label)]);
|
|
232
232
|
const main = html`${appHeader(a, 'pages', s)}
|
|
233
233
|
${message ?? ''}
|
|
234
|
-
${region('
|
|
235
|
-
<p class="muted u-mt0"
|
|
236
|
-
${svc ? '' : html`<div class="alert alert-info"
|
|
234
|
+
${region(tx('aib.create_pages_with_ai'), html`
|
|
235
|
+
<p class="muted u-mt0">${tx.rich('aib.intro', { role: a.db_role ?? tx('aib.the_owner'), descriptions: html`<a href="${BASE}/sql/ai/describe">${tx('aib.descriptions')}</a>` })}</p>
|
|
236
|
+
${svc ? '' : html`<div class="alert alert-info">${tx.rich('aib.no_service', { link: html`<a href="${BASE}/sql/ai">${tx('aib.sql_workshop_ai')}</a>` })}</div>`}
|
|
237
237
|
<form method="post" action="${BASE}/apps/${a.id}/ai-pages">${csrf(s)}
|
|
238
|
-
<div class="field"><label class="label" for="f_description"
|
|
239
|
-
<textarea id="f_description" name="description" rows="4" maxlength="4000" required placeholder="
|
|
240
|
-
<div class="buttons"><button class="btn btn-hot" data-busy="
|
|
238
|
+
<div class="field"><label class="label" for="f_description">${tx('aib.description')}</label>
|
|
239
|
+
<textarea id="f_description" name="description" rows="4" maxlength="4000" required placeholder="${tx('aib.e_g_a_page_to')}">${description}</textarea></div>
|
|
240
|
+
<div class="buttons"><button class="btn btn-hot" data-busy="${tx('aib.thinking')}"${svc ? '' : raw(' disabled')}>${tx('aib.propose_pages')}</button></div>
|
|
241
241
|
</form>`)}
|
|
242
|
-
${proposals ? region('
|
|
242
|
+
${proposals ? region(tx('aib.proposed_pages'), proposals.length ? html`
|
|
243
243
|
<form method="post" action="${BASE}/apps/${a.id}/ai-pages/create">${csrf(s)}
|
|
244
244
|
<input type="hidden" name="count" value="${proposals.length}">
|
|
245
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col"
|
|
245
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">${tx('aib.create')}</th><th scope="col">${tx('aib.page_type')}</th><th scope="col">${tx('aib.table_or_view')}</th><th scope="col">${tx('aib.page')}</th><th scope="col">${tx('aib.form_page')}</th><th scope="col">${tx('aib.label')}</th><th scope="col">${tx('aib.why')}</th></tr></thead><tbody>
|
|
246
246
|
${proposals.map((p, i) => html`<tr>
|
|
247
|
-
<td data-label="
|
|
248
|
-
<td data-label="
|
|
249
|
-
<td data-label="
|
|
250
|
-
<td data-label="
|
|
251
|
-
<td data-label="
|
|
252
|
-
<td data-label="
|
|
253
|
-
<td data-label="
|
|
247
|
+
<td data-label="${tx('aib.create')}"><input type="checkbox" name="on_${i}" value="true" checked aria-label="${tx('aib.p_create', { n: i + 1 })}"></td>
|
|
248
|
+
<td data-label="${tx('aib.page_type')}"><select name="kind_${i}" aria-label="${tx('aib.p_kind', { n: i + 1 })}">${kindOptions.map(([k, l]) => opt(k, l, p.kind))}</select></td>
|
|
249
|
+
<td data-label="${tx('aib.table_or_view')}"><select name="table_${i}" aria-label="${tx('aib.p_table', { n: i + 1 })}">${tables.map((t) => opt(t, t, p.table))}</select></td>
|
|
250
|
+
<td data-label="${tx('aib.page')}"><input name="page_${i}" type="number" min="1" max="99999" value="${p.page}" aria-label="${tx('aib.p_page', { n: i + 1 })}"></td>
|
|
251
|
+
<td data-label="${tx('aib.form_page')}"><input name="form_${i}" type="number" min="1" max="99999" value="${p.form_page ?? ''}" aria-label="${tx('aib.p_form', { n: i + 1 })}"></td>
|
|
252
|
+
<td data-label="${tx('aib.label')}"><input name="label_${i}" maxlength="80" value="${p.label}" aria-label="${tx('aib.p_label', { n: i + 1 })}"></td>
|
|
253
|
+
<td data-label="${tx('aib.why')}" class="muted">${p.reason ?? ''}</td></tr>`)}
|
|
254
254
|
</tbody></table></div>
|
|
255
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
256
|
-
</form>` : html`<p class="muted"
|
|
257
|
-
return send(reply, s, shell(s, a.name, [['
|
|
255
|
+
<div class="buttons"><button class="btn btn-hot">${tx('aib.create_the_ticked_pages')}</button></div>
|
|
256
|
+
</form>` : html`<p class="muted">${tx('aib.no_pages_were_proposed_describe')}</p>`) : ''}`;
|
|
257
|
+
return send(reply, s, shell(s, a.name, [[tx('aib.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('aib.create_pages_with_ai')]], main));
|
|
258
258
|
}
|
|
259
259
|
|
|
260
260
|
/** The model's proposals checked: known page types and tables, free page numbers (others moved to the next free ones). */
|
|
@@ -293,7 +293,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
293
293
|
app.post(`${BASE}/sql/ai/service`, async (req: Req, reply) => {
|
|
294
294
|
const s = await developer(req, reply);
|
|
295
295
|
if (!s) return;
|
|
296
|
-
if (!(await isAdmin(s.username))) return reply.code(403).send('
|
|
296
|
+
if (!(await isAdmin(s.username))) return reply.code(403).send(tx('aib.admins_only'));
|
|
297
297
|
const name = String(req.body?.service ?? '').toUpperCase();
|
|
298
298
|
const svc = name ? await owner.one<{ id: number }>('select id from meta.ai_service where name = $1', [name]) : null;
|
|
299
299
|
if (name && !svc) {
|
|
@@ -452,9 +452,9 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
452
452
|
if (!a) return reply.code(404).send('Not found');
|
|
453
453
|
const description = String(req.body?.description ?? '').trim().slice(0, 4000);
|
|
454
454
|
try {
|
|
455
|
-
if (!description) throw new AiError('config', '
|
|
455
|
+
if (!description) throw new AiError('config', tx('aib.describe_the_pages_you_want'));
|
|
456
456
|
const tables = (await wizardTables(a.db_role)).filter((t) => t.access).map((t) => t.t).slice(0, 300);
|
|
457
|
-
if (!tables.length) throw new AiError('config',
|
|
457
|
+
if (!tables.length) throw new AiError('config', tx('aib.no_tables', { role: a.db_role }));
|
|
458
458
|
const used = await freePages(a.id);
|
|
459
459
|
const firstFree = (() => {
|
|
460
460
|
let n = 1;
|
|
@@ -517,13 +517,13 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
517
517
|
const form = num(b[`form_${i}`]);
|
|
518
518
|
const label = String(b[`label_${i}`] ?? '').trim().slice(0, 80);
|
|
519
519
|
if (!KINDS.has(kind) || !tables.has(table) || page === null || (kind === 'report_form' && form === null)) {
|
|
520
|
-
errors.push(
|
|
520
|
+
errors.push(tx('aib.p_invalid', { n: i + 1 }));
|
|
521
521
|
continue;
|
|
522
522
|
}
|
|
523
523
|
chosen.push({ kind, table, page, form_page: kind === 'report_form' ? form : null, label: label || table.split('.').pop()! });
|
|
524
524
|
}
|
|
525
525
|
if (errors.length || !chosen.length) {
|
|
526
|
-
flash(s, errors.length ? errors.join(' ') : '
|
|
526
|
+
flash(s, errors.length ? errors.join(' ') : tx('aib.tick_the_pages_to_create'), 'error');
|
|
527
527
|
return back(reply, s, `${BASE}/apps/${a.id}/ai-pages`);
|
|
528
528
|
}
|
|
529
529
|
try {
|
|
@@ -538,10 +538,10 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
538
538
|
}
|
|
539
539
|
return out;
|
|
540
540
|
});
|
|
541
|
-
flash(s,
|
|
541
|
+
flash(s, tx('aib.created', { list: created.join(', ') }));
|
|
542
542
|
return back(reply, s, `${BASE}/apps/${a.id}`);
|
|
543
543
|
} catch (e) {
|
|
544
|
-
flash(s,
|
|
544
|
+
flash(s, tx('aib.not_created', { error: (e as Error).message }), 'error');
|
|
545
545
|
return back(reply, s, `${BASE}/apps/${a.id}/ai-pages`);
|
|
546
546
|
}
|
|
547
547
|
});
|
package/src/builder/ai.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { encryptSecret, secretKeyConfigured } from '../secrets.ts';
|
|
|
10
10
|
import type { Session } from '../session.ts';
|
|
11
11
|
import { isAdmin } from './locks.ts';
|
|
12
12
|
import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
13
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
13
14
|
|
|
14
15
|
// Workspace utilities → AI services (administrators): the AI services of
|
|
15
16
|
// this installation (Claude, OpenAI, Gemini or an OpenAI-compatible endpoint,
|
|
@@ -18,8 +19,8 @@ import { back, BASE, csrf, developer, flash, input, region, select, send, shell,
|
|
|
18
19
|
// usage log. Applications → Activity → AI usage (developers): what an application may use and what it used.
|
|
19
20
|
// API keys are encrypted (src/secrets.ts) and never sent back to the browser.
|
|
20
21
|
|
|
21
|
-
const PROVIDERS: [string, string][]
|
|
22
|
-
const EFFORTS: [string, string][]
|
|
22
|
+
const PROVIDERS = (): [string, string][] => [['anthropic', tx('aisv.claude_anthropic')], ['openai', tx('aisv.openai')], ['gemini', tx('aisv.gemini_google_openai_compatible_endpoint')], ['openai_compatible', tx('aisv.openai_compatible_ollama_vllm_azure')]];
|
|
23
|
+
const EFFORTS = (): [string, string][] => [['', tx('aisv.model_default')], ['low', 'low'], ['medium', 'medium'], ['high', 'high'], ['xhigh', 'xhigh'], ['max', 'max']];
|
|
23
24
|
const TEST_RESULT = '__AITEST';
|
|
24
25
|
|
|
25
26
|
const check = (name: string, label: string, on: boolean, help?: string) =>
|
|
@@ -32,25 +33,25 @@ const KEY_TEXT = { stored: 'stored (encrypted)', env: 'server default', missing:
|
|
|
32
33
|
function serviceForm(s: Partial<AiService>, action: string, csrfField: ReturnType<typeof csrf>, isNew: boolean) {
|
|
33
34
|
const source = s.provider ? keySource(s as AiService) : null;
|
|
34
35
|
return html`<form method="post" action="${action}">${csrfField}
|
|
35
|
-
<fieldset class="prop-group"><legend
|
|
36
|
-
${isNew ? input('name', '
|
|
37
|
-
${input('description', '
|
|
38
|
-
${select('provider', '
|
|
39
|
-
${input('model', '
|
|
40
|
-
${select('effort', '
|
|
41
|
-
${input('max_tokens', '
|
|
42
|
-
${input('timeout_s', '
|
|
43
|
-
${input('base_url', '
|
|
36
|
+
<fieldset class="prop-group"><legend>${tx('aisv.service')}</legend><div class="form-grid">
|
|
37
|
+
${isNew ? input('name', tx('aisv.name'), '', { required: true, placeholder: tx('aisv.e_g_claude'), help: tx('aisv.upper_case_letters_digits_and') }) : ''}
|
|
38
|
+
${input('description', tx('aisv.description'), s.description)}
|
|
39
|
+
${select('provider', tx('aisv.provider'), s.provider ?? 'anthropic', PROVIDERS(), tx('aisv.prompts_and_the_item_values'))}
|
|
40
|
+
${input('model', tx('aisv.model'), s.model ?? (isNew ? DEFAULT_CLAUDE_MODEL : ''), { required: true, help: tx('aisv.model_help', { model: DEFAULT_CLAUDE_MODEL }) })}
|
|
41
|
+
${select('effort', tx('aisv.effort_claude'), isNew ? 'medium' : s.effort ?? '', EFFORTS(), tx('aisv.how_deeply_claude_thinks_thinking'))}
|
|
42
|
+
${input('max_tokens', tx('aisv.maximum_output_tokens'), s.max_tokens ?? 4000, { type: 'number', help: tx('aisv.per_request_1_128000_a') })}
|
|
43
|
+
${input('timeout_s', tx('aisv.time_limit_seconds'), s.timeout_s ?? 120, { type: 'number', help: tx('aisv.5_600_pages_wait_for') })}
|
|
44
|
+
${input('base_url', tx('aisv.base_url'), s.base_url, { placeholder: tx('aisv.empty_the_provider_s_own'), 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).` })}
|
|
44
45
|
</div>
|
|
45
|
-
${check('refusal_fallback', '
|
|
46
|
-
${check('enabled', '
|
|
46
|
+
${check('refusal_fallback', tx('aisv.refusal_fallbacks_claude'), s.refusal_fallback !== false, tx('aisv.when_claude_declines_a_request'))}
|
|
47
|
+
${check('enabled', tx('aisv.enabled'), s.enabled !== false)}
|
|
47
48
|
</fieldset>
|
|
48
|
-
<fieldset class="prop-group"><legend
|
|
49
|
-
${input('api_key', '
|
|
50
|
-
${source === 'stored' ? check('remove_key', '
|
|
51
|
-
${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert"
|
|
49
|
+
<fieldset class="prop-group"><legend>${tx('aisv.api_key')}</legend>
|
|
50
|
+
${input('api_key', tx('aisv.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' : ''}.` })}
|
|
51
|
+
${source === 'stored' ? check('remove_key', tx('aisv.remove_the_stored_key_use'), false) : ''}
|
|
52
|
+
${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">${tx.code('aisv.the_server_has_no_pgkiln')}</div>`}
|
|
52
53
|
</fieldset>
|
|
53
|
-
<div class="buttons"><button class="btn btn-hot">${isNew ? '
|
|
54
|
+
<div class="buttons"><button class="btn btn-hot">${isNew ? tx('aisv.add_service') : tx('aisv.save')}</button></div>
|
|
54
55
|
</form>`;
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -58,33 +59,33 @@ function serviceForm(s: Partial<AiService>, action: string, csrfField: ReturnTyp
|
|
|
58
59
|
function formValues(b: Record<string, string | undefined>, hasServer = false) {
|
|
59
60
|
const provider: ProviderName = (['openai', 'gemini', 'openai_compatible'] as const).find((p) => p === b.provider) ?? 'anthropic';
|
|
60
61
|
const model = (b.model ?? '').trim();
|
|
61
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/.test(model)) throw new Error('
|
|
62
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/.test(model)) throw new Error(tx('aisv.the_model_is_a_name'));
|
|
62
63
|
const maxTokens = Number(b.max_tokens || 4000);
|
|
63
|
-
if (!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > 128000) throw new Error('
|
|
64
|
+
if (!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > 128000) throw new Error(tx('aisv.maximum_output_tokens_1_to'));
|
|
64
65
|
const timeout = Number(b.timeout_s || 120);
|
|
65
|
-
if (!Number.isInteger(timeout) || timeout < 5 || timeout > 600) throw new Error('
|
|
66
|
+
if (!Number.isInteger(timeout) || timeout < 5 || timeout > 600) throw new Error(tx('aisv.time_limit_5_to_600'));
|
|
66
67
|
const baseUrl = (b.base_url ?? '').trim() || null;
|
|
67
|
-
if (baseUrl && (!/^https?:\/\/[^\s]+$/i.test(baseUrl) || baseUrl.length > 500)) throw new Error('
|
|
68
|
-
if (!baseUrl && provider === 'openai_compatible' && !hasServer) throw new Error('
|
|
68
|
+
if (baseUrl && (!/^https?:\/\/[^\s]+$/i.test(baseUrl) || baseUrl.length > 500)) throw new Error(tx('aisv.the_base_url_starts_with'));
|
|
69
|
+
if (!baseUrl && provider === 'openai_compatible' && !hasServer) throw new Error(tx('aisv.an_openai_compatible_service_needs_2'));
|
|
69
70
|
const effort = ['low', 'medium', 'high', 'xhigh', 'max'].includes(b.effort ?? '') ? b.effort! : null;
|
|
70
71
|
return [(b.description ?? '').trim() || null, provider, model, provider === 'anthropic' ? effort : null, b.refusal_fallback === 'true', maxTokens, timeout, baseUrl, b.enabled === 'true'];
|
|
71
72
|
}
|
|
72
73
|
const COLUMNS = 'description, provider, model, effort, refusal_fallback, max_tokens, timeout_s, base_url, enabled';
|
|
73
74
|
|
|
74
75
|
const usageTable = (rows: any[], withApp: boolean) => html`<div class="table-wrap"><table class="report report-reflow">
|
|
75
|
-
<thead><tr><th
|
|
76
|
+
<thead><tr><th>${tx('aisv.when_utc')}</th>${withApp ? html`<th>${tx('aisv.application')}</th>` : ''}<th class="num">${tx('aisv.page')}</th><th>${tx('aisv.user')}</th><th>${tx('aisv.service')}</th><th>${tx('aisv.model')}</th><th>${tx('aisv.source')}</th><th class="num">${tx('aisv.input')}</th><th class="num">${tx('aisv.output')}</th><th class="num">ms</th><th>${tx('aisv.status')}</th></tr></thead>
|
|
76
77
|
<tbody>${rows.length ? rows.map((u) => html`<tr>
|
|
77
|
-
<td data-label="
|
|
78
|
-
<td class="num" data-label="
|
|
79
|
-
<td data-label="
|
|
80
|
-
<td class="num" data-label="
|
|
81
|
-
<td data-label="
|
|
82
|
-
: html`<tr><td colspan="${withApp ? 11 : 10}" class="empty"
|
|
78
|
+
<td data-label="${tx('aisv.when')}">${when(u.at)}</td>${withApp ? html`<td data-label="${tx('aisv.application')}">${u.app ?? html`<span class="muted">builder</span>`}</td>` : ''}
|
|
79
|
+
<td class="num" data-label="${tx('aisv.page')}">${u.page_no ?? ''}</td><td data-label="${tx('aisv.user')}">${u.username ?? ''}</td><td data-label="${tx('aisv.service')}">${u.service}</td>
|
|
80
|
+
<td data-label="${tx('aisv.model')}">${u.model}</td><td data-label="${tx('aisv.source')}">${u.source}</td>
|
|
81
|
+
<td class="num" data-label="${tx('aisv.input')}">${u.input_tokens}</td><td class="num" data-label="${tx('aisv.output')}">${u.output_tokens}</td><td class="num" data-label="ms">${u.duration_ms}</td>
|
|
82
|
+
<td data-label="${tx('aisv.status')}"><span class="tag${u.status === 'ok' ? ' tag-ok' : ' tag-error'}">${u.status}</span>${u.message ? html` <span class="muted">${u.message}</span>` : ''}</td></tr>`)
|
|
83
|
+
: html`<tr><td colspan="${withApp ? 11 : 10}" class="empty">${tx('aisv.no_ai_requests_yet')}</td></tr>`}</tbody></table></div>`;
|
|
83
84
|
|
|
84
85
|
async function adminOnly(s: Session, reply: any, title: string) {
|
|
85
86
|
if (await isAdmin(s.username)) return true;
|
|
86
|
-
const main = html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert"
|
|
87
|
-
await send(reply.code(403), s, shell(s, title, [['
|
|
87
|
+
const main = html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert">${tx('aisv.only_administrators_configure_ai_services')}</div>`;
|
|
88
|
+
await send(reply.code(403), s, shell(s, title, [[tx('aisv.app_builder'), BASE], [tx('aisv.workspace_utilities'), `${BASE}/utilities`], [title]], main));
|
|
88
89
|
return false;
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -100,27 +101,27 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
100
101
|
group by 1, 2 order by 1 nulls first, 2`)).rows;
|
|
101
102
|
const recent = (await owner.query(`select u.*, a.alias as app from meta.ai_usage u left join meta.app a on a.id = u.app_id order by u.id desc limit 100`)).rows;
|
|
102
103
|
const main = html`
|
|
103
|
-
<div class="title-row"><h1
|
|
104
|
-
<p class="muted u-mt0"
|
|
104
|
+
<div class="title-row"><h1>${tx('aisv.ai_services')}</h1></div>
|
|
105
|
+
<p class="muted u-mt0">${tx.rich('aisv.intro', { process: html`<b>${tx('aisv.generate_text_with_ai')}</b>`, fn: html`<code>meta.ai_generate()</code>` })}</p>
|
|
105
106
|
<div class="columns wide-left">
|
|
106
|
-
${region('
|
|
107
|
-
<thead><tr><th
|
|
107
|
+
${region(tx('aisv.services'), html`<div class="table-wrap"><table class="report report-reflow">
|
|
108
|
+
<thead><tr><th>${tx('aisv.service')}</th><th>${tx('aisv.provider')}</th><th>${tx('aisv.model')}</th><th>${tx('aisv.api_key')}</th><th>${tx('aisv.applications')}</th><th>${tx('aisv.status')}</th></tr></thead>
|
|
108
109
|
<tbody>${services.length ? services.map((x) => html`<tr>
|
|
109
|
-
<td data-label="
|
|
110
|
-
<td data-label="
|
|
111
|
-
<td data-label="
|
|
112
|
-
<td data-label="
|
|
113
|
-
<td data-label="
|
|
114
|
-
<td data-label="
|
|
115
|
-
: html`<tr><td colspan="6" class="empty"
|
|
116
|
-
${region('
|
|
110
|
+
<td data-label="${tx('aisv.service')}"><a href="${BASE}/ai/${x.id}">${x.name}</a>${x.description ? html` <span class="muted">${x.description}</span>` : ''}</td>
|
|
111
|
+
<td data-label="${tx('aisv.provider')}">${providerLabel(x.provider)}</td>
|
|
112
|
+
<td data-label="${tx('aisv.model')}">${x.model}${x.effort ? html` <span class="muted">(${x.effort})</span>` : ''}</td>
|
|
113
|
+
<td data-label="${tx('aisv.api_key')}">${KEY_TEXT[keySource(x)]}</td>
|
|
114
|
+
<td data-label="${tx('aisv.applications')}">${x.apps ?? html`<span class="muted">none</span>`}</td>
|
|
115
|
+
<td data-label="${tx('aisv.status')}">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
|
|
116
|
+
: html`<tr><td colspan="6" class="empty">${tx('aisv.no_ai_services_yet')}</td></tr>`}</tbody></table></div>`)}
|
|
117
|
+
${region(tx('aisv.add_service'), serviceForm({}, `${BASE}/ai`, csrf(s), true))}
|
|
117
118
|
</div>
|
|
118
|
-
${region('
|
|
119
|
-
<tbody>${summary.length ? summary.map((r) => html`<tr><td data-label="
|
|
120
|
-
<td class="num" data-label="
|
|
121
|
-
: html`<tr><td colspan="6" class="empty"
|
|
122
|
-
${region('
|
|
123
|
-
return send(reply, s, shell(s, '
|
|
119
|
+
${region(tx('aisv.usage_30_days'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('aisv.application')}</th><th>${tx('aisv.service')}</th><th class="num">${tx('aisv.requests')}</th><th class="num">${tx('aisv.input_tokens')}</th><th class="num">${tx('aisv.output_tokens')}</th><th class="num">${tx('aisv.not_ok')}</th></tr></thead>
|
|
120
|
+
<tbody>${summary.length ? summary.map((r) => html`<tr><td data-label="${tx('aisv.application')}">${r.app ?? html`<span class="muted">${tx('aisv.builder_tests')}</span>`}</td><td data-label="${tx('aisv.service')}">${r.service}</td><td class="num" data-label="${tx('aisv.requests')}">${r.requests}</td>
|
|
121
|
+
<td class="num" data-label="${tx('aisv.input_tokens')}">${r.input}</td><td class="num" data-label="${tx('aisv.output_tokens')}">${r.output}</td><td class="num" data-label="${tx('aisv.not_ok')}">${r.problems}</td></tr>`)
|
|
122
|
+
: html`<tr><td colspan="6" class="empty">${tx('aisv.no_ai_requests_in_the')}</td></tr>`}</tbody></table></div>`)}
|
|
123
|
+
${region(tx('aisv.recent_requests'), html`<p class="muted u-mt0">${tx('aisv.tokens_model_duration_and_status')}</p>${usageTable(recent, true)}`)}`;
|
|
124
|
+
return send(reply, s, shell(s, tx('aisv.ai_services'), [[tx('aisv.app_builder'), BASE], [tx('aisv.workspace_utilities'), `${BASE}/utilities`], [tx('aisv.ai_services')]], main));
|
|
124
125
|
});
|
|
125
126
|
|
|
126
127
|
app.post(`${BASE}/ai`, async (req: Req, reply) => {
|
|
@@ -129,10 +130,10 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
129
130
|
const b = req.body ?? {};
|
|
130
131
|
try {
|
|
131
132
|
const name = (b.name ?? '').trim().toUpperCase();
|
|
132
|
-
if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(name)) throw new Error('
|
|
133
|
+
if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(name)) throw new Error(tx('aisv.the_name_is_upper_case'));
|
|
133
134
|
const key = b.api_key ? encryptSecret(b.api_key) : null;
|
|
134
135
|
const r = await owner.one(`insert into meta.ai_service (name, ${COLUMNS}, api_key_enc) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) returning id`, [name, ...formValues(b), key]);
|
|
135
|
-
flash(s, '
|
|
136
|
+
flash(s, tx('aisv.ai_service_added_allow_applications'));
|
|
136
137
|
return back(reply, s, `${BASE}/ai/${r.id}`);
|
|
137
138
|
} catch (e) {
|
|
138
139
|
flash(s, (e as Error).message.includes('ai_service_name_key') ? 'A service with this name exists already.' : (e as Error).message, 'error');
|
|
@@ -163,33 +164,33 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
163
164
|
const main = html`
|
|
164
165
|
<div class="title-row"><h1 class="ai-name">${x.name}</h1></div>
|
|
165
166
|
<div class="columns wide-left">
|
|
166
|
-
${region('
|
|
167
|
+
${region(tx('aisv.settings'), html`${serviceForm(view, `${BASE}/ai/${x.id}`, csrf(s), false)}
|
|
167
168
|
<form method="post" action="${BASE}/ai/${x.id}/delete" class="danger-zone">${csrf(s)}
|
|
168
|
-
<button class="btn btn-danger" data-confirm="
|
|
169
|
-
${region('
|
|
170
|
-
<p class="muted u-mt0"
|
|
171
|
-
<div class="form-grid">${select('server_id', '
|
|
172
|
-
${input('server_path', '
|
|
173
|
-
<div class="buttons"><button class="btn"
|
|
174
|
-
${region('
|
|
175
|
-
<p class="muted u-mt0"
|
|
169
|
+
<button class="btn btn-danger" data-confirm="${tx('aisv.delete_confirm', { name: x.name })}">${tx('aisv.delete_service')}</button></form>`)}
|
|
170
|
+
${region(tx('aisv.remote_server'), html`<form method="post" action="${BASE}/ai/${x.id}/server">${csrf(s)}
|
|
171
|
+
<p class="muted u-mt0">${tx('aisv.instead_of_the_base_url')}</p>
|
|
172
|
+
<div class="form-grid">${select('server_id', tx('aisv.remote_server'), x.server_id ? String(x.server_id) : '', [['', tx('aisv.none_the_base_url_above')], ...servers.map((r): [string, string] => [String(r.id), `${r.static_id} (${r.workspace})`])])}
|
|
173
|
+
${input('server_path', tx('aisv.path'), x.server_path ?? '', { placeholder: '/v1', help: tx('aisv.after_the_server_s_base') })}</div>
|
|
174
|
+
<div class="buttons"><button class="btn">${tx('aisv.save')}</button></div></form>`)}
|
|
175
|
+
${region(tx('aisv.test'), html`
|
|
176
|
+
<p class="muted u-mt0">${tx('aisv.sends_a_prompt_to_the')}</p>
|
|
176
177
|
<form method="post" action="${BASE}/ai/${x.id}/test">${csrf(s)}
|
|
177
|
-
<div class="field"><label class="label" for="ai_test_prompt"
|
|
178
|
-
<div class="buttons"><button class="btn">${icon('play')}
|
|
178
|
+
<div class="field"><label class="label" for="ai_test_prompt">${tx('aisv.prompt')}</label><textarea id="ai_test_prompt" name="prompt" rows="3" maxlength="4000">${tx('aisv.test_prompt')}</textarea></div>
|
|
179
|
+
<div class="buttons"><button class="btn">${icon('play')} ${tx('aisv.test')}</button></div></form>
|
|
179
180
|
${test ? html`<div class="alert ${test.ok ? 'alert-success' : 'alert-error'}" role="status">${test.meta ?? ''}</div><pre class="source">${test.text}</pre>` : ''}`)}
|
|
180
181
|
</div>
|
|
181
|
-
${region('
|
|
182
|
+
${region(tx('aisv.applications'), html`<p class="muted u-mt0">${tx.rich('aisv.apps_intro', { limited: html`<i>${tx('aisv.limited')}</i>` })}</p>
|
|
182
183
|
<form method="post" action="${BASE}/ai/${x.id}/apps">${csrf(s)}
|
|
183
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
184
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('aisv.application')}</th><th>${tx('aisv.may_use')}</th><th>${tx('aisv.requests_per_day')}</th><th>${tx('aisv.tokens_per_day')}</th></tr></thead>
|
|
184
185
|
<tbody>${apps.length ? apps.map((a) => html`<tr>
|
|
185
|
-
<td data-label="
|
|
186
|
-
<td data-label="
|
|
187
|
-
<td data-label="
|
|
188
|
-
<td data-label="
|
|
189
|
-
: html`<tr><td colspan="4" class="empty"
|
|
190
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
191
|
-
${region('
|
|
192
|
-
return send(reply, s, shell(s, x.name, [['
|
|
186
|
+
<td data-label="${tx('aisv.application')}">${a.name} <span class="muted">${a.alias}</span></td>
|
|
187
|
+
<td data-label="${tx('aisv.may_use')}"><label class="check"><input type="checkbox" name="allow" value="${a.id}"${a.allowed ? raw(' checked') : ''}> allowed</label></td>
|
|
188
|
+
<td data-label="${tx('aisv.requests_per_day')}"><input type="number" min="0" name="req_${a.id}" value="${a.max_requests ?? ''}" aria-label="${tx('aisv.aria_req', { app: a.alias })}"></td>
|
|
189
|
+
<td data-label="${tx('aisv.tokens_per_day')}"><input type="number" min="0" name="tok_${a.id}" value="${a.max_tokens ?? ''}" aria-label="${tx('aisv.aria_tok', { app: a.alias })}"></td></tr>`)
|
|
190
|
+
: html`<tr><td colspan="4" class="empty">${tx('aisv.no_applications')}</td></tr>`}</tbody></table></div>
|
|
191
|
+
<div class="buttons"><button class="btn btn-hot">${tx('aisv.save_access')}</button></div></form>`)}
|
|
192
|
+
${region(tx('aisv.recent_requests'), usageTable(recent, true))}`;
|
|
193
|
+
return send(reply, s, shell(s, x.name, [[tx('aisv.app_builder'), BASE], [tx('aisv.workspace_utilities'), `${BASE}/utilities`], [tx('aisv.ai_services'), `${BASE}/ai`], [x.name]], main));
|
|
193
194
|
});
|
|
194
195
|
|
|
195
196
|
app.post(`${BASE}/ai/:id(^\\d+$)`, async (req: Req, reply) => {
|
|
@@ -204,7 +205,7 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
204
205
|
where id = $1`,
|
|
205
206
|
[req.params.id, ...formValues(b, !!(await owner.one('select 1 as ok from meta.ai_service where id = $1 and server_id is not null', [req.params.id]))), key, b.remove_key === 'true'],
|
|
206
207
|
);
|
|
207
|
-
flash(s, r.rowCount ? 'AI service saved.' : '
|
|
208
|
+
flash(s, r.rowCount ? 'AI service saved.' : tx('aisv.not_found'), r.rowCount ? 'ok' : 'error');
|
|
208
209
|
} catch (e) {
|
|
209
210
|
flash(s, (e as Error).message, 'error');
|
|
210
211
|
}
|
|
@@ -219,12 +220,12 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
219
220
|
try {
|
|
220
221
|
const serverId = /^\d{1,9}$/.test(String(b.server_id ?? '')) ? Number(b.server_id) : null;
|
|
221
222
|
const path = String(b.server_path ?? '').trim() || null;
|
|
222
|
-
if (path && (!/^\/[^\s]*$/.test(path) || path.length > 500)) throw new Error('
|
|
223
|
-
if (serverId && !(await owner.one('select 1 as ok from meta.remote_server where id = $1', [serverId]))) throw new Error('
|
|
223
|
+
if (path && (!/^\/[^\s]*$/.test(path) || path.length > 500)) throw new Error(tx('aisv.the_path_starts_with_without'));
|
|
224
|
+
if (serverId && !(await owner.one('select 1 as ok from meta.remote_server where id = $1', [serverId]))) throw new Error(tx('aisv.no_such_remote_server'));
|
|
224
225
|
const r = await owner.query('update meta.ai_service set server_id = $2, server_path = $3, updated_at = now() where id = $1', [req.params.id, serverId, serverId ? path : null]);
|
|
225
|
-
flash(s, r.rowCount ? (serverId ? '
|
|
226
|
+
flash(s, r.rowCount ? (serverId ? tx('aisv.the_service_now_calls_its') : tx('aisv.the_service_calls_its_base')) : tx('aisv.not_found'), r.rowCount ? 'ok' : 'error');
|
|
226
227
|
} catch (e) {
|
|
227
|
-
flash(s, (e as Error).message.includes('ai_service_compatible_url') ? '
|
|
228
|
+
flash(s, (e as Error).message.includes('ai_service_compatible_url') ? tx('aisv.an_openai_compatible_service_needs') : (e as Error).message, 'error');
|
|
228
229
|
}
|
|
229
230
|
return back(reply, s, `${BASE}/ai/${req.params.id}`);
|
|
230
231
|
});
|
|
@@ -238,7 +239,7 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
238
239
|
const t = String(v ?? '').trim();
|
|
239
240
|
if (!t) return null;
|
|
240
241
|
const n = Number(t);
|
|
241
|
-
if (!Number.isInteger(n) || n < 0) throw new Error('
|
|
242
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(tx('aisv.limits_are_whole_numbers_of'));
|
|
242
243
|
return n;
|
|
243
244
|
};
|
|
244
245
|
try {
|
|
@@ -252,7 +253,7 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
252
253
|
[id, req.params.id, limit(b[`req_${id}`]), limit(b[`tok_${id}`])],
|
|
253
254
|
);
|
|
254
255
|
});
|
|
255
|
-
flash(s, '
|
|
256
|
+
flash(s, tx('aisv.access_saved'));
|
|
256
257
|
} catch (e) {
|
|
257
258
|
flash(s, (e as Error).message, 'error');
|
|
258
259
|
}
|
|
@@ -269,9 +270,9 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
269
270
|
const prompt = String(req.body?.prompt ?? '').slice(0, 4000) || 'Reply with one short sentence.';
|
|
270
271
|
const r = await generate(x.name, { prompt, maxTokens: 1000 }, { appId: null, user: s.username, source: 'builder' });
|
|
271
272
|
const text = r.text.length > 4000 ? `${r.text.slice(0, 4000)}…` : r.text;
|
|
272
|
-
result = { id: x.id, ok: true, text, meta:
|
|
273
|
+
result = { id: x.id, ok: true, text, meta: tx('aisv.answered_by', { model: r.model, input: r.inputTokens, output: r.outputTokens }) };
|
|
273
274
|
} catch (e) {
|
|
274
|
-
result = { id: x.id, ok: false, text: '', meta: e instanceof AiError ? e.message : '
|
|
275
|
+
result = { id: x.id, ok: false, text: '', meta: e instanceof AiError ? e.message : tx('aisv.the_test_failed') };
|
|
275
276
|
}
|
|
276
277
|
s.state[TEST_RESULT] = JSON.stringify(result);
|
|
277
278
|
return back(reply, s, `${BASE}/ai/${x.id}`);
|
|
@@ -281,7 +282,7 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
281
282
|
const s = await developer(req, reply);
|
|
282
283
|
if (!s || !(await adminOnly(s, reply, 'AI services'))) return;
|
|
283
284
|
await owner.query('delete from meta.ai_service where id = $1', [req.params.id]);
|
|
284
|
-
flash(s, '
|
|
285
|
+
flash(s, tx('aisv.ai_service_deleted'));
|
|
285
286
|
return back(reply, s, `${BASE}/ai`);
|
|
286
287
|
});
|
|
287
288
|
|
|
@@ -298,15 +299,15 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
298
299
|
const admin = await isAdmin(s.username);
|
|
299
300
|
const main = html`
|
|
300
301
|
<div class="title-row"><h1 class="ai-name">AI usage · ${a.name}</h1></div>
|
|
301
|
-
<p class="muted u-mt0">The AI services this application may use (an administrator allows them${admin ? html` under <a href="${BASE}/ai"
|
|
302
|
-
${region('
|
|
303
|
-
<tbody>${allowed.length ? allowed.map((x, i) => html`<tr><td data-label="
|
|
304
|
-
<td class="num" data-label="
|
|
305
|
-
<td class="num" data-label="
|
|
306
|
-
<td data-label="
|
|
307
|
-
: html`<tr><td colspan="5" class="empty"
|
|
308
|
-
${region('
|
|
302
|
+
<p class="muted u-mt0">The AI services this application may use (an administrator allows them${admin ? html` under <a href="${BASE}/ai">${tx('aisv.ai_services')}</a>` : ''}), today's use against their limits, and the recent requests.</p>
|
|
303
|
+
${region(tx('aisv.services'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('aisv.service')}</th><th>${tx('aisv.model')}</th><th class="num">${tx('aisv.requests_today')}</th><th class="num">${tx('aisv.tokens_today')}</th><th>${tx('aisv.status')}</th></tr></thead>
|
|
304
|
+
<tbody>${allowed.length ? allowed.map((x, i) => html`<tr><td data-label="${tx('aisv.service')}">${x.name}</td><td data-label="${tx('aisv.model')}">${providerLabel(x.provider)} ${x.model}</td>
|
|
305
|
+
<td class="num" data-label="${tx('aisv.requests_today')}">${today[i].requests}${x.max_requests !== null ? ` / ${x.max_requests}` : ''}</td>
|
|
306
|
+
<td class="num" data-label="${tx('aisv.tokens_today')}">${today[i].tokens}${x.max_tokens !== null ? ` / ${x.max_tokens}` : ''}</td>
|
|
307
|
+
<td data-label="${tx('aisv.status')}">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
|
|
308
|
+
: html`<tr><td colspan="5" class="empty">${tx('aisv.this_application_may_not_use')}</td></tr>`}</tbody></table></div>`)}
|
|
309
|
+
${region(tx('aisv.recent_requests'), usageTable(recent, false))}
|
|
309
310
|
${await toolCallTable(a.id)}`;
|
|
310
|
-
return send(reply, s, shell(s, `AI usage · ${a.name}`, [['
|
|
311
|
+
return send(reply, s, shell(s, `AI usage · ${a.name}`, [[tx('aisv.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('aisv.activity'), `${BASE}/apps/${a.id}/activity`], [tx('aisv.ai_usage')]], main));
|
|
311
312
|
});
|
|
312
313
|
}
|