pgkiln 0.51.0 → 0.53.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 (124) hide show
  1. package/README.md +1 -1
  2. package/SECURITY.md +10 -1
  3. package/db/migrations/121_ai_agents.sql +94 -0
  4. package/db/migrations/122_team_development.sql +186 -0
  5. package/db/migrations/123_workflow_outcomes.sql +407 -0
  6. package/db/migrations/124_component_details.sql +51 -0
  7. package/db/migrations/125_app_change.sql +23 -0
  8. package/db/migrations/126_mail_queue_network_access.sql +119 -0
  9. package/docs/apex-feature-parity.md +50 -19
  10. package/docs/guide/01-installation.md +8 -4
  11. package/docs/guide/03-builder.md +197 -10
  12. package/docs/guide/04-pages-and-regions.md +71 -21
  13. package/docs/guide/05-items.md +7 -1
  14. package/docs/guide/06-processing.md +129 -9
  15. package/docs/guide/07-dynamic-actions.md +34 -0
  16. package/docs/guide/08-security.md +55 -0
  17. package/docs/guide/09-reference.md +5 -2
  18. package/docs/guide/11-from-apex.md +3 -1
  19. package/docs/guide/12-development.md +87 -16
  20. package/docs/guide/14-globalization.md +49 -1
  21. package/docs/guide/18-cli.md +21 -1
  22. package/docs/guide/19-rest-data-sources.md +12 -0
  23. package/docs/guide/20-ai-agents.md +12 -4
  24. package/examples/hr/README.md +2 -0
  25. package/examples/hr/hr_64_ai_agent.sql +97 -0
  26. package/examples/hr/hr_65_ask_reports.sql +21 -0
  27. package/examples/hr/hr_66_team_development.sql +58 -0
  28. package/examples/hr/hr_67_workflow_outcomes.sql +113 -0
  29. package/examples/hr/hr_68_component_details.sql +96 -0
  30. package/examples/hr/hr_70_data_security.sql +79 -0
  31. package/package.json +1 -1
  32. package/public/app.css +135 -21
  33. package/public/app.js +134 -20
  34. package/public/builder.css +133 -1
  35. package/public/builder.js +51 -0
  36. package/src/ai/chat.ts +18 -18
  37. package/src/ai/service.ts +35 -11
  38. package/src/ai/types.ts +7 -2
  39. package/src/app.ts +15 -0
  40. package/src/builder/advisor.ts +6 -3
  41. package/src/builder/ai-agents.ts +372 -0
  42. package/src/builder/ai-builder.ts +3 -3
  43. package/src/builder/ai.ts +13 -9
  44. package/src/builder/app-updates.ts +55 -0
  45. package/src/builder/assistant.ts +22 -7
  46. package/src/builder/components.ts +38 -9
  47. package/src/builder/data-security.ts +182 -0
  48. package/src/builder/designer.ts +8 -3
  49. package/src/builder/feedback.ts +6 -1
  50. package/src/builder/home.ts +16 -5
  51. package/src/builder/locks.ts +7 -1
  52. package/src/builder/mail-queue.ts +212 -0
  53. package/src/builder/mail.ts +72 -45
  54. package/src/builder/network-access.ts +140 -0
  55. package/src/builder/object-actions.ts +1 -0
  56. package/src/builder/object-browser.ts +1 -1
  57. package/src/builder/region-help.ts +2 -2
  58. package/src/builder/rest-catalogs.ts +19 -4
  59. package/src/builder/routes.ts +4 -1
  60. package/src/builder/shared.ts +1 -1
  61. package/src/builder/sql.ts +2 -0
  62. package/src/builder/team.ts +991 -0
  63. package/src/builder/themeroller.ts +47 -6
  64. package/src/builder/ui.ts +7 -1
  65. package/src/builder/websources.ts +1 -1
  66. package/src/builder/workflow-migrate.ts +210 -0
  67. package/src/builder/workflows.ts +29 -6
  68. package/src/builder/workspace-home.ts +2 -0
  69. package/src/builder/workspace-mail.ts +121 -0
  70. package/src/builder/workspace-transfer.ts +1 -1
  71. package/src/builder/workspaces.ts +1 -0
  72. package/src/cli/main.ts +25 -3
  73. package/src/cli/mcp.ts +40 -4
  74. package/src/cli/only.ts +52 -0
  75. package/src/cli/replace.ts +11 -1
  76. package/src/datasec.ts +343 -0
  77. package/src/i18n/ar.ts +17 -0
  78. package/src/i18n/cs.ts +17 -0
  79. package/src/i18n/da.ts +17 -0
  80. package/src/i18n/de.ts +17 -0
  81. package/src/i18n/el.ts +17 -0
  82. package/src/i18n/es.ts +17 -0
  83. package/src/i18n/fi.ts +17 -0
  84. package/src/i18n/fr.ts +17 -0
  85. package/src/i18n/he.ts +17 -0
  86. package/src/i18n/it.ts +17 -0
  87. package/src/i18n/ja.ts +17 -0
  88. package/src/i18n/ko.ts +17 -0
  89. package/src/i18n/nb.ts +17 -0
  90. package/src/i18n/pl.ts +17 -0
  91. package/src/i18n/pt.ts +17 -0
  92. package/src/i18n/ru.ts +17 -0
  93. package/src/i18n/sv.ts +17 -0
  94. package/src/i18n/tr.ts +17 -0
  95. package/src/i18n/uk.ts +17 -0
  96. package/src/i18n/zh.ts +17 -0
  97. package/src/i18n.ts +34 -0
  98. package/src/instance.ts +2 -0
  99. package/src/mail/mail.ts +50 -11
  100. package/src/mail/providers.ts +6 -3
  101. package/src/metadata.ts +11 -3
  102. package/src/network-acl.ts +126 -0
  103. package/src/objectstore.ts +3 -0
  104. package/src/runtime/ai-agents.ts +365 -0
  105. package/src/runtime/ai-filter.ts +385 -107
  106. package/src/runtime/assistant.ts +64 -11
  107. package/src/runtime/context.ts +2 -0
  108. package/src/runtime/engine.ts +3 -1
  109. package/src/runtime/ir-phrases.ts +812 -0
  110. package/src/runtime/ir-plan.ts +449 -0
  111. package/src/runtime/items.ts +1 -1
  112. package/src/runtime/regions.ts +6 -2
  113. package/src/runtime/render.ts +5 -3
  114. package/src/runtime/report.ts +1 -1
  115. package/src/runtime/styles.ts +54 -12
  116. package/src/runtime/tasks.ts +63 -12
  117. package/src/task-events.ts +232 -0
  118. package/src/team-samples.ts +74 -0
  119. package/src/team.ts +342 -0
  120. package/src/webclient.ts +45 -4
  121. package/src/webrequests.ts +5 -0
  122. package/src/websources.ts +30 -12
  123. package/src/workflow.ts +135 -18
  124. package/src/workspace-export.ts +16 -1
package/public/app.js CHANGED
@@ -483,6 +483,9 @@ const pgkilnUnsaved = (() => {
483
483
  // (077) APEX: apex.page.isChanged()
484
484
  isChanged: () => pgkilnUnsaved.changed(),
485
485
  setValue: (name, value) => setItemValue(name, value),
486
+ // (124) APEX: apex.widget.waitPopup; the returned function removes it
487
+ waitPopup: (message) => (showWait(message == null ? '' : String(message)), removeWait),
488
+ removeWaitPopup: () => removeWait(),
486
489
  showSuccess: (message) => showMessage(String(message), 'success'),
487
490
  showError: (message, item) => (item ? itemError(item, String(message)) : showMessage(String(message), 'error')),
488
491
  clearErrors: (...items) => clearErrors(items),
@@ -521,6 +524,39 @@ const pgkilnUnsaved = (() => {
521
524
  }
522
525
  }
523
526
 
527
+ // (124) APEX: Show / Remove Wait Popup. A modal dialog (the page below is inert, Escape doesn't
528
+ // close it) with a spinner and a status text read out by screen readers; <main> is aria-busy.
529
+ // It goes with the page on a submit; a submit the browser refused (an invalid field) or a page
530
+ // restored from the back/forward cache removes it.
531
+ let waitDlg = null;
532
+ function showWait(message) {
533
+ if (!waitDlg) {
534
+ waitDlg = document.createElement('dialog');
535
+ waitDlg.className = 'wait-popup';
536
+ waitDlg.tabIndex = -1;
537
+ const spin = document.createElement('span');
538
+ spin.className = 'wait-popup-spinner';
539
+ spin.setAttribute('aria-hidden', 'true');
540
+ const p = document.createElement('p');
541
+ p.className = 'wait-popup-text';
542
+ p.id = 'pgkiln-wait-text';
543
+ p.setAttribute('role', 'status');
544
+ waitDlg.setAttribute('aria-labelledby', p.id);
545
+ waitDlg.append(spin, p);
546
+ waitDlg.addEventListener('cancel', (e) => e.preventDefault());
547
+ body.appendChild(waitDlg);
548
+ }
549
+ waitDlg.querySelector('.wait-popup-text').textContent = message || text('da.wait');
550
+ if (!waitDlg.open) waitDlg.showModal();
551
+ (document.getElementById('main') || body).setAttribute('aria-busy', 'true');
552
+ }
553
+ function removeWait() {
554
+ if (waitDlg && waitDlg.open) waitDlg.close();
555
+ (document.getElementById('main') || body).removeAttribute('aria-busy');
556
+ }
557
+ document.addEventListener('invalid', () => waitDlg && waitDlg.open && removeWait(), true);
558
+ window.addEventListener('pageshow', (e) => e.persisted && removeWait());
559
+
524
560
  async function runDa(da, initial, value, extra) {
525
561
  const holds = conditionHolds(da, value);
526
562
  switch (da.action) {
@@ -567,6 +603,13 @@ const pgkilnUnsaved = (() => {
567
603
  case 'print':
568
604
  window.print();
569
605
  return;
606
+ // (124) the wait popup: until remove_wait_popup, the next page or a refused submit
607
+ case 'wait_popup':
608
+ showWait(da.message);
609
+ return;
610
+ case 'remove_wait_popup':
611
+ removeWait();
612
+ return;
570
613
  case 'current_position':
571
614
  return new Promise((resolve) => {
572
615
  if (!navigator.geolocation) return showError(text('item.locate_error')), resolve();
@@ -679,6 +722,28 @@ const pgkilnUnsaved = (() => {
679
722
  }
680
723
 
681
724
  const das = meta.das || [];
725
+ // (121) an AI agent's javascript tools: after the answer, the registered function of the action
726
+ // (pgkiln.actions.register) and the dynamic actions of the custom event of that name, with the arguments
727
+ scriptsLoaded.then(async () => {
728
+ for (const el of document.querySelectorAll('.assistant-action[data-ai-action]')) {
729
+ const name = el.dataset.aiAction;
730
+ let args = {};
731
+ try {
732
+ args = JSON.parse(el.dataset.aiArgs || '{}');
733
+ } catch {
734
+ // ignored
735
+ }
736
+ const region = el.closest('[id^="R"]');
737
+ el.remove();
738
+ const fn = actions.get(name);
739
+ try {
740
+ if (fn) await fn({ value: args, items: [], region, elements: [], message: null });
741
+ await runChain(das.filter((da) => da.event === 'custom' && da.trigger.some((t) => String(t).toLowerCase() === name.toLowerCase())), JSON.stringify(args));
742
+ } catch (e) {
743
+ console.error(e);
744
+ }
745
+ }
746
+ });
682
747
  document.addEventListener('change', (e) => {
683
748
  const name = e.target.name;
684
749
  if (!name) return;
@@ -688,7 +753,7 @@ const pgkilnUnsaved = (() => {
688
753
  });
689
754
  document.addEventListener('click', (e) => {
690
755
  const btn = e.target.closest('[data-button]');
691
- if (!btn) return;
756
+ if (!btn || e.target.closest('[data-menu-entry]')) return;
692
757
  const run = das.filter((da) => da.event === 'click' && da.trigger.includes(btn.dataset.button));
693
758
  // "Generate text with AI" on a submit button: the AJAX call replaces the submit
694
759
  // (without JavaScript the button submits and the page process runs instead)
@@ -705,6 +770,21 @@ const pgkilnUnsaved = (() => {
705
770
  });
706
771
  }
707
772
  });
773
+ // (124) menu_entry: an entry {"action": NAME} of a menu button was chosen (a declined confirm
774
+ // stops it in the capture handler below). Trigger: the entry's name or the menu button's name;
775
+ // the value a client-side condition tests is the entry's name.
776
+ document.addEventListener('click', (e) => {
777
+ const entry = e.target.closest?.('[data-menu-entry]');
778
+ if (!entry) return;
779
+ const name = entry.dataset.menuEntry;
780
+ const menu = entry.closest('details.menu');
781
+ const owner = (menu && menu.dataset.button) || '';
782
+ if (menu) {
783
+ menu.open = false;
784
+ menu.querySelector('summary')?.focus();
785
+ }
786
+ runChain(das.filter((da) => da.event === 'menu_entry' && (da.trigger.includes(name) || (owner && da.trigger.includes(owner)))), name);
787
+ });
708
788
  for (const da of das) if (da.event !== 'dialog_closed' && ['show', 'hide', 'enable', 'disable'].includes(da.action)) runDa(da, true);
709
789
  runChain(das.filter((da) => da.event === 'load'));
710
790
 
@@ -1882,7 +1962,7 @@ document.querySelectorAll('[data-rds]').forEach((nav) => {
1882
1962
 
1883
1963
  // Popup list of values: the select (which works without JavaScript) is hidden
1884
1964
  // behind a read-only field and a button that opens a dialog searching the
1885
- // item's list of values on the server, page by page.
1965
+ // item's list of values on the server, page by page (loaded as the list scrolls).
1886
1966
  function enhancePopupLovs(root) {
1887
1967
  const selects = root.matches?.('select[data-popup-lov]') ? [root] : [...(root.querySelectorAll?.('select[data-popup-lov]') ?? [])];
1888
1968
  selects.forEach(popupLov);
@@ -1964,8 +2044,16 @@ function popupLov(sel) {
1964
2044
  sel.dispatchEvent(new Event('change', { bubbles: true }));
1965
2045
  dlg.close();
1966
2046
  };
2047
+ // (124) infinite scrolling: the next page loads when the end of the list scrolls into view
2048
+ // (APEX: lazy loading); without IntersectionObserver the dialog pages with ‹ and More
2049
+ const infinite = 'IntersectionObserver' in window;
2050
+ let observer = null;
2051
+ let seq = 0;
2052
+ let loading = false;
1967
2053
  async function load(p) {
1968
2054
  page = p;
2055
+ const my = ++seq;
2056
+ const append = infinite && p > 0;
1969
2057
  const results = dlg.querySelector('.popup-lov-results');
1970
2058
  const params = new URLSearchParams({ q: dlg.querySelector('.popup-lov-search').value, p: String(p), __csrf: csrf });
1971
2059
  // a cascading LOV sends its parents' current values
@@ -1975,31 +2063,45 @@ function popupLov(sel) {
1975
2063
  if (el) params.set(parent, el.value);
1976
2064
  }
1977
2065
  let json;
2066
+ loading = true;
1978
2067
  try {
1979
2068
  const res = await fetch(d.popupLov, { method: 'POST', body: params, headers: { accept: 'application/json' }, credentials: 'same-origin' });
1980
2069
  json = await res.json();
1981
2070
  if (!res.ok) throw new Error(json.error || res.statusText);
1982
2071
  } catch (e) {
1983
- results.textContent = e.message;
2072
+ if (my !== seq) return;
2073
+ loading = false;
2074
+ const end = results.querySelector('.popup-lov-sentinel');
2075
+ if (append && end) end.textContent = e.message;
2076
+ else results.textContent = e.message;
1984
2077
  return;
1985
2078
  }
1986
- results.replaceChildren();
1987
- if (!json.rows.length) {
1988
- const none = document.createElement('p');
1989
- none.textContent = d.noneLabel || 'Nothing found.';
1990
- results.append(none);
1991
- return;
1992
- }
1993
- const table = document.createElement('table');
1994
- table.className = 'popup-lov-table';
1995
- const tr = table.createTHead().insertRow();
1996
- for (const hd of json.headings) {
1997
- const th = document.createElement('th');
1998
- th.scope = 'col';
1999
- th.textContent = hd;
2000
- tr.append(th);
2079
+ // a newer search (or page) replaced this one
2080
+ if (my !== seq) return;
2081
+ loading = false;
2082
+ observer?.disconnect();
2083
+ results.querySelector('.popup-lov-pages, .popup-lov-sentinel')?.remove();
2084
+ let tb = append ? results.querySelector('.popup-lov-table tbody') : null;
2085
+ if (!tb) {
2086
+ results.replaceChildren();
2087
+ if (!json.rows.length) {
2088
+ const none = document.createElement('p');
2089
+ none.textContent = d.noneLabel || 'Nothing found.';
2090
+ results.append(none);
2091
+ return;
2092
+ }
2093
+ const table = document.createElement('table');
2094
+ table.className = 'popup-lov-table';
2095
+ const tr = table.createTHead().insertRow();
2096
+ for (const hd of json.headings) {
2097
+ const th = document.createElement('th');
2098
+ th.scope = 'col';
2099
+ th.textContent = hd;
2100
+ tr.append(th);
2101
+ }
2102
+ tb = table.createTBody();
2103
+ results.append(table);
2001
2104
  }
2002
- const tb = table.createTBody();
2003
2105
  for (const r of json.rows) {
2004
2106
  const row = tb.insertRow();
2005
2107
  r.columns.forEach((v, i) => {
@@ -2015,7 +2117,19 @@ function popupLov(sel) {
2015
2117
  });
2016
2118
  if (r.value === sel.value) row.className = 'is-current';
2017
2119
  }
2018
- results.append(table);
2120
+ if (infinite) {
2121
+ if (!json.more) return;
2122
+ const end = document.createElement('p');
2123
+ end.className = 'popup-lov-sentinel muted';
2124
+ end.textContent = d.loadingLabel || 'Loading more…';
2125
+ results.append(end);
2126
+ // the implicit root: whichever box scrolls the list (the dialog's body or the results), it clips
2127
+ observer = observer || new IntersectionObserver((entries) => {
2128
+ if (entries.some((x) => x.isIntersecting) && !loading) load(page + 1);
2129
+ });
2130
+ observer.observe(end);
2131
+ return;
2132
+ }
2019
2133
  if (json.more || page > 0) {
2020
2134
  const nav = document.createElement('div');
2021
2135
  nav.className = 'popup-lov-pages';
@@ -1016,6 +1016,30 @@ button.is-busy { opacity: 0.7; cursor: progress; }
1016
1016
  .tr-preview .btn { background: var(--surface); color: var(--text); border-color: var(--border); border-radius: calc(var(--radius) * 0.75); font: inherit; }
1017
1017
  .tr-preview .btn-hot { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
1018
1018
  .tr-preview-lavender .btn-hot { border-radius: 999px; }
1019
+ /* (sprint 117) the preview's menu: current page, its parent, and the Pillar Light / Dark appearances */
1020
+ .tr-preview { --nav-bg: var(--surface); --nav-text: var(--text); --nav-active: var(--accent-soft); --nav-current: var(--accent); }
1021
+ .tr-preview-iris { --nav-bg: #2f2b28; --nav-text: #ece8e4; --nav-active: #4a443f; --nav-current: #ffffff; }
1022
+ .tr-preview-main { display: grid; grid-template-columns: minmax(8rem, 11rem) minmax(0, 1fr); }
1023
+ .tr-preview-nav, .tr-preview-nav ul { list-style: none; margin: 0; padding: 0; }
1024
+ .tr-preview-nav { padding: 0.5rem; background: var(--nav-bg); color: var(--nav-text); border-inline-end: 1px solid var(--border); font-size: 0.85rem; }
1025
+ .tr-preview-nav ul { margin-inline-start: 0.9rem; padding-inline-start: 0.3rem; border-inline-start: 1px solid color-mix(in srgb, var(--nav-current) 55%, transparent); }
1026
+ .tr-preview-nav span { display: flex; align-items: center; gap: 0.4rem; }
1027
+ .tr-preview-nav li > span { padding: 0.3rem 0.5rem; border-radius: 6px; border-inline-start: 3px solid transparent; }
1028
+ .tr-preview-nav .icon { width: 1em; height: 1em; }
1029
+ .tr-preview-nav .tr-nav-parent { font-weight: 600; }
1030
+ .tr-preview-nav .tr-nav-parent .icon { color: var(--nav-current); }
1031
+ .tr-preview-nav .tr-nav-current { background: var(--nav-active); color: var(--nav-current); font-weight: 600; border-inline-start-color: var(--accent); }
1032
+ .tr-preview[data-appearance^="pillar"] .tr-preview-header { box-shadow: inset 0 4px 0 var(--strip, var(--accent)); }
1033
+ .tr-preview[data-appearance^="pillar"] .tr-preview-nav li > span { border-radius: 999px; }
1034
+ .tr-preview[data-appearance^="pillar"] .tr-nav-current { background: var(--accent); color: var(--accent-text); border-inline-start-color: transparent; }
1035
+ .tr-preview[data-appearance="pillar_light"] { --header: var(--surface-2); --header-text: var(--text); --nav-bg: var(--surface-2); --nav-text: var(--text); --nav-current: var(--accent); }
1036
+ .tr-preview[data-appearance="pillar_light"] .tr-preview-header { border-bottom: 1px solid var(--border); }
1037
+ .tr-preview[data-appearance="pillar_dark"] { --nav-bg: var(--header); --nav-text: var(--header-text); --nav-current: var(--header-text); }
1038
+ .tr-preview[data-appearance="pillar_dark"] .tr-preview-nav { border-inline-end-color: transparent; }
1039
+ @media (max-width: 640px) {
1040
+ .tr-preview-main { grid-template-columns: minmax(0, 1fr); }
1041
+ .tr-preview-nav { border-inline-end: 0; border-bottom: 1px solid var(--border); }
1042
+ }
1019
1043
 
1020
1044
  /* icon picker (forms.ts iconPicker) */
1021
1045
  .icon-picker > summary { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 34px; padding: 0.3rem 0.6rem; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; list-style: none; background: var(--surface); }
@@ -1274,6 +1298,13 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
1274
1298
  .dbu-members { max-height: 16rem; overflow: auto; }
1275
1299
  .dbu-schemas .te-c { text-align: center; width: 1%; white-space: nowrap; }
1276
1300
  .dbu-sql { white-space: pre-wrap; overflow-wrap: anywhere; }
1301
+ /* (sprint 119) a table's Security: roles × columns and masks */
1302
+ .ds-section { margin-block: 1.25rem; }
1303
+ .ds-section h2 { display: flex; align-items: center; gap: 0.4rem; }
1304
+ .ds-table .ds-cell .check { display: flex; white-space: nowrap; margin: 0; }
1305
+ .ds-table .ds-c { text-align: center; width: 1%; }
1306
+ .ds-table th code { white-space: nowrap; }
1307
+ .ds .source { white-space: pre-wrap; overflow-wrap: anywhere; }
1277
1308
  .dbu-list td { white-space: normal; overflow: visible; max-width: 24rem; }
1278
1309
  .dbu-list td .tag { margin: 0.1rem 0; }
1279
1310
  .dbu-facts { display: grid; grid-template-columns: max-content 1fr; gap: 0.35rem 1rem; margin: 0; }
@@ -1369,7 +1400,7 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
1369
1400
  .wh-top-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; }
1370
1401
  .wh-top-head h2 { margin: 0; }
1371
1402
  .wh-timeframe { display: inline-flex; }
1372
- .wh-top { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 16px; margin-top: 10px; }
1403
+ .wh-top { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 16px; margin: 10px 0 20px; }
1373
1404
  .wh-top > .region { margin: 0; min-width: 0; }
1374
1405
  .wh-message { margin-bottom: 16px; }
1375
1406
  .wh-message-text { margin: 0 0 8px; white-space: pre-line; overflow-wrap: anywhere; }
@@ -1393,3 +1424,104 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
1393
1424
  .dataset-page pre.code { white-space: pre-wrap; overflow-wrap: anywhere; }
1394
1425
  .dataset-schema { flex-wrap: wrap; }
1395
1426
  .dataset-schema > select { flex: 1 1 10rem; min-width: 0; }
1427
+
1428
+ /* (sprint 113) Team Development: issues, board, labels, milestones, templates */
1429
+ .team-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; margin-bottom: 8px; }
1430
+ .team-head h1 { margin: 0; font-size: var(--fs-xl); }
1431
+ .team-head .team-counts { margin: 0; flex: 1 1 200px; }
1432
+ .team-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 18px; }
1433
+ @media (min-width: 1024px) { .team-layout { grid-template-columns: minmax(0, 1fr) 280px; } }
1434
+ .team-main { min-width: 0; }
1435
+ .team-side { min-width: 0; }
1436
+ .team-side section { padding: 10px 0; border-top: 1px solid var(--border); }
1437
+ .team-side section:first-child { border-top: 0; padding-top: 0; }
1438
+ .team-side h2 { margin: 0 0 6px; font-size: var(--fs-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
1439
+ .team-side p, .team-side li { overflow-wrap: anywhere; }
1440
+ .team-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr)); gap: 8px 12px; align-items: end; margin-bottom: 10px; }
1441
+ .team-filters .field { margin: 0; min-width: 0; }
1442
+ .team-filters select, .team-filters input { width: 100%; min-width: 0; }
1443
+ .team-filters .buttons { margin: 0; }
1444
+ .team-issues { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
1445
+ .team-issue { padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); min-width: 0; }
1446
+ .team-issue.is-closed { opacity: 0.8; }
1447
+ .team-issue-title { display: flex; gap: 6px; align-items: baseline; font-weight: 600; overflow-wrap: anywhere; min-width: 0; }
1448
+ .team-issue-title .icon { flex: none; width: 15px; height: 15px; color: var(--hot); align-self: center; }
1449
+ .team-issue.is-closed .team-issue-title .icon { color: var(--success, #2f7d32); }
1450
+ .team-issue-title a { min-width: 0; }
1451
+ .team-labels { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; }
1452
+ .team-issue-meta { display: flex; flex-wrap: wrap; gap: 2px 12px; margin-top: 6px; overflow-wrap: anywhere; }
1453
+ .team-issue-meta span { display: inline-flex; gap: 4px; align-items: center; min-width: 0; }
1454
+ .team-issue-meta .icon { width: 13px; height: 13px; flex: none; }
1455
+ .team-label { display: inline-block; max-width: 100%; padding: 1px 8px; border-radius: 999px; font-size: 0.75rem; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
1456
+ .team-label-red { background: #b42318; color: #fff; }
1457
+ .team-label-orange { background: #c4520f; color: #fff; }
1458
+ .team-label-yellow { background: #e6b400; color: #1e1d1b; }
1459
+ .team-label-green { background: #2f7d32; color: #fff; }
1460
+ .team-label-teal { background: #0e7a74; color: #fff; }
1461
+ .team-label-blue { background: #1a5f99; color: #fff; }
1462
+ .team-label-purple { background: #6941c6; color: #fff; }
1463
+ .team-label-gray { background: #5e5a54; color: #fff; }
1464
+ .team-board-bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
1465
+ .team-board-filters { margin-bottom: 10px; }
1466
+ .team-board-filters > summary { cursor: pointer; display: inline-flex; gap: 6px; align-items: center; color: var(--muted); }
1467
+ .team-board-filters[open] > summary { margin-bottom: 8px; }
1468
+ .team-board { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 12px; align-items: start; }
1469
+ .team-col { min-width: 0; padding: 10px; border-radius: var(--radius-sm); background: var(--surface-2); border: 1px solid var(--border); }
1470
+ .team-col-head { display: flex; gap: 8px; align-items: center; margin: 0 0 8px; font-size: var(--fs-sm, 0.9rem); overflow-wrap: anywhere; }
1471
+ .team-move { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; }
1472
+ .team-move select { flex: 1 1 120px; min-width: 0; }
1473
+ .team-status { display: inline-block; padding: 1px 10px; border-radius: 999px; font-size: 0.8rem; font-weight: 700; margin-right: 6px; }
1474
+ .team-status-open { background: var(--accent-soft); color: var(--text); }
1475
+ .team-status-closed { background: var(--surface-3); color: var(--muted); }
1476
+ .team-issue-h { margin: 0 0 4px; font-size: var(--fs-lg, 1.2rem); overflow-wrap: anywhere; }
1477
+ .team-md { overflow-wrap: anywhere; min-width: 0; }
1478
+ .team-md pre { overflow-x: auto; max-width: 100%; }
1479
+ .team-body { padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
1480
+ .mention { font-weight: 600; color: var(--accent); }
1481
+ .team-timeline { list-style: none; margin: 0 0 12px; padding: 0; display: grid; gap: 8px; }
1482
+ .team-comment { padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); min-width: 0; }
1483
+ .team-comment-head { margin-bottom: 4px; overflow-wrap: anywhere; }
1484
+ .team-event { display: flex; gap: 6px; align-items: flex-start; overflow-wrap: anywhere; min-width: 0; }
1485
+ .team-event .icon { flex: none; width: 13px; height: 13px; margin-top: 3px; }
1486
+ .team-response-pick, .team-template-pick, .team-save-view { display: flex; flex-wrap: wrap; gap: 6px; align-items: end; margin-bottom: 8px; }
1487
+ .team-response-pick .field, .team-template-pick .field { margin: 0; min-width: 0; flex: 1 1 200px; }
1488
+ .team-save-view input { flex: 1 1 140px; min-width: 0; }
1489
+ .team-edit { margin-top: 16px; }
1490
+ .team-edit > summary { list-style: none; }
1491
+ .team-edit[open] > summary { margin-bottom: 10px; }
1492
+ .team-fieldset { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 12px; margin: 8px 0; min-width: 0; }
1493
+ .team-check-group { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; margin-bottom: 4px; }
1494
+ .team-check-group > .small { flex-basis: 100%; }
1495
+ .team-label-list, .team-template-list, .team-milestones, .team-views, .team-mentions { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
1496
+ .team-label-list li, .team-template-list li, .team-views li, .team-mentions li { overflow-wrap: anywhere; min-width: 0; }
1497
+ .team-views li { display: flex; gap: 6px; justify-content: space-between; align-items: center; }
1498
+ .team-views a[aria-current] { font-weight: 700; }
1499
+ .team-inline-edit { margin-top: 4px; }
1500
+ .team-inline-edit[open] { padding: 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
1501
+ .team-milestone { padding: 8px 0; border-bottom: 1px solid var(--border); }
1502
+ .team-milestone-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; overflow-wrap: anywhere; }
1503
+ .team-milestone-head .icon { width: 14px; height: 14px; margin-right: 4px; }
1504
+ .team-progress { width: 100%; max-width: 360px; height: 8px; }
1505
+ .team-page-issues { display: flex; gap: 6px; align-items: flex-start; margin: 0 0 6px; padding: 6px 10px; border-radius: var(--radius-sm); background: var(--warning-soft); overflow-wrap: anywhere; }
1506
+ .team-page-issues .icon { flex: none; width: 14px; height: 14px; margin-top: 2px; }
1507
+ .wh-team-n { margin-left: auto; padding-left: 8px; }
1508
+ .feedback-issue { margin: 4px 0; }
1509
+
1510
+ /* (116) App Builder list: updated by, working copies and the application lock (WS-12) */
1511
+ .ab-name .ab-lock { display: inline-flex; vertical-align: middle; margin-left: 8px; color: var(--muted); }
1512
+ .ab-name .ab-lock .icon { width: 15px; height: 15px; }
1513
+ .ab-by { overflow-wrap: anywhere; }
1514
+ .ab-card-badges { display: flex; flex-wrap: wrap; gap: 6px; }
1515
+ .ab-card-badges .tag { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; text-decoration: none; }
1516
+ .ab-card-badges .tag .icon { width: 13px; height: 13px; flex: none; }
1517
+ /* (sprint 118) Administration → Mail queue, Network access */
1518
+ .mail-queue-filters { display: flex; flex-wrap: wrap; gap: 0 12px; align-items: flex-end; margin-bottom: 12px; }
1519
+ .mail-queue-filters .field { min-width: 0; flex: 1 1 160px; max-width: 260px; }
1520
+ .mail-queue-stats { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
1521
+ .mail-queue-stats .stat b { font-size: 1.3rem; }
1522
+ .stat.stat-bad b { color: var(--danger, #b42318); }
1523
+ .inline-form { display: inline; margin: 0; }
1524
+ .mail-failures .buttons { flex-wrap: wrap; gap: 6px; }
1525
+ .mail-answer { overflow-wrap: anywhere; font-family: var(--font-mono, monospace); font-size: 0.85em; }
1526
+ .host-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
1527
+ .host-list code { overflow-wrap: anywhere; }
package/public/builder.js CHANGED
@@ -823,6 +823,10 @@
823
823
  set('--font', from(lists.fonts, form.elements.font?.value));
824
824
  set('--font-size', from(lists.sizes, form.elements.font_size?.value));
825
825
  set('--radius', from(lists.radii, form.elements.radius?.value));
826
+ // (sprint 117) the navigation appearance ('' = the application's, 'default' = none) and the header strip
827
+ const ap = form.elements.appearance?.value ?? '';
828
+ preview.dataset.appearance = ap === 'default' ? '' : ap || preview.dataset.trAppAppearance || '';
829
+ set('--strip', own('strip') ? hex(form.elements.strip?.value) : '');
826
830
  };
827
831
  form.addEventListener('input', update);
828
832
  form.addEventListener('change', update);
@@ -1282,3 +1286,50 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
1282
1286
  });
1283
1287
  for (const b of form.querySelectorAll('[data-cron-nojs]')) b.hidden = true;
1284
1288
  })();
1289
+
1290
+ // (123) Workflow diagram as PNG: the server's SVG file drawn on a canvas. Without JavaScript the
1291
+ // button stays hidden and the SVG link is the download.
1292
+ (() => {
1293
+ for (const button of document.querySelectorAll('[data-wf-png]')) {
1294
+ const link = button.parentElement?.querySelector('[data-wf-svg]');
1295
+ if (!link || typeof HTMLCanvasElement === 'undefined') continue;
1296
+ button.hidden = false;
1297
+ button.addEventListener('click', async () => {
1298
+ button.disabled = true;
1299
+ try {
1300
+ const res = await fetch(link.href, { credentials: 'same-origin' });
1301
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1302
+ const svg = await res.text();
1303
+ const img = new Image();
1304
+ await new Promise((ok, fail) => {
1305
+ img.onload = ok;
1306
+ img.onerror = () => fail(new Error('The diagram could not be drawn.'));
1307
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
1308
+ });
1309
+ const scale = 2;
1310
+ const canvas = document.createElement('canvas');
1311
+ canvas.width = Math.max(1, Math.round((img.naturalWidth || 800) * scale));
1312
+ canvas.height = Math.max(1, Math.round((img.naturalHeight || 600) * scale));
1313
+ const g = canvas.getContext('2d');
1314
+ g.fillStyle = '#ffffff';
1315
+ g.fillRect(0, 0, canvas.width, canvas.height);
1316
+ g.drawImage(img, 0, 0, canvas.width, canvas.height);
1317
+ const blob = await new Promise((ok) => canvas.toBlob(ok, 'image/png'));
1318
+ if (!blob) throw new Error('The browser could not make a PNG.');
1319
+ const a = document.createElement('a');
1320
+ a.href = URL.createObjectURL(blob);
1321
+ a.download = 'workflow.png';
1322
+ const name = /([^/?]+)\.svg/.exec(res.headers.get('content-disposition') || '');
1323
+ if (name) a.download = `${name[1]}.png`;
1324
+ document.body.append(a);
1325
+ a.click();
1326
+ a.remove();
1327
+ setTimeout(() => URL.revokeObjectURL(a.href), 10000);
1328
+ } catch (e) {
1329
+ window.alert(e.message);
1330
+ } finally {
1331
+ button.disabled = false;
1332
+ }
1333
+ });
1334
+ }
1335
+ })();
package/src/ai/chat.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
2
  import OpenAI from 'openai';
3
3
  import { owner } from '../db.ts';
4
- import { decryptSecret } from '../secrets.ts';
5
4
  import { claudeError } from './anthropic.ts';
6
5
  import { openAiError } from './openai.ts';
7
- import { envKeyName, MAX_PROMPT_CHARS, MAX_SYSTEM_CHARS, serviceForApp, usageToday, logUsage, type AiService, type CallInfo } from './service.ts';
6
+ import { baseUrlFor, MAX_PROMPT_CHARS, MAX_SYSTEM_CHARS, serviceForApp, serviceKey, usageToday, logUsage, type AiService, type CallInfo } from './service.ts';
8
7
  import { AiError } from './types.ts';
9
8
 
10
9
  // Conversations with tools (AI assistant regions, src/runtime/assistant.ts):
@@ -23,7 +22,9 @@ import { AiError } from './types.ts';
23
22
  // service's effort, server-side refusal fallbacks. The assistant's content
24
23
  // is appended to the history unchanged (thinking blocks included): the
25
24
  // history only ever grows, as the API expects. OpenAI: Chat Completions with
26
- // strict function tools.
25
+ // strict function tools; also Gemini (its OpenAI-compatible endpoint) and
26
+ // OpenAI-compatible endpoints (121). An agent (src/runtime/ai-agents.ts)
27
+ // may set the reasoning effort and a lower output limit per call.
27
28
 
28
29
  export interface ChatTool {
29
30
  name: string;
@@ -47,6 +48,10 @@ export interface ChatRequest {
47
48
  /** at most this many rounds of tool calls (then the model must answer) */
48
49
  maxRounds: number;
49
50
  runTool(name: string, input: Record<string, unknown>): Promise<ToolOutcome>;
51
+ /** (121) an agent's reasoning effort: Claude's output_config.effort, OpenAI's reasoning_effort (else the service's own) */
52
+ effort?: 'low' | 'medium' | 'high' | null;
53
+ /** (121) an agent's output limit per call (never above the service's maximum) */
54
+ maxTokens?: number | null;
50
55
  }
51
56
 
52
57
  export interface ChatResult {
@@ -69,16 +74,9 @@ export interface ChatInfo {
69
74
  /** Tool results are cut to this many characters (the model sees that they were). */
70
75
  export const MAX_TOOL_RESULT_CHARS = 20_000;
71
76
 
72
- function apiKey(s: AiService) {
73
- let key: string | undefined;
74
- try {
75
- key = s.api_key_enc ? decryptSecret(s.api_key_enc) : process.env[envKeyName(s.provider)];
76
- } catch (e) {
77
- throw new AiError('config', `AI service ${s.name}: ${(e as Error).message}`);
78
- }
79
- if (!key) throw new AiError('config', `AI service ${s.name} has no API key (enter one in the builder, or set ${envKeyName(s.provider)} on the server).`);
80
- return key;
81
- }
77
+
78
+ /** The output limit of one call: the agent's, never above the service's. */
79
+ const outputLimit = (s: AiService, req: ChatRequest) => Math.min(s.max_tokens, req.maxTokens ?? s.max_tokens);
82
80
 
83
81
  const clip = (text: string) => (text.length > MAX_TOOL_RESULT_CHARS ? `${text.slice(0, MAX_TOOL_RESULT_CHARS)}\n[cut off: the result was longer]` : text);
84
82
 
@@ -114,7 +112,7 @@ async function metered<T extends { model: string; input: number; output: number
114
112
  const TOO_MANY_ROUNDS = 'The assistant needed more steps than it may take for one question: ask a simpler question, or start a new conversation.';
115
113
 
116
114
  async function claudeChat(s: AiService, limits: Parameters<typeof metered>[1], req: ChatRequest, info: ChatInfo): Promise<ChatResult> {
117
- const client = new Anthropic({ apiKey: apiKey(s), authToken: null, baseURL: s.base_url || undefined, timeout: s.timeout_s * 1000, maxRetries: 1 });
115
+ const client = new Anthropic({ apiKey: serviceKey(s), authToken: null, baseURL: baseUrlFor(s), timeout: s.timeout_s * 1000, maxRetries: 1 });
118
116
  const messages = [...(req.history as Anthropic.Beta.BetaMessageParam[]), { role: 'user', content: req.message } as Anthropic.Beta.BetaMessageParam];
119
117
  const tools: Anthropic.Beta.BetaTool[] = req.tools.map((t) => ({
120
118
  name: t.name, description: t.description, strict: true, input_schema: t.parameters as Anthropic.Beta.BetaTool.InputSchema,
@@ -126,11 +124,11 @@ async function claudeChat(s: AiService, limits: Parameters<typeof metered>[1], r
126
124
  try {
127
125
  m = await client.beta.messages.stream({
128
126
  model: s.model,
129
- max_tokens: s.max_tokens,
127
+ max_tokens: outputLimit(s, req),
130
128
  system: req.system,
131
129
  messages,
132
130
  ...(tools.length ? { tools } : {}),
133
- ...(s.effort ? { output_config: { effort: s.effort } } : {}),
131
+ ...((req.effort ?? s.effort) ? { output_config: { effort: (req.effort ?? s.effort)! } } : {}),
134
132
  ...(s.refusal_fallback ? { betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default' as const } : {}),
135
133
  }).finalMessage();
136
134
  } catch (e) {
@@ -161,7 +159,7 @@ async function claudeChat(s: AiService, limits: Parameters<typeof metered>[1], r
161
159
  }
162
160
 
163
161
  async function openAiChat(s: AiService, limits: Parameters<typeof metered>[1], req: ChatRequest, info: ChatInfo): Promise<ChatResult> {
164
- const client = new OpenAI({ apiKey: apiKey(s), baseURL: s.base_url || undefined, timeout: s.timeout_s * 1000, maxRetries: 1 });
162
+ const client = new OpenAI({ apiKey: serviceKey(s), baseURL: baseUrlFor(s), timeout: s.timeout_s * 1000, maxRetries: 1 });
165
163
  const messages = [...(req.history as OpenAI.Chat.ChatCompletionMessageParam[]), { role: 'user', content: req.message } as OpenAI.Chat.ChatCompletionMessageParam];
166
164
  const tools: OpenAI.Chat.ChatCompletionTool[] = req.tools.map((t) => ({
167
165
  type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters, strict: true },
@@ -175,8 +173,10 @@ async function openAiChat(s: AiService, limits: Parameters<typeof metered>[1], r
175
173
  model: s.model,
176
174
  // the system prompt is sent with each request, not kept in the history
177
175
  messages: [{ role: 'system', content: req.system }, ...messages],
178
- max_completion_tokens: s.max_tokens,
176
+ max_completion_tokens: outputLimit(s, req),
179
177
  ...(tools.length ? { tools } : {}),
178
+ // (121) an agent's reasoning effort; models without reasoning refuse it, so none is sent without one
179
+ ...(req.effort ? { reasoning_effort: req.effort } : {}),
180
180
  });
181
181
  } catch (e) {
182
182
  throw openAiError(e);
package/src/ai/service.ts CHANGED
@@ -4,7 +4,7 @@ import { ClaudeProvider } from './anthropic.ts';
4
4
  import { OpenAiProvider } from './openai.ts';
5
5
  import { AiError, type AiProvider, type GenerateRequest, type GenerateResult, type ProviderName } from './types.ts';
6
6
 
7
- // AI services (migration 060): an administrator configures them in the
7
+ // AI services (migration 060; Gemini and OpenAI-compatible endpoints: 121): an administrator configures them in the
8
8
  // builder (Workspace utilities → AI services) and allows each application
9
9
  // to use some of them, with optional limits per day. Every call goes
10
10
  // through generate() here: the service must exist, be enabled and be
@@ -35,33 +35,57 @@ export const DEFAULT_CLAUDE_MODEL = 'claude-opus-5-5';
35
35
  export const MAX_PROMPT_CHARS = 200_000;
36
36
  export const MAX_SYSTEM_CHARS = 50_000;
37
37
 
38
- const ENV_KEY: Record<ProviderName, string> = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY' };
38
+ const ENV_KEY: Record<ProviderName, string> = {
39
+ anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', gemini: 'GEMINI_API_KEY', openai_compatible: 'OPENAI_COMPATIBLE_API_KEY',
40
+ };
39
41
 
40
42
  export const envKeyName = (p: ProviderName) => ENV_KEY[p];
41
43
 
44
+ /** (121) The providers, as the builder names them. */
45
+ export const PROVIDER_LABELS: Record<ProviderName, string> = {
46
+ anthropic: 'Claude', openai: 'OpenAI', gemini: 'Gemini', openai_compatible: 'OpenAI-compatible',
47
+ };
48
+ export const providerLabel = (p: string) => PROVIDER_LABELS[p as ProviderName] ?? p;
49
+
50
+ /** (121) Google's OpenAI-compatible endpoint for Gemini (used when a Gemini service names no base URL). */
51
+ export const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai/';
52
+
53
+ /** The base URL a service's SDK client uses: its own, Gemini's default, or the SDK's default (undefined). */
54
+ export const baseUrlFor = (s: Pick<AiService, 'provider' | 'base_url'>) => s.base_url || (s.provider === 'gemini' ? GEMINI_BASE_URL : undefined);
55
+
42
56
  /** Where the service's key comes from: stored (encrypted), the server's environment, or nowhere. */
43
57
  export function keySource(s: Pick<AiService, 'provider' | 'api_key_enc'>): 'stored' | 'env' | 'missing' {
44
58
  if (s.api_key_enc) return 'stored';
45
59
  return process.env[ENV_KEY[s.provider]] ? 'env' : 'missing';
46
60
  }
47
61
 
62
+ /**
63
+ * The service's API key, decrypted only here (never stored elsewhere): the stored one, else the server's
64
+ * environment variable. An OpenAI-compatible endpoint may need none (a local Ollama): the SDK then sends a
65
+ * placeholder.
66
+ */
67
+ export function serviceKey(s: AiService): string {
68
+ let key: string | undefined;
69
+ try {
70
+ key = s.api_key_enc ? decryptSecret(s.api_key_enc) : process.env[ENV_KEY[s.provider]];
71
+ } catch (e) {
72
+ throw new AiError('config', `AI service ${s.name}: ${(e as Error).message}`);
73
+ }
74
+ if (!key && s.provider === 'openai_compatible') return 'none';
75
+ if (!key) throw new AiError('config', `AI service ${s.name} has no API key (enter one in the builder, or set ${ENV_KEY[s.provider]} on the server).`);
76
+ return key;
77
+ }
78
+
48
79
  export async function loadService(name: string): Promise<AiService | undefined> {
49
80
  return owner.one<AiService>('select * from meta.ai_service where name = $1', [name.toUpperCase()]);
50
81
  }
51
82
 
52
83
  /** The provider of a service, with its key (decrypted only here, never stored elsewhere). */
53
84
  export function providerFor(s: AiService): AiProvider {
54
- let apiKey: string | undefined;
55
- try {
56
- apiKey = s.api_key_enc ? decryptSecret(s.api_key_enc) : process.env[ENV_KEY[s.provider]];
57
- } catch (e) {
58
- throw new AiError('config', `AI service ${s.name}: ${(e as Error).message}`);
59
- }
60
- if (!apiKey) throw new AiError('config', `AI service ${s.name} has no API key (enter one in the builder, or set ${ENV_KEY[s.provider]} on the server).`);
61
85
  const conf = {
62
86
  model: s.model,
63
- apiKey,
64
- baseUrl: s.base_url,
87
+ apiKey: serviceKey(s),
88
+ baseUrl: baseUrlFor(s),
65
89
  timeoutMs: s.timeout_s * 1000,
66
90
  maxTokens: s.max_tokens,
67
91
  effort: s.effort,
package/src/ai/types.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  // The provider interface of pgkiln's AI features (src/ai/): one request in,
2
2
  // one answer out, whatever the provider. Providers live in anthropic.ts
3
3
  // (Claude, with the official @anthropic-ai/sdk) and openai.ts (the official
4
- // openai SDK); service.ts picks one for an AI service and adds the checks,
4
+ // openai SDK: OpenAI itself, Google Gemini through its OpenAI-compatible
5
+ // endpoint, and any OpenAI-compatible endpoint such as Ollama, vLLM or Azure
6
+ // OpenAI); service.ts picks one for an AI service and adds the checks,
5
7
  // limits and the usage log.
6
8
 
7
- export type ProviderName = 'anthropic' | 'openai';
9
+ export type ProviderName = 'anthropic' | 'openai' | 'gemini' | 'openai_compatible';
10
+
11
+ /** Providers that speak OpenAI's Chat Completions (the openai SDK). */
12
+ export const isOpenAiFamily = (p: ProviderName) => p !== 'anthropic';
8
13
 
9
14
  export interface ProviderConfig {
10
15
  /** the model, exactly as the developer entered it (never changed or downgraded) */