pgkiln 0.51.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SECURITY.md +5 -0
- package/db/migrations/121_ai_agents.sql +94 -0
- package/db/migrations/122_team_development.sql +186 -0
- package/db/migrations/123_workflow_outcomes.sql +407 -0
- package/db/migrations/124_component_details.sql +51 -0
- package/docs/apex-feature-parity.md +12 -10
- package/docs/guide/01-installation.md +7 -3
- package/docs/guide/03-builder.md +121 -7
- package/docs/guide/04-pages-and-regions.md +71 -21
- package/docs/guide/05-items.md +7 -1
- package/docs/guide/06-processing.md +123 -8
- package/docs/guide/07-dynamic-actions.md +34 -0
- package/docs/guide/09-reference.md +2 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +44 -8
- package/docs/guide/20-ai-agents.md +4 -2
- package/examples/hr/README.md +2 -0
- package/examples/hr/hr_64_ai_agent.sql +97 -0
- package/examples/hr/hr_65_ask_reports.sql +21 -0
- package/examples/hr/hr_66_team_development.sql +58 -0
- package/examples/hr/hr_67_workflow_outcomes.sql +113 -0
- package/examples/hr/hr_68_component_details.sql +96 -0
- package/package.json +1 -1
- package/public/app.css +40 -0
- package/public/app.js +134 -20
- package/public/builder.css +83 -1
- package/public/builder.js +47 -0
- package/src/ai/chat.ts +18 -18
- package/src/ai/service.ts +35 -11
- package/src/ai/types.ts +7 -2
- package/src/app.ts +6 -0
- package/src/builder/advisor.ts +6 -3
- package/src/builder/ai-agents.ts +372 -0
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/ai.ts +13 -9
- package/src/builder/assistant.ts +22 -7
- package/src/builder/components.ts +38 -9
- package/src/builder/designer.ts +8 -3
- package/src/builder/feedback.ts +6 -1
- package/src/builder/locks.ts +7 -1
- package/src/builder/region-help.ts +2 -2
- package/src/builder/routes.ts +4 -1
- package/src/builder/shared.ts +1 -1
- package/src/builder/team.ts +991 -0
- package/src/builder/ui.ts +2 -1
- package/src/builder/workflow-migrate.ts +210 -0
- package/src/builder/workflows.ts +29 -6
- package/src/builder/workspace-home.ts +2 -0
- package/src/builder/workspace-transfer.ts +1 -1
- package/src/cli/replace.ts +7 -1
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/instance.ts +2 -0
- package/src/metadata.ts +9 -3
- package/src/runtime/ai-agents.ts +365 -0
- package/src/runtime/ai-filter.ts +385 -107
- package/src/runtime/assistant.ts +64 -11
- package/src/runtime/context.ts +2 -0
- package/src/runtime/ir-phrases.ts +812 -0
- package/src/runtime/ir-plan.ts +449 -0
- package/src/runtime/items.ts +1 -1
- package/src/runtime/regions.ts +6 -2
- package/src/runtime/render.ts +1 -1
- package/src/runtime/report.ts +1 -1
- package/src/runtime/tasks.ts +63 -12
- package/src/task-events.ts +232 -0
- package/src/team-samples.ts +74 -0
- package/src/team.ts +342 -0
- package/src/workflow.ts +135 -18
- package/src/workspace-export.ts +16 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
-- =====================================================================
|
|
2
|
+
-- HR example, part 68: component details of APEX 26.1 (migration 124;
|
|
3
|
+
-- docs/guide/05-items.md "Popup LOV", docs/guide/07-dynamic-actions.md
|
|
4
|
+
-- "Wait popup" and "Menu entry chosen")
|
|
5
|
+
--
|
|
6
|
+
-- Page 47 "Component details":
|
|
7
|
+
-- * P47_REF, a popup LOV over 6,000 reference numbers: the dialog loads
|
|
8
|
+
-- the next 100 as the list scrolls (no More button).
|
|
9
|
+
-- * RECALC, a submit button whose process takes two seconds: a dynamic
|
|
10
|
+
-- action shows the wait popup on its click; the next page removes it.
|
|
11
|
+
-- * CHECK ("Check in the background"): wait popup, an Ajax call (execute_sql,
|
|
12
|
+
-- a second and a half) that fills P47_RESULT, remove wait popup.
|
|
13
|
+
-- * ACTIONS, a menu button: a link, and two entries that run dynamic
|
|
14
|
+
-- actions (event menu_entry): COUNT (the same Ajax call with a wait
|
|
15
|
+
-- popup) and HELLO (a success message).
|
|
16
|
+
-- =====================================================================
|
|
17
|
+
|
|
18
|
+
insert into meta.page (app_id, page_no, name, title, parent_page)
|
|
19
|
+
select id, 47, 'Component details', 'Component details', 1 from meta.app where alias = 'hr';
|
|
20
|
+
|
|
21
|
+
insert into meta.region (page_id, seq, title, type, columns, template, source)
|
|
22
|
+
select p.id, 10, 'Component details', 'static', 12, 'standard',
|
|
23
|
+
'<p>A popup list of values that loads more rows as you scroll, a wait popup around a long process and an Ajax call, and a menu button whose entries run dynamic actions.</p>'
|
|
24
|
+
from meta.page p join meta.app a on a.id = p.app_id
|
|
25
|
+
where a.alias = 'hr' and p.page_no = 47;
|
|
26
|
+
|
|
27
|
+
insert into meta.item (page_id, region_id, seq, name, label, type, lov, config, help)
|
|
28
|
+
select p.id, r.id, i.seq, i.name, i.label, i.type, i.lov, i.config::jsonb, i.help
|
|
29
|
+
from meta.page p join meta.app a on a.id = p.app_id
|
|
30
|
+
join meta.region r on r.page_id = p.id and r.title = 'Component details', (values
|
|
31
|
+
(10, 'P47_REF', 'Reference', 'popup_lov',
|
|
32
|
+
'select ''REF-'' || lpad(n::text, 5, ''0'') || '' · '' || (array[''Accounting'', ''Research'', ''Sales'', ''Operations''])[1 + n % 4] as name, n as ref
|
|
33
|
+
from generate_series(1, 6000) n
|
|
34
|
+
order by n',
|
|
35
|
+
'{"page_size": 100}', '6,000 reference numbers: scroll the dialog to load more.'),
|
|
36
|
+
(20, 'P47_RESULT', 'Result', 'text', null, '{}', 'Filled by the Ajax call behind Check in the background, or Actions → Count employees.')
|
|
37
|
+
) as i (seq, name, label, type, lov, config, help)
|
|
38
|
+
where a.alias = 'hr' and p.page_no = 47;
|
|
39
|
+
|
|
40
|
+
insert into meta.button (page_id, region_id, seq, name, label, action, hot, menu)
|
|
41
|
+
select p.id, r.id, b.seq, b.name, b.label, b.action, b.hot, b.menu::jsonb
|
|
42
|
+
from meta.page p join meta.app a on a.id = p.app_id
|
|
43
|
+
join meta.region r on r.page_id = p.id and r.title = 'Component details', (values
|
|
44
|
+
(10, 'RECALC', 'Recalculate', 'submit', true, null),
|
|
45
|
+
(20, 'CHECK', 'Check in the background', 'da', false, null),
|
|
46
|
+
(30, 'ACTIONS', 'Actions', 'menu', false,
|
|
47
|
+
'[{"label": "Count employees", "action": "COUNT", "icon": "users"},
|
|
48
|
+
{"label": "Say hello", "action": "HELLO", "icon": "bell"},
|
|
49
|
+
{"label": "Pick an employee", "page": 26, "icon": "search"}]')
|
|
50
|
+
) as b (seq, name, label, action, hot, menu)
|
|
51
|
+
where a.alias = 'hr' and p.page_no = 47;
|
|
52
|
+
|
|
53
|
+
-- the long process: two seconds on RECALC
|
|
54
|
+
insert into meta.process (page_id, seq, name, type, point, code, when_button, success_message)
|
|
55
|
+
select p.id, 10, 'Recalculate (slow)', 'sql', 'submit', 'select pg_sleep(2)', 'RECALC', 'Recalculated.'
|
|
56
|
+
from meta.page p join meta.app a on a.id = p.app_id
|
|
57
|
+
where a.alias = 'hr' and p.page_no = 47;
|
|
58
|
+
|
|
59
|
+
insert into meta.dynamic_action (page_id, seq, name, event, trigger_element, action, affected_items, code, message)
|
|
60
|
+
select p.id, d.seq, d.name, d.event, d.trig, d.action, d.affected, d.code, d.message
|
|
61
|
+
from meta.page p join meta.app a on a.id = p.app_id, (values
|
|
62
|
+
(10, 'Wait while recalculating', 'click', 'RECALC', 'wait_popup', null, null, 'Recalculating…'),
|
|
63
|
+
(20, 'Wait for the check', 'click', 'CHECK', 'wait_popup', null, null, 'Checking…'),
|
|
64
|
+
(30, 'The check', 'click', 'CHECK', 'execute_sql', 'P47_RESULT',
|
|
65
|
+
'select (select count(*) from hr.emp) || '' employees, checked at '' || to_char(clock_timestamp(), ''HH24:MI:SS'') as p47_result from pg_sleep(1.5)', null),
|
|
66
|
+
(40, 'Check done', 'click', 'CHECK', 'remove_wait_popup', null, null, null),
|
|
67
|
+
(50, 'Wait for the count', 'menu_entry', 'COUNT', 'wait_popup', null, null, 'Counting employees…'),
|
|
68
|
+
(60, 'The count', 'menu_entry', 'COUNT', 'execute_sql', 'P47_RESULT',
|
|
69
|
+
'select (select count(*) from hr.emp) || '' employees, counted at '' || to_char(clock_timestamp(), ''HH24:MI:SS'') as p47_result from pg_sleep(1.5)', null),
|
|
70
|
+
(70, 'Count done', 'menu_entry', 'COUNT', 'remove_wait_popup', null, null, null),
|
|
71
|
+
(80, 'Hello', 'menu_entry', 'HELLO', 'show_success', null, null, 'Hello from a menu entry.')
|
|
72
|
+
) as d (seq, name, event, trig, action, affected, code, message)
|
|
73
|
+
where a.alias = 'hr' and p.page_no = 47;
|
|
74
|
+
|
|
75
|
+
insert into meta.nav_entry (app_id, seq, label, icon, target_page)
|
|
76
|
+
select id, 34, 'Component details', 'list', 47 from meta.app where alias = 'hr';
|
|
77
|
+
|
|
78
|
+
insert into meta.translation (app_id, language, source, target)
|
|
79
|
+
select a.id, 'nl', t.source, t.target
|
|
80
|
+
from meta.app a, (values
|
|
81
|
+
('Component details', 'Componentdetails'),
|
|
82
|
+
('Reference', 'Referentie'),
|
|
83
|
+
('Result', 'Resultaat'),
|
|
84
|
+
('Recalculate', 'Herberekenen'),
|
|
85
|
+
('Check in the background', 'Op de achtergrond controleren'),
|
|
86
|
+
('Actions', 'Acties'),
|
|
87
|
+
('Count employees', 'Medewerkers tellen'),
|
|
88
|
+
('Say hello', 'Hallo zeggen'),
|
|
89
|
+
('Recalculating…', 'Herberekenen…'),
|
|
90
|
+
('Checking…', 'Controleren…'),
|
|
91
|
+
('Counting employees…', 'Medewerkers tellen…'),
|
|
92
|
+
('Recalculated.', 'Herberekend.'),
|
|
93
|
+
('Hello from a menu entry.', 'Hallo vanuit een menu-item.')
|
|
94
|
+
) as t(source, target)
|
|
95
|
+
where a.alias = 'hr'
|
|
96
|
+
on conflict do nothing;
|
package/package.json
CHANGED
package/public/app.css
CHANGED
|
@@ -2048,3 +2048,43 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
|
|
|
2048
2048
|
.reporter-actions .buttons { margin: 0; }
|
|
2049
2049
|
.reporter-link { flex: 1 1 14rem; min-width: 0; font-family: var(--mono, monospace); font-size: 0.85rem; }
|
|
2050
2050
|
.reporter-coldesc { display: block; font-size: 0.78rem; }
|
|
2051
|
+
|
|
2052
|
+
/* (121) AI agents: links a navigate tool built under an answer; tool arguments in the AI usage log */
|
|
2053
|
+
.assistant-links { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.4rem; }
|
|
2054
|
+
.assistant-links .btn { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
|
|
2055
|
+
.ai-args { overflow-wrap: anywhere; word-break: break-word; }
|
|
2056
|
+
|
|
2057
|
+
/* (sprint 112) the report's settings as chips under "Ask in your own words" (src/runtime/ai-filter.ts): Edit unfolds a small form */
|
|
2058
|
+
.ask-chips { margin: 0.6rem 0 0; padding: 0; list-style: none; }
|
|
2059
|
+
.ask-chip { flex-wrap: wrap; max-width: 100%; min-width: 0; overflow-wrap: anywhere; padding-right: 0.2rem; }
|
|
2060
|
+
.ask-chip-text { min-width: 0; }
|
|
2061
|
+
.ask-edit > summary { cursor: pointer; font-size: 0.78rem; text-decoration: underline; list-style: none; padding: 0 0.2rem; }
|
|
2062
|
+
.ask-edit > summary::-webkit-details-marker { display: none; }
|
|
2063
|
+
.ask-edit[open] { flex-basis: 100%; }
|
|
2064
|
+
.ask-edit-body { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.4rem; padding: 0.4rem 0.2rem 0.3rem; }
|
|
2065
|
+
.ask-field { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; flex: 1 1 8rem; max-width: 100%; }
|
|
2066
|
+
.ask-field label { font-size: 0.75rem; }
|
|
2067
|
+
.ask-field select, .ask-field input { min-width: 0; width: 100%; box-sizing: border-box; }
|
|
2068
|
+
.ask-chip-x { background: none; border: 0; color: inherit; font-size: 1rem; line-height: 1; padding: 0.15rem 0.4rem; border-radius: 99px; cursor: pointer; }
|
|
2069
|
+
.ask-chip-x:hover, .ask-chip-x:focus-visible { background: var(--surface); }
|
|
2070
|
+
/* (123) workflows: draft and send e-mail steps; custom task outcomes */
|
|
2071
|
+
.wf-send_mail rect { stroke: var(--success); stroke-width: 2; }
|
|
2072
|
+
.wf-draft rect { fill: var(--surface-2, var(--surface)); stroke: var(--muted); stroke-width: 1.5; stroke-dasharray: 6 4; }
|
|
2073
|
+
.wf-draft .wf-name, .wf-draft .wf-type { font-style: italic; }
|
|
2074
|
+
.btn.to-warning { background: var(--warning-soft); border-color: color-mix(in srgb, #b86e00 45%, var(--border)); color: var(--text); }
|
|
2075
|
+
.btn.to-warning:hover { background: color-mix(in srgb, var(--warning-soft) 80%, #b86e00); }
|
|
2076
|
+
.wf-export { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.5rem 0 0; }
|
|
2077
|
+
.wf-migrate-map { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr)); gap: 0.5rem 1rem; }
|
|
2078
|
+
.wf-migrate-map label { display: flex; flex-direction: column; gap: 0.2rem; overflow-wrap: anywhere; }
|
|
2079
|
+
.wf-migrate-map select { max-width: 100%; }
|
|
2080
|
+
.wf-problem { color: var(--danger); overflow-wrap: anywhere; }
|
|
2081
|
+
|
|
2082
|
+
/* (sprint 115) the popup LOV's end of list while the next page loads, and the wait popup (dynamic action wait_popup) */
|
|
2083
|
+
.popup-lov-sentinel { margin: 0.5rem 0 0; text-align: center; font-size: 0.875rem; }
|
|
2084
|
+
dialog.wait-popup { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); box-shadow: var(--shadow); padding: 1.25rem 1.5rem; max-width: min(360px, calc(100vw - 2rem)); display: none; }
|
|
2085
|
+
dialog.wait-popup[open] { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
|
|
2086
|
+
dialog.wait-popup:focus { outline: none; }
|
|
2087
|
+
dialog.wait-popup::backdrop { background: rgb(15 20 30 / 35%); cursor: wait; }
|
|
2088
|
+
.wait-popup-spinner { width: 2rem; height: 2rem; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--accent); }
|
|
2089
|
+
.wait-popup-text { margin: 0; text-align: center; overflow-wrap: anywhere; }
|
|
2090
|
+
@media (prefers-reduced-motion: no-preference) { .wait-popup-spinner { animation: icon-spin 0.9s linear infinite; } }
|
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
|
-
|
|
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
|
-
|
|
1987
|
-
if (
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
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
|
-
|
|
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';
|
package/public/builder.css
CHANGED
|
@@ -1369,7 +1369,7 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
1369
1369
|
.wh-top-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; }
|
|
1370
1370
|
.wh-top-head h2 { margin: 0; }
|
|
1371
1371
|
.wh-timeframe { display: inline-flex; }
|
|
1372
|
-
.wh-top { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 16px; margin
|
|
1372
|
+
.wh-top { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 16px; margin: 10px 0 20px; }
|
|
1373
1373
|
.wh-top > .region { margin: 0; min-width: 0; }
|
|
1374
1374
|
.wh-message { margin-bottom: 16px; }
|
|
1375
1375
|
.wh-message-text { margin: 0 0 8px; white-space: pre-line; overflow-wrap: anywhere; }
|
|
@@ -1393,3 +1393,85 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
1393
1393
|
.dataset-page pre.code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
1394
1394
|
.dataset-schema { flex-wrap: wrap; }
|
|
1395
1395
|
.dataset-schema > select { flex: 1 1 10rem; min-width: 0; }
|
|
1396
|
+
|
|
1397
|
+
/* (sprint 113) Team Development: issues, board, labels, milestones, templates */
|
|
1398
|
+
.team-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; margin-bottom: 8px; }
|
|
1399
|
+
.team-head h1 { margin: 0; font-size: var(--fs-xl); }
|
|
1400
|
+
.team-head .team-counts { margin: 0; flex: 1 1 200px; }
|
|
1401
|
+
.team-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 18px; }
|
|
1402
|
+
@media (min-width: 1024px) { .team-layout { grid-template-columns: minmax(0, 1fr) 280px; } }
|
|
1403
|
+
.team-main { min-width: 0; }
|
|
1404
|
+
.team-side { min-width: 0; }
|
|
1405
|
+
.team-side section { padding: 10px 0; border-top: 1px solid var(--border); }
|
|
1406
|
+
.team-side section:first-child { border-top: 0; padding-top: 0; }
|
|
1407
|
+
.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); }
|
|
1408
|
+
.team-side p, .team-side li { overflow-wrap: anywhere; }
|
|
1409
|
+
.team-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr)); gap: 8px 12px; align-items: end; margin-bottom: 10px; }
|
|
1410
|
+
.team-filters .field { margin: 0; min-width: 0; }
|
|
1411
|
+
.team-filters select, .team-filters input { width: 100%; min-width: 0; }
|
|
1412
|
+
.team-filters .buttons { margin: 0; }
|
|
1413
|
+
.team-issues { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
|
1414
|
+
.team-issue { padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); min-width: 0; }
|
|
1415
|
+
.team-issue.is-closed { opacity: 0.8; }
|
|
1416
|
+
.team-issue-title { display: flex; gap: 6px; align-items: baseline; font-weight: 600; overflow-wrap: anywhere; min-width: 0; }
|
|
1417
|
+
.team-issue-title .icon { flex: none; width: 15px; height: 15px; color: var(--hot); align-self: center; }
|
|
1418
|
+
.team-issue.is-closed .team-issue-title .icon { color: var(--success, #2f7d32); }
|
|
1419
|
+
.team-issue-title a { min-width: 0; }
|
|
1420
|
+
.team-labels { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; }
|
|
1421
|
+
.team-issue-meta { display: flex; flex-wrap: wrap; gap: 2px 12px; margin-top: 6px; overflow-wrap: anywhere; }
|
|
1422
|
+
.team-issue-meta span { display: inline-flex; gap: 4px; align-items: center; min-width: 0; }
|
|
1423
|
+
.team-issue-meta .icon { width: 13px; height: 13px; flex: none; }
|
|
1424
|
+
.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; }
|
|
1425
|
+
.team-label-red { background: #b42318; color: #fff; }
|
|
1426
|
+
.team-label-orange { background: #c4520f; color: #fff; }
|
|
1427
|
+
.team-label-yellow { background: #e6b400; color: #1e1d1b; }
|
|
1428
|
+
.team-label-green { background: #2f7d32; color: #fff; }
|
|
1429
|
+
.team-label-teal { background: #0e7a74; color: #fff; }
|
|
1430
|
+
.team-label-blue { background: #1a5f99; color: #fff; }
|
|
1431
|
+
.team-label-purple { background: #6941c6; color: #fff; }
|
|
1432
|
+
.team-label-gray { background: #5e5a54; color: #fff; }
|
|
1433
|
+
.team-board-bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
|
|
1434
|
+
.team-board-filters { margin-bottom: 10px; }
|
|
1435
|
+
.team-board-filters > summary { cursor: pointer; display: inline-flex; gap: 6px; align-items: center; color: var(--muted); }
|
|
1436
|
+
.team-board-filters[open] > summary { margin-bottom: 8px; }
|
|
1437
|
+
.team-board { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 12px; align-items: start; }
|
|
1438
|
+
.team-col { min-width: 0; padding: 10px; border-radius: var(--radius-sm); background: var(--surface-2); border: 1px solid var(--border); }
|
|
1439
|
+
.team-col-head { display: flex; gap: 8px; align-items: center; margin: 0 0 8px; font-size: var(--fs-sm, 0.9rem); overflow-wrap: anywhere; }
|
|
1440
|
+
.team-move { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; }
|
|
1441
|
+
.team-move select { flex: 1 1 120px; min-width: 0; }
|
|
1442
|
+
.team-status { display: inline-block; padding: 1px 10px; border-radius: 999px; font-size: 0.8rem; font-weight: 700; margin-right: 6px; }
|
|
1443
|
+
.team-status-open { background: var(--accent-soft); color: var(--text); }
|
|
1444
|
+
.team-status-closed { background: var(--surface-3); color: var(--muted); }
|
|
1445
|
+
.team-issue-h { margin: 0 0 4px; font-size: var(--fs-lg, 1.2rem); overflow-wrap: anywhere; }
|
|
1446
|
+
.team-md { overflow-wrap: anywhere; min-width: 0; }
|
|
1447
|
+
.team-md pre { overflow-x: auto; max-width: 100%; }
|
|
1448
|
+
.team-body { padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
|
|
1449
|
+
.mention { font-weight: 600; color: var(--accent); }
|
|
1450
|
+
.team-timeline { list-style: none; margin: 0 0 12px; padding: 0; display: grid; gap: 8px; }
|
|
1451
|
+
.team-comment { padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); min-width: 0; }
|
|
1452
|
+
.team-comment-head { margin-bottom: 4px; overflow-wrap: anywhere; }
|
|
1453
|
+
.team-event { display: flex; gap: 6px; align-items: flex-start; overflow-wrap: anywhere; min-width: 0; }
|
|
1454
|
+
.team-event .icon { flex: none; width: 13px; height: 13px; margin-top: 3px; }
|
|
1455
|
+
.team-response-pick, .team-template-pick, .team-save-view { display: flex; flex-wrap: wrap; gap: 6px; align-items: end; margin-bottom: 8px; }
|
|
1456
|
+
.team-response-pick .field, .team-template-pick .field { margin: 0; min-width: 0; flex: 1 1 200px; }
|
|
1457
|
+
.team-save-view input { flex: 1 1 140px; min-width: 0; }
|
|
1458
|
+
.team-edit { margin-top: 16px; }
|
|
1459
|
+
.team-edit > summary { list-style: none; }
|
|
1460
|
+
.team-edit[open] > summary { margin-bottom: 10px; }
|
|
1461
|
+
.team-fieldset { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 12px; margin: 8px 0; min-width: 0; }
|
|
1462
|
+
.team-check-group { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; margin-bottom: 4px; }
|
|
1463
|
+
.team-check-group > .small { flex-basis: 100%; }
|
|
1464
|
+
.team-label-list, .team-template-list, .team-milestones, .team-views, .team-mentions { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
|
1465
|
+
.team-label-list li, .team-template-list li, .team-views li, .team-mentions li { overflow-wrap: anywhere; min-width: 0; }
|
|
1466
|
+
.team-views li { display: flex; gap: 6px; justify-content: space-between; align-items: center; }
|
|
1467
|
+
.team-views a[aria-current] { font-weight: 700; }
|
|
1468
|
+
.team-inline-edit { margin-top: 4px; }
|
|
1469
|
+
.team-inline-edit[open] { padding: 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
|
1470
|
+
.team-milestone { padding: 8px 0; border-bottom: 1px solid var(--border); }
|
|
1471
|
+
.team-milestone-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; overflow-wrap: anywhere; }
|
|
1472
|
+
.team-milestone-head .icon { width: 14px; height: 14px; margin-right: 4px; }
|
|
1473
|
+
.team-progress { width: 100%; max-width: 360px; height: 8px; }
|
|
1474
|
+
.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; }
|
|
1475
|
+
.team-page-issues .icon { flex: none; width: 14px; height: 14px; margin-top: 2px; }
|
|
1476
|
+
.wh-team-n { margin-left: auto; padding-left: 8px; }
|
|
1477
|
+
.feedback-issue { margin: 4px 0; }
|
package/public/builder.js
CHANGED
|
@@ -1282,3 +1282,50 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
|
|
|
1282
1282
|
});
|
|
1283
1283
|
for (const b of form.querySelectorAll('[data-cron-nojs]')) b.hidden = true;
|
|
1284
1284
|
})();
|
|
1285
|
+
|
|
1286
|
+
// (123) Workflow diagram as PNG: the server's SVG file drawn on a canvas. Without JavaScript the
|
|
1287
|
+
// button stays hidden and the SVG link is the download.
|
|
1288
|
+
(() => {
|
|
1289
|
+
for (const button of document.querySelectorAll('[data-wf-png]')) {
|
|
1290
|
+
const link = button.parentElement?.querySelector('[data-wf-svg]');
|
|
1291
|
+
if (!link || typeof HTMLCanvasElement === 'undefined') continue;
|
|
1292
|
+
button.hidden = false;
|
|
1293
|
+
button.addEventListener('click', async () => {
|
|
1294
|
+
button.disabled = true;
|
|
1295
|
+
try {
|
|
1296
|
+
const res = await fetch(link.href, { credentials: 'same-origin' });
|
|
1297
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1298
|
+
const svg = await res.text();
|
|
1299
|
+
const img = new Image();
|
|
1300
|
+
await new Promise((ok, fail) => {
|
|
1301
|
+
img.onload = ok;
|
|
1302
|
+
img.onerror = () => fail(new Error('The diagram could not be drawn.'));
|
|
1303
|
+
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
1304
|
+
});
|
|
1305
|
+
const scale = 2;
|
|
1306
|
+
const canvas = document.createElement('canvas');
|
|
1307
|
+
canvas.width = Math.max(1, Math.round((img.naturalWidth || 800) * scale));
|
|
1308
|
+
canvas.height = Math.max(1, Math.round((img.naturalHeight || 600) * scale));
|
|
1309
|
+
const g = canvas.getContext('2d');
|
|
1310
|
+
g.fillStyle = '#ffffff';
|
|
1311
|
+
g.fillRect(0, 0, canvas.width, canvas.height);
|
|
1312
|
+
g.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
1313
|
+
const blob = await new Promise((ok) => canvas.toBlob(ok, 'image/png'));
|
|
1314
|
+
if (!blob) throw new Error('The browser could not make a PNG.');
|
|
1315
|
+
const a = document.createElement('a');
|
|
1316
|
+
a.href = URL.createObjectURL(blob);
|
|
1317
|
+
a.download = 'workflow.png';
|
|
1318
|
+
const name = /([^/?]+)\.svg/.exec(res.headers.get('content-disposition') || '');
|
|
1319
|
+
if (name) a.download = `${name[1]}.png`;
|
|
1320
|
+
document.body.append(a);
|
|
1321
|
+
a.click();
|
|
1322
|
+
a.remove();
|
|
1323
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
|
|
1324
|
+
} catch (e) {
|
|
1325
|
+
window.alert(e.message);
|
|
1326
|
+
} finally {
|
|
1327
|
+
button.disabled = false;
|
|
1328
|
+
}
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
})();
|
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 {
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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);
|