@nxisai/agent-harness 0.5.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 +38 -4
- package/dist/handoff/a2a.d.ts +152 -0
- package/dist/handoff/a2a.d.ts.map +1 -0
- package/dist/handoff/a2a.js +227 -0
- package/dist/handoff/a2a.js.map +1 -0
- package/dist/handoff/controller.d.ts +181 -0
- package/dist/handoff/controller.d.ts.map +1 -0
- package/dist/handoff/controller.js +543 -0
- package/dist/handoff/controller.js.map +1 -0
- package/dist/handoff/envelope.d.ts +130 -0
- package/dist/handoff/envelope.d.ts.map +1 -0
- package/dist/handoff/envelope.js +178 -0
- package/dist/handoff/envelope.js.map +1 -0
- package/dist/handoff/index.d.ts +16 -0
- package/dist/handoff/index.d.ts.map +1 -0
- package/dist/handoff/index.js +12 -0
- package/dist/handoff/index.js.map +1 -0
- package/dist/handoff/tools.d.ts +36 -0
- package/dist/handoff/tools.d.ts.map +1 -0
- package/dist/handoff/tools.js +155 -0
- package/dist/handoff/tools.js.map +1 -0
- package/dist/harness/harness.d.ts +4 -2
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +53 -12
- 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/router/coordinator.d.ts +13 -0
- package/dist/harness/router/coordinator.d.ts.map +1 -1
- package/dist/harness/router/coordinator.js +7 -0
- package/dist/harness/router/coordinator.js.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/discovery.d.ts +12 -1
- package/dist/harness/tools/discovery.d.ts.map +1 -1
- package/dist/harness/tools/discovery.js +19 -1
- package/dist/harness/tools/discovery.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 +2 -1
- 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 +795 -17
- 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/a2a.ts +326 -0
- package/src/handoff/controller.ts +661 -0
- package/src/handoff/envelope.ts +216 -0
- package/src/handoff/index.ts +53 -0
- package/src/handoff/tools.ts +182 -0
- package/src/harness/harness.ts +55 -14
- package/src/harness/renderer.ts +22 -0
- package/src/harness/router/coordinator.ts +21 -0
- package/src/harness/tools/action-tools.ts +74 -4
- package/src/harness/tools/discovery.ts +20 -2
- package/src/harness/tools/site-actions.ts +58 -0
- 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 +842 -16
- package/src/ui/tailwind.generated.ts +1 -1
|
@@ -34,14 +34,19 @@ 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';
|
|
48
|
+
import { HandoffController, isHandoffOver, } from '../handoff/controller.js';
|
|
49
|
+
import { registerHandoffTools, HANDOFF_SESSION_RESULT } from '../handoff/tools.js';
|
|
45
50
|
import { LocalGemmaProvider, humanize, escapeHtml, NXIS_LOGO_SVG, NXIS_SITE_URL, ICON_CHECK, ICON_WARN, ICON_ARROW, ICON_PLUS, ICON_MENU, ICON_TRASH, ICON_STOP, ICON_COPY, ICON_PENCIL, ICON_REDO, ICON_CHECK_MARK, } from '../engine.js';
|
|
46
51
|
import { DEFAULT_SYSTEM_PROMPT, DEFAULT_SUGGESTIONS, DEFAULT_EMPTY_STATE, DEFAULT_AGENT_NAME, DEFAULT_LOGO, THEME_TOKENS, PROFILE_ALIASES, } from './config.js';
|
|
47
52
|
// Prefixed keys so state never collides with other Nxis examples on the same origin.
|
|
@@ -76,6 +81,11 @@ const fmt = (n) => n.toLocaleString();
|
|
|
76
81
|
* be disabled under `prefers-reduced-motion` while the bubble still reads as
|
|
77
82
|
* pending. The label and byte spans are filled per phase and hidden while empty.
|
|
78
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;
|
|
79
89
|
const THINKING_MARKUP = '<span class="think-row">' +
|
|
80
90
|
'<span class="think-dots" aria-hidden="true"><i></i><i></i><i></i></span>' +
|
|
81
91
|
'<span class="think-label"></span>' +
|
|
@@ -186,6 +196,35 @@ function fillMessage(el, text) {
|
|
|
186
196
|
}
|
|
187
197
|
}
|
|
188
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* A handoff's state as a line in the thread, or '' for a state the bar alone
|
|
201
|
+
* shows.
|
|
202
|
+
*/
|
|
203
|
+
function handoffLine(session) {
|
|
204
|
+
const who = session.label;
|
|
205
|
+
switch (session.state) {
|
|
206
|
+
case 'connecting':
|
|
207
|
+
return `Connecting you to ${who}…`;
|
|
208
|
+
case 'queued':
|
|
209
|
+
// The bar says where they are in line; a desk usually says it in words too.
|
|
210
|
+
return '';
|
|
211
|
+
case 'active':
|
|
212
|
+
return session.operator && session.operator !== who ? `${session.operator} has joined` : `You're talking with ${who}`;
|
|
213
|
+
case 'awaiting-visitor':
|
|
214
|
+
return '';
|
|
215
|
+
case 'handed-back':
|
|
216
|
+
return 'Back with the assistant';
|
|
217
|
+
case 'ended':
|
|
218
|
+
return `${who} has finished. Back with the assistant.`;
|
|
219
|
+
case 'failed':
|
|
220
|
+
return `${session.error ?? `${who} couldn't continue.`} Back with the assistant.`;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function ordinal(n) {
|
|
224
|
+
const s = ['th', 'st', 'nd', 'rd'];
|
|
225
|
+
const v = n % 100;
|
|
226
|
+
return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
|
|
227
|
+
}
|
|
189
228
|
class NxisAgentFullscreen extends HTMLElement {
|
|
190
229
|
constructor() {
|
|
191
230
|
super();
|
|
@@ -214,6 +253,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
214
253
|
};
|
|
215
254
|
/** Watches whether the running journey's card is on screen. */
|
|
216
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;
|
|
217
262
|
/** Built only when the host defines agents — otherwise there is one harness. */
|
|
218
263
|
this._coordinator = null;
|
|
219
264
|
this._agentDefs = null;
|
|
@@ -221,6 +266,14 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
221
266
|
this._fallbackAgent = '';
|
|
222
267
|
/** The live ask/confirm surface, if one is open. */
|
|
223
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();
|
|
224
277
|
this._persistPrefs = false;
|
|
225
278
|
this._systemPrompt = DEFAULT_SYSTEM_PROMPT;
|
|
226
279
|
this._suggestions = DEFAULT_SUGGESTIONS;
|
|
@@ -249,6 +302,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
249
302
|
this._emptyLoading = null;
|
|
250
303
|
/** A provider the host supplied, used instead of the local model. */
|
|
251
304
|
this._provider = null;
|
|
305
|
+
/** Runs handoffs to agents and people, when the host configured any. */
|
|
306
|
+
this._handoff = null;
|
|
307
|
+
this._offHandoff = null;
|
|
252
308
|
this.attachShadow({ mode: 'open' });
|
|
253
309
|
this._turnActive = false;
|
|
254
310
|
this._tool = null; // tool of the live inline form (if any)
|
|
@@ -279,6 +335,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
279
335
|
status: root.querySelector('.statusline'),
|
|
280
336
|
bar: root.querySelector('.prog > i'),
|
|
281
337
|
jstatus: root.querySelector('.jstatus'),
|
|
338
|
+
handoffBar: root.querySelector('.handoff-bar'),
|
|
339
|
+
handoffLabel: root.querySelector('.handoff-bar .hb-label'),
|
|
340
|
+
handoffBack: root.querySelector('.handoff-bar .hb-back'),
|
|
282
341
|
input: root.querySelector('.composer textarea'),
|
|
283
342
|
go: root.querySelector('.composer .go'),
|
|
284
343
|
toolsBtn: root.querySelector('.tools-btn'),
|
|
@@ -312,13 +371,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
312
371
|
this._buildAgents();
|
|
313
372
|
// The element renders; the harness decides. Everything from choosing a
|
|
314
373
|
// tool to running it happens behind harness.turn().
|
|
315
|
-
this._harness =
|
|
316
|
-
provider: this.provider,
|
|
317
|
-
toolSource: this.toolSource,
|
|
318
|
-
renderer: this._renderer(),
|
|
319
|
-
profile: (schema) => this._profileForSchema(schema),
|
|
320
|
-
onMessages: () => this._saveConversations(),
|
|
321
|
-
});
|
|
374
|
+
this._harness = this._singleHarness();
|
|
322
375
|
// Preferences are supplied by the host page through `element.preferences`.
|
|
323
376
|
// Anything a previous build persisted is picked up so upgrading does not
|
|
324
377
|
// silently drop a user's saved details; `persist` then stays true so they
|
|
@@ -350,6 +403,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
350
403
|
this._els.jstatus.addEventListener('click', () => {
|
|
351
404
|
const cards = this._els.thread.querySelectorAll('.journey.is-running');
|
|
352
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();
|
|
353
408
|
});
|
|
354
409
|
// Whoever builds the tools, a call that leaves this page reports back to
|
|
355
410
|
// the thread it was made in.
|
|
@@ -359,6 +414,11 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
359
414
|
void this._resumePendingAction();
|
|
360
415
|
this._resumePendingVisit();
|
|
361
416
|
this._els.launcher.addEventListener('click', () => this._open());
|
|
417
|
+
this._els.handoffBack.addEventListener('click', () => void this._endHandoff());
|
|
418
|
+
// A handoff left open on the last page carries on here.
|
|
419
|
+
this._paintHandoffBar();
|
|
420
|
+
if (this._activeId)
|
|
421
|
+
void this._handoff?.resume(this._activeId);
|
|
362
422
|
this._els.close.addEventListener('click', () => {
|
|
363
423
|
this._els.overlay.classList.remove('show');
|
|
364
424
|
// A remote turn can outlive the panel, and its answer would go unread.
|
|
@@ -440,6 +500,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
440
500
|
this._offJourney = null;
|
|
441
501
|
this._journeyObserver?.disconnect();
|
|
442
502
|
this._journeyObserver = null;
|
|
503
|
+
this._clearJourneyStatusTimer();
|
|
504
|
+
// Streams stop with the element; the handoff itself stays saved, and resumes when it returns.
|
|
505
|
+
this._handoff?.dispose();
|
|
443
506
|
}
|
|
444
507
|
/** Programmatic open (used by pages that dock the agent full-page). */
|
|
445
508
|
open() {
|
|
@@ -507,6 +570,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
507
570
|
this._renderHistory();
|
|
508
571
|
this._renderThread();
|
|
509
572
|
this._saveConversations();
|
|
573
|
+
this._paintHandoffBar();
|
|
510
574
|
}
|
|
511
575
|
if (this._els.input)
|
|
512
576
|
this._els.input.focus();
|
|
@@ -521,6 +585,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
521
585
|
this._setNav(false);
|
|
522
586
|
this._renderHistory();
|
|
523
587
|
this._renderThread();
|
|
588
|
+
this._paintHandoffBar();
|
|
589
|
+
void this._handoff?.resume(id);
|
|
524
590
|
}
|
|
525
591
|
_renderHistory() {
|
|
526
592
|
this._els.history.innerHTML = this._conversations
|
|
@@ -620,8 +686,15 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
620
686
|
this._els.thread.appendChild(this._resultsEl(it));
|
|
621
687
|
else if (it.kind === 'metrics')
|
|
622
688
|
this._els.thread.appendChild(this._metricsEl(it));
|
|
623
|
-
else if (it.kind === 'journey')
|
|
624
|
-
|
|
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);
|
|
696
|
+
else if (it.kind === 'remote' || it.kind === 'handoff')
|
|
697
|
+
this._paintHandoffItem(it);
|
|
625
698
|
}
|
|
626
699
|
this._scroll();
|
|
627
700
|
this._watchRunningJourney();
|
|
@@ -989,6 +1062,14 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
989
1062
|
item = { kind: 'journey', id: view.id, steps: all };
|
|
990
1063
|
conv.items.push(item);
|
|
991
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
|
+
}
|
|
992
1073
|
if (conv.id === this._activeId && this._els?.thread) {
|
|
993
1074
|
const fresh = this._journeyEl(item);
|
|
994
1075
|
const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
|
|
@@ -1022,7 +1103,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1022
1103
|
return;
|
|
1023
1104
|
const cards = this._els.thread.querySelectorAll('.journey.is-running');
|
|
1024
1105
|
const card = cards[cards.length - 1];
|
|
1025
|
-
|
|
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') {
|
|
1026
1112
|
bar.hidden = true;
|
|
1027
1113
|
return;
|
|
1028
1114
|
}
|
|
@@ -1032,6 +1118,37 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1032
1118
|
}, { root: this._els.thread, threshold: 0.2 });
|
|
1033
1119
|
this._journeyObserver.observe(card);
|
|
1034
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
|
+
}
|
|
1035
1152
|
_paintToolResult(name, text, isError) {
|
|
1036
1153
|
this._clearEmpty();
|
|
1037
1154
|
const el = this._toolEl(name, text, isError);
|
|
@@ -1088,6 +1205,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1088
1205
|
const el = this._paintMsg(role, text, conv.items.length - 1);
|
|
1089
1206
|
this._saveConversations();
|
|
1090
1207
|
this._scroll();
|
|
1208
|
+
this._noteThreadGrew();
|
|
1091
1209
|
return el;
|
|
1092
1210
|
}
|
|
1093
1211
|
/**
|
|
@@ -1107,6 +1225,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1107
1225
|
// without the focus moving to it.
|
|
1108
1226
|
el.setAttribute('aria-live', 'polite');
|
|
1109
1227
|
this._scroll();
|
|
1228
|
+
this._noteThreadGrew();
|
|
1110
1229
|
const setLabel = (label) => {
|
|
1111
1230
|
const node = el.querySelector('.think-label');
|
|
1112
1231
|
if (node)
|
|
@@ -1196,6 +1315,18 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1196
1315
|
this._renderHistory();
|
|
1197
1316
|
this._saveConversations();
|
|
1198
1317
|
}
|
|
1318
|
+
// Handed off: this message is for the target, not the assistant.
|
|
1319
|
+
const handed = this._handoff?.active(this._activeId ?? undefined);
|
|
1320
|
+
if (handed && this._activeId) {
|
|
1321
|
+
try {
|
|
1322
|
+
await this._handoff.send(this._activeId, goal);
|
|
1323
|
+
}
|
|
1324
|
+
catch (err) {
|
|
1325
|
+
const why = this._handoff.session(this._activeId)?.error ?? String(err?.message || err);
|
|
1326
|
+
this._appendHandoffItem(this._activeId, { kind: 'handoff', text: why, tone: 'error' });
|
|
1327
|
+
}
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1199
1330
|
this._turnActive = true;
|
|
1200
1331
|
this._setStoppable(true);
|
|
1201
1332
|
let settle;
|
|
@@ -1237,8 +1368,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1237
1368
|
message: (role, text) => this._appendMsg(role, text),
|
|
1238
1369
|
streaming: () => this._appendStreaming(),
|
|
1239
1370
|
status: (text, isError) => this._setStatus(text, isError),
|
|
1240
|
-
|
|
1241
|
-
|
|
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),
|
|
1242
1375
|
results: (tool, res) => {
|
|
1243
1376
|
const conv = this._active();
|
|
1244
1377
|
const item = this._buildResultsItem(tool, res);
|
|
@@ -1260,6 +1393,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1260
1393
|
this._scroll();
|
|
1261
1394
|
},
|
|
1262
1395
|
detail: (tool, values, text) => {
|
|
1396
|
+
if (this._cardDone(tool, values, text))
|
|
1397
|
+
return;
|
|
1263
1398
|
const conv = this._active();
|
|
1264
1399
|
const item = this._buildDetailItem(tool, values, text);
|
|
1265
1400
|
conv?.items.push(item);
|
|
@@ -1284,8 +1419,24 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1284
1419
|
this._saveConversations();
|
|
1285
1420
|
this._scroll();
|
|
1286
1421
|
},
|
|
1287
|
-
toolError: (tool, message) =>
|
|
1288
|
-
|
|
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
|
+
},
|
|
1289
1440
|
// The harness says when stopping stops being possible, because it is the
|
|
1290
1441
|
// part that knows a tool has been invoked. Once a call is out, offering
|
|
1291
1442
|
// to stop would be offering to un-send a booking.
|
|
@@ -1417,6 +1568,378 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1417
1568
|
this._formEl = wrap;
|
|
1418
1569
|
this._scroll();
|
|
1419
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
|
+
}
|
|
1420
1943
|
// ── Inline generated form (from JSON-Schema) ───────────────────────────────
|
|
1421
1944
|
_renderForm(tool, args) {
|
|
1422
1945
|
if (this._useA2ui) {
|
|
@@ -1626,6 +2149,19 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1626
2149
|
submitBtn.disabled = false;
|
|
1627
2150
|
return;
|
|
1628
2151
|
}
|
|
2152
|
+
// A handoff started: what happens next arrives from the target, and the
|
|
2153
|
+
// controller's events draw it. There is nothing to show as "Submitted".
|
|
2154
|
+
const started = res?.structuredContent?.[HANDOFF_SESSION_RESULT];
|
|
2155
|
+
if (started) {
|
|
2156
|
+
this._dismissPrompt();
|
|
2157
|
+
this._active()?.messages.push({
|
|
2158
|
+
role: 'user',
|
|
2159
|
+
content: `[${tool.name}: the conversation was handed to ${started.label ?? 'another agent'}. Their replies go to the visitor directly.]`,
|
|
2160
|
+
});
|
|
2161
|
+
this._saveConversations();
|
|
2162
|
+
this._paintHandoffBar();
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
1629
2165
|
// A call that left for another page has filled in nothing yet, so it
|
|
1630
2166
|
// must not become a "Submitted" card. The page it is opening finishes
|
|
1631
2167
|
// the job and draws the card there.
|
|
@@ -2293,6 +2829,16 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2293
2829
|
* `fallback`. Inexpensive: each agent is a role over the one shared provider,
|
|
2294
2830
|
* so no additional model is loaded.
|
|
2295
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
|
+
}
|
|
2296
2842
|
_buildAgents() {
|
|
2297
2843
|
if (!this.provider || !this._agentDefs) {
|
|
2298
2844
|
this._coordinator = null;
|
|
@@ -2332,6 +2878,172 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2332
2878
|
onRoute: (routed) => this._paintRoute(routed.agent, routed.via),
|
|
2333
2879
|
});
|
|
2334
2880
|
}
|
|
2881
|
+
// ── Handoff ────────────────────────────────────────────────────────────────
|
|
2882
|
+
/**
|
|
2883
|
+
* Handing conversations to other agents and to people.
|
|
2884
|
+
*
|
|
2885
|
+
* Set to `{ relay, targets }` to offer `transfer_to_agent` and
|
|
2886
|
+
* `talk_to_a_person` for those targets (see docs/handoff.md). While a
|
|
2887
|
+
* conversation is handed off, what the visitor types goes to the target,
|
|
2888
|
+
* and a bar above the input shows who is listening and the way back.
|
|
2889
|
+
*/
|
|
2890
|
+
get handoff() {
|
|
2891
|
+
return this._handoff?.config ?? null;
|
|
2892
|
+
}
|
|
2893
|
+
set handoff(next) {
|
|
2894
|
+
this._offHandoff?.();
|
|
2895
|
+
this._offHandoff = null;
|
|
2896
|
+
this._handoff?.dispose();
|
|
2897
|
+
this._handoff = null;
|
|
2898
|
+
if (!next?.relay || !next.targets) {
|
|
2899
|
+
if (this._els?.thread)
|
|
2900
|
+
this._paintHandoffBar();
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
const controller = new HandoffController(next);
|
|
2904
|
+
const listener = (e) => this._onHandoffEvent(e.detail);
|
|
2905
|
+
controller.addEventListener('handoff', listener);
|
|
2906
|
+
this._offHandoff = () => controller.removeEventListener('handoff', listener);
|
|
2907
|
+
this._handoff = controller;
|
|
2908
|
+
// Tools already connecting or connected: add the handoff tools, and make the next turn wait for them.
|
|
2909
|
+
if (this._connect)
|
|
2910
|
+
this._connect = this._connect.then(() => this._registerHandoffBounded());
|
|
2911
|
+
if (this._els?.thread) {
|
|
2912
|
+
this._paintHandoffBar();
|
|
2913
|
+
if (this._activeId)
|
|
2914
|
+
void controller.resume(this._activeId);
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
/** {@link _registerHandoff}, given at most four seconds: a slow relay must not hold up the conversation. */
|
|
2918
|
+
_registerHandoffBounded() {
|
|
2919
|
+
return Promise.race([this._registerHandoff(), new Promise((r) => setTimeout(r, 4000))]).catch((err) => console.warn('[nxis-agent] handoff tools unavailable:', err));
|
|
2920
|
+
}
|
|
2921
|
+
/** Offer the handoff tools on the page's registry. */
|
|
2922
|
+
async _registerHandoff() {
|
|
2923
|
+
const controller = this._handoff;
|
|
2924
|
+
const mc = this.toolSource?.modelContext();
|
|
2925
|
+
if (!controller || !mc)
|
|
2926
|
+
return;
|
|
2927
|
+
const added = await registerHandoffTools(mc, controller, {
|
|
2928
|
+
conversationId: () => this._activeId ?? undefined,
|
|
2929
|
+
transcript: () => this._transcript(),
|
|
2930
|
+
});
|
|
2931
|
+
if (added.length)
|
|
2932
|
+
void this._refreshToolCount();
|
|
2933
|
+
}
|
|
2934
|
+
/** The conversation's last turns, text only, for a target allowed to see them. */
|
|
2935
|
+
_transcript() {
|
|
2936
|
+
const conv = this._active();
|
|
2937
|
+
if (!conv)
|
|
2938
|
+
return [];
|
|
2939
|
+
return conv.items
|
|
2940
|
+
.filter((it) => it.kind === 'msg' && (it.role === 'user' || it.role === 'assistant'))
|
|
2941
|
+
.slice(-10)
|
|
2942
|
+
.map((it) => ({ role: it.role === 'user' ? 'visitor' : 'assistant', text: it.text }));
|
|
2943
|
+
}
|
|
2944
|
+
/** What a handoff reported, into the conversation it belongs to. */
|
|
2945
|
+
_onHandoffEvent(e) {
|
|
2946
|
+
const conv = this._conversations.find((c) => c.id === e.conversationId);
|
|
2947
|
+
if (!conv)
|
|
2948
|
+
return;
|
|
2949
|
+
if (e.type === 'message') {
|
|
2950
|
+
this._appendHandoffItem(conv.id, { kind: 'remote', from: e.from, fromKind: e.fromKind, text: e.text });
|
|
2951
|
+
}
|
|
2952
|
+
else if (e.type === 'handback') {
|
|
2953
|
+
const { outcome, note, references } = e.handBack;
|
|
2954
|
+
const refs = references.map((r) => `${r.label}: ${r.value}`).join(' · ');
|
|
2955
|
+
// The assistant carries on from here, so it learns what happened: the note, and nothing else the
|
|
2956
|
+
// target said. A remote reply is shown to the visitor and never read as an instruction.
|
|
2957
|
+
conv.messages.push({
|
|
2958
|
+
role: 'user',
|
|
2959
|
+
content: `[${e.session.label} handed the conversation back (${outcome})${note ? `: ${note}` : ''}${refs ? `. ${refs}` : ''}]`,
|
|
2960
|
+
});
|
|
2961
|
+
if (refs)
|
|
2962
|
+
this._appendHandoffItem(conv.id, { kind: 'handoff', text: refs, tone: 'done' });
|
|
2963
|
+
}
|
|
2964
|
+
else {
|
|
2965
|
+
const text = handoffLine(e.session);
|
|
2966
|
+
const last = [...conv.items].reverse().find((it) => it.kind === 'handoff');
|
|
2967
|
+
if (text && last?.text !== text) {
|
|
2968
|
+
const tone = e.state === 'failed' ? 'error' : isHandoffOver(e.state) ? 'done' : 'info';
|
|
2969
|
+
this._appendHandoffItem(conv.id, { kind: 'handoff', text, tone });
|
|
2970
|
+
}
|
|
2971
|
+
if (e.state === 'ended' || e.state === 'failed') {
|
|
2972
|
+
conv.messages.push({
|
|
2973
|
+
role: 'user',
|
|
2974
|
+
content: `[The conversation with ${e.session.label} ${e.state === 'failed' ? `failed${e.session.error ? `: ${e.session.error}` : ''}` : 'ended'}. You are handling it again.]`,
|
|
2975
|
+
});
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
this._saveConversations();
|
|
2979
|
+
if (conv.id === this._activeId)
|
|
2980
|
+
this._paintHandoffBar();
|
|
2981
|
+
}
|
|
2982
|
+
/** Add a handoff item to a conversation, and draw it when that conversation is on screen. */
|
|
2983
|
+
_appendHandoffItem(conversationId, item) {
|
|
2984
|
+
const conv = this._conversations.find((c) => c.id === conversationId);
|
|
2985
|
+
if (!conv)
|
|
2986
|
+
return;
|
|
2987
|
+
conv.items.push(item);
|
|
2988
|
+
this._saveConversations();
|
|
2989
|
+
if (conversationId === this._activeId && this._els?.thread) {
|
|
2990
|
+
this._paintHandoffItem(item);
|
|
2991
|
+
this._scroll();
|
|
2992
|
+
this._noteThreadGrew();
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
_paintHandoffItem(item) {
|
|
2996
|
+
this._clearEmpty();
|
|
2997
|
+
let el;
|
|
2998
|
+
if (item.kind === 'remote') {
|
|
2999
|
+
el = document.createElement('div');
|
|
3000
|
+
el.className = 'remote';
|
|
3001
|
+
const who = document.createElement('div');
|
|
3002
|
+
who.className = 'remote-from';
|
|
3003
|
+
who.textContent = item.fromKind === 'agent' ? `${item.from} · agent` : item.from;
|
|
3004
|
+
el.append(who, this._msgEl('assistant', item.text));
|
|
3005
|
+
}
|
|
3006
|
+
else {
|
|
3007
|
+
el = document.createElement('div');
|
|
3008
|
+
el.className = `handoff-note ${item.tone}`;
|
|
3009
|
+
el.textContent = item.text;
|
|
3010
|
+
}
|
|
3011
|
+
this._els.thread.appendChild(el);
|
|
3012
|
+
return el;
|
|
3013
|
+
}
|
|
3014
|
+
/** The bar above the input: shown while the conversation is handed off. */
|
|
3015
|
+
_paintHandoffBar() {
|
|
3016
|
+
if (!this._els?.handoffBar)
|
|
3017
|
+
return;
|
|
3018
|
+
const session = this._handoff?.active(this._activeId ?? undefined) ?? null;
|
|
3019
|
+
this._els.handoffBar.hidden = !session;
|
|
3020
|
+
const input = this._els.input;
|
|
3021
|
+
if (!session) {
|
|
3022
|
+
input.placeholder = 'Message the agent…';
|
|
3023
|
+
input.setAttribute('aria-label', 'Message the agent');
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
3026
|
+
const who = session.operator ?? session.label;
|
|
3027
|
+
const q = session.queue;
|
|
3028
|
+
const inLine = q?.position ? ` · ${ordinal(q.position)} in line${q.waitMinutes ? `, about ${q.waitMinutes} min` : ''}` : ' · waiting in line';
|
|
3029
|
+
const doing = session.state === 'connecting'
|
|
3030
|
+
? ' · connecting…'
|
|
3031
|
+
: session.state === 'queued'
|
|
3032
|
+
? inLine
|
|
3033
|
+
: session.state === 'awaiting-visitor'
|
|
3034
|
+
? ' · waiting for your reply'
|
|
3035
|
+
: '';
|
|
3036
|
+
this._els.handoffLabel.textContent = `Talking with ${who}${doing}`;
|
|
3037
|
+
input.placeholder = `Message ${who}…`;
|
|
3038
|
+
input.setAttribute('aria-label', `Message ${who}`);
|
|
3039
|
+
}
|
|
3040
|
+
/** "Back to the assistant": end the handoff for this conversation. */
|
|
3041
|
+
async _endHandoff() {
|
|
3042
|
+
if (!this._activeId || !this._handoff)
|
|
3043
|
+
return;
|
|
3044
|
+
await this._handoff.end(this._activeId);
|
|
3045
|
+
this._els.input.focus();
|
|
3046
|
+
}
|
|
2335
3047
|
/** Zero when the provider cannot count, which hides the token metrics. */
|
|
2336
3048
|
async countTokens(text) {
|
|
2337
3049
|
return (await this.provider.countTokens?.(text)) ?? 0;
|
|
@@ -2424,6 +3136,42 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2424
3136
|
});
|
|
2425
3137
|
if (outcome.status !== 'none')
|
|
2426
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
|
+
}
|
|
2427
3175
|
switch (outcome.status) {
|
|
2428
3176
|
case 'none':
|
|
2429
3177
|
return;
|
|
@@ -2511,7 +3259,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2511
3259
|
void this._renderTools();
|
|
2512
3260
|
});
|
|
2513
3261
|
})
|
|
2514
|
-
.catch((err) => console.warn('[nxis-agent] tool discovery failed:', err))
|
|
3262
|
+
.catch((err) => console.warn('[nxis-agent] tool discovery failed:', err))
|
|
3263
|
+
// Part of connecting, so the first turn waits for the handoff tools: a turn that ran without
|
|
3264
|
+
// them could only claim, in words, to be handing the visitor over.
|
|
3265
|
+
.then(() => this._registerHandoffBounded());
|
|
2515
3266
|
return this._connect;
|
|
2516
3267
|
}
|
|
2517
3268
|
// ── Tools menu ─────────────────────────────────────────────────────────────
|
|
@@ -2742,6 +3493,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2742
3493
|
if (this._els?.thread) {
|
|
2743
3494
|
this.provider = next ?? this.provider;
|
|
2744
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();
|
|
2745
3499
|
this._buildAgents();
|
|
2746
3500
|
}
|
|
2747
3501
|
}
|
|
@@ -2945,6 +3699,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2945
3699
|
/* A2UI surfaces inherit the tokens above through --a2ui-* bridges. */
|
|
2946
3700
|
${A2UI_THEME_CSS}
|
|
2947
3701
|
|
|
3702
|
+
/* The action card: forms and handoffs as one message. */
|
|
3703
|
+
${ACTION_CARD_CSS}
|
|
3704
|
+
|
|
2948
3705
|
/* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
|
|
2949
3706
|
.launcher {
|
|
2950
3707
|
position: fixed; right: 20px; bottom: 20px; z-index: 9998;
|
|
@@ -3396,6 +4153,23 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3396
4153
|
padding: 2px 0 6px;
|
|
3397
4154
|
letter-spacing: .01em;
|
|
3398
4155
|
}
|
|
4156
|
+
|
|
4157
|
+
/* Handoffs. A target's message is a bubble with its name above; where the
|
|
4158
|
+
handoff stands is a quiet centred line; the bar says who is listening. */
|
|
4159
|
+
.remote { align-self: flex-start; display: flex; flex-direction: column; gap: 4px; max-width: 76%; }
|
|
4160
|
+
.remote .msg { max-width: 100%; }
|
|
4161
|
+
.remote-from { font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em; color: var(--ink-3); padding-left: 4px; }
|
|
4162
|
+
.handoff-note { align-self: center; font-size: 12px; color: var(--ink-3); padding: 2px 12px 6px; text-align: center; letter-spacing: .01em; }
|
|
4163
|
+
.handoff-note.done { color: var(--green-strong); }
|
|
4164
|
+
.handoff-note.error { color: var(--red); }
|
|
4165
|
+
.handoff-bar { display: flex; align-items: center; gap: 8px; margin: 0 20px 6px; padding: 6px 6px 6px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--green-tint); color: var(--ink); font-family: var(--sans); font-size: 12.5px; }
|
|
4166
|
+
.handoff-bar[hidden] { display: none; }
|
|
4167
|
+
.handoff-bar .hb-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex: none; }
|
|
4168
|
+
.handoff-bar .hb-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
|
4169
|
+
.handoff-bar .hb-back { flex: none; border: 1px solid var(--border-strong); background: var(--surface); color: var(--ink); border-radius: var(--radius-pill); padding: 4px 11px; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
|
|
4170
|
+
.handoff-bar .hb-back:hover { border-color: var(--ink-3); }
|
|
4171
|
+
.handoff-bar .hb-back:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
|
4172
|
+
.overlay.window .handoff-bar, .overlay.inline .handoff-bar { margin: 0 14px 6px; }
|
|
3399
4173
|
.composer .go svg { width: 17px; height: 17px; }
|
|
3400
4174
|
|
|
3401
4175
|
/* Attribution. Quiet enough to sit under a customer's own chat surface
|
|
@@ -3609,6 +4383,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3609
4383
|
</button>
|
|
3610
4384
|
<div class="thread"></div>
|
|
3611
4385
|
<div class="statusline"></div>
|
|
4386
|
+
<div class="handoff-bar" role="status" hidden>
|
|
4387
|
+
<span class="hb-dot" aria-hidden="true"></span><span class="hb-label"></span>
|
|
4388
|
+
<button class="hb-back" type="button">Back to the assistant</button>
|
|
4389
|
+
</div>
|
|
3612
4390
|
<div class="composer">
|
|
3613
4391
|
<div class="cbox">
|
|
3614
4392
|
<textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>
|