@nxisai/agent-harness 0.2.4 → 0.4.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 +90 -2
- package/dist/a2ui/stepper.d.ts +1 -1
- package/dist/a2ui/stepper.d.ts.map +1 -1
- package/dist/a2ui/stepper.js +2 -2
- package/dist/a2ui/stepper.js.map +1 -1
- package/dist/engine.d.ts +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1 -0
- package/dist/engine.js.map +1 -1
- package/dist/harness/harness.d.ts +58 -0
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +179 -4
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/renderer.d.ts +30 -0
- package/dist/harness/renderer.d.ts.map +1 -1
- package/dist/harness/steps.d.ts +101 -0
- package/dist/harness/steps.d.ts.map +1 -0
- package/dist/harness/steps.js +102 -0
- package/dist/harness/steps.js.map +1 -0
- package/dist/harness/tools/action-tools.d.ts +213 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -0
- package/dist/harness/tools/action-tools.js +724 -0
- package/dist/harness/tools/action-tools.js.map +1 -0
- package/dist/harness/tools/bridge.d.ts.map +1 -1
- package/dist/harness/tools/bridge.js +28 -2
- package/dist/harness/tools/bridge.js.map +1 -1
- package/dist/harness/tools/open-page.d.ts +42 -0
- package/dist/harness/tools/open-page.d.ts.map +1 -0
- package/dist/harness/tools/open-page.js +118 -0
- package/dist/harness/tools/open-page.js.map +1 -0
- package/dist/harness/tools/pending-action.d.ts +99 -0
- package/dist/harness/tools/pending-action.d.ts.map +1 -0
- package/dist/harness/tools/pending-action.js +175 -0
- package/dist/harness/tools/pending-action.js.map +1 -0
- package/dist/harness/tools/resume.d.ts +92 -0
- package/dist/harness/tools/resume.d.ts.map +1 -0
- package/dist/harness/tools/resume.js +84 -0
- package/dist/harness/tools/resume.js.map +1 -0
- package/dist/harness/tools/schema.js +29 -2
- package/dist/harness/tools/schema.js.map +1 -1
- package/dist/harness/tools/site-actions.d.ts +81 -0
- package/dist/harness/tools/site-actions.d.ts.map +1 -0
- package/dist/harness/tools/site-actions.js +314 -0
- package/dist/harness/tools/site-actions.js.map +1 -0
- package/dist/harness/tools/site-pages.d.ts +95 -0
- package/dist/harness/tools/site-pages.d.ts.map +1 -0
- package/dist/harness/tools/site-pages.js +199 -0
- package/dist/harness/tools/site-pages.js.map +1 -0
- package/dist/harness/tools/source.d.ts +58 -3
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +88 -7
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/proxy.d.ts +5 -0
- package/dist/providers/proxy.d.ts.map +1 -1
- package/dist/providers/proxy.js +16 -1
- package/dist/providers/proxy.js.map +1 -1
- package/dist/providers/types.d.ts +15 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/types.d.ts +7 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +835 -36
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/journey.d.ts +33 -0
- package/dist/ui/journey.d.ts.map +1 -0
- package/dist/ui/journey.js +27 -0
- package/dist/ui/journey.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/dist/webmcp-contract.d.ts +6 -2
- package/dist/webmcp-contract.d.ts.map +1 -1
- package/docs/remote-providers.md +7 -1
- package/package.json +1 -1
|
@@ -34,6 +34,13 @@ import { AgentHarness, isAbort } from '../harness/harness.js';
|
|
|
34
34
|
import { WebMcpTools } from '../harness/tools/webmcp.js';
|
|
35
35
|
import { extractResult, 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';
|
|
38
|
+
import { resumePendingAction } from '../harness/tools/resume.js';
|
|
39
|
+
import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
|
|
40
|
+
import { pageForEntity } from '../harness/tools/site-pages.js';
|
|
41
|
+
import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
|
|
42
|
+
import { onJourneyChange, recordStep, restoreJourney } from '../harness/steps.js';
|
|
43
|
+
import { viewJourney } from './journey.js';
|
|
37
44
|
import { AgentCoordinator } from '../harness/router/coordinator.js';
|
|
38
45
|
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';
|
|
39
46
|
import { DEFAULT_SYSTEM_PROMPT, DEFAULT_SUGGESTIONS, DEFAULT_EMPTY_STATE, DEFAULT_AGENT_NAME, DEFAULT_LOGO, THEME_TOKENS, PROFILE_ALIASES, } from './config.js';
|
|
@@ -82,6 +89,103 @@ function formatBytes(n) {
|
|
|
82
89
|
return `${Math.round(mb)} MB`;
|
|
83
90
|
}
|
|
84
91
|
const uid = () => 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
92
|
+
/**
|
|
93
|
+
* Names for the retrieval tools the package itself bridges in.
|
|
94
|
+
*
|
|
95
|
+
* `nxis_query` is a fine name for a model and a poor one for a visitor, and a
|
|
96
|
+
* native registry may strip any title we attach, so the few tools this package
|
|
97
|
+
* registers are named here.
|
|
98
|
+
*/
|
|
99
|
+
const FRIENDLY_TOOL_NAMES = {
|
|
100
|
+
nxis_query: 'Search records',
|
|
101
|
+
nxis_get: 'Get a record',
|
|
102
|
+
nxis_list_types: 'List record types',
|
|
103
|
+
};
|
|
104
|
+
const svg = (body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
105
|
+
/** A plug: the tools the agent can reach. */
|
|
106
|
+
const ICON_TOOLS = svg('<path d="M7 3v4M13 3v4M5 7h10v3a5 5 0 0 1-10 0V7ZM10 15v3"/>');
|
|
107
|
+
const ICON_LOCK = svg('<rect x="4.5" y="9" width="11" height="8" rx="2"/><path d="M7 9V6.5a3 3 0 0 1 6 0V9"/>');
|
|
108
|
+
const ICON_FORM = svg('<rect x="4" y="3.5" width="12" height="13" rx="2"/><path d="M7 8h6M7 11h6M7 14h3"/>');
|
|
109
|
+
const ICON_SEARCH = svg('<circle cx="9" cy="9" r="4.8"/><path d="m13 13 3.5 3.5"/>');
|
|
110
|
+
const ICON_GLOBE = svg('<circle cx="10" cy="10" r="6.5"/><path d="M3.5 10h13M10 3.5c2 2 2 11 0 13M10 3.5c-2 2-2 11 0 13"/>');
|
|
111
|
+
/**
|
|
112
|
+
* Split a message into prose and code.
|
|
113
|
+
*
|
|
114
|
+
* Two things read as code: a fenced block, and a message that is nothing but
|
|
115
|
+
* JSON — which is what a tool result looks like when something hands one back
|
|
116
|
+
* raw. Wrapped as prose it becomes a wall of text that breaks at the wrong
|
|
117
|
+
* places, taking any URL inside it with it.
|
|
118
|
+
*
|
|
119
|
+
* @param text - The message.
|
|
120
|
+
* @returns The parts in order, each marked as code or not.
|
|
121
|
+
*/
|
|
122
|
+
function splitCode(text) {
|
|
123
|
+
const fenced = text.split(/```[a-zA-Z]*\n?/);
|
|
124
|
+
// An opening and a closing fence produce at least three parts; the odd ones
|
|
125
|
+
// are what sat between them.
|
|
126
|
+
if (fenced.length > 2) {
|
|
127
|
+
return fenced.map((part, i) => ({ text: i % 2 ? part.replace(/\n$/, '') : part, code: i % 2 === 1 }));
|
|
128
|
+
}
|
|
129
|
+
const trimmed = text.trim();
|
|
130
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
131
|
+
try {
|
|
132
|
+
return [{ text: JSON.stringify(JSON.parse(trimmed), null, 2), code: true }];
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Starts like JSON without being it — ordinary prose.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return [{ text, code: false }];
|
|
139
|
+
}
|
|
140
|
+
/** Machine output rather than something someone wrote: JSON, most often raw. */
|
|
141
|
+
function looksMachine(text) {
|
|
142
|
+
const trimmed = text.trim();
|
|
143
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('['))
|
|
144
|
+
return false;
|
|
145
|
+
try {
|
|
146
|
+
JSON.parse(trimmed);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Longer than this, a block is folded away rather than filling the thread. */
|
|
154
|
+
const FOLD_LINES = 10;
|
|
155
|
+
/**
|
|
156
|
+
* Write a message into its bubble, keeping code as code.
|
|
157
|
+
*
|
|
158
|
+
* A long block is folded behind a summary: output worth keeping, but not
|
|
159
|
+
* worth scrolling past every time. Everything is written as text rather than
|
|
160
|
+
* markup — a message may carry a tool's output, which is not ours to trust
|
|
161
|
+
* as HTML.
|
|
162
|
+
*/
|
|
163
|
+
function fillMessage(el, text) {
|
|
164
|
+
for (const part of splitCode(text)) {
|
|
165
|
+
if (part.code) {
|
|
166
|
+
const pre = document.createElement('pre');
|
|
167
|
+
pre.className = 'codeblock';
|
|
168
|
+
const code = document.createElement('code');
|
|
169
|
+
code.textContent = part.text;
|
|
170
|
+
pre.appendChild(code);
|
|
171
|
+
const lines = part.text.split('\n').length;
|
|
172
|
+
if (lines > FOLD_LINES) {
|
|
173
|
+
const fold = document.createElement('details');
|
|
174
|
+
fold.className = 'codefold';
|
|
175
|
+
const summary = document.createElement('summary');
|
|
176
|
+
summary.textContent = `${lines} lines`;
|
|
177
|
+
fold.append(summary, pre);
|
|
178
|
+
el.appendChild(fold);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
el.appendChild(pre);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else if (part.text) {
|
|
185
|
+
el.appendChild(document.createTextNode(part.text));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
85
189
|
class NxisAgentFullscreen extends HTMLElement {
|
|
86
190
|
constructor() {
|
|
87
191
|
super();
|
|
@@ -95,6 +199,21 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
95
199
|
* on, the re-run would race `_onSend`'s own guard and be dropped.
|
|
96
200
|
*/
|
|
97
201
|
this._turnDone = null;
|
|
202
|
+
/** Stops listening to the journal. */
|
|
203
|
+
this._offJourney = null;
|
|
204
|
+
/** Whether the tools menu is showing. */
|
|
205
|
+
this._toolsOpen = false;
|
|
206
|
+
/** Closes the tools menu on a press outside it. An arrow, so it can be removed. */
|
|
207
|
+
this._onOutsidePress = (e) => {
|
|
208
|
+
if (!this._toolsOpen)
|
|
209
|
+
return;
|
|
210
|
+
const path = e.composedPath();
|
|
211
|
+
if (path.includes(this._els.toolsPop) || path.includes(this._els.toolsBtn))
|
|
212
|
+
return;
|
|
213
|
+
this._toggleTools(false);
|
|
214
|
+
};
|
|
215
|
+
/** Watches whether the running journey's card is on screen. */
|
|
216
|
+
this._journeyObserver = null;
|
|
98
217
|
/** Built only when the host defines agents — otherwise there is one harness. */
|
|
99
218
|
this._coordinator = null;
|
|
100
219
|
this._agentDefs = null;
|
|
@@ -115,6 +234,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
115
234
|
this._stage = '';
|
|
116
235
|
/** The one discovery run, kicked off on first open and awaited before a turn. */
|
|
117
236
|
this._connect = null;
|
|
237
|
+
/** The site's pages, for linking results to where they came from. */
|
|
238
|
+
this._sitePages = [];
|
|
118
239
|
this._loadPct = 0;
|
|
119
240
|
/**
|
|
120
241
|
* Which mode the empty state is currently drawn in, or null when a
|
|
@@ -157,9 +278,15 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
157
278
|
thread: root.querySelector('.thread'),
|
|
158
279
|
status: root.querySelector('.statusline'),
|
|
159
280
|
bar: root.querySelector('.prog > i'),
|
|
281
|
+
jstatus: root.querySelector('.jstatus'),
|
|
160
282
|
input: root.querySelector('.composer textarea'),
|
|
161
283
|
go: root.querySelector('.composer .go'),
|
|
284
|
+
toolsBtn: root.querySelector('.tools-btn'),
|
|
285
|
+
toolsPop: root.querySelector('.tools-pop'),
|
|
286
|
+
toolsCount: root.querySelector('.tools-count'),
|
|
287
|
+
model: root.querySelector('.composer .model'),
|
|
162
288
|
};
|
|
289
|
+
this._applyMode();
|
|
163
290
|
// A host-supplied provider wins; otherwise the local model, configured from
|
|
164
291
|
// the attributes that only mean anything to it.
|
|
165
292
|
this.provider =
|
|
@@ -203,6 +330,34 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
203
330
|
this._loadConversations();
|
|
204
331
|
this._renderHistory();
|
|
205
332
|
this._renderThread();
|
|
333
|
+
// Journey cards follow the journal rather than the renderer, because the
|
|
334
|
+
// steps that finish a cross-page action are recorded by the resume below,
|
|
335
|
+
// before any turn — and so before any renderer call — exists on this page.
|
|
336
|
+
this._offJourney?.();
|
|
337
|
+
this._offJourney = onJourneyChange((_step, all) => this._onJourney(all));
|
|
338
|
+
this._els.toolsBtn.addEventListener('click', () => this._toggleTools());
|
|
339
|
+
this._els.toolsPop.addEventListener('keydown', (e) => {
|
|
340
|
+
if (e.key !== 'Escape')
|
|
341
|
+
return;
|
|
342
|
+
e.stopPropagation();
|
|
343
|
+
this._toggleTools(false);
|
|
344
|
+
this._els.toolsBtn.focus();
|
|
345
|
+
});
|
|
346
|
+
// Outside the menu closes it. On the document, with the composed path,
|
|
347
|
+
// so a press anywhere on the host page counts as outside too.
|
|
348
|
+
document.removeEventListener('pointerdown', this._onOutsidePress);
|
|
349
|
+
document.addEventListener('pointerdown', this._onOutsidePress);
|
|
350
|
+
this._els.jstatus.addEventListener('click', () => {
|
|
351
|
+
const cards = this._els.thread.querySelectorAll('.journey.is-running');
|
|
352
|
+
cards[cards.length - 1]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
353
|
+
});
|
|
354
|
+
// Whoever builds the tools, a call that leaves this page reports back to
|
|
355
|
+
// the thread it was made in.
|
|
356
|
+
setConversationSource(() => this._activeId ?? undefined);
|
|
357
|
+
// A form confirmed on another page submits here, now that the page it
|
|
358
|
+
// belongs to is the one open.
|
|
359
|
+
void this._resumePendingAction();
|
|
360
|
+
this._resumePendingVisit();
|
|
206
361
|
this._els.launcher.addEventListener('click', () => this._open());
|
|
207
362
|
this._els.close.addEventListener('click', () => {
|
|
208
363
|
this._els.overlay.classList.remove('show');
|
|
@@ -278,6 +433,14 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
278
433
|
if (!open)
|
|
279
434
|
this._els.menu.focus();
|
|
280
435
|
}
|
|
436
|
+
disconnectedCallback() {
|
|
437
|
+
document.removeEventListener('pointerdown', this._onOutsidePress);
|
|
438
|
+
// Moved or removed: nothing left on screen to keep in step with the journal.
|
|
439
|
+
this._offJourney?.();
|
|
440
|
+
this._offJourney = null;
|
|
441
|
+
this._journeyObserver?.disconnect();
|
|
442
|
+
this._journeyObserver = null;
|
|
443
|
+
}
|
|
281
444
|
/** Programmatic open (used by pages that dock the agent full-page). */
|
|
282
445
|
open() {
|
|
283
446
|
this._open();
|
|
@@ -457,8 +620,11 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
457
620
|
this._els.thread.appendChild(this._resultsEl(it));
|
|
458
621
|
else if (it.kind === 'metrics')
|
|
459
622
|
this._els.thread.appendChild(this._metricsEl(it));
|
|
623
|
+
else if (it.kind === 'journey')
|
|
624
|
+
this._els.thread.appendChild(this._journeyEl(it));
|
|
460
625
|
}
|
|
461
626
|
this._scroll();
|
|
627
|
+
this._watchRunningJourney();
|
|
462
628
|
}
|
|
463
629
|
_clearEmpty() {
|
|
464
630
|
const e = this._els.thread.querySelector('.empty');
|
|
@@ -484,7 +650,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
484
650
|
? 'self-end bg-blue text-white rounded-2xl rounded-br-md'
|
|
485
651
|
: 'self-start bg-surface text-ink border border-border-hair rounded-2xl rounded-bl-md shadow-resting';
|
|
486
652
|
el.className = `msg ${role} ${base} ${byRole}`;
|
|
487
|
-
el
|
|
653
|
+
fillMessage(el, text);
|
|
488
654
|
return el;
|
|
489
655
|
}
|
|
490
656
|
/**
|
|
@@ -502,15 +668,27 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
502
668
|
(isError
|
|
503
669
|
? 'bg-surface border-red/25 text-red'
|
|
504
670
|
: 'bg-surface border-border-hair text-green-strong');
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
671
|
+
const head = document.createElement('b');
|
|
672
|
+
head.className =
|
|
673
|
+
'flex items-center gap-1.5 font-mono text-[11px] font-medium uppercase tracking-[0.14em]';
|
|
674
|
+
head.innerHTML = `${isError ? ICON_WARN : ICON_CHECK}${escapeHtml(name)}`;
|
|
675
|
+
const body = document.createElement('div');
|
|
676
|
+
body.className = 'mt-1 font-sans text-[13px] leading-normal text-ink-2';
|
|
677
|
+
// A tool's own sentence is worth showing; its raw payload is for whoever
|
|
678
|
+
// is building the agent, and lives in the trace, which `debug` governs.
|
|
679
|
+
const shown = this.debug || !looksMachine(text)
|
|
680
|
+
? text
|
|
681
|
+
: `${humanize(name)} could not be completed.`;
|
|
682
|
+
fillMessage(body, shown);
|
|
683
|
+
el.append(head, body);
|
|
509
684
|
return el;
|
|
510
685
|
}
|
|
511
686
|
_detailEl(item) {
|
|
512
687
|
const rows = (item.rows || [])
|
|
513
|
-
.map((r) =>
|
|
688
|
+
.map((r) =>
|
|
689
|
+
// Focusable, so a keyboard can open a truncated row the way a pointer does.
|
|
690
|
+
`<div class="dc-row" tabindex="0"><dt title="${escapeHtml(r.label)}">${escapeHtml(r.label)}</dt>` +
|
|
691
|
+
`<dd title="${escapeHtml(r.value)}">${escapeHtml(r.value)}</dd></div>`)
|
|
514
692
|
.join('');
|
|
515
693
|
const el = document.createElement('div');
|
|
516
694
|
el.className = 'msg detail';
|
|
@@ -727,6 +905,133 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
727
905
|
el.appendChild(body);
|
|
728
906
|
return el;
|
|
729
907
|
}
|
|
908
|
+
/**
|
|
909
|
+
* A journey as a card in the thread.
|
|
910
|
+
*
|
|
911
|
+
* Open while it runs, so the step being taken is on screen; folded to one
|
|
912
|
+
* line once it finishes, so the thread reads as a record rather than a wall
|
|
913
|
+
* of finished checklists. A `<details>` either way, so a folded journey can
|
|
914
|
+
* still be opened.
|
|
915
|
+
*/
|
|
916
|
+
_journeyEl(item) {
|
|
917
|
+
const view = viewJourney(item.steps);
|
|
918
|
+
const el = document.createElement('details');
|
|
919
|
+
el.className = `msg journey is-${view?.outcome ?? 'running'}`;
|
|
920
|
+
el.dataset.journey = item.id;
|
|
921
|
+
el.open = view?.outcome === 'running';
|
|
922
|
+
if (view?.active)
|
|
923
|
+
el.dataset.active = view.active.label;
|
|
924
|
+
const summary = document.createElement('summary');
|
|
925
|
+
const mark = document.createElement('span');
|
|
926
|
+
mark.className = 'j-sum-mark';
|
|
927
|
+
mark.setAttribute('aria-hidden', 'true');
|
|
928
|
+
if (view?.outcome === 'done')
|
|
929
|
+
mark.textContent = '✓';
|
|
930
|
+
if (view?.outcome === 'failed')
|
|
931
|
+
mark.textContent = '!';
|
|
932
|
+
const title = document.createElement('span');
|
|
933
|
+
title.className = 'j-title';
|
|
934
|
+
title.textContent = view?.title ?? 'Working on it';
|
|
935
|
+
const state = document.createElement('span');
|
|
936
|
+
state.className = 'j-state';
|
|
937
|
+
// Running, the header says what is happening now; finished, it says how
|
|
938
|
+
// it ended, which is the one thing a folded card has to carry.
|
|
939
|
+
state.textContent =
|
|
940
|
+
view?.outcome === 'running' ? (view.active?.label ?? '') : (view?.summary.split(' · ')[1] ?? '');
|
|
941
|
+
summary.append(mark, title, state);
|
|
942
|
+
el.appendChild(summary);
|
|
943
|
+
const list = document.createElement('ol');
|
|
944
|
+
list.className = 'j-steps';
|
|
945
|
+
(view?.steps ?? []).forEach((step, i) => {
|
|
946
|
+
const li = document.createElement('li');
|
|
947
|
+
li.className = `j-step is-${step.status}`;
|
|
948
|
+
const m = document.createElement('span');
|
|
949
|
+
m.className = 'j-mark';
|
|
950
|
+
m.textContent = step.status === 'done' ? '✓' : step.status === 'failed' ? '!' : String(i + 1);
|
|
951
|
+
const label = document.createElement('span');
|
|
952
|
+
label.className = 'j-label';
|
|
953
|
+
// Text throughout: a label can carry a business name from the site's data.
|
|
954
|
+
label.textContent = step.label;
|
|
955
|
+
label.title = step.detail ? `${step.label} — ${step.detail}` : step.label;
|
|
956
|
+
if (step.detail) {
|
|
957
|
+
const small = document.createElement('small');
|
|
958
|
+
small.textContent = step.detail;
|
|
959
|
+
label.appendChild(small);
|
|
960
|
+
}
|
|
961
|
+
li.append(m, label);
|
|
962
|
+
list.appendChild(li);
|
|
963
|
+
});
|
|
964
|
+
el.appendChild(list);
|
|
965
|
+
return el;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Keep a journey's card, and its record in the conversation, up to date.
|
|
969
|
+
*
|
|
970
|
+
* Looks for the journey in every conversation before assuming it is new:
|
|
971
|
+
* on arrival after a page change the resume records steps before the
|
|
972
|
+
* conversation they came from has been reopened, and those steps belong
|
|
973
|
+
* with the card saved on the page before, not in whichever thread is open.
|
|
974
|
+
*/
|
|
975
|
+
_onJourney(all) {
|
|
976
|
+
const view = viewJourney(all);
|
|
977
|
+
if (!view?.id)
|
|
978
|
+
return;
|
|
979
|
+
const isThis = (it) => it.kind === 'journey' && it.id === view.id;
|
|
980
|
+
let conv = this._conversations.find((c) => c.items.some(isThis));
|
|
981
|
+
let item = conv?.items.find(isThis);
|
|
982
|
+
if (conv && item) {
|
|
983
|
+
item.steps = all;
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
conv = this._active() ?? undefined;
|
|
987
|
+
if (!conv)
|
|
988
|
+
return;
|
|
989
|
+
item = { kind: 'journey', id: view.id, steps: all };
|
|
990
|
+
conv.items.push(item);
|
|
991
|
+
}
|
|
992
|
+
if (conv.id === this._activeId && this._els?.thread) {
|
|
993
|
+
const fresh = this._journeyEl(item);
|
|
994
|
+
const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
|
|
995
|
+
if (old) {
|
|
996
|
+
// Keep a folded card open if someone opened it to read.
|
|
997
|
+
if (old.open && view.outcome !== 'running')
|
|
998
|
+
fresh.open = true;
|
|
999
|
+
old.replaceWith(fresh);
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
this._clearEmpty();
|
|
1003
|
+
this._els.thread.appendChild(fresh);
|
|
1004
|
+
this._scroll();
|
|
1005
|
+
}
|
|
1006
|
+
this._watchRunningJourney();
|
|
1007
|
+
}
|
|
1008
|
+
this._saveConversations();
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Show the status line only while the running journey is out of view.
|
|
1012
|
+
*
|
|
1013
|
+
* The card is where the steps are; the line exists for the moment a long
|
|
1014
|
+
* reply pushes it off the top of the thread mid-action, and hides again as
|
|
1015
|
+
* soon as the card is back.
|
|
1016
|
+
*/
|
|
1017
|
+
_watchRunningJourney() {
|
|
1018
|
+
this._journeyObserver?.disconnect();
|
|
1019
|
+
this._journeyObserver = null;
|
|
1020
|
+
const bar = this._els?.jstatus;
|
|
1021
|
+
if (!bar)
|
|
1022
|
+
return;
|
|
1023
|
+
const cards = this._els.thread.querySelectorAll('.journey.is-running');
|
|
1024
|
+
const card = cards[cards.length - 1];
|
|
1025
|
+
if (!card || typeof IntersectionObserver === 'undefined') {
|
|
1026
|
+
bar.hidden = true;
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
bar.querySelector('.js-label').textContent = card.dataset.active ?? '';
|
|
1030
|
+
this._journeyObserver = new IntersectionObserver((entries) => {
|
|
1031
|
+
bar.hidden = entries[entries.length - 1].isIntersecting;
|
|
1032
|
+
}, { root: this._els.thread, threshold: 0.2 });
|
|
1033
|
+
this._journeyObserver.observe(card);
|
|
1034
|
+
}
|
|
730
1035
|
_paintToolResult(name, text, isError) {
|
|
731
1036
|
this._clearEmpty();
|
|
732
1037
|
const el = this._toolEl(name, text, isError);
|
|
@@ -771,6 +1076,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
771
1076
|
go.classList.toggle('stop', canStop);
|
|
772
1077
|
go.innerHTML = canStop ? ICON_STOP : ICON_ARROW;
|
|
773
1078
|
go.setAttribute('aria-label', canStop ? 'Stop' : 'Send');
|
|
1079
|
+
// The model's dot pulses while it is working, so the two controls agree.
|
|
1080
|
+
this._els.model.classList.toggle('busy', canStop);
|
|
774
1081
|
// Enabled either way: disabling it during a turn would disable the very
|
|
775
1082
|
// control needed to stop that turn.
|
|
776
1083
|
go.disabled = false;
|
|
@@ -901,6 +1208,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
901
1208
|
this._syncProfileContext();
|
|
902
1209
|
// One agent, or several with a router in front: the signatures match.
|
|
903
1210
|
await (this._coordinator ?? this._harness).turn(goal, conv.messages);
|
|
1211
|
+
// A remote endpoint reports which model answered; the name beside the
|
|
1212
|
+
// input follows it rather than keeping whatever was configured.
|
|
1213
|
+
this._paintProvenance();
|
|
904
1214
|
}
|
|
905
1215
|
catch (err) {
|
|
906
1216
|
// A cancelled turn has already reported itself in the thread, and is not
|
|
@@ -1059,8 +1369,17 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1059
1369
|
this._clearEmpty();
|
|
1060
1370
|
this._dismissPrompt();
|
|
1061
1371
|
const rows = req.slots
|
|
1062
|
-
.map((sl) =>
|
|
1372
|
+
.map((sl) => {
|
|
1373
|
+
const label = escapeHtml(sl.schema.description || humanize(sl.name));
|
|
1374
|
+
const value = escapeHtml(String(sl.value ?? ''));
|
|
1375
|
+
return `<div class="dc-row" tabindex="0"><dt title="${label}">${label}</dt><dd title="${value}">${value}</dd></div>`;
|
|
1376
|
+
})
|
|
1063
1377
|
.join('');
|
|
1378
|
+
// Where pressing submit leads, when that is another page. Said here in a
|
|
1379
|
+
// sentence so the button itself can stay short enough for a narrow card.
|
|
1380
|
+
const sub = req.destination
|
|
1381
|
+
? `Submitting opens the page for ${escapeHtml(req.destination)} and sends it from there.`
|
|
1382
|
+
: 'Review these details, then submit.';
|
|
1064
1383
|
const wrap = document.createElement('div');
|
|
1065
1384
|
wrap.className = 'msg detail';
|
|
1066
1385
|
wrap.innerHTML = `
|
|
@@ -1068,12 +1387,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1068
1387
|
<div class="dc-badge review">${ICON_ARROW}</div>
|
|
1069
1388
|
<div class="dc-eyebrow review">Ready to send</div>
|
|
1070
1389
|
<div class="dc-title">${escapeHtml(humanize(req.tool.name))}</div>
|
|
1071
|
-
<div class="dc-sub"
|
|
1390
|
+
<div class="dc-sub">${sub}</div>
|
|
1072
1391
|
<dl class="dc-list">${rows || '<div class="dc-row"><dd>No details captured.</dd></div>'}</dl>
|
|
1073
1392
|
<div class="dc-actions">
|
|
1074
1393
|
<button type="button" class="btn ghost" data-act="cancel">Cancel</button>
|
|
1075
1394
|
<button type="button" class="btn ghost" data-act="edit">Edit</button>
|
|
1076
|
-
<button type="button" class="btn primary" data-act="submit"
|
|
1395
|
+
<button type="button" class="btn primary" data-act="submit">${escapeHtml(req.submitLabel ?? 'Submit')} ${ICON_ARROW}</button>
|
|
1077
1396
|
</div>
|
|
1078
1397
|
</div>`;
|
|
1079
1398
|
wrap.querySelector('[data-act="cancel"]').addEventListener('click', () => req.onCancel());
|
|
@@ -1281,6 +1600,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1281
1600
|
submitBtn.disabled = true;
|
|
1282
1601
|
result.className = 'genform-result';
|
|
1283
1602
|
result.textContent = 'Submitting…';
|
|
1603
|
+
// Read before the call: a registry may return only the response's text,
|
|
1604
|
+
// and the annotations already say whether this call leaves the page.
|
|
1605
|
+
const leaving = handoffFor(tool, values);
|
|
1284
1606
|
try {
|
|
1285
1607
|
this._setStatus(`Running ${tool.name}…`);
|
|
1286
1608
|
const res = await this.toolSource.execTool(tool, tool.name, values);
|
|
@@ -1293,6 +1615,18 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1293
1615
|
submitBtn.disabled = false;
|
|
1294
1616
|
return;
|
|
1295
1617
|
}
|
|
1618
|
+
// A call that left for another page has filled in nothing yet, so it
|
|
1619
|
+
// must not become a "Submitted" card. The page it is opening finishes
|
|
1620
|
+
// the job and draws the card there.
|
|
1621
|
+
if (leaving || res?.structuredContent?.[HANDOFF_RESULT]) {
|
|
1622
|
+
this._dismissPrompt();
|
|
1623
|
+
// From what we know, not from the response: a registry may hand back
|
|
1624
|
+
// the whole response object as JSON.
|
|
1625
|
+
this._appendMsg('assistant', leaving
|
|
1626
|
+
? `Opening ${leaving.label} to submit this…`
|
|
1627
|
+
: 'Opening the page that can submit this…');
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1296
1630
|
// Success → the form transitions inline. A write tool (form/action)
|
|
1297
1631
|
// becomes a "Submitted" detail card; a read-only tool (typed retrieval)
|
|
1298
1632
|
// becomes result cards for the records it returned.
|
|
@@ -1493,7 +1827,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1493
1827
|
}
|
|
1494
1828
|
}
|
|
1495
1829
|
const id = entity['@id'] != null ? str(entity['@id']) : undefined;
|
|
1496
|
-
|
|
1830
|
+
const found = pageForEntity(entity, this._sitePages, location.origin);
|
|
1831
|
+
const page = found ? { url: found.url, label: found.name } : undefined;
|
|
1832
|
+
return { title, type, rating, emergency, sub, rows, id, ...(page ? { page } : {}) };
|
|
1497
1833
|
}
|
|
1498
1834
|
_buildResultsItem(tool, res) {
|
|
1499
1835
|
const items = this._extractItems(res);
|
|
@@ -1575,7 +1911,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1575
1911
|
const cardsHtml = item.cards
|
|
1576
1912
|
.map((c) => {
|
|
1577
1913
|
const rows = c.rows
|
|
1578
|
-
.map((r) => `<div class="rc-row"><dt
|
|
1914
|
+
.map((r) => `<div class="rc-row"><dt title="${escapeHtml(r.label)}">${escapeHtml(r.label)}</dt>` +
|
|
1915
|
+
`<dd title="${escapeHtml(r.value)}">${r.href ? `<a href="${escapeHtml(r.href)}">${escapeHtml(r.value)}</a>` : escapeHtml(r.value)}</dd></div>`)
|
|
1579
1916
|
.join('');
|
|
1580
1917
|
const tags = (c.type ? `<span class="tag blue"><i class="dot"></i>${escapeHtml(c.type)}</span>` : '') +
|
|
1581
1918
|
(c.rating ? `<span class="tag green"><i class="dot"></i>${escapeHtml(c.rating)}</span>` : '') +
|
|
@@ -1585,14 +1922,26 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1585
1922
|
<div class="rc-chip">${this._logoMarkup()}</div>
|
|
1586
1923
|
<div class="rc-tags">${tags}</div>
|
|
1587
1924
|
</div>
|
|
1588
|
-
<div class="rc-title">${escapeHtml(c.title)}</div>
|
|
1589
|
-
${c.sub ? `<div class="rc-sub">${escapeHtml(c.sub)}</div>` : ''}
|
|
1925
|
+
<div class="rc-title" title="${escapeHtml(c.title)}">${escapeHtml(c.title)}</div>
|
|
1926
|
+
${c.sub ? `<div class="rc-sub" title="${escapeHtml(c.sub)}">${escapeHtml(c.sub)}</div>` : ''}
|
|
1590
1927
|
${rows ? `<dl class="rc-list">${rows}</dl>` : ''}
|
|
1591
1928
|
${c.id ? `<div class="rc-id"><code>${escapeHtml(c.id)}</code></div>` : ''}
|
|
1929
|
+
${c.page
|
|
1930
|
+
? `<a class="rc-open" href="${escapeHtml(c.page.url)}" data-label="${escapeHtml(c.page.label)}" ` +
|
|
1931
|
+
`title="${escapeHtml(c.page.url)}">Open page<span aria-hidden="true">→</span></a>`
|
|
1932
|
+
: ''}
|
|
1592
1933
|
</div>`;
|
|
1593
1934
|
})
|
|
1594
1935
|
.join('');
|
|
1595
1936
|
wrap.innerHTML = `<div class="rc-head">${escapeHtml(item.note)}</div><div class="rc-cards">${cardsHtml}</div>`;
|
|
1937
|
+
// An ordinary link, so it opens in a new tab when asked to. Followed in
|
|
1938
|
+
// this one, the conversation goes with it.
|
|
1939
|
+
wrap.addEventListener('click', (e) => {
|
|
1940
|
+
const link = e.target.closest('a.rc-open');
|
|
1941
|
+
if (!link || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
1942
|
+
return;
|
|
1943
|
+
savePendingVisit({ pageUrl: link.href, label: link.dataset.label ?? '', conversationId: this._activeId ?? undefined });
|
|
1944
|
+
});
|
|
1596
1945
|
return wrap;
|
|
1597
1946
|
}
|
|
1598
1947
|
// ── Metrics panel (real Gemma tokenizer counts vs a RAG estimate) ──────────
|
|
@@ -1997,12 +2346,118 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1997
2346
|
el.textContent = `routed to ${item.agent} · ${item.via}`;
|
|
1998
2347
|
return el;
|
|
1999
2348
|
}
|
|
2349
|
+
/**
|
|
2350
|
+
* Submit a form that was confirmed on another page.
|
|
2351
|
+
*
|
|
2352
|
+
* An action runs by filling the form on its own page, so a call made
|
|
2353
|
+
* elsewhere was confirmed there and carried here. The values were already
|
|
2354
|
+
* reviewed and pressed, which is why this submits rather than asking again —
|
|
2355
|
+
* the button that started it said it would open this page and submit.
|
|
2356
|
+
*
|
|
2357
|
+
* Nothing waiting is the ordinary case, and costs a single storage read.
|
|
2358
|
+
*/
|
|
2359
|
+
/**
|
|
2360
|
+
* Continue the conversation that opened this page, and say it is open.
|
|
2361
|
+
*
|
|
2362
|
+
* Reached when the agent opened it or a result card's link was followed.
|
|
2363
|
+
* The thread is the one the visitor was in: by id, or failing that the one
|
|
2364
|
+
* holding the journey that left, so a visit saved without an id still
|
|
2365
|
+
* lands where it started rather than in whichever thread loaded first.
|
|
2366
|
+
*/
|
|
2367
|
+
_resumePendingVisit() {
|
|
2368
|
+
const visit = takePendingVisit();
|
|
2369
|
+
if (!visit)
|
|
2370
|
+
return;
|
|
2371
|
+
const journey = visit.steps?.find((s) => s.journey)?.journey;
|
|
2372
|
+
const conv = this._conversations.find((c) => c.id === visit.conversationId) ??
|
|
2373
|
+
(journey
|
|
2374
|
+
? this._conversations.find((c) => c.items.some((it) => it.kind === 'journey' && it.id === journey))
|
|
2375
|
+
: undefined) ??
|
|
2376
|
+
this._active();
|
|
2377
|
+
if (!conv)
|
|
2378
|
+
return;
|
|
2379
|
+
this._activeId = conv.id;
|
|
2380
|
+
this._renderHistory();
|
|
2381
|
+
this._renderThread();
|
|
2382
|
+
// Finished before the line below, so the card reads done above it.
|
|
2383
|
+
if (visit.steps?.length) {
|
|
2384
|
+
restoreJourney(visit.steps);
|
|
2385
|
+
recordStep({ id: 'navigate', label: `Opened ${visit.label}`, status: 'done' });
|
|
2386
|
+
recordStep({ id: 'submit', label: 'Arrived', status: 'done' });
|
|
2387
|
+
}
|
|
2388
|
+
const label = visit.label || 'the page';
|
|
2389
|
+
this._appendMsg('assistant', `Opened ${label}. You are on its page now.`);
|
|
2390
|
+
// What the model needs to know to answer "what is on this page".
|
|
2391
|
+
conv.messages.push({ role: 'user', content: `[${OPEN_PAGE_TOOL} opened ${label}: ${visit.pageUrl}]` });
|
|
2392
|
+
this._saveConversations();
|
|
2393
|
+
this._scroll();
|
|
2394
|
+
// Covering the page just opened would hide what was asked for.
|
|
2395
|
+
if (this.mode !== 'fullscreen')
|
|
2396
|
+
this._open();
|
|
2397
|
+
}
|
|
2398
|
+
async _resumePendingAction() {
|
|
2399
|
+
const outcome = await resumePendingAction({
|
|
2400
|
+
onStart: (pending) => {
|
|
2401
|
+
// Back to the conversation it was started in, so the result lands
|
|
2402
|
+
// under the question that prompted it rather than in a fresh thread.
|
|
2403
|
+
const conv = this._conversations.find((c) => c.id === pending.conversationId);
|
|
2404
|
+
if (conv) {
|
|
2405
|
+
this._activeId = conv.id;
|
|
2406
|
+
this._renderHistory();
|
|
2407
|
+
this._renderThread();
|
|
2408
|
+
}
|
|
2409
|
+
this._open();
|
|
2410
|
+
this._setStatus('Submitting the details you confirmed…');
|
|
2411
|
+
},
|
|
2412
|
+
exec: (tool, values) => this.toolSource.execTool(tool, tool.name, values),
|
|
2413
|
+
});
|
|
2414
|
+
if (outcome.status !== 'none')
|
|
2415
|
+
this._setStatus('');
|
|
2416
|
+
switch (outcome.status) {
|
|
2417
|
+
case 'none':
|
|
2418
|
+
return;
|
|
2419
|
+
// Either way the form is out of reach, and saying which of the two
|
|
2420
|
+
// failed would describe our plumbing rather than their situation.
|
|
2421
|
+
case 'no-registry':
|
|
2422
|
+
case 'no-tool':
|
|
2423
|
+
this._appendMsg('assistant', `I could not find the ${humanize(outcome.pending.pageToolName)} on this page, so ` +
|
|
2424
|
+
'nothing was submitted. Ask me again and I will fill it in from here.');
|
|
2425
|
+
return;
|
|
2426
|
+
case 'incomplete':
|
|
2427
|
+
this._appendMsg('assistant', `I brought your details over. This form also needs ${outcome.missing
|
|
2428
|
+
.map((m) => humanize(m).toLowerCase())
|
|
2429
|
+
.join(', ')}.`);
|
|
2430
|
+
this._renderForm(outcome.tool, outcome.pending.values);
|
|
2431
|
+
return;
|
|
2432
|
+
case 'rejected':
|
|
2433
|
+
this._appendMsg('assistant', outcome.text || 'That form did not accept the details.');
|
|
2434
|
+
return;
|
|
2435
|
+
case 'failed':
|
|
2436
|
+
this._appendMsg('assistant', `That form could not be submitted: ${outcome.error}`);
|
|
2437
|
+
return;
|
|
2438
|
+
case 'submitted': {
|
|
2439
|
+
const active = this._active();
|
|
2440
|
+
const item = this._buildDetailItem(outcome.tool, outcome.pending.values, outcome.text);
|
|
2441
|
+
active?.items.push(item);
|
|
2442
|
+
active?.messages.push({
|
|
2443
|
+
role: 'user',
|
|
2444
|
+
content: `[${outcome.tool.name} submitted with: ${JSON.stringify(outcome.pending.values)}]. ` +
|
|
2445
|
+
'Keep these unless I ask to change them.',
|
|
2446
|
+
});
|
|
2447
|
+
this._els.thread.appendChild(this._detailEl(item));
|
|
2448
|
+
this._saveConversations();
|
|
2449
|
+
this._scroll();
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2000
2453
|
/**
|
|
2001
2454
|
* Bring in the site's own MCP server, once.
|
|
2002
2455
|
*
|
|
2003
2456
|
* The page's own action tools register themselves. Remote tools do not: they
|
|
2004
2457
|
* live on an MCP server that must be discovered through
|
|
2005
|
-
* `/.well-known/ai-catalog.json` and bridged into the page's registry.
|
|
2458
|
+
* `/.well-known/ai-catalog.json` and bridged into the page's registry. The
|
|
2459
|
+
* same connection lists the actions on the site's other pages, unless
|
|
2460
|
+
* `site-actions="false"`.
|
|
2006
2461
|
*
|
|
2007
2462
|
* Runs on first open rather than on connect, so an unopened launcher costs no
|
|
2008
2463
|
* network request. Failure is neither fatal nor loud: a site with no MCP
|
|
@@ -2023,17 +2478,127 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2023
2478
|
// Short, because this blocks the first turn: a provider that has not
|
|
2024
2479
|
// appeared within a few seconds is not going to.
|
|
2025
2480
|
timeout: 4000,
|
|
2481
|
+
siteActions: this.getAttribute('site-actions') !== 'false',
|
|
2026
2482
|
})
|
|
2027
|
-
.then(({ remoteTools, warning }) => {
|
|
2483
|
+
.then(({ remoteTools, siteActions, siteTools, sitePages, warning }) => {
|
|
2484
|
+
this._sitePages = sitePages;
|
|
2028
2485
|
if (warning)
|
|
2029
2486
|
console.warn('[nxis-agent]', warning);
|
|
2030
|
-
|
|
2487
|
+
if (remoteTools.length) {
|
|
2031
2488
|
console.info(`[nxis-agent] discovered remote tools: ${remoteTools.join(', ')}`);
|
|
2032
2489
|
}
|
|
2490
|
+
if (siteActions.length) {
|
|
2491
|
+
console.info(`[nxis-agent] ${siteActions.length} site action(s) → ` +
|
|
2492
|
+
`${siteTools.length} tool(s): ${siteTools.join(', ')}`);
|
|
2493
|
+
}
|
|
2494
|
+
void this._refreshToolCount();
|
|
2495
|
+
// The page's own script can register after we connect, and a native
|
|
2496
|
+
// registry announces it. Keep the count honest as it does.
|
|
2497
|
+
this.toolSource.modelContext()?.addEventListener('toolchange', () => {
|
|
2498
|
+
void this._refreshToolCount();
|
|
2499
|
+
if (this._toolsOpen)
|
|
2500
|
+
void this._renderTools();
|
|
2501
|
+
});
|
|
2033
2502
|
})
|
|
2034
2503
|
.catch((err) => console.warn('[nxis-agent] tool discovery failed:', err));
|
|
2035
2504
|
return this._connect;
|
|
2036
2505
|
}
|
|
2506
|
+
// ── Tools menu ─────────────────────────────────────────────────────────────
|
|
2507
|
+
/** Open or close the list of tools the agent can use. */
|
|
2508
|
+
_toggleTools(open = !this._toolsOpen) {
|
|
2509
|
+
this._toolsOpen = open;
|
|
2510
|
+
this._els.toolsBtn.setAttribute('aria-expanded', String(open));
|
|
2511
|
+
this._els.toolsPop.hidden = !open;
|
|
2512
|
+
if (open)
|
|
2513
|
+
void this._renderTools();
|
|
2514
|
+
}
|
|
2515
|
+
/** The number on the tools button, from what the agent would be offered now. */
|
|
2516
|
+
async _refreshToolCount() {
|
|
2517
|
+
const tools = await this._harness.offeredTools();
|
|
2518
|
+
this._els.toolsCount.textContent = String(tools.length);
|
|
2519
|
+
this._els.toolsCount.hidden = tools.length === 0;
|
|
2520
|
+
this._els.toolsBtn.setAttribute('aria-label', tools.length ? `Tools on this page, ${tools.length}` : 'Tools on this page');
|
|
2521
|
+
}
|
|
2522
|
+
/**
|
|
2523
|
+
* Draw the tools menu.
|
|
2524
|
+
*
|
|
2525
|
+
* Lists what the agent would be offered, not every tool registered: that
|
|
2526
|
+
* is the honest answer to "what can it do here". Grouped by where a tool
|
|
2527
|
+
* reaches — this page's own forms, then what works across the site — which
|
|
2528
|
+
* is what explains why a form from another listing is on the list. Every
|
|
2529
|
+
* row is disabled: the site provides these, and they are not the visitor's
|
|
2530
|
+
* to switch off.
|
|
2531
|
+
*/
|
|
2532
|
+
async _renderTools() {
|
|
2533
|
+
const pop = this._els.toolsPop;
|
|
2534
|
+
const head = '<div class="tp-head"><div class="tp-title">What the agent can use here</div>' +
|
|
2535
|
+
'<div class="tp-sub">This site provides these tools. They load with the page and cannot be turned off.</div></div>';
|
|
2536
|
+
pop.innerHTML = `${head}<div class="tp-empty">Looking for tools…</div>`;
|
|
2537
|
+
const tools = await this._harness.offeredTools();
|
|
2538
|
+
if (!this._toolsOpen)
|
|
2539
|
+
return;
|
|
2540
|
+
this._els.toolsCount.textContent = String(tools.length);
|
|
2541
|
+
this._els.toolsCount.hidden = tools.length === 0;
|
|
2542
|
+
pop.innerHTML = head;
|
|
2543
|
+
if (!tools.length) {
|
|
2544
|
+
pop.insertAdjacentHTML('beforeend', '<div class="tp-empty">No tools on this page yet.</div>');
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
const readOnly = (t) => !!t.annotations?.readOnlyHint;
|
|
2548
|
+
const onPage = tools.filter((t) => !isSiteTool(t) && !readOnly(t));
|
|
2549
|
+
const acrossSite = tools.filter((t) => isSiteTool(t) || readOnly(t));
|
|
2550
|
+
const group = (label, list) => {
|
|
2551
|
+
if (!list.length)
|
|
2552
|
+
return;
|
|
2553
|
+
const title = document.createElement('div');
|
|
2554
|
+
title.className = 'tp-grp';
|
|
2555
|
+
title.textContent = label;
|
|
2556
|
+
pop.appendChild(title);
|
|
2557
|
+
for (const tool of list)
|
|
2558
|
+
pop.appendChild(this._toolRow(tool));
|
|
2559
|
+
};
|
|
2560
|
+
group('On this page', onPage);
|
|
2561
|
+
group('Across the site', acrossSite);
|
|
2562
|
+
if (tools.some((t) => !readOnly(t))) {
|
|
2563
|
+
pop.insertAdjacentHTML('beforeend', '<div class="tp-foot">Anything that submits a form asks you first.</div>');
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
/** One tool in the menu: what it is, whether it writes, and a lock. */
|
|
2567
|
+
_toolRow(tool) {
|
|
2568
|
+
const site = isSiteTool(tool);
|
|
2569
|
+
const reads = !!tool.annotations?.readOnlyHint;
|
|
2570
|
+
const base = FRIENDLY_TOOL_NAMES[tool.name] ?? humanize(withoutSiteSuffix(tool.name));
|
|
2571
|
+
const name = (base.charAt(0).toUpperCase() + base.slice(1)) + (site ? ' · any page' : '');
|
|
2572
|
+
const desc = (tool.description ?? '').split(/(?<=[.!?])\s/)[0] ?? '';
|
|
2573
|
+
const row = document.createElement('div');
|
|
2574
|
+
row.className = 'tp-tool';
|
|
2575
|
+
row.setAttribute('aria-disabled', 'true');
|
|
2576
|
+
row.title = desc ? `${name} — ${desc}` : name;
|
|
2577
|
+
const icon = document.createElement('span');
|
|
2578
|
+
icon.className = 'tp-ti';
|
|
2579
|
+
icon.innerHTML = site ? ICON_GLOBE : reads ? ICON_SEARCH : ICON_FORM;
|
|
2580
|
+
const text = document.createElement('span');
|
|
2581
|
+
text.className = 'tp-tn';
|
|
2582
|
+
const n = document.createElement('span');
|
|
2583
|
+
n.className = 'tp-name';
|
|
2584
|
+
// Text, not markup: names and descriptions come from the site's data.
|
|
2585
|
+
n.textContent = name;
|
|
2586
|
+
text.appendChild(n);
|
|
2587
|
+
if (desc) {
|
|
2588
|
+
const d = document.createElement('span');
|
|
2589
|
+
d.className = 'tp-desc';
|
|
2590
|
+
d.textContent = desc;
|
|
2591
|
+
text.appendChild(d);
|
|
2592
|
+
}
|
|
2593
|
+
const kind = document.createElement('span');
|
|
2594
|
+
kind.className = 'tp-kind';
|
|
2595
|
+
kind.textContent = reads ? 'reads' : 'asks first';
|
|
2596
|
+
const lock = document.createElement('span');
|
|
2597
|
+
lock.className = 'tp-lock';
|
|
2598
|
+
lock.innerHTML = ICON_LOCK;
|
|
2599
|
+
row.append(icon, text, kind, lock);
|
|
2600
|
+
return row;
|
|
2601
|
+
}
|
|
2037
2602
|
/**
|
|
2038
2603
|
* Report a failure in the thread, where it will be seen.
|
|
2039
2604
|
*
|
|
@@ -2179,6 +2744,43 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2179
2744
|
*
|
|
2180
2745
|
* Enabling it affects subsequent turns, not those already recorded.
|
|
2181
2746
|
*/
|
|
2747
|
+
/**
|
|
2748
|
+
* How much of the page the agent takes when open.
|
|
2749
|
+
*
|
|
2750
|
+
* `'fullscreen'` covers the page, which suits a site where the agent is the
|
|
2751
|
+
* experience. `'window'` docks it in the corner at
|
|
2752
|
+
* `--window-w` × `--window-h` (400 × 620 by default), for a site whose own
|
|
2753
|
+
* content should stay visible and usable beside it. Below 520px a window
|
|
2754
|
+
* covers the page anyway, since at that width the two are the same thing.
|
|
2755
|
+
*
|
|
2756
|
+
* `'inline'` fills the box the host puts the element in and is always
|
|
2757
|
+
* open — for a copilot rail or a sidebar the host lays out itself. The
|
|
2758
|
+
* element does not position itself or move the page; the host does both.
|
|
2759
|
+
*/
|
|
2760
|
+
get mode() {
|
|
2761
|
+
const raw = this.getAttribute('mode');
|
|
2762
|
+
return raw === 'window' || raw === 'inline' ? raw : 'fullscreen';
|
|
2763
|
+
}
|
|
2764
|
+
set mode(next) {
|
|
2765
|
+
this.setAttribute('mode', next === 'window' || next === 'inline' ? next : 'fullscreen');
|
|
2766
|
+
this._applyMode();
|
|
2767
|
+
}
|
|
2768
|
+
/** Put the overlay in the shape {@link mode} asks for. */
|
|
2769
|
+
_applyMode() {
|
|
2770
|
+
const overlay = this._els?.overlay;
|
|
2771
|
+
if (!overlay)
|
|
2772
|
+
return;
|
|
2773
|
+
const mode = this.mode;
|
|
2774
|
+
overlay.classList.toggle('window', mode === 'window');
|
|
2775
|
+
overlay.classList.toggle('inline', mode === 'inline');
|
|
2776
|
+
if (mode === 'inline') {
|
|
2777
|
+
// Always on screen, so it has to be ready without an open. Tools are
|
|
2778
|
+
// cheap to connect; the model still waits for `preload` or a first
|
|
2779
|
+
// message, so a rail on every page does not cost every visitor 3.4 GB.
|
|
2780
|
+
overlay.classList.add('show');
|
|
2781
|
+
void this._connectTools();
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2182
2784
|
get debug() {
|
|
2183
2785
|
const raw = (this.getAttribute('debug') || '').toLowerCase();
|
|
2184
2786
|
if (!this.hasAttribute('debug'))
|
|
@@ -2258,10 +2860,18 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2258
2860
|
* can be replaced later.
|
|
2259
2861
|
*/
|
|
2260
2862
|
_paintProvenance() {
|
|
2863
|
+
const local = this.provider?.local ?? true;
|
|
2864
|
+
// Beside the input: which model is answering, and where it runs.
|
|
2865
|
+
const model = this.shadowRoot?.querySelector('.composer .model');
|
|
2866
|
+
if (model) {
|
|
2867
|
+
const label = this.provider?.label || this.provider?.name || 'Model';
|
|
2868
|
+
model.querySelector('.model-name').textContent = label;
|
|
2869
|
+
model.querySelector('.model-where').textContent = local ? 'on-device' : 'remote';
|
|
2870
|
+
model.setAttribute('title', `${label}, ${local ? 'running in this browser' : 'running on a server'}`);
|
|
2871
|
+
}
|
|
2261
2872
|
const badge = this.shadowRoot?.querySelector('.provenance');
|
|
2262
2873
|
if (!badge?.lastChild)
|
|
2263
2874
|
return;
|
|
2264
|
-
const local = this.provider?.local ?? true;
|
|
2265
2875
|
badge.lastChild.textContent = local ? 'On-device' : 'Remote';
|
|
2266
2876
|
badge.setAttribute('title', local
|
|
2267
2877
|
? 'The model runs in this browser. Nothing you type leaves the device.'
|
|
@@ -2292,7 +2902,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2292
2902
|
--ink:#0F172A; --ink-2:#475569; --ink-3:#8A97AC;
|
|
2293
2903
|
--blue:#2563EB; --blue-strong:#1D4ED8; --blue-tint:#EEF3FF;
|
|
2294
2904
|
--green:#22C55E; --green-strong:#16A34A; --green-tint:#E8FAEF;
|
|
2295
|
-
--inline-bg:#EEF2F8; --red:#B91C1C;
|
|
2905
|
+
--inline-bg:#EEF2F8; --red:#B91C1C; --stop:#DC2626; --stop-strong:#B91C1C;
|
|
2296
2906
|
--radius:14px; --radius-sm:10px; --radius-pill:999px;
|
|
2297
2907
|
--shadow-sm:0 1px 2px rgba(15,23,42,.05);
|
|
2298
2908
|
--shadow:0 1px 2px rgba(15,23,42,.04), 0 10px 30px rgba(15,23,42,.06);
|
|
@@ -2340,6 +2950,101 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2340
2950
|
.overlay.show { display: flex; animation: fade .2s ease; }
|
|
2341
2951
|
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
|
2342
2952
|
|
|
2953
|
+
/* ── Window mode ────────────────────────────────────────────
|
|
2954
|
+
The same panel, docked in a corner rather than covering the
|
|
2955
|
+
page — for a site that wants an assistant beside its content
|
|
2956
|
+
instead of in front of it. The host sets the size through
|
|
2957
|
+
--window-w and --window-h. */
|
|
2958
|
+
.overlay.window {
|
|
2959
|
+
inset: auto; right: 20px; bottom: 20px;
|
|
2960
|
+
width: min(var(--window-w, 400px), calc(100vw - 32px));
|
|
2961
|
+
height: min(var(--window-h, 620px), calc(100vh - 32px));
|
|
2962
|
+
border: 1px solid var(--border-strong);
|
|
2963
|
+
border-radius: var(--radius);
|
|
2964
|
+
box-shadow: 0 24px 60px rgba(15,23,42,.22);
|
|
2965
|
+
overflow: hidden;
|
|
2966
|
+
}
|
|
2967
|
+
/* ── Inline mode ────────────────────────────────────────────
|
|
2968
|
+
Fills whatever box the host puts it in — a copilot rail, a sidebar,
|
|
2969
|
+
a column — and leaves where that box goes, and what the page does
|
|
2970
|
+
to make room, to the host. Always open: there is no launcher to
|
|
2971
|
+
press and nothing to close back to. */
|
|
2972
|
+
:host([mode="inline"]) { display: block; position: relative; height: 100%; }
|
|
2973
|
+
:host([mode="inline"]) .launcher { display: none; }
|
|
2974
|
+
/* overflow: hidden is what keeps the conversation drawer inside the
|
|
2975
|
+
host's box. Closed, the drawer waits just off the left edge; without
|
|
2976
|
+
the clip it sits on top of the host's page, and pressing the menu
|
|
2977
|
+
only slides it between two visible places. */
|
|
2978
|
+
.overlay.inline { position: absolute; inset: 0; z-index: auto; animation: none; overflow: hidden; }
|
|
2979
|
+
.overlay.inline .close { display: none; }
|
|
2980
|
+
|
|
2981
|
+
/* The conversation list cannot sit beside a narrow thread, so in a
|
|
2982
|
+
window or a rail it behaves as the drawer the narrow layout already
|
|
2983
|
+
defines, whatever the viewport is doing. */
|
|
2984
|
+
.overlay.window .side, .overlay.inline .side {
|
|
2985
|
+
position: absolute; inset: 0 auto 0 0; z-index: 6;
|
|
2986
|
+
transform: translateX(-100%); transition: transform .2s ease;
|
|
2987
|
+
box-shadow: 0 18px 48px rgba(15,23,42,.18);
|
|
2988
|
+
}
|
|
2989
|
+
.overlay.window.nav .side, .overlay.inline.nav .side { transform: none; }
|
|
2990
|
+
.overlay.window.nav .scrim, .overlay.inline.nav .scrim {
|
|
2991
|
+
display: block; position: absolute; inset: 0; z-index: 5;
|
|
2992
|
+
background: rgba(15,23,42,.32);
|
|
2993
|
+
}
|
|
2994
|
+
.overlay.window .menu, .overlay.inline .menu { display: flex; }
|
|
2995
|
+
.overlay.window .msg, .overlay.inline .msg { max-width: 88%; }
|
|
2996
|
+
.overlay.window .hist-del, .overlay.inline .hist-del { opacity: 1; }
|
|
2997
|
+
.overlay.window .main-head, .overlay.inline .main-head { padding: 12px 14px; }
|
|
2998
|
+
.overlay.window .thread, .overlay.inline .thread { padding: 14px; }
|
|
2999
|
+
@media (prefers-reduced-motion: reduce) {
|
|
3000
|
+
.overlay.window .side, .overlay.inline .side { transition: none; }
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
/* ── Journeys ───────────────────────────────────────────────
|
|
3004
|
+
What the agent did about a request, as a card under it. */
|
|
3005
|
+
.msg.journey { align-self: stretch; max-width: 100%; padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--bg); overflow: hidden; }
|
|
3006
|
+
.msg.journey > summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 8px; padding: 9px 12px; font-size: 12.5px; color: var(--ink-2); }
|
|
3007
|
+
.msg.journey > summary::-webkit-details-marker { display: none; }
|
|
3008
|
+
.msg.journey > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
|
|
3009
|
+
.msg.journey[open] > summary { background: var(--surface); border-bottom: 1px solid var(--border); }
|
|
3010
|
+
.j-title { font-family: var(--display); font-weight: 700; color: var(--ink); white-space: nowrap; }
|
|
3011
|
+
.j-state { margin-left: auto; min-width: 0; font-size: 11.5px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3012
|
+
.j-sum-mark { width: 16px; height: 16px; flex: none; border-radius: 50%; display: grid; place-items: center; font-size: 9px; font-weight: 700; }
|
|
3013
|
+
.is-running .j-sum-mark { border: 2px solid var(--blue); border-right-color: transparent; animation: jspin 1s linear infinite; }
|
|
3014
|
+
.is-done .j-sum-mark { background: var(--green-tint); color: var(--green-strong); }
|
|
3015
|
+
.is-failed .j-sum-mark { background: rgba(185,28,28,.1); color: var(--red); }
|
|
3016
|
+
.is-failed .j-state { color: var(--red); }
|
|
3017
|
+
@keyframes jspin { to { transform: rotate(360deg); } }
|
|
3018
|
+
.j-steps { list-style: none; margin: 0; padding: 12px; display: grid; }
|
|
3019
|
+
.j-step { display: grid; grid-template-columns: 20px 1fr; gap: 10px; position: relative; padding-bottom: 10px; font-size: 12.5px; }
|
|
3020
|
+
.j-step:last-child { padding-bottom: 0; }
|
|
3021
|
+
.j-step:not(:last-child)::before { content: ''; position: absolute; left: 9px; top: 21px; bottom: 1px; width: 1px; background: var(--border-strong); }
|
|
3022
|
+
.j-mark { width: 19px; height: 19px; border-radius: 50%; border: 1px solid var(--border-strong); background: var(--bg); color: var(--ink-3); font-size: 10px; display: grid; place-items: center; z-index: 1; }
|
|
3023
|
+
.j-step.is-done .j-mark { background: var(--green-tint); border-color: var(--green-strong); color: var(--green-strong); }
|
|
3024
|
+
.j-step.is-active .j-mark { background: var(--blue); border-color: var(--blue); color: #fff; }
|
|
3025
|
+
.j-step.is-failed .j-mark { background: rgba(185,28,28,.1); border-color: var(--red); color: var(--red); }
|
|
3026
|
+
.j-label { color: var(--ink-2); line-height: 1.35; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3027
|
+
.j-label small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3028
|
+
.j-step:hover .j-label, .j-step:hover .j-label small { white-space: normal; overflow: visible; }
|
|
3029
|
+
.j-step.is-done .j-label, .j-step.is-active .j-label { color: var(--ink); }
|
|
3030
|
+
.j-step.is-active .j-label { font-weight: 600; }
|
|
3031
|
+
.j-label small { display: block; font-size: 11px; font-weight: 400; color: var(--ink-3); margin-top: 1px; }
|
|
3032
|
+
|
|
3033
|
+
/* The running step, while its card is scrolled out of view. */
|
|
3034
|
+
.jstatus { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; border-bottom: 1px solid var(--border); background: var(--blue-tint); color: var(--ink); font-family: var(--sans); font-size: 12.5px; font-weight: 600; padding: 8px 20px; cursor: pointer; text-align: left; }
|
|
3035
|
+
.jstatus[hidden] { display: none; }
|
|
3036
|
+
.jstatus:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
|
|
3037
|
+
.js-spin { width: 12px; height: 12px; flex: none; border-radius: 50%; border: 2px solid var(--blue); border-right-color: transparent; animation: jspin 1s linear infinite; }
|
|
3038
|
+
.js-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
3039
|
+
.js-go { margin-left: auto; font-weight: 500; color: var(--blue-strong); }
|
|
3040
|
+
.overlay.window .jstatus, .overlay.inline .jstatus { padding: 8px 14px; }
|
|
3041
|
+
@media (prefers-reduced-motion: reduce) { .is-running .j-sum-mark, .js-spin { animation: none; } }
|
|
3042
|
+
/* Narrower than the card, a card is the viewport: drop the chrome
|
|
3043
|
+
rather than leaving a 16px frame around a full-height panel. */
|
|
3044
|
+
@media (max-width: 520px) {
|
|
3045
|
+
.overlay.window { inset: 0; width: auto; height: auto; border: 0; border-radius: 0; box-shadow: none; }
|
|
3046
|
+
}
|
|
3047
|
+
|
|
2343
3048
|
/* ── Side panel ─────────────────────────────────────────────── */
|
|
2344
3049
|
.scrim { display: none; }
|
|
2345
3050
|
.side { width: 280px; flex-shrink: 0; display: flex; flex-direction: column; min-height: 0; background: var(--surface); border-right: 1px solid var(--border); }
|
|
@@ -2561,24 +3266,66 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2561
3266
|
|
|
2562
3267
|
.statusline { font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.04em; color: var(--ink-3); padding: 4px 20px 0; min-height: 15px; }
|
|
2563
3268
|
.statusline.err { color: var(--red); }
|
|
2564
|
-
|
|
3269
|
+
/* ── Composer: the input, with a bar beneath it ─────────────
|
|
3270
|
+
Tools on the left, the model beside them, send or stop on the
|
|
3271
|
+
right. One box around both, so the bar reads as part of the input
|
|
3272
|
+
rather than a second control strip. */
|
|
3273
|
+
.composer { padding: 12px 20px 14px; border-top: 1px solid var(--border); }
|
|
3274
|
+
.overlay.window .composer, .overlay.inline .composer { padding: 10px 12px 12px; }
|
|
3275
|
+
.cbox { border: 1px solid var(--border-strong); border-radius: 14px; background: var(--bg); padding: 8px 8px 8px 12px; transition: border-color .15s ease, box-shadow .15s ease; }
|
|
3276
|
+
.cbox:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-tint); }
|
|
2565
3277
|
.composer textarea {
|
|
2566
|
-
|
|
2567
|
-
background:
|
|
2568
|
-
max-height: 140px; overflow-y: auto; transition: border-color .15s ease, box-shadow .15s ease;
|
|
3278
|
+
display: block; width: 100%; font-family: var(--sans); font-size: 14px; line-height: 1.45; color: var(--ink); resize: none;
|
|
3279
|
+
background: transparent; border: 0; padding: 2px 2px 8px; outline: none; max-height: 140px; overflow-y: auto;
|
|
2569
3280
|
}
|
|
2570
|
-
.composer textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-tint); }
|
|
2571
3281
|
.composer textarea::placeholder { color: var(--ink-3); }
|
|
2572
|
-
.
|
|
3282
|
+
.cbar { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
3283
|
+
.composer .go { margin-left: auto; flex-shrink: 0; width: 34px; height: 34px; border-radius: 10px; border: none; background: var(--blue); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 2px rgba(37,99,235,.3); }
|
|
2573
3284
|
.composer .go:hover { background: var(--blue-strong); }
|
|
2574
3285
|
.composer .go:disabled { opacity: .45; cursor: not-allowed; }
|
|
2575
|
-
/*
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
.composer .go.stop { background: var(--surface-2); color: var(--ink-2); box-shadow: none; border: 1px solid var(--border-strong); }
|
|
2580
|
-
.composer .go.stop:hover { background: var(--surface); color: var(--ink); }
|
|
3286
|
+
/* Stop is red, with a filled white square: while a prompt runs this is
|
|
3287
|
+
the one control that matters, and it has to be found at a glance. */
|
|
3288
|
+
.composer .go.stop { background: var(--stop); color: #fff; border: 0; box-shadow: 0 0 0 3px rgba(220,38,38,.18); }
|
|
3289
|
+
.composer .go.stop:hover { background: var(--stop-strong); }
|
|
2581
3290
|
.composer .go.stop svg { width: 15px; height: 15px; }
|
|
3291
|
+
.composer .go.stop svg path { fill: currentColor; }
|
|
3292
|
+
.composer .go:focus-visible, .tools-btn:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
|
3293
|
+
|
|
3294
|
+
.model { display: inline-flex; align-items: center; gap: 6px; min-width: 0; margin-left: 2px; font-size: 12px; font-weight: 500; color: var(--ink-2); white-space: nowrap; overflow: hidden; }
|
|
3295
|
+
.model-dot { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--green); }
|
|
3296
|
+
.model.busy .model-dot { background: var(--blue); animation: mpulse 1.2s ease-in-out infinite; }
|
|
3297
|
+
@keyframes mpulse { 50% { opacity: .35; } }
|
|
3298
|
+
.model-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
3299
|
+
.model-where { flex: none; font-family: var(--mono); font-size: 10px; letter-spacing: .04em; color: var(--ink-3); }
|
|
3300
|
+
|
|
3301
|
+
/* ── Tools menu ── */
|
|
3302
|
+
.tools { position: relative; flex: none; }
|
|
3303
|
+
.tools-btn { position: relative; width: 32px; height: 32px; border: 1px solid var(--border-strong); border-radius: 9px; background: var(--bg); color: var(--ink-2); display: grid; place-items: center; cursor: pointer; padding: 0; }
|
|
3304
|
+
.tools-btn:hover { color: var(--ink); border-color: var(--ink-3); }
|
|
3305
|
+
.tools-btn[aria-expanded="true"] { border-color: var(--blue); color: var(--blue); background: var(--blue-tint); }
|
|
3306
|
+
.tools-btn svg { width: 17px; height: 17px; }
|
|
3307
|
+
.tools-count { position: absolute; top: -7px; right: -7px; min-width: 17px; height: 17px; padding: 0 4px; border-radius: 9px; background: var(--ink); color: var(--bg); font-size: 10px; font-weight: 600; display: grid; place-items: center; border: 2px solid var(--bg); }
|
|
3308
|
+
.tools-count[hidden] { display: none; }
|
|
3309
|
+
.tools-pop { position: absolute; left: -4px; bottom: calc(100% + 12px); z-index: 20; width: 320px; max-width: calc(100vw - 48px); max-height: 380px; overflow-y: auto; background: var(--bg); border: 1px solid var(--border-strong); border-radius: 13px; box-shadow: 0 14px 36px rgba(15,23,42,.16), 0 2px 6px rgba(15,23,42,.06); padding: 6px; }
|
|
3310
|
+
.tools-pop[hidden] { display: none; }
|
|
3311
|
+
.overlay.window .tools-pop, .overlay.inline .tools-pop { width: 296px; }
|
|
3312
|
+
.tp-head { padding: 8px 10px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
|
|
3313
|
+
.tp-title { font-family: var(--display); font-weight: 700; font-size: 13px; color: var(--ink); }
|
|
3314
|
+
.tp-sub { font-size: 11.5px; line-height: 1.4; color: var(--ink-3); margin-top: 2px; }
|
|
3315
|
+
.tp-grp { font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3); padding: 8px 10px 3px; }
|
|
3316
|
+
/* Greyed and locked: visible, not the visitor's to change. */
|
|
3317
|
+
.tp-tool { display: flex; align-items: center; gap: 9px; padding: 6px 10px; border-radius: 8px; cursor: default; }
|
|
3318
|
+
.tp-ti { width: 24px; height: 24px; flex: none; border-radius: 6px; background: var(--surface-2); color: var(--ink-3); display: grid; place-items: center; opacity: .75; }
|
|
3319
|
+
.tp-ti svg { width: 13px; height: 13px; }
|
|
3320
|
+
.tp-tn { min-width: 0; flex: 1; }
|
|
3321
|
+
.tp-name { display: block; font-size: 12.5px; font-weight: 500; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3322
|
+
.tp-desc { display: block; font-size: 11px; color: var(--ink-3); opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3323
|
+
.tp-kind { flex: none; font-family: var(--mono); font-size: 9px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-3); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
|
|
3324
|
+
.tp-lock { flex: none; width: 13px; height: 13px; color: var(--ink-3); opacity: .7; display: grid; }
|
|
3325
|
+
.tp-lock svg { width: 13px; height: 13px; }
|
|
3326
|
+
.tp-empty { padding: 12px 10px; font-size: 12px; color: var(--ink-3); }
|
|
3327
|
+
.tp-foot { padding: 8px 10px 4px; margin-top: 4px; border-top: 1px solid var(--border); font-size: 11px; color: var(--ink-3); }
|
|
3328
|
+
@media (prefers-reduced-motion: reduce) { .model.busy .model-dot { animation: none; } }
|
|
2582
3329
|
/* A message the person sent, plus the row of things they can do to it.
|
|
2583
3330
|
The wrapper carries the alignment the bubble used to carry on its own. */
|
|
2584
3331
|
.turn { display: flex; flex-direction: column; align-items: flex-end; }
|
|
@@ -2713,8 +3460,36 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2713
3460
|
.dc-eyebrow.review { color: var(--blue-strong); }
|
|
2714
3461
|
.dc-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
|
2715
3462
|
.dc-row { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
|
2716
|
-
|
|
2717
|
-
|
|
3463
|
+
/* One line per row, truncated. A card in a window or a rail is too
|
|
3464
|
+
narrow for a long value to wrap without becoming a column of
|
|
3465
|
+
fragments. The whole row comes back on hover or keyboard focus, and
|
|
3466
|
+
every cell carries its full text as a title too. */
|
|
3467
|
+
.dc-row dt { font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); padding-top: 2px; flex: 0 1 auto; min-width: 0; max-width: 45%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3468
|
+
.dc-row dd { margin: 0; flex: 1 1 auto; min-width: 0; font-size: 14px; color: var(--ink); text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3469
|
+
.dc-row:hover dt, .dc-row:hover dd, .dc-row:focus-within dt, .dc-row:focus-within dd { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
|
|
3470
|
+
.dc-row:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; border-radius: 4px; }
|
|
3471
|
+
/* Buttons keep their words on one line and wrap as whole buttons. */
|
|
3472
|
+
.dc-actions { flex-wrap: wrap; }
|
|
3473
|
+
.dc-actions .btn { white-space: nowrap; }
|
|
3474
|
+
.overlay.window .dc-actions .btn, .overlay.inline .dc-actions .btn { padding: 9px 14px; font-size: 13px; }
|
|
3475
|
+
|
|
3476
|
+
/* Code inside a message: monospace, and scrolled rather than wrapped,
|
|
3477
|
+
so a long line does not break at arbitrary places. */
|
|
3478
|
+
.msg .codeblock { margin: 6px 0 0; padding: 10px 12px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border); overflow-x: auto; max-width: 100%; }
|
|
3479
|
+
.msg .codeblock:first-child { margin-top: 0; }
|
|
3480
|
+
.msg .codeblock code { font-family: var(--mono); font-size: 12.5px; line-height: 1.55; white-space: pre; color: var(--ink); }
|
|
3481
|
+
.msg.user .codeblock { background: rgba(255,255,255,.16); border-color: rgba(255,255,255,.28); }
|
|
3482
|
+
.msg.user .codeblock code { color: #fff; }
|
|
3483
|
+
|
|
3484
|
+
/* A long block folds away: kept, but not filling the thread. */
|
|
3485
|
+
.codefold { margin-top: 6px; }
|
|
3486
|
+
.codefold > summary { cursor: pointer; list-style: none; font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); padding: 2px 0; }
|
|
3487
|
+
.codefold > summary::-webkit-details-marker { display: none; }
|
|
3488
|
+
.codefold > summary::before { content: '▸ '; }
|
|
3489
|
+
.codefold[open] > summary::before { content: '▾ '; }
|
|
3490
|
+
.codefold > summary:hover { color: var(--ink-2); }
|
|
3491
|
+
.codefold > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; border-radius: 4px; }
|
|
3492
|
+
.codefold .codeblock { margin-top: 4px; }
|
|
2718
3493
|
|
|
2719
3494
|
/* ── Inline result cards — feature-card pattern: tinted icon chip,
|
|
2720
3495
|
tags, tight Manrope headline, one supporting line. ─────── */
|
|
@@ -2733,9 +3508,21 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2733
3508
|
.rc-sub { font-size: 13px; line-height: 1.5; color: var(--ink-2); margin-top: 2px; }
|
|
2734
3509
|
.rc-list { margin: 10px 0 0; border-top: 1px solid var(--border); display: flex; flex-direction: column; }
|
|
2735
3510
|
.rc-row { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; line-height: 1.4; }
|
|
2736
|
-
.rc-row dt { font-family: var(--mono); font-weight: 500; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); flex-
|
|
2737
|
-
.rc-row dd { margin: 0; color: var(--ink); text-align: right;
|
|
3511
|
+
.rc-row dt { font-family: var(--mono); font-weight: 500; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); flex: 0 1 auto; min-width: 0; max-width: 45%; padding-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3512
|
+
.rc-row dd { margin: 0; flex: 1 1 auto; min-width: 0; color: var(--ink); text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3513
|
+
.rc-row:hover dt, .rc-row:hover dd, .rc-row:focus-within dt, .rc-row:focus-within dd { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
|
|
3514
|
+
/* The headline and its supporting line truncate too, and come back
|
|
3515
|
+
when the card is hovered or focused. */
|
|
3516
|
+
.rc-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3517
|
+
.rc-sub { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
3518
|
+
.resultcard:hover .rc-title, .resultcard:focus-within .rc-title { white-space: normal; overflow: visible; }
|
|
3519
|
+
.resultcard:hover .rc-sub, .resultcard:focus-within .rc-sub { -webkit-line-clamp: unset; overflow: visible; }
|
|
2738
3520
|
.rc-id { margin-top: 10px; min-width: 0; }
|
|
3521
|
+
.rc-open { display: inline-flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 13px; font-weight: 600; color: var(--blue-strong); text-decoration: none; }
|
|
3522
|
+
.rc-open span { transition: transform 120ms ease; }
|
|
3523
|
+
.rc-open:hover { text-decoration: underline; }
|
|
3524
|
+
.rc-open:hover span { transform: translateX(2px); }
|
|
3525
|
+
.rc-open:focus-visible { outline: 2px solid var(--blue-strong); outline-offset: 2px; border-radius: 4px; }
|
|
2739
3526
|
.rc-id code { display: block; font-family: var(--mono); font-size: 10px; color: var(--blue-strong); background: var(--inline-bg); border: 1px solid var(--border); border-radius: 6px; padding: 3px 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
2740
3527
|
.rc-row dd a { color: var(--blue-strong); text-decoration: none; }
|
|
2741
3528
|
.rc-row dd a:hover { text-decoration: underline; }
|
|
@@ -2806,11 +3593,23 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2806
3593
|
<button class="close" aria-label="Close">×</button>
|
|
2807
3594
|
</div>
|
|
2808
3595
|
<div class="prog"><i></i></div>
|
|
3596
|
+
<button class="jstatus" type="button" hidden>
|
|
3597
|
+
<span class="js-spin" aria-hidden="true"></span><span class="js-label"></span><span class="js-go">Show</span>
|
|
3598
|
+
</button>
|
|
2809
3599
|
<div class="thread"></div>
|
|
2810
3600
|
<div class="statusline"></div>
|
|
2811
3601
|
<div class="composer">
|
|
2812
|
-
<
|
|
2813
|
-
|
|
3602
|
+
<div class="cbox">
|
|
3603
|
+
<textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>
|
|
3604
|
+
<div class="cbar">
|
|
3605
|
+
<div class="tools">
|
|
3606
|
+
<button class="tools-btn" type="button" aria-haspopup="dialog" aria-expanded="false" aria-label="Tools on this page">${ICON_TOOLS}<span class="tools-count" hidden></span></button>
|
|
3607
|
+
<div class="tools-pop" role="dialog" aria-label="What the agent can use here" hidden></div>
|
|
3608
|
+
</div>
|
|
3609
|
+
<span class="model"><i class="model-dot" aria-hidden="true"></i><span class="model-name"></span><span class="model-where"></span></span>
|
|
3610
|
+
<button class="go" aria-label="Send">${ICON_ARROW}</button>
|
|
3611
|
+
</div>
|
|
3612
|
+
</div>
|
|
2814
3613
|
</div>
|
|
2815
3614
|
<footer class="foot">
|
|
2816
3615
|
<a class="powered" href="${NXIS_SITE_URL}" target="_blank" rel="noopener noreferrer">
|