@nxisai/agent-harness 0.6.0 → 0.8.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/LICENSE +201 -0
- package/README.md +32 -7
- 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 +16 -0
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +70 -10
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/recommend.d.ts +46 -0
- package/dist/harness/recommend.d.ts.map +1 -0
- package/dist/harness/recommend.js +144 -0
- package/dist/harness/recommend.js.map +1 -0
- package/dist/harness/records.d.ts +70 -0
- package/dist/harness/records.d.ts.map +1 -0
- package/dist/harness/records.js +209 -0
- package/dist/harness/records.js.map +1 -0
- package/dist/harness/renderer.d.ts +31 -1
- package/dist/harness/renderer.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.d.ts +39 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +82 -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/site-pages.js +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 +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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 +615 -88
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/result-pick.d.ts +52 -0
- package/dist/ui/result-pick.d.ts.map +1 -0
- package/dist/ui/result-pick.js +196 -0
- package/dist/ui/result-pick.js.map +1 -0
- 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 +82 -17
- package/src/harness/recommend.ts +194 -0
- package/src/harness/records.ts +237 -0
- package/src/harness/renderer.ts +34 -1
- package/src/harness/tools/action-tools.ts +92 -4
- package/src/harness/tools/site-actions.ts +58 -0
- package/src/harness/tools/site-pages.ts +1 -1
- package/src/harness/tools/source.ts +5 -2
- package/src/index.ts +4 -1
- package/src/ui/action-card.ts +496 -0
- package/src/ui/agent-fullscreen.ts +655 -78
- package/src/ui/result-pick.ts +233 -0
- package/src/ui/tailwind.generated.ts +1 -1
|
@@ -34,12 +34,17 @@ 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, formsFor, 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';
|
|
41
|
+
import { resultsAnswer, RESULTS_ANSWER_CSS } from './result-pick.js';
|
|
42
|
+
import { extractRecords, openStatus, recordFacts } from '../harness/records.js';
|
|
38
43
|
import { resumePendingAction } from '../harness/tools/resume.js';
|
|
39
44
|
import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
|
|
40
45
|
import { pageForEntity } from '../harness/tools/site-pages.js';
|
|
41
46
|
import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
|
|
42
|
-
import { onJourneyChange, recordStep, restoreJourney } from '../harness/steps.js';
|
|
47
|
+
import { currentJourney, onJourneyChange, recordStep, restoreJourney } from '../harness/steps.js';
|
|
43
48
|
import { viewJourney } from './journey.js';
|
|
44
49
|
import { AgentCoordinator } from '../harness/router/coordinator.js';
|
|
45
50
|
import { HandoffController, isHandoffOver, } from '../handoff/controller.js';
|
|
@@ -78,6 +83,11 @@ const fmt = (n) => n.toLocaleString();
|
|
|
78
83
|
* be disabled under `prefers-reduced-motion` while the bubble still reads as
|
|
79
84
|
* pending. The label and byte spans are filled per phase and hidden while empty.
|
|
80
85
|
*/
|
|
86
|
+
/**
|
|
87
|
+
* How long the running-step line stays once the conversation moves on past
|
|
88
|
+
* its card. A glance's worth: long enough to notice and open it.
|
|
89
|
+
*/
|
|
90
|
+
const JSTATUS_TIMEOUT_MS = 10000;
|
|
81
91
|
const THINKING_MARKUP = '<span class="think-row">' +
|
|
82
92
|
'<span class="think-dots" aria-hidden="true"><i></i><i></i><i></i></span>' +
|
|
83
93
|
'<span class="think-label"></span>' +
|
|
@@ -245,6 +255,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
245
255
|
};
|
|
246
256
|
/** Watches whether the running journey's card is on screen. */
|
|
247
257
|
this._journeyObserver = null;
|
|
258
|
+
/** The journey the status line is about, while one is running. */
|
|
259
|
+
this._jstatusJourney = null;
|
|
260
|
+
/** Journeys whose status line was opened once or timed out: it does not come back. */
|
|
261
|
+
this._jstatusDismissed = new Set();
|
|
262
|
+
/** Dismisses the status line a while after the thread moves on past the card. */
|
|
263
|
+
this._jstatusTimer = null;
|
|
248
264
|
/** Built only when the host defines agents — otherwise there is one harness. */
|
|
249
265
|
this._coordinator = null;
|
|
250
266
|
this._agentDefs = null;
|
|
@@ -252,6 +268,14 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
252
268
|
this._fallbackAgent = '';
|
|
253
269
|
/** The live ask/confirm surface, if one is open. */
|
|
254
270
|
this._prompt = null;
|
|
271
|
+
/**
|
|
272
|
+
* The action card still waiting on the visitor or a result, and what it can
|
|
273
|
+
* do: the harness's ask or confirm request, or, after a page change, a call
|
|
274
|
+
* of the widget's own.
|
|
275
|
+
*/
|
|
276
|
+
this._live = null;
|
|
277
|
+
/** Handoff targets as the relay last reported them, for a card's availability line. */
|
|
278
|
+
this._handoffTargets = new Map();
|
|
255
279
|
this._persistPrefs = false;
|
|
256
280
|
this._systemPrompt = DEFAULT_SYSTEM_PROMPT;
|
|
257
281
|
this._suggestions = DEFAULT_SUGGESTIONS;
|
|
@@ -349,13 +373,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
349
373
|
this._buildAgents();
|
|
350
374
|
// The element renders; the harness decides. Everything from choosing a
|
|
351
375
|
// 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
|
-
});
|
|
376
|
+
this._harness = this._singleHarness();
|
|
359
377
|
// Preferences are supplied by the host page through `element.preferences`.
|
|
360
378
|
// Anything a previous build persisted is picked up so upgrading does not
|
|
361
379
|
// silently drop a user's saved details; `persist` then stays true so they
|
|
@@ -387,6 +405,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
387
405
|
this._els.jstatus.addEventListener('click', () => {
|
|
388
406
|
const cards = this._els.thread.querySelectorAll('.journey.is-running');
|
|
389
407
|
cards[cards.length - 1]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
408
|
+
// One look is what it is for; after that it would only be in the way.
|
|
409
|
+
this._dismissJourneyStatus();
|
|
390
410
|
});
|
|
391
411
|
// Whoever builds the tools, a call that leaves this page reports back to
|
|
392
412
|
// the thread it was made in.
|
|
@@ -482,6 +502,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
482
502
|
this._offJourney = null;
|
|
483
503
|
this._journeyObserver?.disconnect();
|
|
484
504
|
this._journeyObserver = null;
|
|
505
|
+
this._clearJourneyStatusTimer();
|
|
485
506
|
// Streams stop with the element; the handoff itself stays saved, and resumes when it returns.
|
|
486
507
|
this._handoff?.dispose();
|
|
487
508
|
}
|
|
@@ -667,8 +688,13 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
667
688
|
this._els.thread.appendChild(this._resultsEl(it));
|
|
668
689
|
else if (it.kind === 'metrics')
|
|
669
690
|
this._els.thread.appendChild(this._metricsEl(it));
|
|
670
|
-
else if (it.kind === 'journey')
|
|
671
|
-
|
|
691
|
+
else if (it.kind === 'journey') {
|
|
692
|
+
// A request drawn as an action card shows its own steps.
|
|
693
|
+
if (!conv.items.some((a) => a.kind === 'action' && a.id === it.id))
|
|
694
|
+
this._els.thread.appendChild(this._journeyEl(it));
|
|
695
|
+
}
|
|
696
|
+
else if (it.kind === 'action')
|
|
697
|
+
this._paintAction(it);
|
|
672
698
|
else if (it.kind === 'remote' || it.kind === 'handoff')
|
|
673
699
|
this._paintHandoffItem(it);
|
|
674
700
|
}
|
|
@@ -1038,6 +1064,14 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1038
1064
|
item = { kind: 'journey', id: view.id, steps: all };
|
|
1039
1065
|
conv.items.push(item);
|
|
1040
1066
|
}
|
|
1067
|
+
// A request with an action card: the steps belong in the card.
|
|
1068
|
+
const action = conv.items.find((it) => it.kind === 'action' && it.id === view.id);
|
|
1069
|
+
if (action) {
|
|
1070
|
+
this._actionFollows(action, all);
|
|
1071
|
+
this._watchRunningJourney();
|
|
1072
|
+
this._saveConversations();
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1041
1075
|
if (conv.id === this._activeId && this._els?.thread) {
|
|
1042
1076
|
const fresh = this._journeyEl(item);
|
|
1043
1077
|
const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
|
|
@@ -1071,7 +1105,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1071
1105
|
return;
|
|
1072
1106
|
const cards = this._els.thread.querySelectorAll('.journey.is-running');
|
|
1073
1107
|
const card = cards[cards.length - 1];
|
|
1074
|
-
|
|
1108
|
+
const id = card?.dataset.journey ?? null;
|
|
1109
|
+
if (id !== this._jstatusJourney) {
|
|
1110
|
+
this._clearJourneyStatusTimer();
|
|
1111
|
+
this._jstatusJourney = id;
|
|
1112
|
+
}
|
|
1113
|
+
if (!card || !id || this._jstatusDismissed.has(id) || typeof IntersectionObserver === 'undefined') {
|
|
1075
1114
|
bar.hidden = true;
|
|
1076
1115
|
return;
|
|
1077
1116
|
}
|
|
@@ -1081,6 +1120,37 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1081
1120
|
}, { root: this._els.thread, threshold: 0.2 });
|
|
1082
1121
|
this._journeyObserver.observe(card);
|
|
1083
1122
|
}
|
|
1123
|
+
/**
|
|
1124
|
+
* The thread grew: start the clock on the status line.
|
|
1125
|
+
*
|
|
1126
|
+
* A journey can stay running while the conversation goes on without it, and a
|
|
1127
|
+
* line that spins through all of that says nothing. So once new messages
|
|
1128
|
+
* arrive, the line gets a while longer, then goes.
|
|
1129
|
+
*/
|
|
1130
|
+
_noteThreadGrew() {
|
|
1131
|
+
const id = this._jstatusJourney;
|
|
1132
|
+
if (!id || this._jstatusTimer || this._jstatusDismissed.has(id))
|
|
1133
|
+
return;
|
|
1134
|
+
this._jstatusTimer = setTimeout(() => {
|
|
1135
|
+
this._jstatusTimer = null;
|
|
1136
|
+
this._dismissJourneyStatus();
|
|
1137
|
+
}, JSTATUS_TIMEOUT_MS);
|
|
1138
|
+
}
|
|
1139
|
+
/** Hide the status line for the running journey, for good. */
|
|
1140
|
+
_dismissJourneyStatus() {
|
|
1141
|
+
this._clearJourneyStatusTimer();
|
|
1142
|
+
if (this._jstatusJourney)
|
|
1143
|
+
this._jstatusDismissed.add(this._jstatusJourney);
|
|
1144
|
+
this._journeyObserver?.disconnect();
|
|
1145
|
+
this._journeyObserver = null;
|
|
1146
|
+
if (this._els?.jstatus)
|
|
1147
|
+
this._els.jstatus.hidden = true;
|
|
1148
|
+
}
|
|
1149
|
+
_clearJourneyStatusTimer() {
|
|
1150
|
+
if (this._jstatusTimer)
|
|
1151
|
+
clearTimeout(this._jstatusTimer);
|
|
1152
|
+
this._jstatusTimer = null;
|
|
1153
|
+
}
|
|
1084
1154
|
_paintToolResult(name, text, isError) {
|
|
1085
1155
|
this._clearEmpty();
|
|
1086
1156
|
const el = this._toolEl(name, text, isError);
|
|
@@ -1137,6 +1207,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1137
1207
|
const el = this._paintMsg(role, text, conv.items.length - 1);
|
|
1138
1208
|
this._saveConversations();
|
|
1139
1209
|
this._scroll();
|
|
1210
|
+
this._noteThreadGrew();
|
|
1140
1211
|
return el;
|
|
1141
1212
|
}
|
|
1142
1213
|
/**
|
|
@@ -1156,6 +1227,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1156
1227
|
// without the focus moving to it.
|
|
1157
1228
|
el.setAttribute('aria-live', 'polite');
|
|
1158
1229
|
this._scroll();
|
|
1230
|
+
this._noteThreadGrew();
|
|
1159
1231
|
const setLabel = (label) => {
|
|
1160
1232
|
const node = el.querySelector('.think-label');
|
|
1161
1233
|
if (node)
|
|
@@ -1298,11 +1370,13 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1298
1370
|
message: (role, text) => this._appendMsg(role, text),
|
|
1299
1371
|
streaming: () => this._appendStreaming(),
|
|
1300
1372
|
status: (text, isError) => this._setStatus(text, isError),
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1373
|
+
inlinePrompts: !this._classicPrompts,
|
|
1374
|
+
ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
|
|
1375
|
+
confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
|
|
1376
|
+
left: (tool, destination) => this._cardLeft(tool, destination),
|
|
1377
|
+
results: (tool, res, extra) => {
|
|
1304
1378
|
const conv = this._active();
|
|
1305
|
-
const item = this._buildResultsItem(tool, res);
|
|
1379
|
+
const item = this._buildResultsItem(tool, res, extra?.pick);
|
|
1306
1380
|
// A read that answers in words — "you are on the home page", "already open" — has no records
|
|
1307
1381
|
// to draw, and an empty result card reading "No results found." contradicts the answer it
|
|
1308
1382
|
// just gave. Say what it said.
|
|
@@ -1321,6 +1395,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1321
1395
|
this._scroll();
|
|
1322
1396
|
},
|
|
1323
1397
|
detail: (tool, values, text) => {
|
|
1398
|
+
if (this._cardDone(tool, values, text))
|
|
1399
|
+
return;
|
|
1324
1400
|
const conv = this._active();
|
|
1325
1401
|
const item = this._buildDetailItem(tool, values, text);
|
|
1326
1402
|
conv?.items.push(item);
|
|
@@ -1345,8 +1421,24 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1345
1421
|
this._saveConversations();
|
|
1346
1422
|
this._scroll();
|
|
1347
1423
|
},
|
|
1348
|
-
toolError: (tool, message) =>
|
|
1349
|
-
|
|
1424
|
+
toolError: (tool, message) => {
|
|
1425
|
+
if (this._cardFailed(tool, message))
|
|
1426
|
+
return;
|
|
1427
|
+
this._paintToolResult(tool.name, message, true);
|
|
1428
|
+
},
|
|
1429
|
+
dismissPrompt: () => {
|
|
1430
|
+
// A sent card stays, and shows the steps; one never sent says so.
|
|
1431
|
+
const live = this._live;
|
|
1432
|
+
if (live) {
|
|
1433
|
+
if (live.sent && (live.item.stage === 'review' || live.item.stage === 'collect')) {
|
|
1434
|
+
live.item.stage = 'working';
|
|
1435
|
+
this._paintAction(live.item);
|
|
1436
|
+
this._saveConversations();
|
|
1437
|
+
}
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
this._dismissPrompt();
|
|
1441
|
+
},
|
|
1350
1442
|
// The harness says when stopping stops being possible, because it is the
|
|
1351
1443
|
// part that knows a tool has been invoked. Once a call is out, offering
|
|
1352
1444
|
// to stop would be offering to un-send a booking.
|
|
@@ -1478,6 +1570,378 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1478
1570
|
this._formEl = wrap;
|
|
1479
1571
|
this._scroll();
|
|
1480
1572
|
}
|
|
1573
|
+
// ── Action cards ─────────────────────────────────────────────────────────────
|
|
1574
|
+
/** `prompt-style="classic"`: the separate ask, review and submitted cards of earlier versions. */
|
|
1575
|
+
get _classicPrompts() {
|
|
1576
|
+
return this.getAttribute('prompt-style') === 'classic';
|
|
1577
|
+
}
|
|
1578
|
+
/** Whether a prompt for this tool is drawn as an action card: every write and handoff. */
|
|
1579
|
+
_usesCard(tool) {
|
|
1580
|
+
return !this._classicPrompts && (isHandoffTool(tool.name) || !tool.annotations?.readOnlyHint);
|
|
1581
|
+
}
|
|
1582
|
+
/** Details are missing: the card asks for them, beside what it already has. */
|
|
1583
|
+
_cardAsk(req) {
|
|
1584
|
+
const needed = new Set(req.plan.steps.flatMap((st) => st.slots.map((sl) => sl.name)));
|
|
1585
|
+
const item = this._liveCard(req.tool, req.resolution.all, req.resolution.values, needed);
|
|
1586
|
+
this._live.ask = req;
|
|
1587
|
+
this._live.confirm = undefined;
|
|
1588
|
+
item.stage = 'collect';
|
|
1589
|
+
this._paintAction(item);
|
|
1590
|
+
this._saveConversations();
|
|
1591
|
+
}
|
|
1592
|
+
/** Everything is known: the card says what will happen, and waits for Send. */
|
|
1593
|
+
_cardConfirm(req) {
|
|
1594
|
+
const all = resolveSlots(toolSchema(req.tool), { modelArgs: req.values }).all;
|
|
1595
|
+
const item = this._liveCard(req.tool, all, req.values, new Set(), req.destination);
|
|
1596
|
+
this._live.confirm = req;
|
|
1597
|
+
item.stage = 'review';
|
|
1598
|
+
this._paintAction(item);
|
|
1599
|
+
this._saveConversations();
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* The live card for a tool's prompt: the one already open for this request,
|
|
1603
|
+
* or a new one, which takes over from the journey card of the same request.
|
|
1604
|
+
*/
|
|
1605
|
+
_liveCard(tool, slots, values, needed, destination) {
|
|
1606
|
+
const conv = this._active();
|
|
1607
|
+
const id = currentJourney() ?? `a${Date.now().toString(36)}`;
|
|
1608
|
+
const live = this._live;
|
|
1609
|
+
const described = this._describeAction(tool, values, destination);
|
|
1610
|
+
const fields = fieldsFromSlots(this._narrowed(tool, slots, values), values, { needed, hidden: described.hidden, labels: described.labels });
|
|
1611
|
+
if (live && live.item.id === id && live.tool.name === tool.name) {
|
|
1612
|
+
// The same request, a step on: keep what was typed, and anything the harness has since learned.
|
|
1613
|
+
live.item.fields = fields.map((f) => {
|
|
1614
|
+
const had = live.item.fields.find((o) => o.name === f.name);
|
|
1615
|
+
return had && f.value === '' ? { ...f, value: had.value } : f;
|
|
1616
|
+
});
|
|
1617
|
+
Object.assign(live.item, described.view);
|
|
1618
|
+
live.tool = tool;
|
|
1619
|
+
return live.item;
|
|
1620
|
+
}
|
|
1621
|
+
// A request left waiting is replaced by the new one.
|
|
1622
|
+
if (live && (live.item.stage === 'collect' || live.item.stage === 'review')) {
|
|
1623
|
+
live.item.stage = 'declined';
|
|
1624
|
+
this._paintAction(live.item);
|
|
1625
|
+
}
|
|
1626
|
+
const item = { kind: 'action', id, tool: tool.name, fields, stage: 'collect', ...described.view };
|
|
1627
|
+
conv.items.push(item);
|
|
1628
|
+
this._live = { item, tool, sent: false, declining: false };
|
|
1629
|
+
this._els?.thread?.querySelector(`[data-journey="${CSS.escape(id)}"]`)?.remove();
|
|
1630
|
+
this._watchRunningJourney();
|
|
1631
|
+
if (isHandoffTool(tool.name))
|
|
1632
|
+
void this._refreshHandoffTargets(item);
|
|
1633
|
+
return item;
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* The slots with each field's options cut to the form the call means: one
|
|
1637
|
+
* business's services rather than every business's.
|
|
1638
|
+
*/
|
|
1639
|
+
_narrowed(tool, slots, values) {
|
|
1640
|
+
const options = formOptions(tool, values);
|
|
1641
|
+
return slots.map((slot) => {
|
|
1642
|
+
const only = options[slot.name];
|
|
1643
|
+
if (!only)
|
|
1644
|
+
return slot;
|
|
1645
|
+
// Keep a value the call already gave, even one the form does not list: the form decides.
|
|
1646
|
+
const current = values[slot.name];
|
|
1647
|
+
const list = current !== undefined && current !== '' && !only.includes(String(current)) ? [String(current), ...only] : only;
|
|
1648
|
+
return { ...slot, schema: { ...slot.schema, enum: list } };
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
/** Who a request goes to, and how the card words it. */
|
|
1652
|
+
_describeAction(tool, values, destination) {
|
|
1653
|
+
const email = String(values.email ?? Object.entries(values).find(([k]) => /e-?mail/i.test(k))?.[1] ?? '').trim();
|
|
1654
|
+
if (isHandoffTool(tool.name)) {
|
|
1655
|
+
const name = String(values.target ?? '');
|
|
1656
|
+
const config = this._handoff?.config?.targets[name];
|
|
1657
|
+
const known = this._handoffTargets.get(name);
|
|
1658
|
+
const label = known?.label ?? config?.label ?? fieldLabel(name || 'the team');
|
|
1659
|
+
const person = (known?.kind ?? config?.kind) !== 'agent';
|
|
1660
|
+
const away = known ? !known.available : false;
|
|
1661
|
+
// "The directory team" leads a line, but mid-sentence it is "the directory team".
|
|
1662
|
+
const inline = label.replace(/^The\s/, 'the ');
|
|
1663
|
+
const about = known?.description ?? config?.description;
|
|
1664
|
+
const status = !known
|
|
1665
|
+
? undefined
|
|
1666
|
+
: away
|
|
1667
|
+
? `Away now${known.hours ? ` · open ${known.hours}` : ''}`
|
|
1668
|
+
: person
|
|
1669
|
+
? known.waitMinutes
|
|
1670
|
+
? `Available · replies in ~${known.waitMinutes} min`
|
|
1671
|
+
: 'Available'
|
|
1672
|
+
: 'Available';
|
|
1673
|
+
const sentence = !person
|
|
1674
|
+
? `I'll hand this conversation to ${inline}. They'll take it from here.`
|
|
1675
|
+
: away
|
|
1676
|
+
? email
|
|
1677
|
+
? `${label} is away right now. They'll reply to ${email}.`
|
|
1678
|
+
: `${label} is away right now. Leave an email and they'll reply there.`
|
|
1679
|
+
: `I'll pass this to ${inline} so you don't have to repeat yourself.`;
|
|
1680
|
+
return {
|
|
1681
|
+
view: {
|
|
1682
|
+
variant: person ? 'handoff-person' : 'handoff-agent',
|
|
1683
|
+
noun: person ? 'handoff' : 'handover',
|
|
1684
|
+
identity: { name: label, sub: about ? about.charAt(0).toUpperCase() + about.slice(1) : undefined, status, tone: away ? 'away' : 'ok' },
|
|
1685
|
+
sentence,
|
|
1686
|
+
note: undefined,
|
|
1687
|
+
sendLabel: !person ? 'Hand over' : away ? `Send to ${inline}` : 'Connect me',
|
|
1688
|
+
},
|
|
1689
|
+
hidden: new Set(['target']),
|
|
1690
|
+
labels: { reason: 'Why', summary: 'What they need to know', email: 'Where should they reply?' },
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
const noun = fieldLabel(withoutSiteSuffix(tool.name)).toLowerCase();
|
|
1694
|
+
// Which business or place it is for: the site tool's own chooser, else a field the form names it in.
|
|
1695
|
+
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;
|
|
1696
|
+
const chosen = chooser ? String(values[chooser] ?? '').trim() : '';
|
|
1697
|
+
const leaving = destination ?? handoffFor(tool, values)?.label;
|
|
1698
|
+
const name = chosen || leaving || this._pageName();
|
|
1699
|
+
// What the conversation already showed about them: their card from a search.
|
|
1700
|
+
const card = this._conversations
|
|
1701
|
+
.find((c) => c.id === this._activeId)
|
|
1702
|
+
?.items.flatMap((it) => (it.kind === 'results' ? it.cards : []))
|
|
1703
|
+
.find((c) => c.title.toLowerCase() === name.toLowerCase());
|
|
1704
|
+
const sub = [fieldLabel(noun), card?.type && card.type !== 'Thing' ? fieldLabel(card.type) : '', card?.rating ? `★ ${card.rating}` : '']
|
|
1705
|
+
.filter(Boolean)
|
|
1706
|
+
.join(' · ');
|
|
1707
|
+
return {
|
|
1708
|
+
view: {
|
|
1709
|
+
variant: 'form',
|
|
1710
|
+
noun,
|
|
1711
|
+
identity: { name, sub },
|
|
1712
|
+
sentence: `I'll send this ${noun} to ${name}.${email ? ` They'll reply to ${email}.` : ''}`,
|
|
1713
|
+
note: leaving ? 'Their form is on its own page. I’ll open it and send it from there.' : undefined,
|
|
1714
|
+
sendLabel: 'Send',
|
|
1715
|
+
},
|
|
1716
|
+
hidden: new Set(chooser && chooser !== 'target' && chosen ? [chooser] : chooser === 'target' ? ['target'] : []),
|
|
1717
|
+
labels: {},
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
/** The site's name for this page: its title, without the site's own name after it. */
|
|
1721
|
+
_pageName() {
|
|
1722
|
+
const title = (typeof document === 'undefined' ? '' : document.title).split(/\s+[|—–·-]\s+/)[0].trim();
|
|
1723
|
+
return title || 'this page';
|
|
1724
|
+
}
|
|
1725
|
+
/** Read availability for a handoff card, and redraw it once known. */
|
|
1726
|
+
async _refreshHandoffTargets(item) {
|
|
1727
|
+
const targets = await this._handoff?.targets().catch(() => []);
|
|
1728
|
+
for (const t of targets ?? [])
|
|
1729
|
+
this._handoffTargets.set(t.name, t);
|
|
1730
|
+
if (item.stage !== 'collect' && item.stage !== 'review')
|
|
1731
|
+
return;
|
|
1732
|
+
const values = Object.fromEntries(item.fields.map((f) => [f.name, f.value]));
|
|
1733
|
+
Object.assign(item, this._describeAction({ name: item.tool }, values).view);
|
|
1734
|
+
this._paintAction(item);
|
|
1735
|
+
}
|
|
1736
|
+
/** What the live card's buttons do. */
|
|
1737
|
+
_actionHandlers() {
|
|
1738
|
+
return {
|
|
1739
|
+
continue: (values) => {
|
|
1740
|
+
const live = this._live;
|
|
1741
|
+
if (!live)
|
|
1742
|
+
return;
|
|
1743
|
+
if (live.ask && !live.confirm) {
|
|
1744
|
+
live.ask.onComplete(values);
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
// Back from a change, or a card of the widget's own: review again, here.
|
|
1748
|
+
Object.assign(live.item, this._describeAction(live.tool, values, live.confirm?.destination).view);
|
|
1749
|
+
live.item.stage = 'review';
|
|
1750
|
+
this._paintAction(live.item);
|
|
1751
|
+
this._saveConversations();
|
|
1752
|
+
},
|
|
1753
|
+
change: () => {
|
|
1754
|
+
const live = this._live;
|
|
1755
|
+
if (!live)
|
|
1756
|
+
return;
|
|
1757
|
+
live.item.fields.forEach((f) => {
|
|
1758
|
+
if (f.group === 'needed' && f.value.trim() !== '')
|
|
1759
|
+
f.group = 'known';
|
|
1760
|
+
});
|
|
1761
|
+
live.sent = false;
|
|
1762
|
+
live.item.stage = 'collect';
|
|
1763
|
+
live.item.open = true;
|
|
1764
|
+
this._paintAction(live.item);
|
|
1765
|
+
this._saveConversations();
|
|
1766
|
+
},
|
|
1767
|
+
send: (values) => {
|
|
1768
|
+
const live = this._live;
|
|
1769
|
+
if (!live)
|
|
1770
|
+
return;
|
|
1771
|
+
live.sent = true;
|
|
1772
|
+
if (live.confirm)
|
|
1773
|
+
live.confirm.onSubmit(values);
|
|
1774
|
+
else if (live.run)
|
|
1775
|
+
void live.run(values);
|
|
1776
|
+
},
|
|
1777
|
+
decline: () => {
|
|
1778
|
+
const live = this._live;
|
|
1779
|
+
if (!live)
|
|
1780
|
+
return;
|
|
1781
|
+
live.declining = true;
|
|
1782
|
+
if (live.confirm)
|
|
1783
|
+
live.confirm.onCancel();
|
|
1784
|
+
else if (live.ask)
|
|
1785
|
+
live.ask.onCancel();
|
|
1786
|
+
// A card the harness is not waiting on declines itself.
|
|
1787
|
+
if (this._live === live) {
|
|
1788
|
+
live.item.stage = 'declined';
|
|
1789
|
+
this._paintAction(live.item);
|
|
1790
|
+
this._live = null;
|
|
1791
|
+
this._saveConversations();
|
|
1792
|
+
}
|
|
1793
|
+
},
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
/** Draw a card where it was, or at the end of the thread. */
|
|
1797
|
+
_paintAction(item) {
|
|
1798
|
+
if (!this._els?.thread)
|
|
1799
|
+
return;
|
|
1800
|
+
const conv = this._active();
|
|
1801
|
+
if (!conv?.items.includes(item))
|
|
1802
|
+
return;
|
|
1803
|
+
const live = this._live?.item === item;
|
|
1804
|
+
const fresh = actionCard(item, live ? this._actionHandlers() : null, () => this._saveConversations());
|
|
1805
|
+
const old = this._els.thread.querySelector(`[data-action="${CSS.escape(item.id)}"]`);
|
|
1806
|
+
if (old)
|
|
1807
|
+
old.replaceWith(fresh);
|
|
1808
|
+
else {
|
|
1809
|
+
this._clearEmpty();
|
|
1810
|
+
this._els.thread.appendChild(fresh);
|
|
1811
|
+
this._scroll();
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
/** A request's steps, as its card shows them once sent. */
|
|
1815
|
+
_actionFollows(item, all) {
|
|
1816
|
+
const live = this._live?.item === item ? this._live : null;
|
|
1817
|
+
const failed = [...all].reverse().find((st) => st.status === 'failed');
|
|
1818
|
+
const done = all.some((st) => st.id === 'submit' && st.status === 'done');
|
|
1819
|
+
const running = ['navigate', 'arrive', 'collect', 'fill', 'submit'];
|
|
1820
|
+
item.steps = all
|
|
1821
|
+
.filter((st) => running.includes(st.id))
|
|
1822
|
+
.map((st) => ({
|
|
1823
|
+
label: st.id === 'fill' && st.status === 'active' && item.variant === 'form'
|
|
1824
|
+
? `Filling in the ${item.noun}`
|
|
1825
|
+
: st.id === 'submit' && st.status === 'done' && item.variant === 'form'
|
|
1826
|
+
? 'Sent'
|
|
1827
|
+
: st.label,
|
|
1828
|
+
status: st.status === 'failed' ? 'failed' : st.status === 'done' ? 'done' : 'active',
|
|
1829
|
+
}));
|
|
1830
|
+
if (failed) {
|
|
1831
|
+
if (live?.declining || failed.label === 'Cancelled') {
|
|
1832
|
+
item.stage = 'declined';
|
|
1833
|
+
if (live)
|
|
1834
|
+
this._live = null;
|
|
1835
|
+
}
|
|
1836
|
+
else {
|
|
1837
|
+
item.stage = 'failed';
|
|
1838
|
+
item.message = failed.detail || failed.label;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
else if (done) {
|
|
1842
|
+
item.stage = 'done';
|
|
1843
|
+
if (live)
|
|
1844
|
+
this._live = null;
|
|
1845
|
+
}
|
|
1846
|
+
else if (item.stage === 'left' || (item.stage === 'working' && item.steps.length)) {
|
|
1847
|
+
item.stage = 'working';
|
|
1848
|
+
}
|
|
1849
|
+
this._paintAction(item);
|
|
1850
|
+
}
|
|
1851
|
+
/** The confirmed call is leaving for its page. The card says so, and the new page finishes it. */
|
|
1852
|
+
_cardLeft(tool, destination) {
|
|
1853
|
+
const live = this._live;
|
|
1854
|
+
if (!live || live.tool.name !== tool.name) {
|
|
1855
|
+
this._appendMsg('assistant', destination ? `Opening ${destination}…` : 'Opening the page that can submit this…');
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
live.item.stage = 'left';
|
|
1859
|
+
live.item.message = `Opening ${destination ?? live.item.identity.name}’s page to send it from there.`;
|
|
1860
|
+
this._live = null;
|
|
1861
|
+
this._paintAction(live.item);
|
|
1862
|
+
this._saveConversations();
|
|
1863
|
+
}
|
|
1864
|
+
/** The form took it: the card becomes the receipt. False when no card is waiting on this tool. */
|
|
1865
|
+
_cardDone(tool, values, text) {
|
|
1866
|
+
const conv = this._active();
|
|
1867
|
+
const id = currentJourney();
|
|
1868
|
+
const item = conv?.items.find((it) => it.kind === 'action' && it.id === id && it.tool === tool.name);
|
|
1869
|
+
if (!item || this._classicPrompts)
|
|
1870
|
+
return false;
|
|
1871
|
+
const note = this._buildDetailItem(tool, values, text).note;
|
|
1872
|
+
// The default note says nothing the card's "Sent" does not.
|
|
1873
|
+
item.message = /submitted successfully/.test(note) ? undefined : note;
|
|
1874
|
+
item.stage = 'done';
|
|
1875
|
+
item.fields.forEach((f) => {
|
|
1876
|
+
if (values[f.name] !== undefined)
|
|
1877
|
+
f.value = String(values[f.name]);
|
|
1878
|
+
});
|
|
1879
|
+
if (this._live?.item === item)
|
|
1880
|
+
this._live = null;
|
|
1881
|
+
this._paintAction(item);
|
|
1882
|
+
this._saveConversations();
|
|
1883
|
+
return true;
|
|
1884
|
+
}
|
|
1885
|
+
/** It did not go through: the card says why and offers to change it. False when no card is waiting. */
|
|
1886
|
+
_cardFailed(tool, message) {
|
|
1887
|
+
const live = this._live;
|
|
1888
|
+
if (!live || live.tool.name !== tool.name)
|
|
1889
|
+
return false;
|
|
1890
|
+
live.sent = false;
|
|
1891
|
+
live.item.stage = 'failed';
|
|
1892
|
+
live.item.message = message;
|
|
1893
|
+
this._paintAction(live.item);
|
|
1894
|
+
this._saveConversations();
|
|
1895
|
+
return true;
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* After a page change: the card of the request that travelled, found by its
|
|
1899
|
+
* journey, now finished or asked about here.
|
|
1900
|
+
*/
|
|
1901
|
+
_resumedCard(journeyId) {
|
|
1902
|
+
if (!journeyId || this._classicPrompts)
|
|
1903
|
+
return null;
|
|
1904
|
+
return this._active()?.items.find((it) => it.kind === 'action' && it.id === journeyId) ?? null;
|
|
1905
|
+
}
|
|
1906
|
+
/** Let a resumed card be changed and sent again from this page, with the page's own tool. */
|
|
1907
|
+
_reviveCard(item, tool, values, needed) {
|
|
1908
|
+
const slots = resolveSlots(toolSchema(tool), { modelArgs: values }).all;
|
|
1909
|
+
const hidden = new Set(item.fields.filter((f) => f.group === 'hidden').map((f) => f.name));
|
|
1910
|
+
item.fields = fieldsFromSlots(slots, values, { needed: new Set(needed), hidden });
|
|
1911
|
+
this._live = {
|
|
1912
|
+
item,
|
|
1913
|
+
tool,
|
|
1914
|
+
sent: false,
|
|
1915
|
+
declining: false,
|
|
1916
|
+
run: async (vals) => {
|
|
1917
|
+
item.stage = 'working';
|
|
1918
|
+
item.steps = [{ label: `Sending the ${item.noun}`, status: 'active' }];
|
|
1919
|
+
this._paintAction(item);
|
|
1920
|
+
try {
|
|
1921
|
+
const res = await this.toolSource.execTool(tool, tool.name, vals);
|
|
1922
|
+
const text = extractResult(res);
|
|
1923
|
+
if (res?.isError) {
|
|
1924
|
+
this._cardFailed(tool, text || 'That form did not accept the details.');
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
this._active()?.messages.push({
|
|
1928
|
+
role: 'user',
|
|
1929
|
+
content: `[${tool.name} submitted with: ${JSON.stringify(vals)}]. Keep these unless I ask to change them.`,
|
|
1930
|
+
});
|
|
1931
|
+
this._live = null;
|
|
1932
|
+
const note = this._buildDetailItem(tool, vals, text).note;
|
|
1933
|
+
item.message = /submitted successfully/.test(note) ? undefined : note;
|
|
1934
|
+
item.stage = 'done';
|
|
1935
|
+
item.steps = [];
|
|
1936
|
+
this._paintAction(item);
|
|
1937
|
+
this._saveConversations();
|
|
1938
|
+
}
|
|
1939
|
+
catch (err) {
|
|
1940
|
+
this._cardFailed(tool, String(err?.message || err));
|
|
1941
|
+
}
|
|
1942
|
+
},
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1481
1945
|
// ── Inline generated form (from JSON-Schema) ───────────────────────────────
|
|
1482
1946
|
_renderForm(tool, args) {
|
|
1483
1947
|
if (this._useA2ui) {
|
|
@@ -1792,70 +2256,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1792
2256
|
* return the first that yields records. Shapes covered: query → results[],
|
|
1793
2257
|
* list_types → types[], get → result{}, or a bare entity/array. */
|
|
1794
2258
|
_extractItems(res) {
|
|
1795
|
-
const
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
// serialized to a string, possibly more than once. Diving into both
|
|
1800
|
-
// `structuredContent` and each `content[].text` covers all of them.
|
|
1801
|
-
const visit = (raw, depth = 0) => {
|
|
1802
|
-
if (raw == null || depth > 5)
|
|
1803
|
-
return;
|
|
1804
|
-
const val = raw;
|
|
1805
|
-
if (typeof val === 'string') {
|
|
1806
|
-
const t = val.trim();
|
|
1807
|
-
if (!t)
|
|
1808
|
-
return;
|
|
1809
|
-
let parsed;
|
|
1810
|
-
try {
|
|
1811
|
-
parsed = JSON.parse(t);
|
|
1812
|
-
}
|
|
1813
|
-
catch {
|
|
1814
|
-
return; // plain (non-JSON) text — nothing to extract
|
|
1815
|
-
}
|
|
1816
|
-
// Parsed value may itself be an object/array OR another JSON string
|
|
1817
|
-
// (double-encoded); recurse either way.
|
|
1818
|
-
return visit(parsed, depth + 1);
|
|
1819
|
-
}
|
|
1820
|
-
if (!val || typeof val !== 'object')
|
|
1821
|
-
return;
|
|
1822
|
-
sources.push(val);
|
|
1823
|
-
const v = val;
|
|
1824
|
-
if (v.structuredContent != null)
|
|
1825
|
-
visit(v.structuredContent, depth + 1);
|
|
1826
|
-
if (Array.isArray(v.content)) {
|
|
1827
|
-
for (const c of v.content) {
|
|
1828
|
-
if (c && typeof c === 'object')
|
|
1829
|
-
visit(c.text, depth + 1);
|
|
1830
|
-
}
|
|
1831
|
-
}
|
|
1832
|
-
};
|
|
1833
|
-
visit(res);
|
|
1834
|
-
for (const data of sources) {
|
|
1835
|
-
const items = this._itemsFrom(data);
|
|
1836
|
-
if (items.length)
|
|
1837
|
-
return items;
|
|
1838
|
-
}
|
|
1839
|
-
console.warn('[nxis-mcp] no items extracted from tool result', { sourcesTried: sources.length, res });
|
|
1840
|
-
return [];
|
|
1841
|
-
}
|
|
1842
|
-
/** Find the record array within one parsed payload. */
|
|
1843
|
-
_itemsFrom(data) {
|
|
1844
|
-
const objs = (arr) => arr.filter((x) => x && typeof x === 'object');
|
|
1845
|
-
if (Array.isArray(data))
|
|
1846
|
-
return objs(data);
|
|
1847
|
-
if (!data || typeof data !== 'object')
|
|
1848
|
-
return [];
|
|
1849
|
-
const d = data;
|
|
1850
|
-
for (const key of ['results', 'types', 'items', 'entities', 'data']) {
|
|
1851
|
-
if (Array.isArray(d[key]))
|
|
1852
|
-
return objs(d[key]);
|
|
1853
|
-
}
|
|
1854
|
-
if (d.result && typeof d.result === 'object')
|
|
1855
|
-
return [d.result];
|
|
1856
|
-
if (d['@id'] != null || d['@type'] != null || d.name != null)
|
|
1857
|
-
return [d];
|
|
1858
|
-
return [];
|
|
2259
|
+
const items = extractRecords(res);
|
|
2260
|
+
if (!items.length)
|
|
2261
|
+
console.warn('[nxis-mcp] no items extracted from tool result', { res });
|
|
2262
|
+
return items;
|
|
1859
2263
|
}
|
|
1860
2264
|
/** Build a display card from a single record (a JSON-LD entity or a type row). */
|
|
1861
2265
|
_entityCard(entity) {
|
|
@@ -1914,15 +2318,77 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1914
2318
|
const id = entity['@id'] != null ? str(entity['@id']) : undefined;
|
|
1915
2319
|
const found = pageForEntity(entity, this._sitePages, location.origin);
|
|
1916
2320
|
const page = found ? { url: found.url, label: found.name } : undefined;
|
|
1917
|
-
|
|
2321
|
+
// What the answer view compares by, beyond the rating and hours its heading already shows.
|
|
2322
|
+
const facts = recordFacts(entity);
|
|
2323
|
+
const status = openStatus(entity) ?? undefined;
|
|
2324
|
+
const lines = [
|
|
2325
|
+
facts.services.length ? facts.services.slice(0, 3).map((x) => x.name).join(', ') : facts.knows.slice(0, 3).join(', '),
|
|
2326
|
+
facts.since ? `In business since ${facts.since}` : '',
|
|
2327
|
+
facts.reviews[0] ? `\u201c${facts.reviews[0].text}\u201d${facts.reviews[0].author ? ` \u2014 ${facts.reviews[0].author}` : ''}` : '',
|
|
2328
|
+
].filter((l) => !!l);
|
|
2329
|
+
return {
|
|
2330
|
+
title,
|
|
2331
|
+
type,
|
|
2332
|
+
rating,
|
|
2333
|
+
emergency,
|
|
2334
|
+
sub,
|
|
2335
|
+
rows,
|
|
2336
|
+
id,
|
|
2337
|
+
...(page ? { page } : {}),
|
|
2338
|
+
...(facts.rating !== undefined ? { stars: facts.rating } : {}),
|
|
2339
|
+
...(facts.reviewCount !== undefined ? { reviews: facts.reviewCount } : {}),
|
|
2340
|
+
...(status ? { status: { open: status.open, label: status.label } } : {}),
|
|
2341
|
+
...(facts.phone ? { phone: facts.phone } : {}),
|
|
2342
|
+
facts: lines,
|
|
2343
|
+
};
|
|
1918
2344
|
}
|
|
1919
|
-
_buildResultsItem(tool, res) {
|
|
2345
|
+
_buildResultsItem(tool, res, pick) {
|
|
1920
2346
|
const items = this._extractItems(res);
|
|
1921
2347
|
const cards = items.slice(0, 12).map((e) => this._entityCard(e));
|
|
1922
2348
|
const note = !cards.length
|
|
1923
2349
|
? 'No results found.'
|
|
1924
2350
|
: `${cards.length} result${cards.length === 1 ? '' : 's'}`;
|
|
1925
|
-
return { kind: 'results', title: humanize(tool.name), note, cards };
|
|
2351
|
+
return { kind: 'results', title: humanize(tool.name), note, cards, ...(pick && pick.index < cards.length ? { pick } : {}) };
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* Results as an answer: the recommended pick with its reasons and the others
|
|
2355
|
+
* to compare, or a short list that opens per result. Records of things
|
|
2356
|
+
* only: a listing of types or counts keeps the plain cards.
|
|
2357
|
+
*/
|
|
2358
|
+
_answerEl(item) {
|
|
2359
|
+
// Records of things: a pick, or at least one result with a rating, hours, a phone or a page.
|
|
2360
|
+
// A sparse record beside them (an index entry with only a name) still shows, by name.
|
|
2361
|
+
const things = item.cards.filter((c) => c.stars !== undefined || c.status || c.phone || c.page);
|
|
2362
|
+
if (!item.pick && !things.length)
|
|
2363
|
+
return null;
|
|
2364
|
+
const cards = item.cards.map((c) => {
|
|
2365
|
+
const form = formsFor(c.title)[0];
|
|
2366
|
+
const quote = form && /quote/.test(form.noun);
|
|
2367
|
+
return {
|
|
2368
|
+
title: c.title,
|
|
2369
|
+
stars: c.stars,
|
|
2370
|
+
reviews: c.reviews,
|
|
2371
|
+
status: c.status,
|
|
2372
|
+
phone: c.phone,
|
|
2373
|
+
page: c.page,
|
|
2374
|
+
facts: c.facts ?? [],
|
|
2375
|
+
...(form
|
|
2376
|
+
? {
|
|
2377
|
+
form: quote
|
|
2378
|
+
? { label: 'Get a quote', message: `Get a quote from ${c.title}` }
|
|
2379
|
+
: { label: `Use their ${form.noun}`, message: `Start the ${form.noun} for ${c.title}` },
|
|
2380
|
+
}
|
|
2381
|
+
: {}),
|
|
2382
|
+
};
|
|
2383
|
+
});
|
|
2384
|
+
return resultsAnswer(cards, item.pick, {
|
|
2385
|
+
ask: (text) => {
|
|
2386
|
+
if (this._turnActive)
|
|
2387
|
+
return;
|
|
2388
|
+
this._els.input.value = text;
|
|
2389
|
+
void this._onSend();
|
|
2390
|
+
},
|
|
2391
|
+
});
|
|
1926
2392
|
}
|
|
1927
2393
|
/** Feature-card pattern: tinted icon chip, tags, tight Manrope headline, one
|
|
1928
2394
|
* supporting line, then mono-labelled detail rows. */
|
|
@@ -1982,9 +2448,14 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1982
2448
|
* surface; a2ui="a2ui-results" opts this element back into it for comparison.
|
|
1983
2449
|
*/
|
|
1984
2450
|
_resultsEl(item) {
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
2451
|
+
if (this.getAttribute('a2ui') === 'a2ui-results')
|
|
2452
|
+
return this._resultsElA2ui(item);
|
|
2453
|
+
if (!this._classicPrompts && item.cards.length) {
|
|
2454
|
+
const answer = this._answerEl(item);
|
|
2455
|
+
if (answer)
|
|
2456
|
+
return answer;
|
|
2457
|
+
}
|
|
2458
|
+
return this._resultsElLegacy(item);
|
|
1988
2459
|
}
|
|
1989
2460
|
_resultsElLegacy(item) {
|
|
1990
2461
|
const wrap = document.createElement('div');
|
|
@@ -2367,6 +2838,16 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2367
2838
|
* `fallback`. Inexpensive: each agent is a role over the one shared provider,
|
|
2368
2839
|
* so no additional model is loaded.
|
|
2369
2840
|
*/
|
|
2841
|
+
/** The one agent a page without `agents` runs, on the current provider. */
|
|
2842
|
+
_singleHarness() {
|
|
2843
|
+
return new AgentHarness({
|
|
2844
|
+
provider: this.provider,
|
|
2845
|
+
toolSource: this.toolSource,
|
|
2846
|
+
renderer: this._renderer(),
|
|
2847
|
+
profile: (schema) => this._profileForSchema(schema),
|
|
2848
|
+
onMessages: () => this._saveConversations(),
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2370
2851
|
_buildAgents() {
|
|
2371
2852
|
if (!this.provider || !this._agentDefs) {
|
|
2372
2853
|
this._coordinator = null;
|
|
@@ -2517,6 +2998,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2517
2998
|
if (conversationId === this._activeId && this._els?.thread) {
|
|
2518
2999
|
this._paintHandoffItem(item);
|
|
2519
3000
|
this._scroll();
|
|
3001
|
+
this._noteThreadGrew();
|
|
2520
3002
|
}
|
|
2521
3003
|
}
|
|
2522
3004
|
_paintHandoffItem(item) {
|
|
@@ -2663,6 +3145,42 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2663
3145
|
});
|
|
2664
3146
|
if (outcome.status !== 'none')
|
|
2665
3147
|
this._setStatus('');
|
|
3148
|
+
// The card that travelled with the request finishes here, when there is one.
|
|
3149
|
+
const card = outcome.status === 'none' ? null : this._resumedCard(outcome.pending.steps?.find((st) => st.journey)?.journey);
|
|
3150
|
+
if (card && outcome.status !== 'none') {
|
|
3151
|
+
switch (outcome.status) {
|
|
3152
|
+
case 'no-registry':
|
|
3153
|
+
case 'no-tool':
|
|
3154
|
+
card.stage = 'failed';
|
|
3155
|
+
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.`;
|
|
3156
|
+
break;
|
|
3157
|
+
case 'incomplete':
|
|
3158
|
+
this._reviveCard(card, outcome.tool, outcome.pending.values, outcome.missing);
|
|
3159
|
+
card.stage = 'collect';
|
|
3160
|
+
card.message = undefined;
|
|
3161
|
+
break;
|
|
3162
|
+
case 'rejected':
|
|
3163
|
+
case 'failed':
|
|
3164
|
+
this._reviveCard(card, outcome.tool, outcome.pending.values, []);
|
|
3165
|
+
card.stage = 'failed';
|
|
3166
|
+
card.message = outcome.status === 'failed' ? outcome.error : outcome.text || 'That form did not accept the details.';
|
|
3167
|
+
break;
|
|
3168
|
+
case 'submitted': {
|
|
3169
|
+
const note = this._buildDetailItem(outcome.tool, outcome.pending.values, outcome.text).note;
|
|
3170
|
+
card.message = /submitted successfully/.test(note) ? undefined : note;
|
|
3171
|
+
card.stage = 'done';
|
|
3172
|
+
this._active()?.messages.push({
|
|
3173
|
+
role: 'user',
|
|
3174
|
+
content: `[${outcome.tool.name} submitted with: ${JSON.stringify(outcome.pending.values)}]. ` +
|
|
3175
|
+
'Keep these unless I ask to change them.',
|
|
3176
|
+
});
|
|
3177
|
+
break;
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
this._paintAction(card);
|
|
3181
|
+
this._saveConversations();
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
2666
3184
|
switch (outcome.status) {
|
|
2667
3185
|
case 'none':
|
|
2668
3186
|
return;
|
|
@@ -2984,6 +3502,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2984
3502
|
if (this._els?.thread) {
|
|
2985
3503
|
this.provider = next ?? this.provider;
|
|
2986
3504
|
this._paintProvenance();
|
|
3505
|
+
// Both the single agent and any configured ones: a page without `agents` kept the old
|
|
3506
|
+
// provider's harness, and answered with the model it had just replaced.
|
|
3507
|
+
this._harness = this._singleHarness();
|
|
2987
3508
|
this._buildAgents();
|
|
2988
3509
|
}
|
|
2989
3510
|
}
|
|
@@ -3187,6 +3708,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3187
3708
|
/* A2UI surfaces inherit the tokens above through --a2ui-* bridges. */
|
|
3188
3709
|
${A2UI_THEME_CSS}
|
|
3189
3710
|
|
|
3711
|
+
/* The action card: forms and handoffs as one message. */
|
|
3712
|
+
${ACTION_CARD_CSS}
|
|
3713
|
+
|
|
3714
|
+
/* Search results as an answer: a pick and why, or a short list. */
|
|
3715
|
+
${RESULTS_ANSWER_CSS}
|
|
3716
|
+
|
|
3190
3717
|
/* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
|
|
3191
3718
|
.launcher {
|
|
3192
3719
|
position: fixed; right: 20px; bottom: 20px; z-index: 9998;
|