pgkiln 0.84.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.
Files changed (81) hide show
  1. package/docs/apex-feature-parity.md +2 -2
  2. package/docs/guide/03-builder.md +14 -5
  3. package/docs/guide/12-development.md +6 -1
  4. package/package.json +1 -1
  5. package/src/app.ts +9 -0
  6. package/src/builder/ai-agents.ts +86 -85
  7. package/src/builder/ai-builder.ts +30 -30
  8. package/src/builder/ai.ts +93 -92
  9. package/src/builder/api.ts +47 -47
  10. package/src/builder/app-groups.ts +33 -32
  11. package/src/builder/appfromfile.ts +57 -58
  12. package/src/builder/appsheets.ts +33 -32
  13. package/src/builder/appwizard.ts +65 -65
  14. package/src/builder/arrange.ts +23 -22
  15. package/src/builder/assistant.ts +39 -38
  16. package/src/builder/automations.ts +32 -31
  17. package/src/builder/availability.ts +24 -24
  18. package/src/builder/backups.ts +30 -31
  19. package/src/builder/blueprints.ts +62 -61
  20. package/src/builder/create-page.ts +52 -50
  21. package/src/builder/cross-app.ts +37 -35
  22. package/src/builder/dataload.ts +2 -2
  23. package/src/builder/datasets.ts +107 -106
  24. package/src/builder/dependencies.ts +20 -19
  25. package/src/builder/designer.ts +36 -36
  26. package/src/builder/diagnostics.ts +58 -57
  27. package/src/builder/features.ts +39 -35
  28. package/src/builder/feedback.ts +23 -20
  29. package/src/builder/find-workspace.ts +28 -24
  30. package/src/builder/form-designer.ts +36 -35
  31. package/src/builder/gallery.ts +25 -29
  32. package/src/builder/globalization.ts +50 -49
  33. package/src/builder/i18n/de.ts +3453 -0
  34. package/src/builder/i18n/en.ts +3453 -0
  35. package/src/builder/i18n/es.ts +3453 -0
  36. package/src/builder/i18n/fr.ts +3453 -0
  37. package/src/builder/i18n/it.ts +3453 -0
  38. package/src/builder/i18n/ja.ts +3453 -0
  39. package/src/builder/i18n/ko.ts +3453 -0
  40. package/src/builder/i18n/nl.ts +3453 -0
  41. package/src/builder/i18n/pt.ts +3453 -0
  42. package/src/builder/i18n/zh-cn.ts +3453 -0
  43. package/src/builder/i18n/zh-tw.ts +3453 -0
  44. package/src/builder/i18n.ts +17 -3
  45. package/src/builder/json-sources.ts +63 -62
  46. package/src/builder/layouts.ts +12 -11
  47. package/src/builder/locks.ts +29 -28
  48. package/src/builder/login-page.ts +26 -25
  49. package/src/builder/mail.ts +148 -147
  50. package/src/builder/newapp.ts +15 -12
  51. package/src/builder/plugins.ts +47 -44
  52. package/src/builder/process-jobs.ts +11 -10
  53. package/src/builder/pwa.ts +22 -21
  54. package/src/builder/remote-databases.ts +55 -56
  55. package/src/builder/report-settings.ts +1 -1
  56. package/src/builder/reporter.ts +43 -42
  57. package/src/builder/rest-catalogs.ts +87 -86
  58. package/src/builder/rest.ts +9 -8
  59. package/src/builder/routes.ts +27 -27
  60. package/src/builder/search.ts +14 -13
  61. package/src/builder/static-files.ts +31 -32
  62. package/src/builder/subscriptions.ts +47 -45
  63. package/src/builder/supporting.ts +23 -18
  64. package/src/builder/team.ts +179 -179
  65. package/src/builder/telemetry.ts +59 -62
  66. package/src/builder/templates.ts +66 -65
  67. package/src/builder/themeroller.ts +60 -63
  68. package/src/builder/ui-defaults.ts +21 -20
  69. package/src/builder/ui.ts +2 -1
  70. package/src/builder/upgrade.ts +25 -24
  71. package/src/builder/utilities.ts +48 -47
  72. package/src/builder/vector-providers.ts +67 -66
  73. package/src/builder/websources.ts +43 -42
  74. package/src/builder/wizards.ts +95 -89
  75. package/src/builder/workflow-migrate.ts +30 -31
  76. package/src/builder/workflows.ts +40 -39
  77. package/src/builder/workingcopies.ts +41 -49
  78. package/src/builder/workspace-backups.ts +21 -20
  79. package/src/builder/workspace-mail.ts +17 -16
  80. package/src/builder/workspace-resources.ts +104 -112
  81. package/src/builder/workspace-transfer.ts +34 -26
@@ -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', 'The App Builder has no AI service yet (an administrator chooses one under SQL Workshop → AI).');
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 : 'The AI request failed.'}</div>`;
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('Create pages with AI', html`
235
- <p class="muted u-mt0">Describe the pages you want. The App Builder's AI service proposes pages of the create page wizards for tables and views the application's role (${a.db_role ?? 'the owner'}) can read; you check the proposals and create the ones you tick. Table and column names, types and <a href="${BASE}/sql/ai/describe">descriptions</a> are sent to the service, never rows.</p>
236
- ${svc ? '' : html`<div class="alert alert-info">The App Builder has no AI service yet: an administrator chooses one under <a href="${BASE}/sql/ai">SQL Workshop → AI</a>.</div>`}
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">Description</label>
239
- <textarea id="f_description" name="description" rows="4" maxlength="4000" required placeholder="e.g. A page to manage employees, a calendar of leave, and a chart of salaries per department">${description}</textarea></div>
240
- <div class="buttons"><button class="btn btn-hot" data-busy="Thinking…"${svc ? '' : raw(' disabled')}>Propose pages</button></div>
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('Proposed pages', proposals.length ? html`
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">Create</th><th scope="col">Page type</th><th scope="col">Table or view</th><th scope="col">Page</th><th scope="col">Form page</th><th scope="col">Label</th><th scope="col">Why</th></tr></thead><tbody>
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="Create"><input type="checkbox" name="on_${i}" value="true" checked aria-label="Create proposal ${i + 1}"></td>
248
- <td data-label="Page type"><select name="kind_${i}" aria-label="Page type of proposal ${i + 1}">${kindOptions.map(([k, l]) => opt(k, l, p.kind))}</select></td>
249
- <td data-label="Table or view"><select name="table_${i}" aria-label="Table of proposal ${i + 1}">${tables.map((t) => opt(t, t, p.table))}</select></td>
250
- <td data-label="Page"><input name="page_${i}" type="number" min="1" max="99999" value="${p.page}" aria-label="Page number of proposal ${i + 1}"></td>
251
- <td data-label="Form page"><input name="form_${i}" type="number" min="1" max="99999" value="${p.form_page ?? ''}" aria-label="Form page of proposal ${i + 1} (report and form)"></td>
252
- <td data-label="Label"><input name="label_${i}" maxlength="80" value="${p.label}" aria-label="Label of proposal ${i + 1}"></td>
253
- <td data-label="Why" class="muted">${p.reason ?? ''}</td></tr>`)}
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">Create the ticked pages</button></div>
256
- </form>` : html`<p class="muted">No pages were proposed: describe them differently, or check that the application's role may read the tables.</p>`) : ''}`;
257
- return send(reply, s, shell(s, a.name, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create pages with AI']], main));
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('Only administrators choose the App Builder\'s AI service.');
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', 'Describe the pages you want.');
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', `The application's role ${a.db_role} can't read any table or view yet.`);
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(`Proposal ${i + 1}: choose a page type, a table the application can read and free page numbers.`);
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(' ') : 'Tick the pages to create.', 'error');
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, `Pages ${created.join(', ')} created. Make sure the application's database role has the privileges they need.`);
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, `No pages were created: ${(e as Error).message}`, 'error');
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][] = [['anthropic', 'Claude (Anthropic)'], ['openai', 'OpenAI'], ['gemini', 'Gemini (Google, OpenAI-compatible endpoint)'], ['openai_compatible', 'OpenAI-compatible (Ollama, vLLM, Azure OpenAI, a gateway)']];
22
- const EFFORTS: [string, string][] = [['', 'Model default'], ['low', 'low'], ['medium', 'medium'], ['high', 'high'], ['xhigh', 'xhigh'], ['max', 'max']];
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>Service</legend><div class="form-grid">
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.' }) : ''}
37
- ${input('description', 'Description', s.description)}
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.')}
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.` })}
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.')}
41
- ${input('max_tokens', 'Maximum output tokens', s.max_tokens ?? 4000, { type: 'number', help: 'Per request (1 – 128000); a process may ask for fewer.' })}
42
- ${input('timeout_s', 'Time limit (seconds)', s.timeout_s ?? 120, { type: 'number', help: '5 – 600. Pages wait for the answer.' })}
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).` })}
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', '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.')}
46
- ${check('enabled', 'Enabled', s.enabled !== false)}
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>API key</legend>
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' : ''}.` })}
50
- ${source === 'stored' ? check('remove_key', 'Remove the stored key (use the server default)', false) : ''}
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>`}
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 ? 'Add service' : 'Save'}</button></div>
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('The model is a name like claude-opus-5-5 (letters, digits and . _ : / @ -).');
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('Maximum output tokens: 1 to 128000.');
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('Time limit: 5 to 600 seconds.');
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('The base URL starts with http:// or https://.');
68
- if (!baseUrl && provider === 'openai_compatible' && !hasServer) throw new Error('An OpenAI-compatible service needs a base URL (e.g. http://localhost:11434/v1 for Ollama).');
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>When (UTC)</th>${withApp ? html`<th>Application</th>` : ''}<th class="num">Page</th><th>User</th><th>Service</th><th>Model</th><th>Source</th><th class="num">Input</th><th class="num">Output</th><th class="num">ms</th><th>Status</th></tr></thead>
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="When">${when(u.at)}</td>${withApp ? html`<td data-label="Application">${u.app ?? html`<span class="muted">builder</span>`}</td>` : ''}
78
- <td class="num" data-label="Page">${u.page_no ?? ''}</td><td data-label="User">${u.username ?? ''}</td><td data-label="Service">${u.service}</td>
79
- <td data-label="Model">${u.model}</td><td data-label="Source">${u.source}</td>
80
- <td class="num" data-label="Input">${u.input_tokens}</td><td class="num" data-label="Output">${u.output_tokens}</td><td class="num" data-label="ms">${u.duration_ms}</td>
81
- <td data-label="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>`)
82
- : html`<tr><td colspan="${withApp ? 11 : 10}" class="empty">No AI requests yet.</td></tr>`}</tbody></table></div>`;
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">Only administrators configure AI services.</div>`;
87
- await send(reply.code(403), s, shell(s, title, [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [title]], main));
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>AI services</h1></div>
104
- <p class="muted u-mt0">Large language models the applications of this installation may use (processes and dynamic actions <b>Generate text with AI</b>, and <code>meta.ai_generate()</code> from SQL). Prompts, including the item values they contain, are sent to the chosen provider. Allow each application on the service's page.</p>
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('Services', html`<div class="table-wrap"><table class="report report-reflow">
107
- <thead><tr><th>Service</th><th>Provider</th><th>Model</th><th>API key</th><th>Applications</th><th>Status</th></tr></thead>
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="Service"><a href="${BASE}/ai/${x.id}">${x.name}</a>${x.description ? html` <span class="muted">${x.description}</span>` : ''}</td>
110
- <td data-label="Provider">${providerLabel(x.provider)}</td>
111
- <td data-label="Model">${x.model}${x.effort ? html` <span class="muted">(${x.effort})</span>` : ''}</td>
112
- <td data-label="API key">${KEY_TEXT[keySource(x)]}</td>
113
- <td data-label="Applications">${x.apps ?? html`<span class="muted">none</span>`}</td>
114
- <td data-label="Status">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
115
- : html`<tr><td colspan="6" class="empty">No AI services yet.</td></tr>`}</tbody></table></div>`)}
116
- ${region('Add service', serviceForm({}, `${BASE}/ai`, csrf(s), true))}
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('Usage (30 days)', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Application</th><th>Service</th><th class="num">Requests</th><th class="num">Input tokens</th><th class="num">Output tokens</th><th class="num">Not ok</th></tr></thead>
119
- <tbody>${summary.length ? summary.map((r) => html`<tr><td data-label="Application">${r.app ?? html`<span class="muted">builder tests</span>`}</td><td data-label="Service">${r.service}</td><td class="num" data-label="Requests">${r.requests}</td>
120
- <td class="num" data-label="Input tokens">${r.input}</td><td class="num" data-label="Output tokens">${r.output}</td><td class="num" data-label="Not ok">${r.problems}</td></tr>`)
121
- : html`<tr><td colspan="6" class="empty">No AI requests in the last 30 days.</td></tr>`}</tbody></table></div>`)}
122
- ${region('Recent requests', html`<p class="muted u-mt0">Tokens, model, duration and status of each call. Prompts and answers are never logged (an application's debug messages at level 9 show them).</p>${usageTable(recent, true)}`)}`;
123
- return send(reply, s, shell(s, 'AI services', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['AI services']], main));
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('The name is upper case letters, digits and _ (e.g. CLAUDE).');
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, 'AI service added. Allow applications to use it below, and test it.');
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('Settings', html`${serviceForm(view, `${BASE}/ai/${x.id}`, csrf(s), false)}
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="Delete AI service ${x.name}? Processes that use it fail until another service gets its name.">Delete service</button></form>`)}
169
- ${region('Remote server', html`<form method="post" action="${BASE}/ai/${x.id}/server">${csrf(s)}
170
- <p class="muted u-mt0">Instead of the base URL: a remote server's URL (Workspace utilities → Remote servers) plus a path, so moving the endpoint changes one value. The API key is sent there: choose a server whose workspace administrators you trust with it.</p>
171
- <div class="form-grid">${select('server_id', 'Remote server', x.server_id ? String(x.server_id) : '', [['', 'none: the base URL above'], ...servers.map((r): [string, string] => [String(r.id), `${r.static_id} (${r.workspace})`])])}
172
- ${input('server_path', 'Path', x.server_path ?? '', { placeholder: '/v1', help: 'After the server\'s base URL, e.g. /v1; empty: the base URL itself.' })}</div>
173
- <div class="buttons"><button class="btn">Save</button></div></form>`)}
174
- ${region('Test', html`
175
- <p class="muted u-mt0">Sends a prompt to the service (counted in the usage log as a builder test).</p>
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">Prompt</label><textarea id="ai_test_prompt" name="prompt" rows="3" maxlength="4000">Reply with one short sentence to confirm you can read this.</textarea></div>
178
- <div class="buttons"><button class="btn">${icon('play')} Test</button></div></form>
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('Applications', html`<p class="muted u-mt0">Which applications may use this service, with optional limits per day (UTC). Empty limits: no limit. Requests over a limit fail with a message and are logged as <i>limited</i>.</p>
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>Application</th><th>May use</th><th>Requests per day</th><th>Tokens per day</th></tr></thead>
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="Application">${a.name} <span class="muted">${a.alias}</span></td>
186
- <td data-label="May use"><label class="check"><input type="checkbox" name="allow" value="${a.id}"${a.allowed ? raw(' checked') : ''}> allowed</label></td>
187
- <td data-label="Requests per day"><input type="number" min="0" name="req_${a.id}" value="${a.max_requests ?? ''}" aria-label="Requests per day for ${a.alias}"></td>
188
- <td data-label="Tokens per day"><input type="number" min="0" name="tok_${a.id}" value="${a.max_tokens ?? ''}" aria-label="Tokens per day for ${a.alias}"></td></tr>`)
189
- : html`<tr><td colspan="4" class="empty">No applications.</td></tr>`}</tbody></table></div>
190
- <div class="buttons"><button class="btn btn-hot">Save access</button></div></form>`)}
191
- ${region('Recent requests', usageTable(recent, true))}`;
192
- return send(reply, s, shell(s, x.name, [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['AI services', `${BASE}/ai`], [x.name]], main));
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.' : 'Not found.', r.rowCount ? 'ok' : 'error');
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('The path starts with /, without spaces, e.g. /v1.');
223
- if (serverId && !(await owner.one('select 1 as ok from meta.remote_server where id = $1', [serverId]))) throw new Error('No such remote server.');
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 ? 'The service now calls its remote server: test it.' : 'The service calls its base URL again.') : 'Not found.', r.rowCount ? 'ok' : 'error');
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') ? 'An OpenAI-compatible service needs a base URL or a remote server.' : (e as Error).message, 'error');
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('Limits are whole numbers of 0 or more (empty: no limit).');
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, 'Access saved.');
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: `Answered by ${r.model}: ${r.inputTokens} input and ${r.outputTokens} output tokens.` };
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 : 'The test failed.' };
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, 'AI service deleted.');
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">AI services</a>` : ''}), today's use against their limits, and the recent requests.</p>
302
- ${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>
303
- <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>
304
- <td class="num" data-label="Requests today">${today[i].requests}${x.max_requests !== null ? ` / ${x.max_requests}` : ''}</td>
305
- <td class="num" data-label="Tokens today">${today[i].tokens}${x.max_tokens !== null ? ` / ${x.max_tokens}` : ''}</td>
306
- <td data-label="Status">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
307
- : html`<tr><td colspan="5" class="empty">This application may not use any AI service yet.</td></tr>`}</tbody></table></div>`)}
308
- ${region('Recent requests', usageTable(recent, false))}
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}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity', `${BASE}/apps/${a.id}/activity`], ['AI usage']], main));
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
  }