@nxisai/agent-harness 0.7.0 → 0.9.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 +17 -5
- package/dist/a2ui/cards.d.ts +3 -0
- package/dist/a2ui/cards.d.ts.map +1 -1
- package/dist/a2ui/cards.js +11 -2
- package/dist/a2ui/cards.js.map +1 -1
- package/dist/harness/cta.d.ts +27 -0
- package/dist/harness/cta.d.ts.map +1 -0
- package/dist/harness/cta.js +43 -0
- package/dist/harness/cta.js.map +1 -0
- package/dist/harness/harness.d.ts +33 -0
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +84 -2
- 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 +11 -1
- package/dist/harness/renderer.d.ts.map +1 -1
- package/dist/harness/router/coordinator.d.ts +6 -0
- package/dist/harness/router/coordinator.d.ts.map +1 -1
- package/dist/harness/router/coordinator.js +14 -0
- package/dist/harness/router/coordinator.js.map +1 -1
- package/dist/harness/tools/action-tools.d.ts +25 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +39 -0
- package/dist/harness/tools/action-tools.js.map +1 -1
- package/dist/harness/tools/inbound.d.ts +88 -0
- package/dist/harness/tools/inbound.d.ts.map +1 -0
- package/dist/harness/tools/inbound.js +156 -0
- package/dist/harness/tools/inbound.js.map +1 -0
- package/dist/harness/tools/site-pages.js +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +154 -76
- 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/package.json +1 -1
- package/src/a2ui/cards.ts +14 -2
- package/src/harness/cta.ts +54 -0
- package/src/harness/harness.ts +93 -2
- package/src/harness/recommend.ts +194 -0
- package/src/harness/records.ts +237 -0
- package/src/harness/renderer.ts +12 -1
- package/src/harness/router/coordinator.ts +14 -0
- package/src/harness/tools/action-tools.ts +41 -0
- package/src/harness/tools/inbound.ts +189 -0
- package/src/harness/tools/site-pages.ts +1 -1
- package/src/index.ts +16 -0
- package/src/ui/agent-fullscreen.ts +162 -66
- package/src/ui/result-pick.ts +233 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arriving from somewhere else to use one of this page's forms.
|
|
3
|
+
*
|
|
4
|
+
* The NxisAI app in Claude or ChatGPT cannot fill a form that runs in the
|
|
5
|
+
* browser, so for such an action it sends the person to the page instead:
|
|
6
|
+
* `?nxis_handoff=<the action's @id>`, and optionally `&nxis_intent=<what they
|
|
7
|
+
* want>`. On arrival the agent opens on that form.
|
|
8
|
+
*
|
|
9
|
+
* In the query, so the fragment, which some sites route by, is left alone. A
|
|
10
|
+
* page's structured data is found by its registered address, so the site tag
|
|
11
|
+
* leaves these two parameters out of the address it asks for.
|
|
12
|
+
*
|
|
13
|
+
* A link can be written by anyone, so it only ever chooses which of the page's
|
|
14
|
+
* own forms to start. The words that start it are the agent's own, built from
|
|
15
|
+
* the form; the intent is left in the composer for the person to send, change
|
|
16
|
+
* or delete, never sent on their behalf. Neither parameter carries anything
|
|
17
|
+
* personal, and both are taken out of the address bar once read.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
import { awaitPageTool, withoutSiteSuffix } from './action-tools.js';
|
|
22
|
+
import { waitForModelContext } from './source.js';
|
|
23
|
+
import type { ModelContext, ModelContextToolInfo } from '../../types.js';
|
|
24
|
+
|
|
25
|
+
/** The action to open on: its `@id`. */
|
|
26
|
+
export const HANDOFF_PARAM = 'nxis_handoff';
|
|
27
|
+
/** What the person wants, in a few words. Optional. */
|
|
28
|
+
export const INTENT_PARAM = 'nxis_intent';
|
|
29
|
+
|
|
30
|
+
/** Longer than any `@id` the crawler writes; a longer value is not one. */
|
|
31
|
+
const MAX_ID = 512;
|
|
32
|
+
/** A few words: enough for "a quote for a panel upgrade", not a message. */
|
|
33
|
+
export const MAX_INTENT = 120;
|
|
34
|
+
|
|
35
|
+
/** How long to wait for the registry, and then for the form's own tool. */
|
|
36
|
+
const WAIT_MS = 8000;
|
|
37
|
+
|
|
38
|
+
/** A handoff read from the address. */
|
|
39
|
+
export interface InboundHandoff {
|
|
40
|
+
/** The `@id` of the action whose form to open. */
|
|
41
|
+
actionId: string;
|
|
42
|
+
/** What the person wants, cleaned up; absent when none was given. */
|
|
43
|
+
intent?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The handoff in an address, if there is one.
|
|
48
|
+
*
|
|
49
|
+
* @param href - The page's address.
|
|
50
|
+
* @returns The handoff, or null when there is none or it is malformed.
|
|
51
|
+
*/
|
|
52
|
+
export function readInboundHandoff(href: string): InboundHandoff | null {
|
|
53
|
+
let url: URL;
|
|
54
|
+
try {
|
|
55
|
+
url = new URL(href);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const actionId = (url.searchParams.get(HANDOFF_PARAM) ?? '').trim();
|
|
60
|
+
if (!actionId || actionId.length > MAX_ID || /\s/.test(actionId)) return null;
|
|
61
|
+
const intent = cleanIntent(url.searchParams.get(INTENT_PARAM) ?? '');
|
|
62
|
+
return intent ? { actionId, intent } : { actionId };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One line of plain text, at most {@link MAX_INTENT} characters. */
|
|
66
|
+
export function cleanIntent(raw: string): string {
|
|
67
|
+
// Control characters, newlines included, become spaces: a draft is one line.
|
|
68
|
+
const flat = raw.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
69
|
+
return flat.length > MAX_INTENT ? flat.slice(0, MAX_INTENT - 1).trimEnd() + '…' : flat;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The address without the handoff parameters, everything else kept.
|
|
74
|
+
*
|
|
75
|
+
* A reload, a bookmark or a copied link would otherwise open the form again.
|
|
76
|
+
*/
|
|
77
|
+
export function withoutHandoff(href: string): string {
|
|
78
|
+
const url = new URL(href);
|
|
79
|
+
if (!url.search) return url.toString();
|
|
80
|
+
// Pair by pair on the raw text: re-serializing would re-encode the other
|
|
81
|
+
// parameters, and the address would no longer be the one the page had.
|
|
82
|
+
const kept = url.search
|
|
83
|
+
.slice(1)
|
|
84
|
+
.split('&')
|
|
85
|
+
.filter((pair) => ![HANDOFF_PARAM, INTENT_PARAM].includes(decodeName(pair.split('=')[0])));
|
|
86
|
+
url.search = kept.length ? `?${kept.join('&')}` : '';
|
|
87
|
+
return url.toString();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A query parameter's name, decoded; as written when it can't be. */
|
|
91
|
+
function decodeName(raw: string): string {
|
|
92
|
+
try {
|
|
93
|
+
return decodeURIComponent(raw.replace(/\+/g, ' '));
|
|
94
|
+
} catch {
|
|
95
|
+
return raw;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The address that hands someone to an action's page.
|
|
101
|
+
*
|
|
102
|
+
* @param pageUrl - The page the action's form is on.
|
|
103
|
+
* @param actionId - The action's `@id`.
|
|
104
|
+
* @param intent - What they want, in a few words. Nothing personal.
|
|
105
|
+
*/
|
|
106
|
+
export function handoffUrl(pageUrl: string, actionId: string, intent?: string): string {
|
|
107
|
+
const url = new URL(pageUrl);
|
|
108
|
+
// Appended to the raw query, so the page's own parameters keep their encoding.
|
|
109
|
+
const added = new URLSearchParams({ [HANDOFF_PARAM]: actionId });
|
|
110
|
+
const said = intent ? cleanIntent(intent) : '';
|
|
111
|
+
if (said) added.set(INTENT_PARAM, said);
|
|
112
|
+
url.search = url.search ? `${url.search}&${added}` : `?${added}`;
|
|
113
|
+
return url.toString();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read the handoff from this page's address and take it out.
|
|
118
|
+
*
|
|
119
|
+
* @param opts - The address and the history to rewrite; the browser's by default.
|
|
120
|
+
*/
|
|
121
|
+
export function takeInboundHandoff(
|
|
122
|
+
opts: { href?: string; replace?: (href: string) => void } = {},
|
|
123
|
+
): InboundHandoff | null {
|
|
124
|
+
const href = opts.href ?? (typeof location !== 'undefined' ? location.href : '');
|
|
125
|
+
const handoff = href ? readInboundHandoff(href) : null;
|
|
126
|
+
if (!handoff) return null;
|
|
127
|
+
const replace =
|
|
128
|
+
opts.replace ??
|
|
129
|
+
((next: string) => {
|
|
130
|
+
try {
|
|
131
|
+
history.replaceState(history.state, '', next);
|
|
132
|
+
} catch {
|
|
133
|
+
// A sandboxed frame can refuse; the handoff is still taken once, below.
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
replace(withoutHandoff(href));
|
|
137
|
+
return handoff;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What the agent says to start a form: "Start the quote form". */
|
|
141
|
+
export function startMessage(tool: ModelContextToolInfo): string {
|
|
142
|
+
return startFormMessage(withoutSiteSuffix(tool.name).replace(/[_-]+/g, ' ').trim().toLowerCase());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* What the agent says to start a site form for one business, in the words a
|
|
147
|
+
* result card's button uses: "Get a quote from McManus Electric".
|
|
148
|
+
*/
|
|
149
|
+
export function startFormMessage(noun: string, choice?: string): string {
|
|
150
|
+
const named = noun || 'form';
|
|
151
|
+
const form = /\bform\b/.test(named) ? named : `${named} form`;
|
|
152
|
+
if (!choice) return `Start the ${form}`;
|
|
153
|
+
return /quote/.test(named) ? `Get a quote from ${choice}` : `Start the ${form} for ${choice}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Where the form a handoff names stands, once the page has loaded. */
|
|
157
|
+
export type ArrivalOutcome =
|
|
158
|
+
/** WebMCP never appeared, so the page's forms cannot be reached. */
|
|
159
|
+
| { status: 'no-registry' }
|
|
160
|
+
/** The page has no form for that action. */
|
|
161
|
+
| { status: 'no-tool' }
|
|
162
|
+
/** The form is here. */
|
|
163
|
+
| { status: 'ready'; tool: ModelContextToolInfo };
|
|
164
|
+
|
|
165
|
+
/** Overrides, so this can be exercised without a page. */
|
|
166
|
+
export interface ArrivalOptions {
|
|
167
|
+
/** How long to wait for the registry, and then for the tool. */
|
|
168
|
+
timeoutMs?: number;
|
|
169
|
+
/** Wait for a WebMCP registry. */
|
|
170
|
+
registry?(timeoutMs: number): Promise<ModelContext | null>;
|
|
171
|
+
/** Wait for the page's own tool for this action. */
|
|
172
|
+
pageTool?(mc: ModelContext, ref: { id: string; name: string }, timeoutMs: number): Promise<ModelContextToolInfo | null>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Find the form a handoff names, waiting for the page to register it.
|
|
177
|
+
*
|
|
178
|
+
* Runs while the page is still loading, as the cross-page resume does: the
|
|
179
|
+
* registry and the form's tool usually appear a moment after the agent.
|
|
180
|
+
*/
|
|
181
|
+
export async function findHandoffForm(handoff: InboundHandoff, opts: ArrivalOptions = {}): Promise<ArrivalOutcome> {
|
|
182
|
+
const timeoutMs = opts.timeoutMs ?? WAIT_MS;
|
|
183
|
+
const mc = await (opts.registry ?? ((ms: number) => waitForModelContext(ms)))(timeoutMs);
|
|
184
|
+
if (!mc) return { status: 'no-registry' };
|
|
185
|
+
// By id only: a name match could be a different form on this page.
|
|
186
|
+
const find = opts.pageTool ?? ((c, ref, ms) => awaitPageTool(c, ref, ms));
|
|
187
|
+
const tool = await find(mc, { id: handoff.actionId, name: '' }, timeoutMs);
|
|
188
|
+
return tool ? { status: 'ready', tool } : { status: 'no-tool' };
|
|
189
|
+
}
|
|
@@ -194,7 +194,7 @@ export function pageForEntity(entity: Obj, pages: SitePage[], origin: string): S
|
|
|
194
194
|
/**
|
|
195
195
|
* The page an id formed from it names, or null.
|
|
196
196
|
*
|
|
197
|
-
*
|
|
197
|
+
* The NxisAI index forms a page entity's id as the page URL plus a fragment, so the page is the id with
|
|
198
198
|
* the fragment removed.
|
|
199
199
|
*/
|
|
200
200
|
function pageOfId(id: string | undefined, origin: string): string | null {
|
package/src/index.ts
CHANGED
|
@@ -50,6 +50,9 @@ export type { Slot, Resolution, ResolveSources } from './harness/slots.js';
|
|
|
50
50
|
|
|
51
51
|
export { buildPlan } from './harness/plan.js';
|
|
52
52
|
|
|
53
|
+
export { actionCta } from './harness/cta.js';
|
|
54
|
+
export type { CardAction } from './harness/cta.js';
|
|
55
|
+
|
|
53
56
|
export { AgentCoordinator } from './harness/router/coordinator.js';
|
|
54
57
|
export type { CoordinatorOptions, RoutedTurn, RouteSource } from './harness/router/coordinator.js';
|
|
55
58
|
export { matchRoute } from './harness/router/rules.js';
|
|
@@ -60,6 +63,7 @@ export type {
|
|
|
60
63
|
HarnessRenderer,
|
|
61
64
|
AskRequest,
|
|
62
65
|
ConfirmRequest,
|
|
66
|
+
ResultsExtra,
|
|
63
67
|
StreamingBubble,
|
|
64
68
|
} from './harness/renderer.js';
|
|
65
69
|
|
|
@@ -133,6 +137,16 @@ export {
|
|
|
133
137
|
export type { AgentStep, AgentStepId, AgentStepStatus } from './harness/steps.js';
|
|
134
138
|
export { resumePendingAction } from './harness/tools/resume.js';
|
|
135
139
|
export type { ResumeOutcome, ResumeOptions } from './harness/tools/resume.js';
|
|
140
|
+
// Arriving from the NxisAI app to use one of this page's forms.
|
|
141
|
+
export {
|
|
142
|
+
HANDOFF_PARAM,
|
|
143
|
+
INTENT_PARAM,
|
|
144
|
+
readInboundHandoff,
|
|
145
|
+
takeInboundHandoff,
|
|
146
|
+
handoffUrl,
|
|
147
|
+
findHandoffForm,
|
|
148
|
+
} from './harness/tools/inbound.js';
|
|
149
|
+
export type { InboundHandoff, ArrivalOutcome, ArrivalOptions } from './harness/tools/inbound.js';
|
|
136
150
|
export {
|
|
137
151
|
loadSitePages,
|
|
138
152
|
parsePage,
|
|
@@ -172,6 +186,8 @@ export {
|
|
|
172
186
|
} from './harness/tools/pending-action.js';
|
|
173
187
|
export type { PendingAction, PendingVisit } from './harness/tools/pending-action.js';
|
|
174
188
|
export { findEmbeddedAction, loadSiteActions, parseAction, type EmbeddedActionQuery } from './harness/tools/site-actions.js';
|
|
189
|
+
export { extractRecords, openStatus, recordFacts, factLines, type OpenStatus, type RecordFacts } from './harness/records.js';
|
|
190
|
+
export { recommend, grounded, RECOMMEND_TOOL, type ResultPick, type RecommendRequest } from './harness/recommend.js';
|
|
175
191
|
export type { ConnectResult, ConnectOptions, SiteActionsOptions } from './harness/tools/source.js';
|
|
176
192
|
export type { BridgeOptions } from './harness/tools/bridge.js';
|
|
177
193
|
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { mountSurface, type MountedSurface } from '../a2ui/host.js';
|
|
24
24
|
import { emitCardsSurface, type ResultCardData } from '../a2ui/cards.js';
|
|
25
|
+
import { actionCta, type CardAction } from '../harness/cta.js';
|
|
25
26
|
import { emitToolForm } from '../a2ui/form.js';
|
|
26
27
|
import { A2UI_THEME_CSS } from '../a2ui/theme.js';
|
|
27
28
|
// Compiled from agent.tw.css at build time (scripts/css-to-ts.mjs). It is a
|
|
@@ -43,8 +44,10 @@ import { connectTools } from '../harness/tools/source.js';
|
|
|
43
44
|
import {
|
|
44
45
|
chooserOf,
|
|
45
46
|
formOptions,
|
|
47
|
+
formsFor,
|
|
46
48
|
handoffFor,
|
|
47
49
|
isSiteTool,
|
|
50
|
+
siteFormFor,
|
|
48
51
|
withoutSiteSuffix,
|
|
49
52
|
HANDOFF_RESULT,
|
|
50
53
|
} from '../harness/tools/action-tools.js';
|
|
@@ -59,7 +62,11 @@ import {
|
|
|
59
62
|
type ActionItem,
|
|
60
63
|
type ActionStep,
|
|
61
64
|
} from './action-card.js';
|
|
65
|
+
import { resultsAnswer, RESULTS_ANSWER_CSS, type AnswerCard } from './result-pick.js';
|
|
66
|
+
import { extractRecords, openStatus, recordFacts } from '../harness/records.js';
|
|
67
|
+
import type { ResultPick } from '../harness/recommend.js';
|
|
62
68
|
import { resumePendingAction } from '../harness/tools/resume.js';
|
|
69
|
+
import { findHandoffForm, startFormMessage, startMessage, takeInboundHandoff } from '../harness/tools/inbound.js';
|
|
63
70
|
import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
|
|
64
71
|
import { pageForEntity, type SitePage } from '../harness/tools/site-pages.js';
|
|
65
72
|
import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
|
|
@@ -329,6 +336,16 @@ interface ResultCard {
|
|
|
329
336
|
id?: string;
|
|
330
337
|
/** The page on this site the record came from, when it can be told. */
|
|
331
338
|
page?: { url: string; label: string };
|
|
339
|
+
/** The rating and how many reviews it rests on. */
|
|
340
|
+
stars?: number;
|
|
341
|
+
reviews?: number;
|
|
342
|
+
/** Open or closed when the search ran: "Open now · 24/7", "Opens 7am". */
|
|
343
|
+
status?: { open: boolean; label: string };
|
|
344
|
+
phone?: string;
|
|
345
|
+
/** What the record states, short, for comparing it. */
|
|
346
|
+
facts?: string[];
|
|
347
|
+
/** The action the record itself offers (its potentialAction), as a button. */
|
|
348
|
+
action?: CardAction;
|
|
332
349
|
}
|
|
333
350
|
|
|
334
351
|
type ThreadItem =
|
|
@@ -337,7 +354,7 @@ type ThreadItem =
|
|
|
337
354
|
| { kind: 'trace'; name: string; args: Record<string, unknown>; text: string; isError: boolean; count?: number }
|
|
338
355
|
| { kind: 'route'; agent: string; via: string }
|
|
339
356
|
| { kind: 'detail'; title: string; note: string; rows: DetailRow[] }
|
|
340
|
-
| { kind: 'results'; title: string; note: string; cards: ResultCard[] }
|
|
357
|
+
| { kind: 'results'; title: string; note: string; cards: ResultCard[]; pick?: ResultPick }
|
|
341
358
|
| { kind: 'metrics'; rounds: MetricsRound[] }
|
|
342
359
|
| { kind: 'form' }
|
|
343
360
|
/** A turn the person called off. Kept so the thread explains its own gap. */
|
|
@@ -670,6 +687,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
670
687
|
// belongs to is the one open.
|
|
671
688
|
void this._resumePendingAction();
|
|
672
689
|
this._resumePendingVisit();
|
|
690
|
+
// Sent here from the NxisAI app to use one of this page's forms.
|
|
691
|
+
void this._arriveFromHandoff();
|
|
673
692
|
|
|
674
693
|
this._els.launcher.addEventListener('click', () => this._open());
|
|
675
694
|
this._els.handoffBack.addEventListener('click', () => void this._endHandoff());
|
|
@@ -1577,7 +1596,14 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1577
1596
|
}
|
|
1578
1597
|
|
|
1579
1598
|
// ── Turn loop ──────────────────────────────────────────────────────────────
|
|
1580
|
-
|
|
1599
|
+
/**
|
|
1600
|
+
* Send what is in the composer.
|
|
1601
|
+
*
|
|
1602
|
+
* @param call - A tool call already decided, run without asking the model
|
|
1603
|
+
* (a handoff names its form). Falls back to an ordinary turn when no agent
|
|
1604
|
+
* here holds the tool.
|
|
1605
|
+
*/
|
|
1606
|
+
private async _onSend(call?: { name: string; args: Record<string, unknown> }): Promise<void> {
|
|
1581
1607
|
const goal = this._els.input.value.trim();
|
|
1582
1608
|
if (!goal || this._turnActive) return;
|
|
1583
1609
|
this._els.input.value = '';
|
|
@@ -1614,7 +1640,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1614
1640
|
await this._connectTools();
|
|
1615
1641
|
this._syncProfileContext();
|
|
1616
1642
|
// One agent, or several with a router in front: the signatures match.
|
|
1617
|
-
|
|
1643
|
+
const engine = this._coordinator ?? this._harness;
|
|
1644
|
+
const begun = call ? await engine.begin(goal, call.name, call.args, conv!.messages) : null;
|
|
1645
|
+
if (!begun || begun.decision === 'unavailable') await engine.turn(goal, conv!.messages);
|
|
1618
1646
|
// A remote endpoint reports which model answered; the name beside the
|
|
1619
1647
|
// input follows it rather than keeping whatever was configured.
|
|
1620
1648
|
this._paintProvenance();
|
|
@@ -1646,9 +1674,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1646
1674
|
ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
|
|
1647
1675
|
confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
|
|
1648
1676
|
left: (tool, destination) => this._cardLeft(tool, destination),
|
|
1649
|
-
results: (tool, res) => {
|
|
1677
|
+
results: (tool, res, extra) => {
|
|
1650
1678
|
const conv = this._active();
|
|
1651
|
-
const item = this._buildResultsItem(tool, res);
|
|
1679
|
+
const item = this._buildResultsItem(tool, res, extra?.pick);
|
|
1652
1680
|
// A read that answers in words — "you are on the home page", "already open" — has no records
|
|
1653
1681
|
// to draw, and an empty result card reading "No results found." contradicts the answer it
|
|
1654
1682
|
// just gave. Say what it said.
|
|
@@ -2582,61 +2610,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2582
2610
|
* return the first that yields records. Shapes covered: query → results[],
|
|
2583
2611
|
* list_types → types[], get → result{}, or a bare entity/array. */
|
|
2584
2612
|
private _extractItems(res: ModelContextToolResponse | string | null): Record<string, unknown>[] {
|
|
2585
|
-
const
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
// extension in the loop) the whole `{content, structuredContent}` envelope
|
|
2589
|
-
// serialized to a string, possibly more than once. Diving into both
|
|
2590
|
-
// `structuredContent` and each `content[].text` covers all of them.
|
|
2591
|
-
const visit = (raw: unknown, depth = 0): void => {
|
|
2592
|
-
if (raw == null || depth > 5) return;
|
|
2593
|
-
const val: unknown = raw;
|
|
2594
|
-
if (typeof val === 'string') {
|
|
2595
|
-
const t = val.trim();
|
|
2596
|
-
if (!t) return;
|
|
2597
|
-
let parsed: unknown;
|
|
2598
|
-
try {
|
|
2599
|
-
parsed = JSON.parse(t);
|
|
2600
|
-
} catch {
|
|
2601
|
-
return; // plain (non-JSON) text — nothing to extract
|
|
2602
|
-
}
|
|
2603
|
-
// Parsed value may itself be an object/array OR another JSON string
|
|
2604
|
-
// (double-encoded); recurse either way.
|
|
2605
|
-
return visit(parsed, depth + 1);
|
|
2606
|
-
}
|
|
2607
|
-
if (!val || typeof val !== 'object') return;
|
|
2608
|
-
sources.push(val);
|
|
2609
|
-
const v = val as Record<string, unknown>;
|
|
2610
|
-
if (v.structuredContent != null) visit(v.structuredContent, depth + 1);
|
|
2611
|
-
if (Array.isArray(v.content)) {
|
|
2612
|
-
for (const c of v.content) {
|
|
2613
|
-
if (c && typeof c === 'object') visit((c as Record<string, unknown>).text, depth + 1);
|
|
2614
|
-
}
|
|
2615
|
-
}
|
|
2616
|
-
};
|
|
2617
|
-
visit(res);
|
|
2618
|
-
|
|
2619
|
-
for (const data of sources) {
|
|
2620
|
-
const items = this._itemsFrom(data);
|
|
2621
|
-
if (items.length) return items;
|
|
2622
|
-
}
|
|
2623
|
-
console.warn('[nxis-mcp] no items extracted from tool result', { sourcesTried: sources.length, res });
|
|
2624
|
-
return [];
|
|
2625
|
-
}
|
|
2626
|
-
|
|
2627
|
-
/** Find the record array within one parsed payload. */
|
|
2628
|
-
private _itemsFrom(data: unknown): Record<string, unknown>[] {
|
|
2629
|
-
const objs = (arr: unknown[]) =>
|
|
2630
|
-
arr.filter((x) => x && typeof x === 'object') as Record<string, unknown>[];
|
|
2631
|
-
if (Array.isArray(data)) return objs(data);
|
|
2632
|
-
if (!data || typeof data !== 'object') return [];
|
|
2633
|
-
const d = data as Record<string, unknown>;
|
|
2634
|
-
for (const key of ['results', 'types', 'items', 'entities', 'data']) {
|
|
2635
|
-
if (Array.isArray(d[key])) return objs(d[key] as unknown[]);
|
|
2636
|
-
}
|
|
2637
|
-
if (d.result && typeof d.result === 'object') return [d.result as Record<string, unknown>];
|
|
2638
|
-
if (d['@id'] != null || d['@type'] != null || d.name != null) return [d];
|
|
2639
|
-
return [];
|
|
2613
|
+
const items = extractRecords(res);
|
|
2614
|
+
if (!items.length) console.warn('[nxis-mcp] no items extracted from tool result', { res });
|
|
2615
|
+
return items;
|
|
2640
2616
|
}
|
|
2641
2617
|
|
|
2642
2618
|
/** Build a display card from a single record (a JSON-LD entity or a type row). */
|
|
@@ -2704,19 +2680,88 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2704
2680
|
const id = entity['@id'] != null ? str(entity['@id']) : undefined;
|
|
2705
2681
|
const found = pageForEntity(entity, this._sitePages, location.origin);
|
|
2706
2682
|
const page = found ? { url: found.url, label: found.name } : undefined;
|
|
2707
|
-
|
|
2683
|
+
|
|
2684
|
+
// What the answer view compares by, beyond the rating and hours its heading already shows.
|
|
2685
|
+
const facts = recordFacts(entity);
|
|
2686
|
+
const status = openStatus(entity) ?? undefined;
|
|
2687
|
+
const lines = [
|
|
2688
|
+
facts.services.length ? facts.services.slice(0, 3).map((x) => x.name).join(', ') : facts.knows.slice(0, 3).join(', '),
|
|
2689
|
+
facts.since ? `In business since ${facts.since}` : '',
|
|
2690
|
+
facts.reviews[0] ? `\u201c${facts.reviews[0].text}\u201d${facts.reviews[0].author ? ` \u2014 ${facts.reviews[0].author}` : ''}` : '',
|
|
2691
|
+
].filter((l): l is string => !!l);
|
|
2692
|
+
const action = actionCta(entity, title);
|
|
2693
|
+
return {
|
|
2694
|
+
title,
|
|
2695
|
+
type,
|
|
2696
|
+
rating,
|
|
2697
|
+
emergency,
|
|
2698
|
+
sub,
|
|
2699
|
+
rows,
|
|
2700
|
+
id,
|
|
2701
|
+
...(page ? { page } : {}),
|
|
2702
|
+
...(facts.rating !== undefined ? { stars: facts.rating } : {}),
|
|
2703
|
+
...(facts.reviewCount !== undefined ? { reviews: facts.reviewCount } : {}),
|
|
2704
|
+
...(status ? { status: { open: status.open, label: status.label } } : {}),
|
|
2705
|
+
...(facts.phone ? { phone: facts.phone } : {}),
|
|
2706
|
+
...(action ? { action } : {}),
|
|
2707
|
+
facts: lines,
|
|
2708
|
+
};
|
|
2708
2709
|
}
|
|
2709
2710
|
|
|
2710
2711
|
private _buildResultsItem(
|
|
2711
2712
|
tool: ModelContextToolInfo,
|
|
2712
2713
|
res: ModelContextToolResponse,
|
|
2714
|
+
pick?: ResultPick,
|
|
2713
2715
|
): Extract<ThreadItem, { kind: 'results' }> {
|
|
2714
2716
|
const items = this._extractItems(res);
|
|
2715
2717
|
const cards = items.slice(0, 12).map((e) => this._entityCard(e));
|
|
2716
2718
|
const note = !cards.length
|
|
2717
2719
|
? 'No results found.'
|
|
2718
2720
|
: `${cards.length} result${cards.length === 1 ? '' : 's'}`;
|
|
2719
|
-
return { kind: 'results', title: humanize(tool.name), note, cards };
|
|
2721
|
+
return { kind: 'results', title: humanize(tool.name), note, cards, ...(pick && pick.index < cards.length ? { pick } : {}) };
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
/**
|
|
2725
|
+
* Results as an answer: the recommended pick with its reasons and the others
|
|
2726
|
+
* to compare, or a short list that opens per result. Records of things
|
|
2727
|
+
* only: a listing of types or counts keeps the plain cards.
|
|
2728
|
+
*/
|
|
2729
|
+
private _answerEl(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement | null {
|
|
2730
|
+
// Records of things: a pick, or at least one result with a rating, hours, a phone or a page.
|
|
2731
|
+
// A sparse record beside them (an index entry with only a name) still shows, by name.
|
|
2732
|
+
const things = item.cards.filter((c) => c.stars !== undefined || c.status || c.phone || c.page);
|
|
2733
|
+
if (!item.pick && !things.length) return null;
|
|
2734
|
+
const cards: AnswerCard[] = item.cards.map((c) => {
|
|
2735
|
+
// The record's own action first: the same map and prompt as the Typed Retrieval skill and the NxisAI
|
|
2736
|
+
// app. A record that states none falls back to a form found on this page for it.
|
|
2737
|
+
const form = c.action ? undefined : formsFor(c.title)[0];
|
|
2738
|
+
const quote = form && /quote/.test(form.noun);
|
|
2739
|
+
return {
|
|
2740
|
+
title: c.title,
|
|
2741
|
+
stars: c.stars,
|
|
2742
|
+
reviews: c.reviews,
|
|
2743
|
+
status: c.status,
|
|
2744
|
+
phone: c.phone,
|
|
2745
|
+
page: c.page,
|
|
2746
|
+
facts: c.facts ?? [],
|
|
2747
|
+
...(c.action
|
|
2748
|
+
? { form: { label: c.action.label, message: c.action.message } }
|
|
2749
|
+
: form
|
|
2750
|
+
? {
|
|
2751
|
+
form: quote
|
|
2752
|
+
? { label: 'Get a quote', message: `Get a quote from ${c.title}` }
|
|
2753
|
+
: { label: `Use their ${form.noun}`, message: `Start the ${form.noun} for ${c.title}` },
|
|
2754
|
+
}
|
|
2755
|
+
: {}),
|
|
2756
|
+
};
|
|
2757
|
+
});
|
|
2758
|
+
return resultsAnswer(cards, item.pick, {
|
|
2759
|
+
ask: (text) => {
|
|
2760
|
+
if (this._turnActive) return;
|
|
2761
|
+
this._els.input.value = text;
|
|
2762
|
+
void this._onSend();
|
|
2763
|
+
},
|
|
2764
|
+
});
|
|
2720
2765
|
}
|
|
2721
2766
|
|
|
2722
2767
|
/** Feature-card pattern: tinted icon chip, tags, tight Manrope headline, one
|
|
@@ -2739,6 +2784,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2739
2784
|
...(c.emergency ? [{ label: '24/7' }] : []),
|
|
2740
2785
|
],
|
|
2741
2786
|
rows: c.rows.map((r) => ({ label: r.label, value: r.value })),
|
|
2787
|
+
actions: c.action ? [c.action] : [],
|
|
2742
2788
|
}));
|
|
2743
2789
|
}
|
|
2744
2790
|
|
|
@@ -2752,7 +2798,15 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2752
2798
|
}
|
|
2753
2799
|
try {
|
|
2754
2800
|
const messages = emitCardsSurface(item.note, this._toA2uiCards(item.cards));
|
|
2755
|
-
const mounted = mountSurface(wrap, messages)
|
|
2801
|
+
const mounted = mountSurface(wrap, messages, (event: any) => {
|
|
2802
|
+
const name = event?.event?.name ?? event?.name ?? '';
|
|
2803
|
+
const ctx = (event?.event?.context ?? event?.context ?? {}) as Record<string, unknown>;
|
|
2804
|
+
// A card's action button: send its message as the visitor, as the inline card does.
|
|
2805
|
+
if (name === 'nxis.ask' && typeof ctx.message === 'string' && !this._turnActive) {
|
|
2806
|
+
this._els.input.value = ctx.message;
|
|
2807
|
+
void this._onSend();
|
|
2808
|
+
}
|
|
2809
|
+
});
|
|
2756
2810
|
if (!mounted) throw new Error('no surface produced');
|
|
2757
2811
|
this._surfaces.push(mounted);
|
|
2758
2812
|
} catch (err) {
|
|
@@ -2777,9 +2831,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2777
2831
|
* surface; a2ui="a2ui-results" opts this element back into it for comparison.
|
|
2778
2832
|
*/
|
|
2779
2833
|
private _resultsEl(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement {
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2834
|
+
if (this.getAttribute('a2ui') === 'a2ui-results') return this._resultsElA2ui(item);
|
|
2835
|
+
if (!this._classicPrompts && item.cards.length) {
|
|
2836
|
+
const answer = this._answerEl(item);
|
|
2837
|
+
if (answer) return answer;
|
|
2838
|
+
}
|
|
2839
|
+
return this._resultsElLegacy(item);
|
|
2783
2840
|
}
|
|
2784
2841
|
|
|
2785
2842
|
private _resultsElLegacy(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement {
|
|
@@ -3477,6 +3534,42 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3477
3534
|
if (this.mode !== 'fullscreen') this._open();
|
|
3478
3535
|
}
|
|
3479
3536
|
|
|
3537
|
+
/**
|
|
3538
|
+
* Open on the form a link from the NxisAI app names.
|
|
3539
|
+
*
|
|
3540
|
+
* In a new conversation: the person arrives from another app, and nothing
|
|
3541
|
+
* on this site led here. The agent starts the form in its own words, so the
|
|
3542
|
+
* link only chooses which form; what the person wanted waits in the
|
|
3543
|
+
* composer for them to send, change or delete.
|
|
3544
|
+
*/
|
|
3545
|
+
private async _arriveFromHandoff(): Promise<void> {
|
|
3546
|
+
const handoff = takeInboundHandoff();
|
|
3547
|
+
if (!handoff) return;
|
|
3548
|
+
this._newConversation();
|
|
3549
|
+
this._open();
|
|
3550
|
+
this._setStatus('Opening the form…');
|
|
3551
|
+
// The site's own tools first: they reach a form from any page, from the
|
|
3552
|
+
// site's records, so they work where the page registers nothing itself.
|
|
3553
|
+
await this._connectTools();
|
|
3554
|
+
const site = siteFormFor(handoff.actionId);
|
|
3555
|
+
const start = site
|
|
3556
|
+
? startFormMessage(site.noun, site.choice)
|
|
3557
|
+
: await findHandoffForm(handoff).then((o) => (o.status === 'ready' ? startMessage(o.tool) : null));
|
|
3558
|
+
this._setStatus('');
|
|
3559
|
+
if (!start) {
|
|
3560
|
+
this._appendMsg('assistant', "I couldn't find that form on this page. Tell me what you need and I'll help from here.");
|
|
3561
|
+
} else if (!this._turnActive) {
|
|
3562
|
+
this._els.input.value = start;
|
|
3563
|
+
// The form is known, so it opens without the model choosing it: left to
|
|
3564
|
+
// choose, a model sometimes asks for the details in prose instead.
|
|
3565
|
+
await this._onSend(site ? { name: site.tool, args: site.args } : undefined);
|
|
3566
|
+
}
|
|
3567
|
+
if (handoff.intent && !this._els.input.value) {
|
|
3568
|
+
this._els.input.value = handoff.intent;
|
|
3569
|
+
this._autosize();
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3480
3573
|
private async _resumePendingAction(): Promise<void> {
|
|
3481
3574
|
const outcome = await resumePendingAction({
|
|
3482
3575
|
onStart: (pending) => {
|
|
@@ -4099,6 +4192,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
4099
4192
|
/* The action card: forms and handoffs as one message. */
|
|
4100
4193
|
${ACTION_CARD_CSS}
|
|
4101
4194
|
|
|
4195
|
+
/* Search results as an answer: a pick and why, or a short list. */
|
|
4196
|
+
${RESULTS_ANSWER_CSS}
|
|
4197
|
+
|
|
4102
4198
|
/* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
|
|
4103
4199
|
.launcher {
|
|
4104
4200
|
position: fixed; right: 20px; bottom: 20px; z-index: 9998;
|