@nxisai/agent-harness 0.6.0 → 0.7.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/README.md +16 -3
- package/dist/handoff/tools.d.ts.map +1 -1
- package/dist/handoff/tools.js +11 -4
- package/dist/handoff/tools.js.map +1 -1
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +26 -8
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/renderer.d.ts +20 -0
- package/dist/harness/renderer.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.d.ts +29 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +64 -4
- package/dist/harness/tools/action-tools.js.map +1 -1
- package/dist/harness/tools/site-actions.d.ts +27 -0
- package/dist/harness/tools/site-actions.d.ts.map +1 -1
- package/dist/harness/tools/site-actions.js +47 -0
- package/dist/harness/tools/site-actions.js.map +1 -1
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +5 -2
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/ui/action-card.d.ts +141 -0
- package/dist/ui/action-card.d.ts.map +1 -0
- package/dist/ui/action-card.js +384 -0
- package/dist/ui/action-card.js.map +1 -0
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +531 -16
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/tailwind.generated.d.ts +1 -1
- package/dist/ui/tailwind.generated.d.ts.map +1 -1
- package/dist/ui/tailwind.generated.js +1 -1
- package/dist/ui/tailwind.generated.js.map +1 -1
- package/package.json +1 -1
- package/src/handoff/tools.ts +11 -7
- package/src/harness/harness.ts +31 -15
- package/src/harness/renderer.ts +22 -0
- package/src/harness/tools/action-tools.ts +74 -4
- package/src/harness/tools/site-actions.ts +58 -0
- package/src/harness/tools/source.ts +5 -2
- package/src/index.ts +1 -1
- package/src/ui/action-card.ts +496 -0
- package/src/ui/agent-fullscreen.ts +563 -15
- package/src/ui/tailwind.generated.ts +1 -1
|
@@ -34,12 +34,15 @@ import { AgentHarness, isAbort } from '../harness/harness.js';
|
|
|
34
34
|
import { WebMcpTools } from '../harness/tools/webmcp.js';
|
|
35
35
|
import { extractResult, proseAnswer, missingRequired, toolSchema, } from '../harness/tools/schema.js';
|
|
36
36
|
import { connectTools } from '../harness/tools/source.js';
|
|
37
|
-
import { handoffFor, isSiteTool, withoutSiteSuffix, HANDOFF_RESULT, } from '../harness/tools/action-tools.js';
|
|
37
|
+
import { chooserOf, formOptions, handoffFor, isSiteTool, withoutSiteSuffix, HANDOFF_RESULT, } from '../harness/tools/action-tools.js';
|
|
38
|
+
import { resolveSlots } from '../harness/slots.js';
|
|
39
|
+
import { isHandoffTool } from '../handoff/tools.js';
|
|
40
|
+
import { actionCard, fieldLabel, fieldsFromSlots, ACTION_CARD_CSS, } from './action-card.js';
|
|
38
41
|
import { resumePendingAction } from '../harness/tools/resume.js';
|
|
39
42
|
import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
|
|
40
43
|
import { pageForEntity } from '../harness/tools/site-pages.js';
|
|
41
44
|
import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
|
|
42
|
-
import { onJourneyChange, recordStep, restoreJourney } from '../harness/steps.js';
|
|
45
|
+
import { currentJourney, onJourneyChange, recordStep, restoreJourney } from '../harness/steps.js';
|
|
43
46
|
import { viewJourney } from './journey.js';
|
|
44
47
|
import { AgentCoordinator } from '../harness/router/coordinator.js';
|
|
45
48
|
import { HandoffController, isHandoffOver, } from '../handoff/controller.js';
|
|
@@ -78,6 +81,11 @@ const fmt = (n) => n.toLocaleString();
|
|
|
78
81
|
* be disabled under `prefers-reduced-motion` while the bubble still reads as
|
|
79
82
|
* pending. The label and byte spans are filled per phase and hidden while empty.
|
|
80
83
|
*/
|
|
84
|
+
/**
|
|
85
|
+
* How long the running-step line stays once the conversation moves on past
|
|
86
|
+
* its card. A glance's worth: long enough to notice and open it.
|
|
87
|
+
*/
|
|
88
|
+
const JSTATUS_TIMEOUT_MS = 10000;
|
|
81
89
|
const THINKING_MARKUP = '<span class="think-row">' +
|
|
82
90
|
'<span class="think-dots" aria-hidden="true"><i></i><i></i><i></i></span>' +
|
|
83
91
|
'<span class="think-label"></span>' +
|
|
@@ -245,6 +253,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
245
253
|
};
|
|
246
254
|
/** Watches whether the running journey's card is on screen. */
|
|
247
255
|
this._journeyObserver = null;
|
|
256
|
+
/** The journey the status line is about, while one is running. */
|
|
257
|
+
this._jstatusJourney = null;
|
|
258
|
+
/** Journeys whose status line was opened once or timed out: it does not come back. */
|
|
259
|
+
this._jstatusDismissed = new Set();
|
|
260
|
+
/** Dismisses the status line a while after the thread moves on past the card. */
|
|
261
|
+
this._jstatusTimer = null;
|
|
248
262
|
/** Built only when the host defines agents — otherwise there is one harness. */
|
|
249
263
|
this._coordinator = null;
|
|
250
264
|
this._agentDefs = null;
|
|
@@ -252,6 +266,14 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
252
266
|
this._fallbackAgent = '';
|
|
253
267
|
/** The live ask/confirm surface, if one is open. */
|
|
254
268
|
this._prompt = null;
|
|
269
|
+
/**
|
|
270
|
+
* The action card still waiting on the visitor or a result, and what it can
|
|
271
|
+
* do: the harness's ask or confirm request, or, after a page change, a call
|
|
272
|
+
* of the widget's own.
|
|
273
|
+
*/
|
|
274
|
+
this._live = null;
|
|
275
|
+
/** Handoff targets as the relay last reported them, for a card's availability line. */
|
|
276
|
+
this._handoffTargets = new Map();
|
|
255
277
|
this._persistPrefs = false;
|
|
256
278
|
this._systemPrompt = DEFAULT_SYSTEM_PROMPT;
|
|
257
279
|
this._suggestions = DEFAULT_SUGGESTIONS;
|
|
@@ -349,13 +371,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
349
371
|
this._buildAgents();
|
|
350
372
|
// The element renders; the harness decides. Everything from choosing a
|
|
351
373
|
// tool to running it happens behind harness.turn().
|
|
352
|
-
this._harness =
|
|
353
|
-
provider: this.provider,
|
|
354
|
-
toolSource: this.toolSource,
|
|
355
|
-
renderer: this._renderer(),
|
|
356
|
-
profile: (schema) => this._profileForSchema(schema),
|
|
357
|
-
onMessages: () => this._saveConversations(),
|
|
358
|
-
});
|
|
374
|
+
this._harness = this._singleHarness();
|
|
359
375
|
// Preferences are supplied by the host page through `element.preferences`.
|
|
360
376
|
// Anything a previous build persisted is picked up so upgrading does not
|
|
361
377
|
// silently drop a user's saved details; `persist` then stays true so they
|
|
@@ -387,6 +403,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
387
403
|
this._els.jstatus.addEventListener('click', () => {
|
|
388
404
|
const cards = this._els.thread.querySelectorAll('.journey.is-running');
|
|
389
405
|
cards[cards.length - 1]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
406
|
+
// One look is what it is for; after that it would only be in the way.
|
|
407
|
+
this._dismissJourneyStatus();
|
|
390
408
|
});
|
|
391
409
|
// Whoever builds the tools, a call that leaves this page reports back to
|
|
392
410
|
// the thread it was made in.
|
|
@@ -482,6 +500,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
482
500
|
this._offJourney = null;
|
|
483
501
|
this._journeyObserver?.disconnect();
|
|
484
502
|
this._journeyObserver = null;
|
|
503
|
+
this._clearJourneyStatusTimer();
|
|
485
504
|
// Streams stop with the element; the handoff itself stays saved, and resumes when it returns.
|
|
486
505
|
this._handoff?.dispose();
|
|
487
506
|
}
|
|
@@ -667,8 +686,13 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
667
686
|
this._els.thread.appendChild(this._resultsEl(it));
|
|
668
687
|
else if (it.kind === 'metrics')
|
|
669
688
|
this._els.thread.appendChild(this._metricsEl(it));
|
|
670
|
-
else if (it.kind === 'journey')
|
|
671
|
-
|
|
689
|
+
else if (it.kind === 'journey') {
|
|
690
|
+
// A request drawn as an action card shows its own steps.
|
|
691
|
+
if (!conv.items.some((a) => a.kind === 'action' && a.id === it.id))
|
|
692
|
+
this._els.thread.appendChild(this._journeyEl(it));
|
|
693
|
+
}
|
|
694
|
+
else if (it.kind === 'action')
|
|
695
|
+
this._paintAction(it);
|
|
672
696
|
else if (it.kind === 'remote' || it.kind === 'handoff')
|
|
673
697
|
this._paintHandoffItem(it);
|
|
674
698
|
}
|
|
@@ -1038,6 +1062,14 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1038
1062
|
item = { kind: 'journey', id: view.id, steps: all };
|
|
1039
1063
|
conv.items.push(item);
|
|
1040
1064
|
}
|
|
1065
|
+
// A request with an action card: the steps belong in the card.
|
|
1066
|
+
const action = conv.items.find((it) => it.kind === 'action' && it.id === view.id);
|
|
1067
|
+
if (action) {
|
|
1068
|
+
this._actionFollows(action, all);
|
|
1069
|
+
this._watchRunningJourney();
|
|
1070
|
+
this._saveConversations();
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1041
1073
|
if (conv.id === this._activeId && this._els?.thread) {
|
|
1042
1074
|
const fresh = this._journeyEl(item);
|
|
1043
1075
|
const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
|
|
@@ -1071,7 +1103,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1071
1103
|
return;
|
|
1072
1104
|
const cards = this._els.thread.querySelectorAll('.journey.is-running');
|
|
1073
1105
|
const card = cards[cards.length - 1];
|
|
1074
|
-
|
|
1106
|
+
const id = card?.dataset.journey ?? null;
|
|
1107
|
+
if (id !== this._jstatusJourney) {
|
|
1108
|
+
this._clearJourneyStatusTimer();
|
|
1109
|
+
this._jstatusJourney = id;
|
|
1110
|
+
}
|
|
1111
|
+
if (!card || !id || this._jstatusDismissed.has(id) || typeof IntersectionObserver === 'undefined') {
|
|
1075
1112
|
bar.hidden = true;
|
|
1076
1113
|
return;
|
|
1077
1114
|
}
|
|
@@ -1081,6 +1118,37 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1081
1118
|
}, { root: this._els.thread, threshold: 0.2 });
|
|
1082
1119
|
this._journeyObserver.observe(card);
|
|
1083
1120
|
}
|
|
1121
|
+
/**
|
|
1122
|
+
* The thread grew: start the clock on the status line.
|
|
1123
|
+
*
|
|
1124
|
+
* A journey can stay running while the conversation goes on without it, and a
|
|
1125
|
+
* line that spins through all of that says nothing. So once new messages
|
|
1126
|
+
* arrive, the line gets a while longer, then goes.
|
|
1127
|
+
*/
|
|
1128
|
+
_noteThreadGrew() {
|
|
1129
|
+
const id = this._jstatusJourney;
|
|
1130
|
+
if (!id || this._jstatusTimer || this._jstatusDismissed.has(id))
|
|
1131
|
+
return;
|
|
1132
|
+
this._jstatusTimer = setTimeout(() => {
|
|
1133
|
+
this._jstatusTimer = null;
|
|
1134
|
+
this._dismissJourneyStatus();
|
|
1135
|
+
}, JSTATUS_TIMEOUT_MS);
|
|
1136
|
+
}
|
|
1137
|
+
/** Hide the status line for the running journey, for good. */
|
|
1138
|
+
_dismissJourneyStatus() {
|
|
1139
|
+
this._clearJourneyStatusTimer();
|
|
1140
|
+
if (this._jstatusJourney)
|
|
1141
|
+
this._jstatusDismissed.add(this._jstatusJourney);
|
|
1142
|
+
this._journeyObserver?.disconnect();
|
|
1143
|
+
this._journeyObserver = null;
|
|
1144
|
+
if (this._els?.jstatus)
|
|
1145
|
+
this._els.jstatus.hidden = true;
|
|
1146
|
+
}
|
|
1147
|
+
_clearJourneyStatusTimer() {
|
|
1148
|
+
if (this._jstatusTimer)
|
|
1149
|
+
clearTimeout(this._jstatusTimer);
|
|
1150
|
+
this._jstatusTimer = null;
|
|
1151
|
+
}
|
|
1084
1152
|
_paintToolResult(name, text, isError) {
|
|
1085
1153
|
this._clearEmpty();
|
|
1086
1154
|
const el = this._toolEl(name, text, isError);
|
|
@@ -1137,6 +1205,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1137
1205
|
const el = this._paintMsg(role, text, conv.items.length - 1);
|
|
1138
1206
|
this._saveConversations();
|
|
1139
1207
|
this._scroll();
|
|
1208
|
+
this._noteThreadGrew();
|
|
1140
1209
|
return el;
|
|
1141
1210
|
}
|
|
1142
1211
|
/**
|
|
@@ -1156,6 +1225,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1156
1225
|
// without the focus moving to it.
|
|
1157
1226
|
el.setAttribute('aria-live', 'polite');
|
|
1158
1227
|
this._scroll();
|
|
1228
|
+
this._noteThreadGrew();
|
|
1159
1229
|
const setLabel = (label) => {
|
|
1160
1230
|
const node = el.querySelector('.think-label');
|
|
1161
1231
|
if (node)
|
|
@@ -1298,8 +1368,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1298
1368
|
message: (role, text) => this._appendMsg(role, text),
|
|
1299
1369
|
streaming: () => this._appendStreaming(),
|
|
1300
1370
|
status: (text, isError) => this._setStatus(text, isError),
|
|
1301
|
-
|
|
1302
|
-
|
|
1371
|
+
inlinePrompts: !this._classicPrompts,
|
|
1372
|
+
ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
|
|
1373
|
+
confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
|
|
1374
|
+
left: (tool, destination) => this._cardLeft(tool, destination),
|
|
1303
1375
|
results: (tool, res) => {
|
|
1304
1376
|
const conv = this._active();
|
|
1305
1377
|
const item = this._buildResultsItem(tool, res);
|
|
@@ -1321,6 +1393,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1321
1393
|
this._scroll();
|
|
1322
1394
|
},
|
|
1323
1395
|
detail: (tool, values, text) => {
|
|
1396
|
+
if (this._cardDone(tool, values, text))
|
|
1397
|
+
return;
|
|
1324
1398
|
const conv = this._active();
|
|
1325
1399
|
const item = this._buildDetailItem(tool, values, text);
|
|
1326
1400
|
conv?.items.push(item);
|
|
@@ -1345,8 +1419,24 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1345
1419
|
this._saveConversations();
|
|
1346
1420
|
this._scroll();
|
|
1347
1421
|
},
|
|
1348
|
-
toolError: (tool, message) =>
|
|
1349
|
-
|
|
1422
|
+
toolError: (tool, message) => {
|
|
1423
|
+
if (this._cardFailed(tool, message))
|
|
1424
|
+
return;
|
|
1425
|
+
this._paintToolResult(tool.name, message, true);
|
|
1426
|
+
},
|
|
1427
|
+
dismissPrompt: () => {
|
|
1428
|
+
// A sent card stays, and shows the steps; one never sent says so.
|
|
1429
|
+
const live = this._live;
|
|
1430
|
+
if (live) {
|
|
1431
|
+
if (live.sent && (live.item.stage === 'review' || live.item.stage === 'collect')) {
|
|
1432
|
+
live.item.stage = 'working';
|
|
1433
|
+
this._paintAction(live.item);
|
|
1434
|
+
this._saveConversations();
|
|
1435
|
+
}
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
this._dismissPrompt();
|
|
1439
|
+
},
|
|
1350
1440
|
// The harness says when stopping stops being possible, because it is the
|
|
1351
1441
|
// part that knows a tool has been invoked. Once a call is out, offering
|
|
1352
1442
|
// to stop would be offering to un-send a booking.
|
|
@@ -1478,6 +1568,378 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1478
1568
|
this._formEl = wrap;
|
|
1479
1569
|
this._scroll();
|
|
1480
1570
|
}
|
|
1571
|
+
// ── Action cards ─────────────────────────────────────────────────────────────
|
|
1572
|
+
/** `prompt-style="classic"`: the separate ask, review and submitted cards of earlier versions. */
|
|
1573
|
+
get _classicPrompts() {
|
|
1574
|
+
return this.getAttribute('prompt-style') === 'classic';
|
|
1575
|
+
}
|
|
1576
|
+
/** Whether a prompt for this tool is drawn as an action card: every write and handoff. */
|
|
1577
|
+
_usesCard(tool) {
|
|
1578
|
+
return !this._classicPrompts && (isHandoffTool(tool.name) || !tool.annotations?.readOnlyHint);
|
|
1579
|
+
}
|
|
1580
|
+
/** Details are missing: the card asks for them, beside what it already has. */
|
|
1581
|
+
_cardAsk(req) {
|
|
1582
|
+
const needed = new Set(req.plan.steps.flatMap((st) => st.slots.map((sl) => sl.name)));
|
|
1583
|
+
const item = this._liveCard(req.tool, req.resolution.all, req.resolution.values, needed);
|
|
1584
|
+
this._live.ask = req;
|
|
1585
|
+
this._live.confirm = undefined;
|
|
1586
|
+
item.stage = 'collect';
|
|
1587
|
+
this._paintAction(item);
|
|
1588
|
+
this._saveConversations();
|
|
1589
|
+
}
|
|
1590
|
+
/** Everything is known: the card says what will happen, and waits for Send. */
|
|
1591
|
+
_cardConfirm(req) {
|
|
1592
|
+
const all = resolveSlots(toolSchema(req.tool), { modelArgs: req.values }).all;
|
|
1593
|
+
const item = this._liveCard(req.tool, all, req.values, new Set(), req.destination);
|
|
1594
|
+
this._live.confirm = req;
|
|
1595
|
+
item.stage = 'review';
|
|
1596
|
+
this._paintAction(item);
|
|
1597
|
+
this._saveConversations();
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* The live card for a tool's prompt: the one already open for this request,
|
|
1601
|
+
* or a new one, which takes over from the journey card of the same request.
|
|
1602
|
+
*/
|
|
1603
|
+
_liveCard(tool, slots, values, needed, destination) {
|
|
1604
|
+
const conv = this._active();
|
|
1605
|
+
const id = currentJourney() ?? `a${Date.now().toString(36)}`;
|
|
1606
|
+
const live = this._live;
|
|
1607
|
+
const described = this._describeAction(tool, values, destination);
|
|
1608
|
+
const fields = fieldsFromSlots(this._narrowed(tool, slots, values), values, { needed, hidden: described.hidden, labels: described.labels });
|
|
1609
|
+
if (live && live.item.id === id && live.tool.name === tool.name) {
|
|
1610
|
+
// The same request, a step on: keep what was typed, and anything the harness has since learned.
|
|
1611
|
+
live.item.fields = fields.map((f) => {
|
|
1612
|
+
const had = live.item.fields.find((o) => o.name === f.name);
|
|
1613
|
+
return had && f.value === '' ? { ...f, value: had.value } : f;
|
|
1614
|
+
});
|
|
1615
|
+
Object.assign(live.item, described.view);
|
|
1616
|
+
live.tool = tool;
|
|
1617
|
+
return live.item;
|
|
1618
|
+
}
|
|
1619
|
+
// A request left waiting is replaced by the new one.
|
|
1620
|
+
if (live && (live.item.stage === 'collect' || live.item.stage === 'review')) {
|
|
1621
|
+
live.item.stage = 'declined';
|
|
1622
|
+
this._paintAction(live.item);
|
|
1623
|
+
}
|
|
1624
|
+
const item = { kind: 'action', id, tool: tool.name, fields, stage: 'collect', ...described.view };
|
|
1625
|
+
conv.items.push(item);
|
|
1626
|
+
this._live = { item, tool, sent: false, declining: false };
|
|
1627
|
+
this._els?.thread?.querySelector(`[data-journey="${CSS.escape(id)}"]`)?.remove();
|
|
1628
|
+
this._watchRunningJourney();
|
|
1629
|
+
if (isHandoffTool(tool.name))
|
|
1630
|
+
void this._refreshHandoffTargets(item);
|
|
1631
|
+
return item;
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* The slots with each field's options cut to the form the call means: one
|
|
1635
|
+
* business's services rather than every business's.
|
|
1636
|
+
*/
|
|
1637
|
+
_narrowed(tool, slots, values) {
|
|
1638
|
+
const options = formOptions(tool, values);
|
|
1639
|
+
return slots.map((slot) => {
|
|
1640
|
+
const only = options[slot.name];
|
|
1641
|
+
if (!only)
|
|
1642
|
+
return slot;
|
|
1643
|
+
// Keep a value the call already gave, even one the form does not list: the form decides.
|
|
1644
|
+
const current = values[slot.name];
|
|
1645
|
+
const list = current !== undefined && current !== '' && !only.includes(String(current)) ? [String(current), ...only] : only;
|
|
1646
|
+
return { ...slot, schema: { ...slot.schema, enum: list } };
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
/** Who a request goes to, and how the card words it. */
|
|
1650
|
+
_describeAction(tool, values, destination) {
|
|
1651
|
+
const email = String(values.email ?? Object.entries(values).find(([k]) => /e-?mail/i.test(k))?.[1] ?? '').trim();
|
|
1652
|
+
if (isHandoffTool(tool.name)) {
|
|
1653
|
+
const name = String(values.target ?? '');
|
|
1654
|
+
const config = this._handoff?.config?.targets[name];
|
|
1655
|
+
const known = this._handoffTargets.get(name);
|
|
1656
|
+
const label = known?.label ?? config?.label ?? fieldLabel(name || 'the team');
|
|
1657
|
+
const person = (known?.kind ?? config?.kind) !== 'agent';
|
|
1658
|
+
const away = known ? !known.available : false;
|
|
1659
|
+
// "The directory team" leads a line, but mid-sentence it is "the directory team".
|
|
1660
|
+
const inline = label.replace(/^The\s/, 'the ');
|
|
1661
|
+
const about = known?.description ?? config?.description;
|
|
1662
|
+
const status = !known
|
|
1663
|
+
? undefined
|
|
1664
|
+
: away
|
|
1665
|
+
? `Away now${known.hours ? ` · open ${known.hours}` : ''}`
|
|
1666
|
+
: person
|
|
1667
|
+
? known.waitMinutes
|
|
1668
|
+
? `Available · replies in ~${known.waitMinutes} min`
|
|
1669
|
+
: 'Available'
|
|
1670
|
+
: 'Available';
|
|
1671
|
+
const sentence = !person
|
|
1672
|
+
? `I'll hand this conversation to ${inline}. They'll take it from here.`
|
|
1673
|
+
: away
|
|
1674
|
+
? email
|
|
1675
|
+
? `${label} is away right now. They'll reply to ${email}.`
|
|
1676
|
+
: `${label} is away right now. Leave an email and they'll reply there.`
|
|
1677
|
+
: `I'll pass this to ${inline} so you don't have to repeat yourself.`;
|
|
1678
|
+
return {
|
|
1679
|
+
view: {
|
|
1680
|
+
variant: person ? 'handoff-person' : 'handoff-agent',
|
|
1681
|
+
noun: person ? 'handoff' : 'handover',
|
|
1682
|
+
identity: { name: label, sub: about ? about.charAt(0).toUpperCase() + about.slice(1) : undefined, status, tone: away ? 'away' : 'ok' },
|
|
1683
|
+
sentence,
|
|
1684
|
+
note: undefined,
|
|
1685
|
+
sendLabel: !person ? 'Hand over' : away ? `Send to ${inline}` : 'Connect me',
|
|
1686
|
+
},
|
|
1687
|
+
hidden: new Set(['target']),
|
|
1688
|
+
labels: { reason: 'Why', summary: 'What they need to know', email: 'Where should they reply?' },
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
const noun = fieldLabel(withoutSiteSuffix(tool.name)).toLowerCase();
|
|
1692
|
+
// Which business or place it is for: the site tool's own chooser, else a field the form names it in.
|
|
1693
|
+
const chooser = chooserOf(tool) ?? Object.keys(values).find((k) => /^(business|company|organi[sz]ation|provider|vendor|store|restaurant|practice|venue)(_name)?$/i.test(k)) ?? null;
|
|
1694
|
+
const chosen = chooser ? String(values[chooser] ?? '').trim() : '';
|
|
1695
|
+
const leaving = destination ?? handoffFor(tool, values)?.label;
|
|
1696
|
+
const name = chosen || leaving || this._pageName();
|
|
1697
|
+
// What the conversation already showed about them: their card from a search.
|
|
1698
|
+
const card = this._conversations
|
|
1699
|
+
.find((c) => c.id === this._activeId)
|
|
1700
|
+
?.items.flatMap((it) => (it.kind === 'results' ? it.cards : []))
|
|
1701
|
+
.find((c) => c.title.toLowerCase() === name.toLowerCase());
|
|
1702
|
+
const sub = [fieldLabel(noun), card?.type && card.type !== 'Thing' ? fieldLabel(card.type) : '', card?.rating ? `★ ${card.rating}` : '']
|
|
1703
|
+
.filter(Boolean)
|
|
1704
|
+
.join(' · ');
|
|
1705
|
+
return {
|
|
1706
|
+
view: {
|
|
1707
|
+
variant: 'form',
|
|
1708
|
+
noun,
|
|
1709
|
+
identity: { name, sub },
|
|
1710
|
+
sentence: `I'll send this ${noun} to ${name}.${email ? ` They'll reply to ${email}.` : ''}`,
|
|
1711
|
+
note: leaving ? 'Their form is on its own page. I’ll open it and send it from there.' : undefined,
|
|
1712
|
+
sendLabel: 'Send',
|
|
1713
|
+
},
|
|
1714
|
+
hidden: new Set(chooser && chooser !== 'target' && chosen ? [chooser] : chooser === 'target' ? ['target'] : []),
|
|
1715
|
+
labels: {},
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
/** The site's name for this page: its title, without the site's own name after it. */
|
|
1719
|
+
_pageName() {
|
|
1720
|
+
const title = (typeof document === 'undefined' ? '' : document.title).split(/\s+[|—–·-]\s+/)[0].trim();
|
|
1721
|
+
return title || 'this page';
|
|
1722
|
+
}
|
|
1723
|
+
/** Read availability for a handoff card, and redraw it once known. */
|
|
1724
|
+
async _refreshHandoffTargets(item) {
|
|
1725
|
+
const targets = await this._handoff?.targets().catch(() => []);
|
|
1726
|
+
for (const t of targets ?? [])
|
|
1727
|
+
this._handoffTargets.set(t.name, t);
|
|
1728
|
+
if (item.stage !== 'collect' && item.stage !== 'review')
|
|
1729
|
+
return;
|
|
1730
|
+
const values = Object.fromEntries(item.fields.map((f) => [f.name, f.value]));
|
|
1731
|
+
Object.assign(item, this._describeAction({ name: item.tool }, values).view);
|
|
1732
|
+
this._paintAction(item);
|
|
1733
|
+
}
|
|
1734
|
+
/** What the live card's buttons do. */
|
|
1735
|
+
_actionHandlers() {
|
|
1736
|
+
return {
|
|
1737
|
+
continue: (values) => {
|
|
1738
|
+
const live = this._live;
|
|
1739
|
+
if (!live)
|
|
1740
|
+
return;
|
|
1741
|
+
if (live.ask && !live.confirm) {
|
|
1742
|
+
live.ask.onComplete(values);
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
// Back from a change, or a card of the widget's own: review again, here.
|
|
1746
|
+
Object.assign(live.item, this._describeAction(live.tool, values, live.confirm?.destination).view);
|
|
1747
|
+
live.item.stage = 'review';
|
|
1748
|
+
this._paintAction(live.item);
|
|
1749
|
+
this._saveConversations();
|
|
1750
|
+
},
|
|
1751
|
+
change: () => {
|
|
1752
|
+
const live = this._live;
|
|
1753
|
+
if (!live)
|
|
1754
|
+
return;
|
|
1755
|
+
live.item.fields.forEach((f) => {
|
|
1756
|
+
if (f.group === 'needed' && f.value.trim() !== '')
|
|
1757
|
+
f.group = 'known';
|
|
1758
|
+
});
|
|
1759
|
+
live.sent = false;
|
|
1760
|
+
live.item.stage = 'collect';
|
|
1761
|
+
live.item.open = true;
|
|
1762
|
+
this._paintAction(live.item);
|
|
1763
|
+
this._saveConversations();
|
|
1764
|
+
},
|
|
1765
|
+
send: (values) => {
|
|
1766
|
+
const live = this._live;
|
|
1767
|
+
if (!live)
|
|
1768
|
+
return;
|
|
1769
|
+
live.sent = true;
|
|
1770
|
+
if (live.confirm)
|
|
1771
|
+
live.confirm.onSubmit(values);
|
|
1772
|
+
else if (live.run)
|
|
1773
|
+
void live.run(values);
|
|
1774
|
+
},
|
|
1775
|
+
decline: () => {
|
|
1776
|
+
const live = this._live;
|
|
1777
|
+
if (!live)
|
|
1778
|
+
return;
|
|
1779
|
+
live.declining = true;
|
|
1780
|
+
if (live.confirm)
|
|
1781
|
+
live.confirm.onCancel();
|
|
1782
|
+
else if (live.ask)
|
|
1783
|
+
live.ask.onCancel();
|
|
1784
|
+
// A card the harness is not waiting on declines itself.
|
|
1785
|
+
if (this._live === live) {
|
|
1786
|
+
live.item.stage = 'declined';
|
|
1787
|
+
this._paintAction(live.item);
|
|
1788
|
+
this._live = null;
|
|
1789
|
+
this._saveConversations();
|
|
1790
|
+
}
|
|
1791
|
+
},
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
/** Draw a card where it was, or at the end of the thread. */
|
|
1795
|
+
_paintAction(item) {
|
|
1796
|
+
if (!this._els?.thread)
|
|
1797
|
+
return;
|
|
1798
|
+
const conv = this._active();
|
|
1799
|
+
if (!conv?.items.includes(item))
|
|
1800
|
+
return;
|
|
1801
|
+
const live = this._live?.item === item;
|
|
1802
|
+
const fresh = actionCard(item, live ? this._actionHandlers() : null, () => this._saveConversations());
|
|
1803
|
+
const old = this._els.thread.querySelector(`[data-action="${CSS.escape(item.id)}"]`);
|
|
1804
|
+
if (old)
|
|
1805
|
+
old.replaceWith(fresh);
|
|
1806
|
+
else {
|
|
1807
|
+
this._clearEmpty();
|
|
1808
|
+
this._els.thread.appendChild(fresh);
|
|
1809
|
+
this._scroll();
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
/** A request's steps, as its card shows them once sent. */
|
|
1813
|
+
_actionFollows(item, all) {
|
|
1814
|
+
const live = this._live?.item === item ? this._live : null;
|
|
1815
|
+
const failed = [...all].reverse().find((st) => st.status === 'failed');
|
|
1816
|
+
const done = all.some((st) => st.id === 'submit' && st.status === 'done');
|
|
1817
|
+
const running = ['navigate', 'arrive', 'collect', 'fill', 'submit'];
|
|
1818
|
+
item.steps = all
|
|
1819
|
+
.filter((st) => running.includes(st.id))
|
|
1820
|
+
.map((st) => ({
|
|
1821
|
+
label: st.id === 'fill' && st.status === 'active' && item.variant === 'form'
|
|
1822
|
+
? `Filling in the ${item.noun}`
|
|
1823
|
+
: st.id === 'submit' && st.status === 'done' && item.variant === 'form'
|
|
1824
|
+
? 'Sent'
|
|
1825
|
+
: st.label,
|
|
1826
|
+
status: st.status === 'failed' ? 'failed' : st.status === 'done' ? 'done' : 'active',
|
|
1827
|
+
}));
|
|
1828
|
+
if (failed) {
|
|
1829
|
+
if (live?.declining || failed.label === 'Cancelled') {
|
|
1830
|
+
item.stage = 'declined';
|
|
1831
|
+
if (live)
|
|
1832
|
+
this._live = null;
|
|
1833
|
+
}
|
|
1834
|
+
else {
|
|
1835
|
+
item.stage = 'failed';
|
|
1836
|
+
item.message = failed.detail || failed.label;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
else if (done) {
|
|
1840
|
+
item.stage = 'done';
|
|
1841
|
+
if (live)
|
|
1842
|
+
this._live = null;
|
|
1843
|
+
}
|
|
1844
|
+
else if (item.stage === 'left' || (item.stage === 'working' && item.steps.length)) {
|
|
1845
|
+
item.stage = 'working';
|
|
1846
|
+
}
|
|
1847
|
+
this._paintAction(item);
|
|
1848
|
+
}
|
|
1849
|
+
/** The confirmed call is leaving for its page. The card says so, and the new page finishes it. */
|
|
1850
|
+
_cardLeft(tool, destination) {
|
|
1851
|
+
const live = this._live;
|
|
1852
|
+
if (!live || live.tool.name !== tool.name) {
|
|
1853
|
+
this._appendMsg('assistant', destination ? `Opening ${destination}…` : 'Opening the page that can submit this…');
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
live.item.stage = 'left';
|
|
1857
|
+
live.item.message = `Opening ${destination ?? live.item.identity.name}’s page to send it from there.`;
|
|
1858
|
+
this._live = null;
|
|
1859
|
+
this._paintAction(live.item);
|
|
1860
|
+
this._saveConversations();
|
|
1861
|
+
}
|
|
1862
|
+
/** The form took it: the card becomes the receipt. False when no card is waiting on this tool. */
|
|
1863
|
+
_cardDone(tool, values, text) {
|
|
1864
|
+
const conv = this._active();
|
|
1865
|
+
const id = currentJourney();
|
|
1866
|
+
const item = conv?.items.find((it) => it.kind === 'action' && it.id === id && it.tool === tool.name);
|
|
1867
|
+
if (!item || this._classicPrompts)
|
|
1868
|
+
return false;
|
|
1869
|
+
const note = this._buildDetailItem(tool, values, text).note;
|
|
1870
|
+
// The default note says nothing the card's "Sent" does not.
|
|
1871
|
+
item.message = /submitted successfully/.test(note) ? undefined : note;
|
|
1872
|
+
item.stage = 'done';
|
|
1873
|
+
item.fields.forEach((f) => {
|
|
1874
|
+
if (values[f.name] !== undefined)
|
|
1875
|
+
f.value = String(values[f.name]);
|
|
1876
|
+
});
|
|
1877
|
+
if (this._live?.item === item)
|
|
1878
|
+
this._live = null;
|
|
1879
|
+
this._paintAction(item);
|
|
1880
|
+
this._saveConversations();
|
|
1881
|
+
return true;
|
|
1882
|
+
}
|
|
1883
|
+
/** It did not go through: the card says why and offers to change it. False when no card is waiting. */
|
|
1884
|
+
_cardFailed(tool, message) {
|
|
1885
|
+
const live = this._live;
|
|
1886
|
+
if (!live || live.tool.name !== tool.name)
|
|
1887
|
+
return false;
|
|
1888
|
+
live.sent = false;
|
|
1889
|
+
live.item.stage = 'failed';
|
|
1890
|
+
live.item.message = message;
|
|
1891
|
+
this._paintAction(live.item);
|
|
1892
|
+
this._saveConversations();
|
|
1893
|
+
return true;
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* After a page change: the card of the request that travelled, found by its
|
|
1897
|
+
* journey, now finished or asked about here.
|
|
1898
|
+
*/
|
|
1899
|
+
_resumedCard(journeyId) {
|
|
1900
|
+
if (!journeyId || this._classicPrompts)
|
|
1901
|
+
return null;
|
|
1902
|
+
return this._active()?.items.find((it) => it.kind === 'action' && it.id === journeyId) ?? null;
|
|
1903
|
+
}
|
|
1904
|
+
/** Let a resumed card be changed and sent again from this page, with the page's own tool. */
|
|
1905
|
+
_reviveCard(item, tool, values, needed) {
|
|
1906
|
+
const slots = resolveSlots(toolSchema(tool), { modelArgs: values }).all;
|
|
1907
|
+
const hidden = new Set(item.fields.filter((f) => f.group === 'hidden').map((f) => f.name));
|
|
1908
|
+
item.fields = fieldsFromSlots(slots, values, { needed: new Set(needed), hidden });
|
|
1909
|
+
this._live = {
|
|
1910
|
+
item,
|
|
1911
|
+
tool,
|
|
1912
|
+
sent: false,
|
|
1913
|
+
declining: false,
|
|
1914
|
+
run: async (vals) => {
|
|
1915
|
+
item.stage = 'working';
|
|
1916
|
+
item.steps = [{ label: `Sending the ${item.noun}`, status: 'active' }];
|
|
1917
|
+
this._paintAction(item);
|
|
1918
|
+
try {
|
|
1919
|
+
const res = await this.toolSource.execTool(tool, tool.name, vals);
|
|
1920
|
+
const text = extractResult(res);
|
|
1921
|
+
if (res?.isError) {
|
|
1922
|
+
this._cardFailed(tool, text || 'That form did not accept the details.');
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
this._active()?.messages.push({
|
|
1926
|
+
role: 'user',
|
|
1927
|
+
content: `[${tool.name} submitted with: ${JSON.stringify(vals)}]. Keep these unless I ask to change them.`,
|
|
1928
|
+
});
|
|
1929
|
+
this._live = null;
|
|
1930
|
+
const note = this._buildDetailItem(tool, vals, text).note;
|
|
1931
|
+
item.message = /submitted successfully/.test(note) ? undefined : note;
|
|
1932
|
+
item.stage = 'done';
|
|
1933
|
+
item.steps = [];
|
|
1934
|
+
this._paintAction(item);
|
|
1935
|
+
this._saveConversations();
|
|
1936
|
+
}
|
|
1937
|
+
catch (err) {
|
|
1938
|
+
this._cardFailed(tool, String(err?.message || err));
|
|
1939
|
+
}
|
|
1940
|
+
},
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1481
1943
|
// ── Inline generated form (from JSON-Schema) ───────────────────────────────
|
|
1482
1944
|
_renderForm(tool, args) {
|
|
1483
1945
|
if (this._useA2ui) {
|
|
@@ -2367,6 +2829,16 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2367
2829
|
* `fallback`. Inexpensive: each agent is a role over the one shared provider,
|
|
2368
2830
|
* so no additional model is loaded.
|
|
2369
2831
|
*/
|
|
2832
|
+
/** The one agent a page without `agents` runs, on the current provider. */
|
|
2833
|
+
_singleHarness() {
|
|
2834
|
+
return new AgentHarness({
|
|
2835
|
+
provider: this.provider,
|
|
2836
|
+
toolSource: this.toolSource,
|
|
2837
|
+
renderer: this._renderer(),
|
|
2838
|
+
profile: (schema) => this._profileForSchema(schema),
|
|
2839
|
+
onMessages: () => this._saveConversations(),
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2370
2842
|
_buildAgents() {
|
|
2371
2843
|
if (!this.provider || !this._agentDefs) {
|
|
2372
2844
|
this._coordinator = null;
|
|
@@ -2517,6 +2989,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2517
2989
|
if (conversationId === this._activeId && this._els?.thread) {
|
|
2518
2990
|
this._paintHandoffItem(item);
|
|
2519
2991
|
this._scroll();
|
|
2992
|
+
this._noteThreadGrew();
|
|
2520
2993
|
}
|
|
2521
2994
|
}
|
|
2522
2995
|
_paintHandoffItem(item) {
|
|
@@ -2663,6 +3136,42 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2663
3136
|
});
|
|
2664
3137
|
if (outcome.status !== 'none')
|
|
2665
3138
|
this._setStatus('');
|
|
3139
|
+
// The card that travelled with the request finishes here, when there is one.
|
|
3140
|
+
const card = outcome.status === 'none' ? null : this._resumedCard(outcome.pending.steps?.find((st) => st.journey)?.journey);
|
|
3141
|
+
if (card && outcome.status !== 'none') {
|
|
3142
|
+
switch (outcome.status) {
|
|
3143
|
+
case 'no-registry':
|
|
3144
|
+
case 'no-tool':
|
|
3145
|
+
card.stage = 'failed';
|
|
3146
|
+
card.message = `I couldn't find the ${card.noun} on this page, so nothing was sent. Ask me again and I'll fill it in from here.`;
|
|
3147
|
+
break;
|
|
3148
|
+
case 'incomplete':
|
|
3149
|
+
this._reviveCard(card, outcome.tool, outcome.pending.values, outcome.missing);
|
|
3150
|
+
card.stage = 'collect';
|
|
3151
|
+
card.message = undefined;
|
|
3152
|
+
break;
|
|
3153
|
+
case 'rejected':
|
|
3154
|
+
case 'failed':
|
|
3155
|
+
this._reviveCard(card, outcome.tool, outcome.pending.values, []);
|
|
3156
|
+
card.stage = 'failed';
|
|
3157
|
+
card.message = outcome.status === 'failed' ? outcome.error : outcome.text || 'That form did not accept the details.';
|
|
3158
|
+
break;
|
|
3159
|
+
case 'submitted': {
|
|
3160
|
+
const note = this._buildDetailItem(outcome.tool, outcome.pending.values, outcome.text).note;
|
|
3161
|
+
card.message = /submitted successfully/.test(note) ? undefined : note;
|
|
3162
|
+
card.stage = 'done';
|
|
3163
|
+
this._active()?.messages.push({
|
|
3164
|
+
role: 'user',
|
|
3165
|
+
content: `[${outcome.tool.name} submitted with: ${JSON.stringify(outcome.pending.values)}]. ` +
|
|
3166
|
+
'Keep these unless I ask to change them.',
|
|
3167
|
+
});
|
|
3168
|
+
break;
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
this._paintAction(card);
|
|
3172
|
+
this._saveConversations();
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
2666
3175
|
switch (outcome.status) {
|
|
2667
3176
|
case 'none':
|
|
2668
3177
|
return;
|
|
@@ -2984,6 +3493,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2984
3493
|
if (this._els?.thread) {
|
|
2985
3494
|
this.provider = next ?? this.provider;
|
|
2986
3495
|
this._paintProvenance();
|
|
3496
|
+
// Both the single agent and any configured ones: a page without `agents` kept the old
|
|
3497
|
+
// provider's harness, and answered with the model it had just replaced.
|
|
3498
|
+
this._harness = this._singleHarness();
|
|
2987
3499
|
this._buildAgents();
|
|
2988
3500
|
}
|
|
2989
3501
|
}
|
|
@@ -3187,6 +3699,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3187
3699
|
/* A2UI surfaces inherit the tokens above through --a2ui-* bridges. */
|
|
3188
3700
|
${A2UI_THEME_CSS}
|
|
3189
3701
|
|
|
3702
|
+
/* The action card: forms and handoffs as one message. */
|
|
3703
|
+
${ACTION_CARD_CSS}
|
|
3704
|
+
|
|
3190
3705
|
/* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
|
|
3191
3706
|
.launcher {
|
|
3192
3707
|
position: fixed; right: 20px; bottom: 20px; z-index: 9998;
|