@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
|
@@ -41,16 +41,33 @@ import {
|
|
|
41
41
|
import type { ModelProvider } from '../providers/types.js';
|
|
42
42
|
import { connectTools } from '../harness/tools/source.js';
|
|
43
43
|
import {
|
|
44
|
+
chooserOf,
|
|
45
|
+
formOptions,
|
|
46
|
+
formsFor,
|
|
44
47
|
handoffFor,
|
|
45
48
|
isSiteTool,
|
|
46
49
|
withoutSiteSuffix,
|
|
47
50
|
HANDOFF_RESULT,
|
|
48
51
|
} from '../harness/tools/action-tools.js';
|
|
52
|
+
import { resolveSlots, type Slot } from '../harness/slots.js';
|
|
53
|
+
import { isHandoffTool } from '../handoff/tools.js';
|
|
54
|
+
import {
|
|
55
|
+
actionCard,
|
|
56
|
+
fieldLabel,
|
|
57
|
+
fieldsFromSlots,
|
|
58
|
+
ACTION_CARD_CSS,
|
|
59
|
+
type ActionHandlers,
|
|
60
|
+
type ActionItem,
|
|
61
|
+
type ActionStep,
|
|
62
|
+
} from './action-card.js';
|
|
63
|
+
import { resultsAnswer, RESULTS_ANSWER_CSS, type AnswerCard } from './result-pick.js';
|
|
64
|
+
import { extractRecords, openStatus, recordFacts } from '../harness/records.js';
|
|
65
|
+
import type { ResultPick } from '../harness/recommend.js';
|
|
49
66
|
import { resumePendingAction } from '../harness/tools/resume.js';
|
|
50
67
|
import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
|
|
51
68
|
import { pageForEntity, type SitePage } from '../harness/tools/site-pages.js';
|
|
52
69
|
import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
|
|
53
|
-
import { onJourneyChange, recordStep, restoreJourney, type AgentStep } from '../harness/steps.js';
|
|
70
|
+
import { currentJourney, onJourneyChange, recordStep, restoreJourney, type AgentStep } from '../harness/steps.js';
|
|
54
71
|
import { viewJourney } from './journey.js';
|
|
55
72
|
import { AgentCoordinator } from '../harness/router/coordinator.js';
|
|
56
73
|
import {
|
|
@@ -59,6 +76,7 @@ import {
|
|
|
59
76
|
type HandoffConfig,
|
|
60
77
|
type HandoffEvent,
|
|
61
78
|
type HandoffSession,
|
|
79
|
+
type HandoffTarget,
|
|
62
80
|
} from '../handoff/controller.js';
|
|
63
81
|
import { registerHandoffTools, HANDOFF_SESSION_RESULT } from '../handoff/tools.js';
|
|
64
82
|
import type { RouteRule } from '../harness/router/rules.js';
|
|
@@ -151,6 +169,12 @@ const fmt = (n: number) => n.toLocaleString();
|
|
|
151
169
|
* be disabled under `prefers-reduced-motion` while the bubble still reads as
|
|
152
170
|
* pending. The label and byte spans are filled per phase and hidden while empty.
|
|
153
171
|
*/
|
|
172
|
+
/**
|
|
173
|
+
* How long the running-step line stays once the conversation moves on past
|
|
174
|
+
* its card. A glance's worth: long enough to notice and open it.
|
|
175
|
+
*/
|
|
176
|
+
const JSTATUS_TIMEOUT_MS = 10_000;
|
|
177
|
+
|
|
154
178
|
const THINKING_MARKUP =
|
|
155
179
|
'<span class="think-row">' +
|
|
156
180
|
'<span class="think-dots" aria-hidden="true"><i></i><i></i><i></i></span>' +
|
|
@@ -309,6 +333,14 @@ interface ResultCard {
|
|
|
309
333
|
id?: string;
|
|
310
334
|
/** The page on this site the record came from, when it can be told. */
|
|
311
335
|
page?: { url: string; label: string };
|
|
336
|
+
/** The rating and how many reviews it rests on. */
|
|
337
|
+
stars?: number;
|
|
338
|
+
reviews?: number;
|
|
339
|
+
/** Open or closed when the search ran: "Open now · 24/7", "Opens 7am". */
|
|
340
|
+
status?: { open: boolean; label: string };
|
|
341
|
+
phone?: string;
|
|
342
|
+
/** What the record states, short, for comparing it. */
|
|
343
|
+
facts?: string[];
|
|
312
344
|
}
|
|
313
345
|
|
|
314
346
|
type ThreadItem =
|
|
@@ -317,7 +349,7 @@ type ThreadItem =
|
|
|
317
349
|
| { kind: 'trace'; name: string; args: Record<string, unknown>; text: string; isError: boolean; count?: number }
|
|
318
350
|
| { kind: 'route'; agent: string; via: string }
|
|
319
351
|
| { kind: 'detail'; title: string; note: string; rows: DetailRow[] }
|
|
320
|
-
| { kind: 'results'; title: string; note: string; cards: ResultCard[] }
|
|
352
|
+
| { kind: 'results'; title: string; note: string; cards: ResultCard[]; pick?: ResultPick }
|
|
321
353
|
| { kind: 'metrics'; rounds: MetricsRound[] }
|
|
322
354
|
| { kind: 'form' }
|
|
323
355
|
/** A turn the person called off. Kept so the thread explains its own gap. */
|
|
@@ -331,7 +363,9 @@ type ThreadItem =
|
|
|
331
363
|
/** Something a handoff target said: an agent, or a person at a desk. */
|
|
332
364
|
| { kind: 'remote'; from: string; fromKind: 'agent' | 'person'; text: string }
|
|
333
365
|
/** Where a handoff stands, as a quiet line in the thread. */
|
|
334
|
-
| { kind: 'handoff'; text: string; tone: 'info' | 'done' | 'error' }
|
|
366
|
+
| { kind: 'handoff'; text: string; tone: 'info' | 'done' | 'error' }
|
|
367
|
+
/** A form or a handoff, asked for, confirmed and sent in one card. See ./action-card.ts. */
|
|
368
|
+
| ActionItem;
|
|
335
369
|
|
|
336
370
|
/**
|
|
337
371
|
* A handoff's state as a line in the thread, or '' for a state the bar alone
|
|
@@ -457,6 +491,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
457
491
|
};
|
|
458
492
|
/** Watches whether the running journey's card is on screen. */
|
|
459
493
|
private _journeyObserver: IntersectionObserver | null = null;
|
|
494
|
+
/** The journey the status line is about, while one is running. */
|
|
495
|
+
private _jstatusJourney: string | null = null;
|
|
496
|
+
/** Journeys whose status line was opened once or timed out: it does not come back. */
|
|
497
|
+
private _jstatusDismissed = new Set<string>();
|
|
498
|
+
/** Dismisses the status line a while after the thread moves on past the card. */
|
|
499
|
+
private _jstatusTimer: ReturnType<typeof setTimeout> | null = null;
|
|
460
500
|
/** The turn loop. This element is only its renderer. */
|
|
461
501
|
private _harness!: AgentHarness;
|
|
462
502
|
/** Built only when the host defines agents — otherwise there is one harness. */
|
|
@@ -466,6 +506,22 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
466
506
|
private _fallbackAgent = '';
|
|
467
507
|
/** The live ask/confirm surface, if one is open. */
|
|
468
508
|
private _prompt: MountedSurface | null = null;
|
|
509
|
+
/**
|
|
510
|
+
* The action card still waiting on the visitor or a result, and what it can
|
|
511
|
+
* do: the harness's ask or confirm request, or, after a page change, a call
|
|
512
|
+
* of the widget's own.
|
|
513
|
+
*/
|
|
514
|
+
private _live: {
|
|
515
|
+
item: ActionItem;
|
|
516
|
+
tool: ModelContextToolInfo;
|
|
517
|
+
ask?: AskRequest;
|
|
518
|
+
confirm?: ConfirmRequest;
|
|
519
|
+
run?: (values: Record<string, unknown>) => Promise<void>;
|
|
520
|
+
sent: boolean;
|
|
521
|
+
declining: boolean;
|
|
522
|
+
} | null = null;
|
|
523
|
+
/** Handoff targets as the relay last reported them, for a card's availability line. */
|
|
524
|
+
private _handoffTargets = new Map<string, HandoffTarget>();
|
|
469
525
|
private _profile: Record<string, string>;
|
|
470
526
|
private _persistPrefs = false;
|
|
471
527
|
private _systemPrompt: string = DEFAULT_SYSTEM_PROMPT;
|
|
@@ -579,13 +635,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
579
635
|
|
|
580
636
|
// The element renders; the harness decides. Everything from choosing a
|
|
581
637
|
// tool to running it happens behind harness.turn().
|
|
582
|
-
this._harness =
|
|
583
|
-
provider: this.provider,
|
|
584
|
-
toolSource: this.toolSource,
|
|
585
|
-
renderer: this._renderer(),
|
|
586
|
-
profile: (schema) => this._profileForSchema(schema as JsonSchema),
|
|
587
|
-
onMessages: () => this._saveConversations(),
|
|
588
|
-
});
|
|
638
|
+
this._harness = this._singleHarness();
|
|
589
639
|
|
|
590
640
|
// Preferences are supplied by the host page through `element.preferences`.
|
|
591
641
|
// Anything a previous build persisted is picked up so upgrading does not
|
|
@@ -620,6 +670,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
620
670
|
this._els.jstatus.addEventListener('click', () => {
|
|
621
671
|
const cards = this._els.thread.querySelectorAll<HTMLElement>('.journey.is-running');
|
|
622
672
|
cards[cards.length - 1]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
673
|
+
// One look is what it is for; after that it would only be in the way.
|
|
674
|
+
this._dismissJourneyStatus();
|
|
623
675
|
});
|
|
624
676
|
|
|
625
677
|
// Whoever builds the tools, a call that leaves this page reports back to
|
|
@@ -715,6 +767,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
715
767
|
this._offJourney = null;
|
|
716
768
|
this._journeyObserver?.disconnect();
|
|
717
769
|
this._journeyObserver = null;
|
|
770
|
+
this._clearJourneyStatusTimer();
|
|
718
771
|
// Streams stop with the element; the handoff itself stays saved, and resumes when it returns.
|
|
719
772
|
this._handoff?.dispose();
|
|
720
773
|
}
|
|
@@ -903,7 +956,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
903
956
|
else if (it.kind === 'detail') this._els.thread.appendChild(this._detailEl(it));
|
|
904
957
|
else if (it.kind === 'results') this._els.thread.appendChild(this._resultsEl(it));
|
|
905
958
|
else if (it.kind === 'metrics') this._els.thread.appendChild(this._metricsEl(it));
|
|
906
|
-
else if (it.kind === 'journey')
|
|
959
|
+
else if (it.kind === 'journey') {
|
|
960
|
+
// A request drawn as an action card shows its own steps.
|
|
961
|
+
if (!conv.items.some((a) => a.kind === 'action' && a.id === it.id)) this._els.thread.appendChild(this._journeyEl(it));
|
|
962
|
+
} else if (it.kind === 'action') this._paintAction(it);
|
|
907
963
|
else if (it.kind === 'remote' || it.kind === 'handoff') this._paintHandoffItem(it);
|
|
908
964
|
}
|
|
909
965
|
this._scroll();
|
|
@@ -1288,6 +1344,15 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1288
1344
|
conv.items.push(item);
|
|
1289
1345
|
}
|
|
1290
1346
|
|
|
1347
|
+
// A request with an action card: the steps belong in the card.
|
|
1348
|
+
const action = conv.items.find((it): it is ActionItem => it.kind === 'action' && it.id === view.id);
|
|
1349
|
+
if (action) {
|
|
1350
|
+
this._actionFollows(action, all);
|
|
1351
|
+
this._watchRunningJourney();
|
|
1352
|
+
this._saveConversations();
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1291
1356
|
if (conv.id === this._activeId && this._els?.thread) {
|
|
1292
1357
|
const fresh = this._journeyEl(item);
|
|
1293
1358
|
const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
|
|
@@ -1319,7 +1384,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1319
1384
|
if (!bar) return;
|
|
1320
1385
|
const cards = this._els.thread.querySelectorAll<HTMLElement>('.journey.is-running');
|
|
1321
1386
|
const card = cards[cards.length - 1];
|
|
1322
|
-
|
|
1387
|
+
const id = card?.dataset.journey ?? null;
|
|
1388
|
+
if (id !== this._jstatusJourney) {
|
|
1389
|
+
this._clearJourneyStatusTimer();
|
|
1390
|
+
this._jstatusJourney = id;
|
|
1391
|
+
}
|
|
1392
|
+
if (!card || !id || this._jstatusDismissed.has(id) || typeof IntersectionObserver === 'undefined') {
|
|
1323
1393
|
bar.hidden = true;
|
|
1324
1394
|
return;
|
|
1325
1395
|
}
|
|
@@ -1333,6 +1403,36 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1333
1403
|
this._journeyObserver.observe(card);
|
|
1334
1404
|
}
|
|
1335
1405
|
|
|
1406
|
+
/**
|
|
1407
|
+
* The thread grew: start the clock on the status line.
|
|
1408
|
+
*
|
|
1409
|
+
* A journey can stay running while the conversation goes on without it, and a
|
|
1410
|
+
* line that spins through all of that says nothing. So once new messages
|
|
1411
|
+
* arrive, the line gets a while longer, then goes.
|
|
1412
|
+
*/
|
|
1413
|
+
private _noteThreadGrew(): void {
|
|
1414
|
+
const id = this._jstatusJourney;
|
|
1415
|
+
if (!id || this._jstatusTimer || this._jstatusDismissed.has(id)) return;
|
|
1416
|
+
this._jstatusTimer = setTimeout(() => {
|
|
1417
|
+
this._jstatusTimer = null;
|
|
1418
|
+
this._dismissJourneyStatus();
|
|
1419
|
+
}, JSTATUS_TIMEOUT_MS);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/** Hide the status line for the running journey, for good. */
|
|
1423
|
+
private _dismissJourneyStatus(): void {
|
|
1424
|
+
this._clearJourneyStatusTimer();
|
|
1425
|
+
if (this._jstatusJourney) this._jstatusDismissed.add(this._jstatusJourney);
|
|
1426
|
+
this._journeyObserver?.disconnect();
|
|
1427
|
+
this._journeyObserver = null;
|
|
1428
|
+
if (this._els?.jstatus) this._els.jstatus.hidden = true;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
private _clearJourneyStatusTimer(): void {
|
|
1432
|
+
if (this._jstatusTimer) clearTimeout(this._jstatusTimer);
|
|
1433
|
+
this._jstatusTimer = null;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1336
1436
|
private _paintToolResult(name: string, text: string, isError: boolean): HTMLElement {
|
|
1337
1437
|
this._clearEmpty();
|
|
1338
1438
|
const el = this._toolEl(name, text, isError);
|
|
@@ -1391,6 +1491,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1391
1491
|
const el = this._paintMsg(role, text, conv.items.length - 1);
|
|
1392
1492
|
this._saveConversations();
|
|
1393
1493
|
this._scroll();
|
|
1494
|
+
this._noteThreadGrew();
|
|
1394
1495
|
return el;
|
|
1395
1496
|
}
|
|
1396
1497
|
|
|
@@ -1411,6 +1512,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1411
1512
|
// without the focus moving to it.
|
|
1412
1513
|
el.setAttribute('aria-live', 'polite');
|
|
1413
1514
|
this._scroll();
|
|
1515
|
+
this._noteThreadGrew();
|
|
1414
1516
|
|
|
1415
1517
|
const setLabel = (label?: string) => {
|
|
1416
1518
|
const node = el.querySelector('.think-label');
|
|
@@ -1552,11 +1654,13 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1552
1654
|
message: (role, text) => this._appendMsg(role, text),
|
|
1553
1655
|
streaming: (): HStreamingBubble => this._appendStreaming(),
|
|
1554
1656
|
status: (text, isError) => this._setStatus(text, isError),
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1657
|
+
inlinePrompts: !this._classicPrompts,
|
|
1658
|
+
ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
|
|
1659
|
+
confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
|
|
1660
|
+
left: (tool, destination) => this._cardLeft(tool, destination),
|
|
1661
|
+
results: (tool, res, extra) => {
|
|
1558
1662
|
const conv = this._active();
|
|
1559
|
-
const item = this._buildResultsItem(tool, res);
|
|
1663
|
+
const item = this._buildResultsItem(tool, res, extra?.pick);
|
|
1560
1664
|
// A read that answers in words — "you are on the home page", "already open" — has no records
|
|
1561
1665
|
// to draw, and an empty result card reading "No results found." contradicts the answer it
|
|
1562
1666
|
// just gave. Say what it said.
|
|
@@ -1575,6 +1679,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1575
1679
|
this._scroll();
|
|
1576
1680
|
},
|
|
1577
1681
|
detail: (tool, values, text) => {
|
|
1682
|
+
if (this._cardDone(tool, values, text)) return;
|
|
1578
1683
|
const conv = this._active();
|
|
1579
1684
|
const item = this._buildDetailItem(tool, values, text);
|
|
1580
1685
|
conv?.items.push(item);
|
|
@@ -1598,8 +1703,23 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1598
1703
|
this._saveConversations();
|
|
1599
1704
|
this._scroll();
|
|
1600
1705
|
},
|
|
1601
|
-
toolError: (tool, message) =>
|
|
1602
|
-
|
|
1706
|
+
toolError: (tool, message) => {
|
|
1707
|
+
if (this._cardFailed(tool, message)) return;
|
|
1708
|
+
this._paintToolResult(tool.name, message, true);
|
|
1709
|
+
},
|
|
1710
|
+
dismissPrompt: () => {
|
|
1711
|
+
// A sent card stays, and shows the steps; one never sent says so.
|
|
1712
|
+
const live = this._live;
|
|
1713
|
+
if (live) {
|
|
1714
|
+
if (live.sent && (live.item.stage === 'review' || live.item.stage === 'collect')) {
|
|
1715
|
+
live.item.stage = 'working';
|
|
1716
|
+
this._paintAction(live.item);
|
|
1717
|
+
this._saveConversations();
|
|
1718
|
+
}
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
this._dismissPrompt();
|
|
1722
|
+
},
|
|
1603
1723
|
// The harness says when stopping stops being possible, because it is the
|
|
1604
1724
|
// part that knows a tool has been invoked. Once a call is out, offering
|
|
1605
1725
|
// to stop would be offering to un-send a booking.
|
|
@@ -1748,6 +1868,390 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1748
1868
|
this._scroll();
|
|
1749
1869
|
}
|
|
1750
1870
|
|
|
1871
|
+
// ── Action cards ─────────────────────────────────────────────────────────────
|
|
1872
|
+
|
|
1873
|
+
/** `prompt-style="classic"`: the separate ask, review and submitted cards of earlier versions. */
|
|
1874
|
+
private get _classicPrompts(): boolean {
|
|
1875
|
+
return this.getAttribute('prompt-style') === 'classic';
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/** Whether a prompt for this tool is drawn as an action card: every write and handoff. */
|
|
1879
|
+
private _usesCard(tool: ModelContextToolInfo): boolean {
|
|
1880
|
+
return !this._classicPrompts && (isHandoffTool(tool.name) || !tool.annotations?.readOnlyHint);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/** Details are missing: the card asks for them, beside what it already has. */
|
|
1884
|
+
private _cardAsk(req: AskRequest): void {
|
|
1885
|
+
const needed = new Set(req.plan.steps.flatMap((st) => st.slots.map((sl) => sl.name)));
|
|
1886
|
+
const item = this._liveCard(req.tool, req.resolution.all, req.resolution.values, needed);
|
|
1887
|
+
this._live!.ask = req;
|
|
1888
|
+
this._live!.confirm = undefined;
|
|
1889
|
+
item.stage = 'collect';
|
|
1890
|
+
this._paintAction(item);
|
|
1891
|
+
this._saveConversations();
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/** Everything is known: the card says what will happen, and waits for Send. */
|
|
1895
|
+
private _cardConfirm(req: ConfirmRequest): void {
|
|
1896
|
+
const all = resolveSlots(toolSchema(req.tool), { modelArgs: req.values }).all;
|
|
1897
|
+
const item = this._liveCard(req.tool, all, req.values, new Set(), req.destination);
|
|
1898
|
+
this._live!.confirm = req;
|
|
1899
|
+
item.stage = 'review';
|
|
1900
|
+
this._paintAction(item);
|
|
1901
|
+
this._saveConversations();
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* The live card for a tool's prompt: the one already open for this request,
|
|
1906
|
+
* or a new one, which takes over from the journey card of the same request.
|
|
1907
|
+
*/
|
|
1908
|
+
private _liveCard(
|
|
1909
|
+
tool: ModelContextToolInfo,
|
|
1910
|
+
slots: Slot[],
|
|
1911
|
+
values: Record<string, unknown>,
|
|
1912
|
+
needed: Set<string>,
|
|
1913
|
+
destination?: string,
|
|
1914
|
+
): ActionItem {
|
|
1915
|
+
const conv = this._active()!;
|
|
1916
|
+
const id = currentJourney() ?? `a${Date.now().toString(36)}`;
|
|
1917
|
+
const live = this._live;
|
|
1918
|
+
const described = this._describeAction(tool, values, destination);
|
|
1919
|
+
const fields = fieldsFromSlots(this._narrowed(tool, slots, values), values, { needed, hidden: described.hidden, labels: described.labels });
|
|
1920
|
+
|
|
1921
|
+
if (live && live.item.id === id && live.tool.name === tool.name) {
|
|
1922
|
+
// The same request, a step on: keep what was typed, and anything the harness has since learned.
|
|
1923
|
+
live.item.fields = fields.map((f) => {
|
|
1924
|
+
const had = live.item.fields.find((o) => o.name === f.name);
|
|
1925
|
+
return had && f.value === '' ? { ...f, value: had.value } : f;
|
|
1926
|
+
});
|
|
1927
|
+
Object.assign(live.item, described.view);
|
|
1928
|
+
live.tool = tool;
|
|
1929
|
+
return live.item;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// A request left waiting is replaced by the new one.
|
|
1933
|
+
if (live && (live.item.stage === 'collect' || live.item.stage === 'review')) {
|
|
1934
|
+
live.item.stage = 'declined';
|
|
1935
|
+
this._paintAction(live.item);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
const item: ActionItem = { kind: 'action', id, tool: tool.name, fields, stage: 'collect', ...described.view };
|
|
1939
|
+
conv.items.push(item);
|
|
1940
|
+
this._live = { item, tool, sent: false, declining: false };
|
|
1941
|
+
this._els?.thread?.querySelector(`[data-journey="${CSS.escape(id)}"]`)?.remove();
|
|
1942
|
+
this._watchRunningJourney();
|
|
1943
|
+
if (isHandoffTool(tool.name)) void this._refreshHandoffTargets(item);
|
|
1944
|
+
return item;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/**
|
|
1948
|
+
* The slots with each field's options cut to the form the call means: one
|
|
1949
|
+
* business's services rather than every business's.
|
|
1950
|
+
*/
|
|
1951
|
+
private _narrowed(tool: ModelContextToolInfo, slots: Slot[], values: Record<string, unknown>): Slot[] {
|
|
1952
|
+
const options = formOptions(tool, values);
|
|
1953
|
+
return slots.map((slot) => {
|
|
1954
|
+
const only = options[slot.name];
|
|
1955
|
+
if (!only) return slot;
|
|
1956
|
+
// Keep a value the call already gave, even one the form does not list: the form decides.
|
|
1957
|
+
const current = values[slot.name];
|
|
1958
|
+
const list = current !== undefined && current !== '' && !only.includes(String(current)) ? [String(current), ...only] : only;
|
|
1959
|
+
return { ...slot, schema: { ...slot.schema, enum: list } };
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/** Who a request goes to, and how the card words it. */
|
|
1964
|
+
private _describeAction(
|
|
1965
|
+
tool: ModelContextToolInfo,
|
|
1966
|
+
values: Record<string, unknown>,
|
|
1967
|
+
destination?: string,
|
|
1968
|
+
): {
|
|
1969
|
+
view: Pick<ActionItem, 'variant' | 'noun' | 'identity' | 'sentence' | 'note' | 'sendLabel'>;
|
|
1970
|
+
hidden: Set<string>;
|
|
1971
|
+
labels: Record<string, string>;
|
|
1972
|
+
} {
|
|
1973
|
+
const email = String(values.email ?? Object.entries(values).find(([k]) => /e-?mail/i.test(k))?.[1] ?? '').trim();
|
|
1974
|
+
|
|
1975
|
+
if (isHandoffTool(tool.name)) {
|
|
1976
|
+
const name = String(values.target ?? '');
|
|
1977
|
+
const config = this._handoff?.config?.targets[name];
|
|
1978
|
+
const known = this._handoffTargets.get(name);
|
|
1979
|
+
const label = known?.label ?? config?.label ?? fieldLabel(name || 'the team');
|
|
1980
|
+
const person = (known?.kind ?? config?.kind) !== 'agent';
|
|
1981
|
+
const away = known ? !known.available : false;
|
|
1982
|
+
// "The directory team" leads a line, but mid-sentence it is "the directory team".
|
|
1983
|
+
const inline = label.replace(/^The\s/, 'the ');
|
|
1984
|
+
const about = known?.description ?? config?.description;
|
|
1985
|
+
const status = !known
|
|
1986
|
+
? undefined
|
|
1987
|
+
: away
|
|
1988
|
+
? `Away now${known.hours ? ` · open ${known.hours}` : ''}`
|
|
1989
|
+
: person
|
|
1990
|
+
? known.waitMinutes
|
|
1991
|
+
? `Available · replies in ~${known.waitMinutes} min`
|
|
1992
|
+
: 'Available'
|
|
1993
|
+
: 'Available';
|
|
1994
|
+
const sentence = !person
|
|
1995
|
+
? `I'll hand this conversation to ${inline}. They'll take it from here.`
|
|
1996
|
+
: away
|
|
1997
|
+
? email
|
|
1998
|
+
? `${label} is away right now. They'll reply to ${email}.`
|
|
1999
|
+
: `${label} is away right now. Leave an email and they'll reply there.`
|
|
2000
|
+
: `I'll pass this to ${inline} so you don't have to repeat yourself.`;
|
|
2001
|
+
return {
|
|
2002
|
+
view: {
|
|
2003
|
+
variant: person ? 'handoff-person' : 'handoff-agent',
|
|
2004
|
+
noun: person ? 'handoff' : 'handover',
|
|
2005
|
+
identity: { name: label, sub: about ? about.charAt(0).toUpperCase() + about.slice(1) : undefined, status, tone: away ? 'away' : 'ok' },
|
|
2006
|
+
sentence,
|
|
2007
|
+
note: undefined,
|
|
2008
|
+
sendLabel: !person ? 'Hand over' : away ? `Send to ${inline}` : 'Connect me',
|
|
2009
|
+
},
|
|
2010
|
+
hidden: new Set(['target']),
|
|
2011
|
+
labels: { reason: 'Why', summary: 'What they need to know', email: 'Where should they reply?' },
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
const noun = fieldLabel(withoutSiteSuffix(tool.name)).toLowerCase();
|
|
2016
|
+
// Which business or place it is for: the site tool's own chooser, else a field the form names it in.
|
|
2017
|
+
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;
|
|
2018
|
+
const chosen = chooser ? String(values[chooser] ?? '').trim() : '';
|
|
2019
|
+
const leaving = destination ?? handoffFor(tool, values)?.label;
|
|
2020
|
+
const name = chosen || leaving || this._pageName();
|
|
2021
|
+
// What the conversation already showed about them: their card from a search.
|
|
2022
|
+
const card = this._conversations
|
|
2023
|
+
.find((c) => c.id === this._activeId)
|
|
2024
|
+
?.items.flatMap((it) => (it.kind === 'results' ? it.cards : []))
|
|
2025
|
+
.find((c) => c.title.toLowerCase() === name.toLowerCase());
|
|
2026
|
+
const sub = [fieldLabel(noun), card?.type && card.type !== 'Thing' ? fieldLabel(card.type) : '', card?.rating ? `★ ${card.rating}` : '']
|
|
2027
|
+
.filter(Boolean)
|
|
2028
|
+
.join(' · ');
|
|
2029
|
+
return {
|
|
2030
|
+
view: {
|
|
2031
|
+
variant: 'form',
|
|
2032
|
+
noun,
|
|
2033
|
+
identity: { name, sub },
|
|
2034
|
+
sentence: `I'll send this ${noun} to ${name}.${email ? ` They'll reply to ${email}.` : ''}`,
|
|
2035
|
+
note: leaving ? 'Their form is on its own page. I’ll open it and send it from there.' : undefined,
|
|
2036
|
+
sendLabel: 'Send',
|
|
2037
|
+
},
|
|
2038
|
+
hidden: new Set(chooser && chooser !== 'target' && chosen ? [chooser] : chooser === 'target' ? ['target'] : []),
|
|
2039
|
+
labels: {},
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/** The site's name for this page: its title, without the site's own name after it. */
|
|
2044
|
+
private _pageName(): string {
|
|
2045
|
+
const title = (typeof document === 'undefined' ? '' : document.title).split(/\s+[|—–·-]\s+/)[0].trim();
|
|
2046
|
+
return title || 'this page';
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/** Read availability for a handoff card, and redraw it once known. */
|
|
2050
|
+
private async _refreshHandoffTargets(item: ActionItem): Promise<void> {
|
|
2051
|
+
const targets = await this._handoff?.targets().catch(() => []);
|
|
2052
|
+
for (const t of targets ?? []) this._handoffTargets.set(t.name, t);
|
|
2053
|
+
if (item.stage !== 'collect' && item.stage !== 'review') return;
|
|
2054
|
+
const values = Object.fromEntries(item.fields.map((f) => [f.name, f.value]));
|
|
2055
|
+
Object.assign(item, this._describeAction({ name: item.tool } as ModelContextToolInfo, values).view);
|
|
2056
|
+
this._paintAction(item);
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/** What the live card's buttons do. */
|
|
2060
|
+
private _actionHandlers(): ActionHandlers {
|
|
2061
|
+
return {
|
|
2062
|
+
continue: (values) => {
|
|
2063
|
+
const live = this._live;
|
|
2064
|
+
if (!live) return;
|
|
2065
|
+
if (live.ask && !live.confirm) {
|
|
2066
|
+
live.ask.onComplete(values);
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
// Back from a change, or a card of the widget's own: review again, here.
|
|
2070
|
+
Object.assign(live.item, this._describeAction(live.tool, values, live.confirm?.destination).view);
|
|
2071
|
+
live.item.stage = 'review';
|
|
2072
|
+
this._paintAction(live.item);
|
|
2073
|
+
this._saveConversations();
|
|
2074
|
+
},
|
|
2075
|
+
change: () => {
|
|
2076
|
+
const live = this._live;
|
|
2077
|
+
if (!live) return;
|
|
2078
|
+
live.item.fields.forEach((f) => {
|
|
2079
|
+
if (f.group === 'needed' && f.value.trim() !== '') f.group = 'known';
|
|
2080
|
+
});
|
|
2081
|
+
live.sent = false;
|
|
2082
|
+
live.item.stage = 'collect';
|
|
2083
|
+
live.item.open = true;
|
|
2084
|
+
this._paintAction(live.item);
|
|
2085
|
+
this._saveConversations();
|
|
2086
|
+
},
|
|
2087
|
+
send: (values) => {
|
|
2088
|
+
const live = this._live;
|
|
2089
|
+
if (!live) return;
|
|
2090
|
+
live.sent = true;
|
|
2091
|
+
if (live.confirm) live.confirm.onSubmit(values);
|
|
2092
|
+
else if (live.run) void live.run(values);
|
|
2093
|
+
},
|
|
2094
|
+
decline: () => {
|
|
2095
|
+
const live = this._live;
|
|
2096
|
+
if (!live) return;
|
|
2097
|
+
live.declining = true;
|
|
2098
|
+
if (live.confirm) live.confirm.onCancel();
|
|
2099
|
+
else if (live.ask) live.ask.onCancel();
|
|
2100
|
+
// A card the harness is not waiting on declines itself.
|
|
2101
|
+
if (this._live === live) {
|
|
2102
|
+
live.item.stage = 'declined';
|
|
2103
|
+
this._paintAction(live.item);
|
|
2104
|
+
this._live = null;
|
|
2105
|
+
this._saveConversations();
|
|
2106
|
+
}
|
|
2107
|
+
},
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/** Draw a card where it was, or at the end of the thread. */
|
|
2112
|
+
private _paintAction(item: ActionItem): void {
|
|
2113
|
+
if (!this._els?.thread) return;
|
|
2114
|
+
const conv = this._active();
|
|
2115
|
+
if (!conv?.items.includes(item)) return;
|
|
2116
|
+
const live = this._live?.item === item;
|
|
2117
|
+
const fresh = actionCard(item, live ? this._actionHandlers() : null, () => this._saveConversations());
|
|
2118
|
+
const old = this._els.thread.querySelector(`[data-action="${CSS.escape(item.id)}"]`);
|
|
2119
|
+
if (old) old.replaceWith(fresh);
|
|
2120
|
+
else {
|
|
2121
|
+
this._clearEmpty();
|
|
2122
|
+
this._els.thread.appendChild(fresh);
|
|
2123
|
+
this._scroll();
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/** A request's steps, as its card shows them once sent. */
|
|
2128
|
+
private _actionFollows(item: ActionItem, all: AgentStep[]): void {
|
|
2129
|
+
const live = this._live?.item === item ? this._live : null;
|
|
2130
|
+
const failed = [...all].reverse().find((st) => st.status === 'failed');
|
|
2131
|
+
const done = all.some((st) => st.id === 'submit' && st.status === 'done');
|
|
2132
|
+
const running = ['navigate', 'arrive', 'collect', 'fill', 'submit'];
|
|
2133
|
+
item.steps = all
|
|
2134
|
+
.filter((st) => running.includes(st.id))
|
|
2135
|
+
.map((st): ActionStep => ({
|
|
2136
|
+
label:
|
|
2137
|
+
st.id === 'fill' && st.status === 'active' && item.variant === 'form'
|
|
2138
|
+
? `Filling in the ${item.noun}`
|
|
2139
|
+
: st.id === 'submit' && st.status === 'done' && item.variant === 'form'
|
|
2140
|
+
? 'Sent'
|
|
2141
|
+
: st.label,
|
|
2142
|
+
status: st.status === 'failed' ? 'failed' : st.status === 'done' ? 'done' : 'active',
|
|
2143
|
+
}));
|
|
2144
|
+
|
|
2145
|
+
if (failed) {
|
|
2146
|
+
if (live?.declining || failed.label === 'Cancelled') {
|
|
2147
|
+
item.stage = 'declined';
|
|
2148
|
+
if (live) this._live = null;
|
|
2149
|
+
} else {
|
|
2150
|
+
item.stage = 'failed';
|
|
2151
|
+
item.message = failed.detail || failed.label;
|
|
2152
|
+
}
|
|
2153
|
+
} else if (done) {
|
|
2154
|
+
item.stage = 'done';
|
|
2155
|
+
if (live) this._live = null;
|
|
2156
|
+
} else if (item.stage === 'left' || (item.stage === 'working' && item.steps.length)) {
|
|
2157
|
+
item.stage = 'working';
|
|
2158
|
+
}
|
|
2159
|
+
this._paintAction(item);
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
/** The confirmed call is leaving for its page. The card says so, and the new page finishes it. */
|
|
2163
|
+
private _cardLeft(tool: ModelContextToolInfo, destination: string | null): void {
|
|
2164
|
+
const live = this._live;
|
|
2165
|
+
if (!live || live.tool.name !== tool.name) {
|
|
2166
|
+
this._appendMsg('assistant', destination ? `Opening ${destination}…` : 'Opening the page that can submit this…');
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
live.item.stage = 'left';
|
|
2170
|
+
live.item.message = `Opening ${destination ?? live.item.identity.name}’s page to send it from there.`;
|
|
2171
|
+
this._live = null;
|
|
2172
|
+
this._paintAction(live.item);
|
|
2173
|
+
this._saveConversations();
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/** The form took it: the card becomes the receipt. False when no card is waiting on this tool. */
|
|
2177
|
+
private _cardDone(tool: ModelContextToolInfo, values: Record<string, unknown>, text: string): boolean {
|
|
2178
|
+
const conv = this._active();
|
|
2179
|
+
const id = currentJourney();
|
|
2180
|
+
const item = conv?.items.find((it): it is ActionItem => it.kind === 'action' && it.id === id && it.tool === tool.name);
|
|
2181
|
+
if (!item || this._classicPrompts) return false;
|
|
2182
|
+
const note = this._buildDetailItem(tool, values, text).note;
|
|
2183
|
+
// The default note says nothing the card's "Sent" does not.
|
|
2184
|
+
item.message = /submitted successfully/.test(note) ? undefined : note;
|
|
2185
|
+
item.stage = 'done';
|
|
2186
|
+
item.fields.forEach((f) => {
|
|
2187
|
+
if (values[f.name] !== undefined) f.value = String(values[f.name]);
|
|
2188
|
+
});
|
|
2189
|
+
if (this._live?.item === item) this._live = null;
|
|
2190
|
+
this._paintAction(item);
|
|
2191
|
+
this._saveConversations();
|
|
2192
|
+
return true;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/** It did not go through: the card says why and offers to change it. False when no card is waiting. */
|
|
2196
|
+
private _cardFailed(tool: ModelContextToolInfo, message: string): boolean {
|
|
2197
|
+
const live = this._live;
|
|
2198
|
+
if (!live || live.tool.name !== tool.name) return false;
|
|
2199
|
+
live.sent = false;
|
|
2200
|
+
live.item.stage = 'failed';
|
|
2201
|
+
live.item.message = message;
|
|
2202
|
+
this._paintAction(live.item);
|
|
2203
|
+
this._saveConversations();
|
|
2204
|
+
return true;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/**
|
|
2208
|
+
* After a page change: the card of the request that travelled, found by its
|
|
2209
|
+
* journey, now finished or asked about here.
|
|
2210
|
+
*/
|
|
2211
|
+
private _resumedCard(journeyId: string | undefined): ActionItem | null {
|
|
2212
|
+
if (!journeyId || this._classicPrompts) return null;
|
|
2213
|
+
return this._active()?.items.find((it): it is ActionItem => it.kind === 'action' && it.id === journeyId) ?? null;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
/** Let a resumed card be changed and sent again from this page, with the page's own tool. */
|
|
2217
|
+
private _reviveCard(item: ActionItem, tool: ModelContextToolInfo, values: Record<string, unknown>, needed: string[]): void {
|
|
2218
|
+
const slots = resolveSlots(toolSchema(tool), { modelArgs: values }).all;
|
|
2219
|
+
const hidden = new Set(item.fields.filter((f) => f.group === 'hidden').map((f) => f.name));
|
|
2220
|
+
item.fields = fieldsFromSlots(slots, values, { needed: new Set(needed), hidden });
|
|
2221
|
+
this._live = {
|
|
2222
|
+
item,
|
|
2223
|
+
tool,
|
|
2224
|
+
sent: false,
|
|
2225
|
+
declining: false,
|
|
2226
|
+
run: async (vals) => {
|
|
2227
|
+
item.stage = 'working';
|
|
2228
|
+
item.steps = [{ label: `Sending the ${item.noun}`, status: 'active' }];
|
|
2229
|
+
this._paintAction(item);
|
|
2230
|
+
try {
|
|
2231
|
+
const res = await this.toolSource.execTool(tool, tool.name, vals);
|
|
2232
|
+
const text = extractResult(res);
|
|
2233
|
+
if (res?.isError) {
|
|
2234
|
+
this._cardFailed(tool, text || 'That form did not accept the details.');
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
this._active()?.messages.push({
|
|
2238
|
+
role: 'user',
|
|
2239
|
+
content: `[${tool.name} submitted with: ${JSON.stringify(vals)}]. Keep these unless I ask to change them.`,
|
|
2240
|
+
});
|
|
2241
|
+
this._live = null;
|
|
2242
|
+
const note = this._buildDetailItem(tool, vals, text).note;
|
|
2243
|
+
item.message = /submitted successfully/.test(note) ? undefined : note;
|
|
2244
|
+
item.stage = 'done';
|
|
2245
|
+
item.steps = [];
|
|
2246
|
+
this._paintAction(item);
|
|
2247
|
+
this._saveConversations();
|
|
2248
|
+
} catch (err) {
|
|
2249
|
+
this._cardFailed(tool, String((err as Error)?.message || err));
|
|
2250
|
+
}
|
|
2251
|
+
},
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
|
|
1751
2255
|
// ── Inline generated form (from JSON-Schema) ───────────────────────────────
|
|
1752
2256
|
private _renderForm(tool: ModelContextToolInfo, args: Record<string, unknown>): void {
|
|
1753
2257
|
if (this._useA2ui) {
|
|
@@ -2090,61 +2594,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2090
2594
|
* return the first that yields records. Shapes covered: query → results[],
|
|
2091
2595
|
* list_types → types[], get → result{}, or a bare entity/array. */
|
|
2092
2596
|
private _extractItems(res: ModelContextToolResponse | string | null): Record<string, unknown>[] {
|
|
2093
|
-
const
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
// extension in the loop) the whole `{content, structuredContent}` envelope
|
|
2097
|
-
// serialized to a string, possibly more than once. Diving into both
|
|
2098
|
-
// `structuredContent` and each `content[].text` covers all of them.
|
|
2099
|
-
const visit = (raw: unknown, depth = 0): void => {
|
|
2100
|
-
if (raw == null || depth > 5) return;
|
|
2101
|
-
const val: unknown = raw;
|
|
2102
|
-
if (typeof val === 'string') {
|
|
2103
|
-
const t = val.trim();
|
|
2104
|
-
if (!t) return;
|
|
2105
|
-
let parsed: unknown;
|
|
2106
|
-
try {
|
|
2107
|
-
parsed = JSON.parse(t);
|
|
2108
|
-
} catch {
|
|
2109
|
-
return; // plain (non-JSON) text — nothing to extract
|
|
2110
|
-
}
|
|
2111
|
-
// Parsed value may itself be an object/array OR another JSON string
|
|
2112
|
-
// (double-encoded); recurse either way.
|
|
2113
|
-
return visit(parsed, depth + 1);
|
|
2114
|
-
}
|
|
2115
|
-
if (!val || typeof val !== 'object') return;
|
|
2116
|
-
sources.push(val);
|
|
2117
|
-
const v = val as Record<string, unknown>;
|
|
2118
|
-
if (v.structuredContent != null) visit(v.structuredContent, depth + 1);
|
|
2119
|
-
if (Array.isArray(v.content)) {
|
|
2120
|
-
for (const c of v.content) {
|
|
2121
|
-
if (c && typeof c === 'object') visit((c as Record<string, unknown>).text, depth + 1);
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
};
|
|
2125
|
-
visit(res);
|
|
2126
|
-
|
|
2127
|
-
for (const data of sources) {
|
|
2128
|
-
const items = this._itemsFrom(data);
|
|
2129
|
-
if (items.length) return items;
|
|
2130
|
-
}
|
|
2131
|
-
console.warn('[nxis-mcp] no items extracted from tool result', { sourcesTried: sources.length, res });
|
|
2132
|
-
return [];
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
/** Find the record array within one parsed payload. */
|
|
2136
|
-
private _itemsFrom(data: unknown): Record<string, unknown>[] {
|
|
2137
|
-
const objs = (arr: unknown[]) =>
|
|
2138
|
-
arr.filter((x) => x && typeof x === 'object') as Record<string, unknown>[];
|
|
2139
|
-
if (Array.isArray(data)) return objs(data);
|
|
2140
|
-
if (!data || typeof data !== 'object') return [];
|
|
2141
|
-
const d = data as Record<string, unknown>;
|
|
2142
|
-
for (const key of ['results', 'types', 'items', 'entities', 'data']) {
|
|
2143
|
-
if (Array.isArray(d[key])) return objs(d[key] as unknown[]);
|
|
2144
|
-
}
|
|
2145
|
-
if (d.result && typeof d.result === 'object') return [d.result as Record<string, unknown>];
|
|
2146
|
-
if (d['@id'] != null || d['@type'] != null || d.name != null) return [d];
|
|
2147
|
-
return [];
|
|
2597
|
+
const items = extractRecords(res);
|
|
2598
|
+
if (!items.length) console.warn('[nxis-mcp] no items extracted from tool result', { res });
|
|
2599
|
+
return items;
|
|
2148
2600
|
}
|
|
2149
2601
|
|
|
2150
2602
|
/** Build a display card from a single record (a JSON-LD entity or a type row). */
|
|
@@ -2212,19 +2664,82 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2212
2664
|
const id = entity['@id'] != null ? str(entity['@id']) : undefined;
|
|
2213
2665
|
const found = pageForEntity(entity, this._sitePages, location.origin);
|
|
2214
2666
|
const page = found ? { url: found.url, label: found.name } : undefined;
|
|
2215
|
-
|
|
2667
|
+
|
|
2668
|
+
// What the answer view compares by, beyond the rating and hours its heading already shows.
|
|
2669
|
+
const facts = recordFacts(entity);
|
|
2670
|
+
const status = openStatus(entity) ?? undefined;
|
|
2671
|
+
const lines = [
|
|
2672
|
+
facts.services.length ? facts.services.slice(0, 3).map((x) => x.name).join(', ') : facts.knows.slice(0, 3).join(', '),
|
|
2673
|
+
facts.since ? `In business since ${facts.since}` : '',
|
|
2674
|
+
facts.reviews[0] ? `\u201c${facts.reviews[0].text}\u201d${facts.reviews[0].author ? ` \u2014 ${facts.reviews[0].author}` : ''}` : '',
|
|
2675
|
+
].filter((l): l is string => !!l);
|
|
2676
|
+
return {
|
|
2677
|
+
title,
|
|
2678
|
+
type,
|
|
2679
|
+
rating,
|
|
2680
|
+
emergency,
|
|
2681
|
+
sub,
|
|
2682
|
+
rows,
|
|
2683
|
+
id,
|
|
2684
|
+
...(page ? { page } : {}),
|
|
2685
|
+
...(facts.rating !== undefined ? { stars: facts.rating } : {}),
|
|
2686
|
+
...(facts.reviewCount !== undefined ? { reviews: facts.reviewCount } : {}),
|
|
2687
|
+
...(status ? { status: { open: status.open, label: status.label } } : {}),
|
|
2688
|
+
...(facts.phone ? { phone: facts.phone } : {}),
|
|
2689
|
+
facts: lines,
|
|
2690
|
+
};
|
|
2216
2691
|
}
|
|
2217
2692
|
|
|
2218
2693
|
private _buildResultsItem(
|
|
2219
2694
|
tool: ModelContextToolInfo,
|
|
2220
2695
|
res: ModelContextToolResponse,
|
|
2696
|
+
pick?: ResultPick,
|
|
2221
2697
|
): Extract<ThreadItem, { kind: 'results' }> {
|
|
2222
2698
|
const items = this._extractItems(res);
|
|
2223
2699
|
const cards = items.slice(0, 12).map((e) => this._entityCard(e));
|
|
2224
2700
|
const note = !cards.length
|
|
2225
2701
|
? 'No results found.'
|
|
2226
2702
|
: `${cards.length} result${cards.length === 1 ? '' : 's'}`;
|
|
2227
|
-
return { kind: 'results', title: humanize(tool.name), note, cards };
|
|
2703
|
+
return { kind: 'results', title: humanize(tool.name), note, cards, ...(pick && pick.index < cards.length ? { pick } : {}) };
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
/**
|
|
2707
|
+
* Results as an answer: the recommended pick with its reasons and the others
|
|
2708
|
+
* to compare, or a short list that opens per result. Records of things
|
|
2709
|
+
* only: a listing of types or counts keeps the plain cards.
|
|
2710
|
+
*/
|
|
2711
|
+
private _answerEl(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement | null {
|
|
2712
|
+
// Records of things: a pick, or at least one result with a rating, hours, a phone or a page.
|
|
2713
|
+
// A sparse record beside them (an index entry with only a name) still shows, by name.
|
|
2714
|
+
const things = item.cards.filter((c) => c.stars !== undefined || c.status || c.phone || c.page);
|
|
2715
|
+
if (!item.pick && !things.length) return null;
|
|
2716
|
+
const cards: AnswerCard[] = item.cards.map((c) => {
|
|
2717
|
+
const form = formsFor(c.title)[0];
|
|
2718
|
+
const quote = form && /quote/.test(form.noun);
|
|
2719
|
+
return {
|
|
2720
|
+
title: c.title,
|
|
2721
|
+
stars: c.stars,
|
|
2722
|
+
reviews: c.reviews,
|
|
2723
|
+
status: c.status,
|
|
2724
|
+
phone: c.phone,
|
|
2725
|
+
page: c.page,
|
|
2726
|
+
facts: c.facts ?? [],
|
|
2727
|
+
...(form
|
|
2728
|
+
? {
|
|
2729
|
+
form: quote
|
|
2730
|
+
? { label: 'Get a quote', message: `Get a quote from ${c.title}` }
|
|
2731
|
+
: { label: `Use their ${form.noun}`, message: `Start the ${form.noun} for ${c.title}` },
|
|
2732
|
+
}
|
|
2733
|
+
: {}),
|
|
2734
|
+
};
|
|
2735
|
+
});
|
|
2736
|
+
return resultsAnswer(cards, item.pick, {
|
|
2737
|
+
ask: (text) => {
|
|
2738
|
+
if (this._turnActive) return;
|
|
2739
|
+
this._els.input.value = text;
|
|
2740
|
+
void this._onSend();
|
|
2741
|
+
},
|
|
2742
|
+
});
|
|
2228
2743
|
}
|
|
2229
2744
|
|
|
2230
2745
|
/** Feature-card pattern: tinted icon chip, tags, tight Manrope headline, one
|
|
@@ -2285,9 +2800,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2285
2800
|
* surface; a2ui="a2ui-results" opts this element back into it for comparison.
|
|
2286
2801
|
*/
|
|
2287
2802
|
private _resultsEl(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement {
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2803
|
+
if (this.getAttribute('a2ui') === 'a2ui-results') return this._resultsElA2ui(item);
|
|
2804
|
+
if (!this._classicPrompts && item.cards.length) {
|
|
2805
|
+
const answer = this._answerEl(item);
|
|
2806
|
+
if (answer) return answer;
|
|
2807
|
+
}
|
|
2808
|
+
return this._resultsElLegacy(item);
|
|
2291
2809
|
}
|
|
2292
2810
|
|
|
2293
2811
|
private _resultsElLegacy(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement {
|
|
@@ -2692,6 +3210,17 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2692
3210
|
* `fallback`. Inexpensive: each agent is a role over the one shared provider,
|
|
2693
3211
|
* so no additional model is loaded.
|
|
2694
3212
|
*/
|
|
3213
|
+
/** The one agent a page without `agents` runs, on the current provider. */
|
|
3214
|
+
private _singleHarness(): AgentHarness {
|
|
3215
|
+
return new AgentHarness({
|
|
3216
|
+
provider: this.provider,
|
|
3217
|
+
toolSource: this.toolSource,
|
|
3218
|
+
renderer: this._renderer(),
|
|
3219
|
+
profile: (schema) => this._profileForSchema(schema as JsonSchema),
|
|
3220
|
+
onMessages: () => this._saveConversations(),
|
|
3221
|
+
});
|
|
3222
|
+
}
|
|
3223
|
+
|
|
2695
3224
|
private _buildAgents(): void {
|
|
2696
3225
|
if (!this.provider || !this._agentDefs) {
|
|
2697
3226
|
this._coordinator = null;
|
|
@@ -2842,6 +3371,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2842
3371
|
if (conversationId === this._activeId && this._els?.thread) {
|
|
2843
3372
|
this._paintHandoffItem(item);
|
|
2844
3373
|
this._scroll();
|
|
3374
|
+
this._noteThreadGrew();
|
|
2845
3375
|
}
|
|
2846
3376
|
}
|
|
2847
3377
|
|
|
@@ -2991,6 +3521,44 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2991
3521
|
});
|
|
2992
3522
|
if (outcome.status !== 'none') this._setStatus('');
|
|
2993
3523
|
|
|
3524
|
+
// The card that travelled with the request finishes here, when there is one.
|
|
3525
|
+
const card = outcome.status === 'none' ? null : this._resumedCard(outcome.pending.steps?.find((st) => st.journey)?.journey);
|
|
3526
|
+
if (card && outcome.status !== 'none') {
|
|
3527
|
+
switch (outcome.status) {
|
|
3528
|
+
case 'no-registry':
|
|
3529
|
+
case 'no-tool':
|
|
3530
|
+
card.stage = 'failed';
|
|
3531
|
+
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.`;
|
|
3532
|
+
break;
|
|
3533
|
+
case 'incomplete':
|
|
3534
|
+
this._reviveCard(card, outcome.tool, outcome.pending.values, outcome.missing);
|
|
3535
|
+
card.stage = 'collect';
|
|
3536
|
+
card.message = undefined;
|
|
3537
|
+
break;
|
|
3538
|
+
case 'rejected':
|
|
3539
|
+
case 'failed':
|
|
3540
|
+
this._reviveCard(card, outcome.tool, outcome.pending.values, []);
|
|
3541
|
+
card.stage = 'failed';
|
|
3542
|
+
card.message = outcome.status === 'failed' ? outcome.error : outcome.text || 'That form did not accept the details.';
|
|
3543
|
+
break;
|
|
3544
|
+
case 'submitted': {
|
|
3545
|
+
const note = this._buildDetailItem(outcome.tool, outcome.pending.values, outcome.text).note;
|
|
3546
|
+
card.message = /submitted successfully/.test(note) ? undefined : note;
|
|
3547
|
+
card.stage = 'done';
|
|
3548
|
+
this._active()?.messages.push({
|
|
3549
|
+
role: 'user',
|
|
3550
|
+
content:
|
|
3551
|
+
`[${outcome.tool.name} submitted with: ${JSON.stringify(outcome.pending.values)}]. ` +
|
|
3552
|
+
'Keep these unless I ask to change them.',
|
|
3553
|
+
});
|
|
3554
|
+
break;
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
this._paintAction(card);
|
|
3558
|
+
this._saveConversations();
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
|
|
2994
3562
|
switch (outcome.status) {
|
|
2995
3563
|
case 'none':
|
|
2996
3564
|
return;
|
|
@@ -3348,6 +3916,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3348
3916
|
if (this._els?.thread) {
|
|
3349
3917
|
this.provider = next ?? this.provider;
|
|
3350
3918
|
this._paintProvenance();
|
|
3919
|
+
// Both the single agent and any configured ones: a page without `agents` kept the old
|
|
3920
|
+
// provider's harness, and answered with the model it had just replaced.
|
|
3921
|
+
this._harness = this._singleHarness();
|
|
3351
3922
|
this._buildAgents();
|
|
3352
3923
|
}
|
|
3353
3924
|
}
|
|
@@ -3551,6 +4122,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3551
4122
|
/* A2UI surfaces inherit the tokens above through --a2ui-* bridges. */
|
|
3552
4123
|
${A2UI_THEME_CSS}
|
|
3553
4124
|
|
|
4125
|
+
/* The action card: forms and handoffs as one message. */
|
|
4126
|
+
${ACTION_CARD_CSS}
|
|
4127
|
+
|
|
4128
|
+
/* Search results as an answer: a pick and why, or a short list. */
|
|
4129
|
+
${RESULTS_ANSWER_CSS}
|
|
4130
|
+
|
|
3554
4131
|
/* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
|
|
3555
4132
|
.launcher {
|
|
3556
4133
|
position: fixed; right: 20px; bottom: 20px; z-index: 9998;
|