@nxisai/agent-harness 0.2.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 +434 -0
- package/dist/a2ui/cards.d.ts +55 -0
- package/dist/a2ui/cards.d.ts.map +1 -0
- package/dist/a2ui/cards.js +133 -0
- package/dist/a2ui/cards.js.map +1 -0
- package/dist/a2ui/emit.d.ts +33 -0
- package/dist/a2ui/emit.d.ts.map +1 -0
- package/dist/a2ui/emit.js +116 -0
- package/dist/a2ui/emit.js.map +1 -0
- package/dist/a2ui/form.d.ts +20 -0
- package/dist/a2ui/form.d.ts.map +1 -0
- package/dist/a2ui/form.js +92 -0
- package/dist/a2ui/form.js.map +1 -0
- package/dist/a2ui/host.d.ts +34 -0
- package/dist/a2ui/host.d.ts.map +1 -0
- package/dist/a2ui/host.js +47 -0
- package/dist/a2ui/host.js.map +1 -0
- package/dist/a2ui/index.d.ts +23 -0
- package/dist/a2ui/index.d.ts.map +1 -0
- package/dist/a2ui/index.js +20 -0
- package/dist/a2ui/index.js.map +1 -0
- package/dist/a2ui/stepper.d.ts +26 -0
- package/dist/a2ui/stepper.d.ts.map +1 -0
- package/dist/a2ui/stepper.js +162 -0
- package/dist/a2ui/stepper.js.map +1 -0
- package/dist/a2ui/theme.d.ts +21 -0
- package/dist/a2ui/theme.d.ts.map +1 -0
- package/dist/a2ui/theme.js +134 -0
- package/dist/a2ui/theme.js.map +1 -0
- package/dist/a2ui/to-v09.d.ts +31 -0
- package/dist/a2ui/to-v09.d.ts.map +1 -0
- package/dist/a2ui/to-v09.js +104 -0
- package/dist/a2ui/to-v09.js.map +1 -0
- package/dist/engine.d.ts +209 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +505 -0
- package/dist/engine.js.map +1 -0
- package/dist/harness/harness.d.ts +284 -0
- package/dist/harness/harness.d.ts.map +1 -0
- package/dist/harness/harness.js +568 -0
- package/dist/harness/harness.js.map +1 -0
- package/dist/harness/plan.d.ts +32 -0
- package/dist/harness/plan.d.ts.map +1 -0
- package/dist/harness/plan.js +68 -0
- package/dist/harness/plan.js.map +1 -0
- package/dist/harness/policy.d.ts +49 -0
- package/dist/harness/policy.d.ts.map +1 -0
- package/dist/harness/policy.js +34 -0
- package/dist/harness/policy.js.map +1 -0
- package/dist/harness/renderer.d.ts +163 -0
- package/dist/harness/renderer.d.ts.map +1 -0
- package/dist/harness/renderer.js +12 -0
- package/dist/harness/renderer.js.map +1 -0
- package/dist/harness/router/coordinator.d.ts +120 -0
- package/dist/harness/router/coordinator.d.ts.map +1 -0
- package/dist/harness/router/coordinator.js +145 -0
- package/dist/harness/router/coordinator.js.map +1 -0
- package/dist/harness/router/rules.d.ts +48 -0
- package/dist/harness/router/rules.d.ts.map +1 -0
- package/dist/harness/router/rules.js +38 -0
- package/dist/harness/router/rules.js.map +1 -0
- package/dist/harness/slots.d.ts +73 -0
- package/dist/harness/slots.d.ts.map +1 -0
- package/dist/harness/slots.js +68 -0
- package/dist/harness/slots.js.map +1 -0
- package/dist/harness/tools/bridge.d.ts +71 -0
- package/dist/harness/tools/bridge.d.ts.map +1 -0
- package/dist/harness/tools/bridge.js +271 -0
- package/dist/harness/tools/bridge.js.map +1 -0
- package/dist/harness/tools/client.d.ts +11 -0
- package/dist/harness/tools/client.d.ts.map +1 -0
- package/dist/harness/tools/client.js +21 -0
- package/dist/harness/tools/client.js.map +1 -0
- package/dist/harness/tools/discovery.d.ts +13 -0
- package/dist/harness/tools/discovery.d.ts.map +1 -0
- package/dist/harness/tools/discovery.js +43 -0
- package/dist/harness/tools/discovery.js.map +1 -0
- package/dist/harness/tools/local-context.d.ts +38 -0
- package/dist/harness/tools/local-context.d.ts.map +1 -0
- package/dist/harness/tools/local-context.js +55 -0
- package/dist/harness/tools/local-context.js.map +1 -0
- package/dist/harness/tools/schema.d.ts +52 -0
- package/dist/harness/tools/schema.d.ts.map +1 -0
- package/dist/harness/tools/schema.js +175 -0
- package/dist/harness/tools/schema.js.map +1 -0
- package/dist/harness/tools/source.d.ts +65 -0
- package/dist/harness/tools/source.d.ts.map +1 -0
- package/dist/harness/tools/source.js +89 -0
- package/dist/harness/tools/source.js.map +1 -0
- package/dist/harness/tools/webmcp.d.ts +50 -0
- package/dist/harness/tools/webmcp.d.ts.map +1 -0
- package/dist/harness/tools/webmcp.js +114 -0
- package/dist/harness/tools/webmcp.js.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/types.d.ts +123 -0
- package/dist/providers/types.d.ts.map +1 -0
- package/dist/providers/types.js +2 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/types.d.ts +223 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/agent-fullscreen.d.ts +41 -0
- package/dist/ui/agent-fullscreen.d.ts.map +1 -0
- package/dist/ui/agent-fullscreen.js +2821 -0
- package/dist/ui/agent-fullscreen.js.map +1 -0
- package/dist/ui/config.d.ts +88 -0
- package/dist/ui/config.d.ts.map +1 -0
- package/dist/ui/config.js +96 -0
- package/dist/ui/config.js.map +1 -0
- package/dist/ui/index.d.ts +22 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +19 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/tailwind.generated.d.ts +2 -0
- package/dist/ui/tailwind.generated.d.ts.map +1 -0
- package/dist/ui/tailwind.generated.js +3 -0
- package/dist/ui/tailwind.generated.js.map +1 -0
- package/dist/webmcp-contract.d.ts +57 -0
- package/dist/webmcp-contract.d.ts.map +1 -0
- package/dist/webmcp-contract.js +11 -0
- package/dist/webmcp-contract.js.map +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1,2821 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<nxis-agent-fullscreen>` custom element: a full-screen chat interface
|
|
3
|
+
* over an {@link AgentHarness}.
|
|
4
|
+
*
|
|
5
|
+
* The layout is a conversation drawer beside a thread. Messages, generated tool
|
|
6
|
+
* forms and submitted detail cards all render inline in that thread, so a form
|
|
7
|
+
* appears where it was asked for rather than in a separate dialog.
|
|
8
|
+
*
|
|
9
|
+
* The model proposes a tool; a form generated from the tool's JSON Schema and
|
|
10
|
+
* prefilled from stored preferences appears inline; submitting it is the
|
|
11
|
+
* confirmation step for a write, after which the form becomes a read-only card.
|
|
12
|
+
*
|
|
13
|
+
* Configurable through properties and attributes: see the property accessors
|
|
14
|
+
* below, and `README.md` for worked examples.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```html
|
|
18
|
+
* <nxis-agent-fullscreen preload="idle"></nxis-agent-fullscreen>
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @module
|
|
22
|
+
*/
|
|
23
|
+
import { mountSurface } from '../a2ui/host.js';
|
|
24
|
+
import { emitCardsSurface } from '../a2ui/cards.js';
|
|
25
|
+
import { emitToolForm } from '../a2ui/form.js';
|
|
26
|
+
import { A2UI_THEME_CSS } from '../a2ui/theme.js';
|
|
27
|
+
// Compiled from agent.tw.css at build time (scripts/css-to-ts.mjs). It is a
|
|
28
|
+
// string rather than a stylesheet import because the UI renders into a shadow
|
|
29
|
+
// root (a <link> in the host page's head never crosses that boundary) and
|
|
30
|
+
// because a published package cannot assume the consumer runs Tailwind.
|
|
31
|
+
import { TAILWIND_CSS } from './tailwind.generated.js';
|
|
32
|
+
import { emitAsk, emitStep } from '../a2ui/stepper.js';
|
|
33
|
+
import { AgentHarness, isAbort } from '../harness/harness.js';
|
|
34
|
+
import { WebMcpTools } from '../harness/tools/webmcp.js';
|
|
35
|
+
import { extractResult, missingRequired, toolSchema, } from '../harness/tools/schema.js';
|
|
36
|
+
import { connectTools } from '../harness/tools/source.js';
|
|
37
|
+
import { AgentCoordinator } from '../harness/router/coordinator.js';
|
|
38
|
+
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
|
+
import { DEFAULT_SYSTEM_PROMPT, DEFAULT_SUGGESTIONS, DEFAULT_EMPTY_STATE, DEFAULT_AGENT_NAME, DEFAULT_LOGO, THEME_TOKENS, PROFILE_ALIASES, } from './config.js';
|
|
40
|
+
// Prefixed keys so state never collides with other Nxis examples on the same origin.
|
|
41
|
+
const PROFILE_KEY = 'nxis-listings-agent-profile';
|
|
42
|
+
const CONV_KEY = 'nxis-listings-agent-conversations';
|
|
43
|
+
// ── Metrics (Typed Agent vs Traditional RAG) ─────────────────────────────────
|
|
44
|
+
// RAG retrieval assumptions, ported from the original demo's MetricsPanel.
|
|
45
|
+
const AVG_HTML_CHUNK_TOKENS = 650; // avg tokens per chunked HTML segment (nav/footer/CSS included)
|
|
46
|
+
const CHUNKS_PER_RESULT = 3; // header + services + reviews chunks to match one JSON-LD record
|
|
47
|
+
const TOOL_DEF_OVERHEAD = 600; // ~tokens for the MCP tool schemas in the prompt
|
|
48
|
+
function estimateRagTokens(rounds) {
|
|
49
|
+
const toolCalls = rounds.reduce((s, m) => s + m.toolCalls, 0);
|
|
50
|
+
// Floor: each retrieval call fetches at least 3 results in a typical RAG setup.
|
|
51
|
+
const numResults = Math.max(rounds.reduce((s, m) => s + m.results, 0), toolCalls * 3);
|
|
52
|
+
// Round 1 input = system prompt + tool definitions + user message. RAG keeps
|
|
53
|
+
// the same prompt + user but swaps tool definitions for retrieved chunks.
|
|
54
|
+
const round1Input = rounds.length ? rounds[0].promptTokens : 3000;
|
|
55
|
+
const ragBaseContext = Math.max(round1Input - TOOL_DEF_OVERHEAD, 0);
|
|
56
|
+
const ragChunks = numResults * CHUNKS_PER_RESULT;
|
|
57
|
+
return {
|
|
58
|
+
ragInput: ragBaseContext + ragChunks * AVG_HTML_CHUNK_TOKENS,
|
|
59
|
+
ragOutput: rounds.reduce((s, m) => s + m.outputTokens, 0),
|
|
60
|
+
numResults,
|
|
61
|
+
ragChunks,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const fmt = (n) => n.toLocaleString();
|
|
65
|
+
/**
|
|
66
|
+
* The contents of a pending bubble: three dots, a phase label and a byte count.
|
|
67
|
+
*
|
|
68
|
+
* The dots are separate spans rather than an animated glyph, so the motion can
|
|
69
|
+
* be disabled under `prefers-reduced-motion` while the bubble still reads as
|
|
70
|
+
* pending. The label and byte spans are filled per phase and hidden while empty.
|
|
71
|
+
*/
|
|
72
|
+
const THINKING_MARKUP = '<span class="think-row">' +
|
|
73
|
+
'<span class="think-dots" aria-hidden="true"><i></i><i></i><i></i></span>' +
|
|
74
|
+
'<span class="think-label"></span>' +
|
|
75
|
+
'<span class="think-bytes"></span>' +
|
|
76
|
+
'</span>';
|
|
77
|
+
/** Format a byte count for display, as `"412 MB"` or `"1.6 GB"`. */
|
|
78
|
+
function formatBytes(n) {
|
|
79
|
+
const mb = n / (1024 * 1024);
|
|
80
|
+
if (mb >= 1024)
|
|
81
|
+
return `${(mb / 1024).toFixed(1)} GB`;
|
|
82
|
+
return `${Math.round(mb)} MB`;
|
|
83
|
+
}
|
|
84
|
+
const uid = () => 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
85
|
+
class NxisAgentFullscreen extends HTMLElement {
|
|
86
|
+
constructor() {
|
|
87
|
+
super();
|
|
88
|
+
/** True while the turn in flight can still be called off. */
|
|
89
|
+
this._stoppable = false;
|
|
90
|
+
/**
|
|
91
|
+
* Settles when the turn in flight is finished, however it finished.
|
|
92
|
+
*
|
|
93
|
+
* Edit and send-again stop the current turn before replacing the question it
|
|
94
|
+
* was answering, and a stop resolves a tick later. Without something to wait
|
|
95
|
+
* on, the re-run would race `_onSend`'s own guard and be dropped.
|
|
96
|
+
*/
|
|
97
|
+
this._turnDone = null;
|
|
98
|
+
/** Built only when the host defines agents — otherwise there is one harness. */
|
|
99
|
+
this._coordinator = null;
|
|
100
|
+
this._agentDefs = null;
|
|
101
|
+
this._routes = [];
|
|
102
|
+
this._fallbackAgent = '';
|
|
103
|
+
/** The live ask/confirm surface, if one is open. */
|
|
104
|
+
this._prompt = null;
|
|
105
|
+
this._persistPrefs = false;
|
|
106
|
+
this._systemPrompt = DEFAULT_SYSTEM_PROMPT;
|
|
107
|
+
this._suggestions = DEFAULT_SUGGESTIONS;
|
|
108
|
+
this._emptyState = DEFAULT_EMPTY_STATE;
|
|
109
|
+
this._name = DEFAULT_AGENT_NAME;
|
|
110
|
+
this._logo = DEFAULT_LOGO;
|
|
111
|
+
this._theme = {};
|
|
112
|
+
/** The pending bubble currently on screen, if any — the target for progress. */
|
|
113
|
+
this._liveBubble = null;
|
|
114
|
+
/** The engine's current phase name, shown in the empty state while loading. */
|
|
115
|
+
this._stage = '';
|
|
116
|
+
/** The one discovery run, kicked off on first open and awaited before a turn. */
|
|
117
|
+
this._connect = null;
|
|
118
|
+
this._loadPct = 0;
|
|
119
|
+
/**
|
|
120
|
+
* Which mode the empty state is currently drawn in, or null when a
|
|
121
|
+
* conversation has replaced it.
|
|
122
|
+
*
|
|
123
|
+
* The load almost never starts while the empty state is being built, since
|
|
124
|
+
* opening the panel is what starts it, so the view has to notice that it moved
|
|
125
|
+
* underneath it and redraw. Without this the chips stay up through a load
|
|
126
|
+
* that was supposed to withhold them, and never come back when it ends.
|
|
127
|
+
*/
|
|
128
|
+
this._emptyLoading = null;
|
|
129
|
+
/** A provider the host supplied, used instead of the local model. */
|
|
130
|
+
this._provider = null;
|
|
131
|
+
this.attachShadow({ mode: 'open' });
|
|
132
|
+
this._turnActive = false;
|
|
133
|
+
this._tool = null; // tool of the live inline form (if any)
|
|
134
|
+
this._formEl = null; // the live inline form element (transient, not persisted)
|
|
135
|
+
this._profile = {}; // device-local profile
|
|
136
|
+
this._conversations = []; // [{ id, title, messages, items }]
|
|
137
|
+
this._activeId = null;
|
|
138
|
+
this._surfaces = [];
|
|
139
|
+
// Opt out with a2ui="false" to compare against the inline renderers, which
|
|
140
|
+
// are kept rather than deleted, since the basic catalog cannot express the tag
|
|
141
|
+
// colours, links and logo chip they use.
|
|
142
|
+
this._useA2ui = this.getAttribute('a2ui') !== 'false';
|
|
143
|
+
}
|
|
144
|
+
connectedCallback() {
|
|
145
|
+
this.shadowRoot.innerHTML = this._template();
|
|
146
|
+
this._applyIdentity();
|
|
147
|
+
this._applyTheme();
|
|
148
|
+
const root = this.shadowRoot;
|
|
149
|
+
this._els = {
|
|
150
|
+
launcher: root.querySelector('.launcher'),
|
|
151
|
+
overlay: root.querySelector('.overlay'),
|
|
152
|
+
close: root.querySelector('.close'),
|
|
153
|
+
menu: root.querySelector('.menu'),
|
|
154
|
+
scrim: root.querySelector('.scrim'),
|
|
155
|
+
newchat: root.querySelector('.newchat'),
|
|
156
|
+
history: root.querySelector('.history'),
|
|
157
|
+
thread: root.querySelector('.thread'),
|
|
158
|
+
status: root.querySelector('.statusline'),
|
|
159
|
+
bar: root.querySelector('.prog > i'),
|
|
160
|
+
input: root.querySelector('.composer textarea'),
|
|
161
|
+
go: root.querySelector('.composer .go'),
|
|
162
|
+
};
|
|
163
|
+
// A host-supplied provider wins; otherwise the local model, configured from
|
|
164
|
+
// the attributes that only mean anything to it.
|
|
165
|
+
this.provider =
|
|
166
|
+
this._provider ??
|
|
167
|
+
new LocalGemmaProvider({
|
|
168
|
+
modelHost: this.getAttribute('model-host') || undefined,
|
|
169
|
+
modelPathTemplate: this.getAttribute('model-path-template') || undefined,
|
|
170
|
+
// cache="false" skips Cache Storage: the escape hatch for a browser
|
|
171
|
+
// whose cache backend fails on entries this size.
|
|
172
|
+
useBrowserCache: this.getAttribute('cache') !== 'false',
|
|
173
|
+
systemPrompt: this._systemPrompt,
|
|
174
|
+
onStatus: (t, e) => this._setStatus(t, e),
|
|
175
|
+
onProgress: (pct, detail) => {
|
|
176
|
+
this._loadPct = pct;
|
|
177
|
+
this._loadDetail = detail;
|
|
178
|
+
this._setBar(pct);
|
|
179
|
+
this._liveBubble?.setProgress(pct, detail);
|
|
180
|
+
this._paintLoad();
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
this.toolSource = new WebMcpTools();
|
|
184
|
+
this._paintProvenance();
|
|
185
|
+
this._buildAgents();
|
|
186
|
+
// The element renders; the harness decides. Everything from choosing a
|
|
187
|
+
// tool to running it happens behind harness.turn().
|
|
188
|
+
this._harness = new AgentHarness({
|
|
189
|
+
provider: this.provider,
|
|
190
|
+
toolSource: this.toolSource,
|
|
191
|
+
renderer: this._renderer(),
|
|
192
|
+
profile: (schema) => this._profileForSchema(schema),
|
|
193
|
+
onMessages: () => this._saveConversations(),
|
|
194
|
+
});
|
|
195
|
+
// Preferences are supplied by the host page through `element.preferences`.
|
|
196
|
+
// Anything a previous build persisted is picked up so upgrading does not
|
|
197
|
+
// silently drop a user's saved details; `persist` then stays true so they
|
|
198
|
+
// are not silently dropped on the next write either.
|
|
199
|
+
this._prefsApi = this._buildPreferencesApi();
|
|
200
|
+
this._profile = this._loadProfile();
|
|
201
|
+
this._persistPrefs = Object.keys(this._profile).length > 0;
|
|
202
|
+
// Conversation history.
|
|
203
|
+
this._loadConversations();
|
|
204
|
+
this._renderHistory();
|
|
205
|
+
this._renderThread();
|
|
206
|
+
this._els.launcher.addEventListener('click', () => this._open());
|
|
207
|
+
this._els.close.addEventListener('click', () => {
|
|
208
|
+
this._els.overlay.classList.remove('show');
|
|
209
|
+
// A remote turn can outlive the panel, and its answer would go unread.
|
|
210
|
+
this._setStoppable(false);
|
|
211
|
+
(this._coordinator ?? this._harness).stop();
|
|
212
|
+
});
|
|
213
|
+
this._els.menu.addEventListener('click', () => this._setNav(!this._navOpen()));
|
|
214
|
+
// Tapping the conversation is how a drawer is dismissed everywhere else.
|
|
215
|
+
// The drawer is a fixed 280px, so on any phone a strip of the thread stays
|
|
216
|
+
// visible beside it, and that strip is the target.
|
|
217
|
+
this._els.scrim.addEventListener('click', () => this._setNav(false));
|
|
218
|
+
this._els.newchat.addEventListener('click', () => this._newConversation());
|
|
219
|
+
this._els.go.addEventListener('click', () => {
|
|
220
|
+
// One control, two jobs. A separate stop button would be inactive for
|
|
221
|
+
// most of a conversation, and appears where attention is not.
|
|
222
|
+
if (this._stoppable)
|
|
223
|
+
this._stopTurn();
|
|
224
|
+
else
|
|
225
|
+
this._onSend();
|
|
226
|
+
});
|
|
227
|
+
this._els.input.addEventListener('input', () => this._autosize());
|
|
228
|
+
this._els.input.addEventListener('keydown', (e) => {
|
|
229
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
this._onSend();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
// Escape stops the turn, and only that. Closing the panel on the same key
|
|
235
|
+
// would mean the reflex for "wait, no" also throws away the thread it was
|
|
236
|
+
// aimed at, and the two mistakes look identical until it is too late.
|
|
237
|
+
this._els.overlay.addEventListener('keydown', (e) => {
|
|
238
|
+
if (e.key !== 'Escape')
|
|
239
|
+
return;
|
|
240
|
+
// The drawer is on top of everything, so it is what Escape is aimed at
|
|
241
|
+
// while it is open. Only once it is gone does the key reach the turn.
|
|
242
|
+
if (this._navOpen()) {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
e.stopPropagation();
|
|
245
|
+
this._setNav(false);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (!this._stoppable)
|
|
249
|
+
return;
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
e.stopPropagation();
|
|
252
|
+
this._stopTurn();
|
|
253
|
+
});
|
|
254
|
+
const preload = this.preload;
|
|
255
|
+
if (preload !== 'none')
|
|
256
|
+
this.provider.preload?.(preload);
|
|
257
|
+
if (this.hasAttribute('auto-open'))
|
|
258
|
+
this._open();
|
|
259
|
+
}
|
|
260
|
+
_navOpen() {
|
|
261
|
+
return this._els.overlay.classList.contains('nav');
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Show or hide the conversation drawer.
|
|
265
|
+
*
|
|
266
|
+
* On a narrow screen the drawer covers the panel, including the button that
|
|
267
|
+
* opened it, so every way out has to be somewhere else: the strip of
|
|
268
|
+
* conversation beside it, Escape, or picking a conversation.
|
|
269
|
+
*/
|
|
270
|
+
_setNav(open) {
|
|
271
|
+
this._els.overlay.classList.toggle('nav', open);
|
|
272
|
+
this._els.menu.setAttribute('aria-expanded', String(open));
|
|
273
|
+
// Kept out of the accessibility tree and out of the tab order when closed:
|
|
274
|
+
// a full-bleed element that swallows taps is not something to leave lying
|
|
275
|
+
// over the conversation.
|
|
276
|
+
this._els.scrim.hidden = !open;
|
|
277
|
+
// Focus follows the drawer, returning to the control it came from.
|
|
278
|
+
if (!open)
|
|
279
|
+
this._els.menu.focus();
|
|
280
|
+
}
|
|
281
|
+
/** Programmatic open (used by pages that dock the agent full-page). */
|
|
282
|
+
open() {
|
|
283
|
+
this._open();
|
|
284
|
+
}
|
|
285
|
+
_open() {
|
|
286
|
+
this._els.overlay.classList.add('show');
|
|
287
|
+
this._els.input.focus();
|
|
288
|
+
void this._connectTools();
|
|
289
|
+
(this.provider.load?.() ?? Promise.resolve())
|
|
290
|
+
// A finished load has to redraw too: the chips were withheld while it ran.
|
|
291
|
+
.then(() => this._paintLoad())
|
|
292
|
+
.catch((err) => this._reportEngineError(err));
|
|
293
|
+
// ensureEngine flips `loading` synchronously, so the empty state can switch
|
|
294
|
+
// to the stage view now rather than waiting for the first status callback.
|
|
295
|
+
this._paintLoad();
|
|
296
|
+
}
|
|
297
|
+
// ── Conversations ──────────────────────────────────────────────────────────
|
|
298
|
+
_loadConversations() {
|
|
299
|
+
try {
|
|
300
|
+
const arr = JSON.parse(localStorage.getItem(CONV_KEY) || 'null');
|
|
301
|
+
if (Array.isArray(arr) && arr.length) {
|
|
302
|
+
this._conversations = arr;
|
|
303
|
+
this._activeId = arr[0].id;
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
/* fall through to a fresh one */
|
|
309
|
+
}
|
|
310
|
+
this._newConversation();
|
|
311
|
+
}
|
|
312
|
+
_saveConversations() {
|
|
313
|
+
try {
|
|
314
|
+
// Live forms are transient: persist only finalized thread items.
|
|
315
|
+
const slim = this._conversations.map((c) => ({
|
|
316
|
+
id: c.id,
|
|
317
|
+
title: c.title,
|
|
318
|
+
messages: c.messages,
|
|
319
|
+
items: c.items.filter((it) => it.kind !== 'form'),
|
|
320
|
+
}));
|
|
321
|
+
localStorage.setItem(CONV_KEY, JSON.stringify(slim));
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
/* storage blocked/full */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
_active() {
|
|
328
|
+
return this._conversations.find((c) => c.id === this._activeId) || null;
|
|
329
|
+
}
|
|
330
|
+
_newConversation() {
|
|
331
|
+
const conv = {
|
|
332
|
+
id: uid(),
|
|
333
|
+
title: 'New conversation',
|
|
334
|
+
messages: [{ role: 'system', content: this._systemPrompt }],
|
|
335
|
+
items: [],
|
|
336
|
+
};
|
|
337
|
+
this._conversations.unshift(conv);
|
|
338
|
+
this._activeId = conv.id;
|
|
339
|
+
this._tool = null;
|
|
340
|
+
this._formEl = null;
|
|
341
|
+
this._syncProfileContext();
|
|
342
|
+
this._setNav(false);
|
|
343
|
+
if (this._els.history) {
|
|
344
|
+
this._renderHistory();
|
|
345
|
+
this._renderThread();
|
|
346
|
+
this._saveConversations();
|
|
347
|
+
}
|
|
348
|
+
if (this._els.input)
|
|
349
|
+
this._els.input.focus();
|
|
350
|
+
}
|
|
351
|
+
_switchConversation(id) {
|
|
352
|
+
if (id === this._activeId)
|
|
353
|
+
return;
|
|
354
|
+
this._activeId = id;
|
|
355
|
+
this._tool = null;
|
|
356
|
+
this._formEl = null;
|
|
357
|
+
this._syncProfileContext();
|
|
358
|
+
this._setNav(false);
|
|
359
|
+
this._renderHistory();
|
|
360
|
+
this._renderThread();
|
|
361
|
+
}
|
|
362
|
+
_renderHistory() {
|
|
363
|
+
this._els.history.innerHTML = this._conversations
|
|
364
|
+
.map((c) => `<div class="hist-row${c.id === this._activeId ? ' active' : ''}">
|
|
365
|
+
<button class="hist-item" data-id="${c.id}">${escapeHtml(c.title || 'New conversation')}</button>
|
|
366
|
+
<button class="hist-del" data-id="${c.id}" title="Delete conversation" aria-label="Delete conversation">${ICON_TRASH}</button>
|
|
367
|
+
</div>`)
|
|
368
|
+
.join('');
|
|
369
|
+
this._els.history.querySelectorAll('.hist-item').forEach((b) => b.addEventListener('click', () => this._switchConversation(b.dataset.id)));
|
|
370
|
+
this._els.history.querySelectorAll('.hist-del').forEach((b) => b.addEventListener('click', (e) => {
|
|
371
|
+
e.stopPropagation();
|
|
372
|
+
this._deleteConversation(b.dataset.id);
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
_deleteConversation(id) {
|
|
376
|
+
const idx = this._conversations.findIndex((c) => c.id === id);
|
|
377
|
+
if (idx === -1)
|
|
378
|
+
return;
|
|
379
|
+
const wasActive = id === this._activeId;
|
|
380
|
+
this._conversations.splice(idx, 1);
|
|
381
|
+
if (!this._conversations.length) {
|
|
382
|
+
this._newConversation(); // creates a fresh one + re-renders + saves
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (wasActive) {
|
|
386
|
+
this._activeId = this._conversations[Math.min(idx, this._conversations.length - 1)].id;
|
|
387
|
+
this._tool = null;
|
|
388
|
+
this._formEl = null;
|
|
389
|
+
this._syncProfileContext();
|
|
390
|
+
this._renderThread();
|
|
391
|
+
}
|
|
392
|
+
this._renderHistory();
|
|
393
|
+
this._saveConversations();
|
|
394
|
+
}
|
|
395
|
+
// ── Thread rendering ───────────────────────────────────────────────────────
|
|
396
|
+
_renderThread() {
|
|
397
|
+
const t = this._els.thread;
|
|
398
|
+
// innerHTML = '' detaches the surface elements but leaves their processors
|
|
399
|
+
// subscribed; dispose explicitly before dropping the references.
|
|
400
|
+
for (const s of this._surfaces) {
|
|
401
|
+
try {
|
|
402
|
+
s.dispose();
|
|
403
|
+
}
|
|
404
|
+
catch { /* already disposed */ }
|
|
405
|
+
}
|
|
406
|
+
this._surfaces = [];
|
|
407
|
+
t.innerHTML = '';
|
|
408
|
+
this._emptyLoading = null;
|
|
409
|
+
const conv = this._active();
|
|
410
|
+
if (!conv || !conv.items.length) {
|
|
411
|
+
// The mark doubles as the loading indicator, with a ring filling around
|
|
412
|
+
// it while the weights download, rather than adding a second widget.
|
|
413
|
+
//
|
|
414
|
+
// The suggestions are withheld until the model exists, since tapping one
|
|
415
|
+
// before then would do nothing.
|
|
416
|
+
const loading = (this.provider?.load ? this.provider.loading : false) ?? false;
|
|
417
|
+
this._emptyLoading = loading;
|
|
418
|
+
const body = loading
|
|
419
|
+
? `<p class="stage">${escapeHtml(this._stage || 'Loading model…')}</p>` +
|
|
420
|
+
`<p class="stage-bytes"></p>`
|
|
421
|
+
: `<p>${escapeHtml(this._emptyState)}</p>` +
|
|
422
|
+
`<div class="chips appear">` +
|
|
423
|
+
this._suggestions.map((c) => `<button class="chip">${escapeHtml(c)}</button>`).join('') +
|
|
424
|
+
`</div>`;
|
|
425
|
+
t.innerHTML =
|
|
426
|
+
`<div class="empty">` +
|
|
427
|
+
`<span class="empty-mark"><span class="ring" hidden></span>${this._logoMarkup()}</span>` +
|
|
428
|
+
body +
|
|
429
|
+
`</div>`;
|
|
430
|
+
this._paintLoad();
|
|
431
|
+
t.querySelectorAll('.chip').forEach((b) => b.addEventListener('click', () => {
|
|
432
|
+
this._els.input.value = b.textContent || '';
|
|
433
|
+
this._onSend();
|
|
434
|
+
}));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
for (let i = 0; i < conv.items.length; i++) {
|
|
438
|
+
const it = conv.items[i];
|
|
439
|
+
if (it.kind === 'msg')
|
|
440
|
+
this._paintMsg(it.role, it.text, i);
|
|
441
|
+
else if (it.kind === 'tool')
|
|
442
|
+
this._paintToolResult(it.name, it.text, it.isError);
|
|
443
|
+
else if (it.kind === 'trace') {
|
|
444
|
+
if (this.debug)
|
|
445
|
+
this._els.thread.appendChild(this._traceEl(it));
|
|
446
|
+
}
|
|
447
|
+
else if (it.kind === 'stopped') {
|
|
448
|
+
this._paintStopped();
|
|
449
|
+
}
|
|
450
|
+
else if (it.kind === 'route') {
|
|
451
|
+
if (this.debug)
|
|
452
|
+
this._els.thread.appendChild(this._routeEl(it));
|
|
453
|
+
}
|
|
454
|
+
else if (it.kind === 'detail')
|
|
455
|
+
this._els.thread.appendChild(this._detailEl(it));
|
|
456
|
+
else if (it.kind === 'results')
|
|
457
|
+
this._els.thread.appendChild(this._resultsEl(it));
|
|
458
|
+
else if (it.kind === 'metrics')
|
|
459
|
+
this._els.thread.appendChild(this._metricsEl(it));
|
|
460
|
+
}
|
|
461
|
+
this._scroll();
|
|
462
|
+
}
|
|
463
|
+
_clearEmpty() {
|
|
464
|
+
const e = this._els.thread.querySelector('.empty');
|
|
465
|
+
if (e)
|
|
466
|
+
e.remove();
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* A chat bubble.
|
|
470
|
+
*
|
|
471
|
+
* `.msg` styles the bubble and also wraps non-bubble content, so the form,
|
|
472
|
+
* detail and results wrappers each had to undo it (`background: none;
|
|
473
|
+
* border: none; padding: 0`. Splitting the two roles removes that undo:
|
|
474
|
+
* bubbles get bubble utilities, wrappers get layout utilities, and neither
|
|
475
|
+
* has to know about the other.
|
|
476
|
+
*
|
|
477
|
+
* The squared corner nearest the sender is the one carrying information: it
|
|
478
|
+
* is how a glance tells user from assistant before any colour is read.
|
|
479
|
+
*/
|
|
480
|
+
_msgEl(role, text) {
|
|
481
|
+
const el = document.createElement('div');
|
|
482
|
+
const base = 'max-w-[76%] whitespace-pre-wrap break-words px-3.5 py-2.5 text-sm leading-relaxed';
|
|
483
|
+
const byRole = role === 'user'
|
|
484
|
+
? 'self-end bg-blue text-white rounded-2xl rounded-br-md'
|
|
485
|
+
: 'self-start bg-surface text-ink border border-border-hair rounded-2xl rounded-bl-md shadow-resting';
|
|
486
|
+
el.className = `msg ${role} ${base} ${byRole}`;
|
|
487
|
+
el.textContent = text;
|
|
488
|
+
return el;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* A tool outcome.
|
|
492
|
+
*
|
|
493
|
+
* Not a bubble, since nobody said it. Styled as a system line: full width, mono
|
|
494
|
+
* eyebrow for the tool name, and the result beneath in body text. Colour
|
|
495
|
+
* carries the outcome (design.md §2: green is a positive result, and error
|
|
496
|
+
* red is reserved for actual failure).
|
|
497
|
+
*/
|
|
498
|
+
_toolEl(name, text, isError) {
|
|
499
|
+
const el = document.createElement('div');
|
|
500
|
+
el.className =
|
|
501
|
+
'msg tool self-stretch w-full rounded-card border px-3.5 py-2.5 ' +
|
|
502
|
+
(isError
|
|
503
|
+
? 'bg-surface border-red/25 text-red'
|
|
504
|
+
: 'bg-surface border-border-hair text-green-strong');
|
|
505
|
+
el.innerHTML =
|
|
506
|
+
`<b class="flex items-center gap-1.5 font-mono text-[11px] font-medium uppercase tracking-[0.14em]">` +
|
|
507
|
+
`${isError ? ICON_WARN : ICON_CHECK}${escapeHtml(name)}</b>` +
|
|
508
|
+
`<div class="mt-1 font-sans text-[13px] leading-normal text-ink-2">${escapeHtml(text)}</div>`;
|
|
509
|
+
return el;
|
|
510
|
+
}
|
|
511
|
+
_detailEl(item) {
|
|
512
|
+
const rows = (item.rows || [])
|
|
513
|
+
.map((r) => `<div class="dc-row"><dt>${escapeHtml(r.label)}</dt><dd>${escapeHtml(r.value)}</dd></div>`)
|
|
514
|
+
.join('');
|
|
515
|
+
const el = document.createElement('div');
|
|
516
|
+
el.className = 'msg detail';
|
|
517
|
+
el.innerHTML = `
|
|
518
|
+
<div class="detailcard">
|
|
519
|
+
<div class="dc-badge">${ICON_CHECK}</div>
|
|
520
|
+
<div class="dc-eyebrow">Submitted</div>
|
|
521
|
+
<div class="dc-title">${escapeHtml(item.title)}</div>
|
|
522
|
+
<div class="dc-sub">${escapeHtml(item.note)}</div>
|
|
523
|
+
<dl class="dc-list">${rows || '<div class="dc-row"><dd>No details captured.</dd></div>'}</dl>
|
|
524
|
+
</div>`;
|
|
525
|
+
return el;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Draw one message.
|
|
529
|
+
*
|
|
530
|
+
* A message the user sent carries a row of actions, so `index` identifies
|
|
531
|
+
* which item it is: copy needs the text, and edit and send-again need to know
|
|
532
|
+
* how much of the conversation they will replace.
|
|
533
|
+
*
|
|
534
|
+
* @param role - Who sent the message.
|
|
535
|
+
* @param text - The message text.
|
|
536
|
+
* @param index - Its position in the conversation's items. Required for the
|
|
537
|
+
* action bar; omit it for messages that should not carry one.
|
|
538
|
+
* @returns The bubble element.
|
|
539
|
+
*/
|
|
540
|
+
_paintMsg(role, text, index) {
|
|
541
|
+
this._clearEmpty();
|
|
542
|
+
const el = this._msgEl(role, text);
|
|
543
|
+
if (role !== 'user' || index === undefined) {
|
|
544
|
+
this._els.thread.appendChild(el);
|
|
545
|
+
return el;
|
|
546
|
+
}
|
|
547
|
+
const turn = document.createElement('div');
|
|
548
|
+
turn.className = 'turn';
|
|
549
|
+
// Focusable so the row can be reached without a pointer: `:focus-within`
|
|
550
|
+
// is what reveals it, and a bubble that cannot take focus never fires it.
|
|
551
|
+
turn.tabIndex = -1;
|
|
552
|
+
turn.dataset.item = String(index);
|
|
553
|
+
turn.appendChild(el);
|
|
554
|
+
turn.appendChild(this._actionBar(index, text));
|
|
555
|
+
this._els.thread.appendChild(turn);
|
|
556
|
+
return el;
|
|
557
|
+
}
|
|
558
|
+
/** Copy, edit, send again, under the person's own message. */
|
|
559
|
+
_actionBar(index, text) {
|
|
560
|
+
const bar = document.createElement('div');
|
|
561
|
+
bar.className = 'actions';
|
|
562
|
+
bar.innerHTML =
|
|
563
|
+
`<button class="act" data-act="copy" type="button" aria-label="Copy message" title="Copy">${ICON_COPY}</button>` +
|
|
564
|
+
`<button class="act" data-act="edit" type="button" aria-label="Edit message" title="Edit">${ICON_PENCIL}</button>` +
|
|
565
|
+
`<button class="act" data-act="resend" type="button" aria-label="Send again" title="Send again">${ICON_REDO}</button>`;
|
|
566
|
+
const copy = bar.querySelector('[data-act="copy"]');
|
|
567
|
+
copy.addEventListener('click', () => {
|
|
568
|
+
// The tick is the confirmation; a toast would be redundant.
|
|
569
|
+
navigator.clipboard?.writeText(text).catch(() => { });
|
|
570
|
+
copy.innerHTML = ICON_CHECK_MARK;
|
|
571
|
+
copy.classList.add('done');
|
|
572
|
+
copy.setAttribute('aria-label', 'Copied');
|
|
573
|
+
setTimeout(() => {
|
|
574
|
+
copy.innerHTML = ICON_COPY;
|
|
575
|
+
copy.classList.remove('done');
|
|
576
|
+
copy.setAttribute('aria-label', 'Copy message');
|
|
577
|
+
}, 1400);
|
|
578
|
+
});
|
|
579
|
+
bar
|
|
580
|
+
.querySelector('[data-act="resend"]')
|
|
581
|
+
.addEventListener('click', () => void this._rerunFrom(index, text));
|
|
582
|
+
bar
|
|
583
|
+
.querySelector('[data-act="edit"]')
|
|
584
|
+
.addEventListener('click', () => this._editMessage(index, text));
|
|
585
|
+
return bar;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Turn the bubble into a field where it already sits.
|
|
589
|
+
*
|
|
590
|
+
* Not the composer. Editing in the composer means looking at one copy of the
|
|
591
|
+
* sentence while typing into another, and the one being changed is the one
|
|
592
|
+
* further from the eye.
|
|
593
|
+
*/
|
|
594
|
+
_editMessage(index, text) {
|
|
595
|
+
const turn = this._els.thread.querySelector(`.turn[data-item="${index}"]`);
|
|
596
|
+
if (!turn)
|
|
597
|
+
return;
|
|
598
|
+
const box = document.createElement('div');
|
|
599
|
+
box.className = 'editing';
|
|
600
|
+
box.innerHTML =
|
|
601
|
+
`<textarea rows="2"></textarea>` +
|
|
602
|
+
`<div class="edit-actions">` +
|
|
603
|
+
`<button class="ebtn" data-e="cancel" type="button">Cancel</button>` +
|
|
604
|
+
`<button class="ebtn primary" data-e="save" type="button">Send</button>` +
|
|
605
|
+
`</div>`;
|
|
606
|
+
const hint = document.createElement('div');
|
|
607
|
+
hint.className = 'edit-hint';
|
|
608
|
+
// Said before the commit, not after. Editing the first of five turns throws
|
|
609
|
+
// away the four below it, because they answered a question that no longer
|
|
610
|
+
// exists, and that is not something to discover by watching it happen.
|
|
611
|
+
hint.textContent = 'everything below is replaced when this is sent';
|
|
612
|
+
const ta = box.querySelector('textarea');
|
|
613
|
+
ta.value = text;
|
|
614
|
+
turn.replaceWith(box);
|
|
615
|
+
box.after(hint);
|
|
616
|
+
ta.focus();
|
|
617
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
618
|
+
const close = () => {
|
|
619
|
+
hint.remove();
|
|
620
|
+
box.remove();
|
|
621
|
+
this._renderThread();
|
|
622
|
+
};
|
|
623
|
+
const send = () => {
|
|
624
|
+
const next = ta.value.trim();
|
|
625
|
+
if (!next)
|
|
626
|
+
return;
|
|
627
|
+
void this._rerunFrom(index, next);
|
|
628
|
+
};
|
|
629
|
+
box.querySelector('[data-e="cancel"]').addEventListener('click', close);
|
|
630
|
+
box.querySelector('[data-e="save"]').addEventListener('click', send);
|
|
631
|
+
ta.addEventListener('keydown', (e) => {
|
|
632
|
+
if (e.key === 'Escape') {
|
|
633
|
+
e.stopPropagation();
|
|
634
|
+
close();
|
|
635
|
+
}
|
|
636
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
637
|
+
e.preventDefault();
|
|
638
|
+
send();
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Send a message again from this point, replacing everything below it.
|
|
644
|
+
*
|
|
645
|
+
* Discards the following items and messages from both the thread and the
|
|
646
|
+
* transcript, since they answered a question that no longer exists.
|
|
647
|
+
*
|
|
648
|
+
* @param index - The item index of the message to resend.
|
|
649
|
+
* @param text - The message to send, edited or unchanged.
|
|
650
|
+
*/
|
|
651
|
+
async _rerunFrom(index, text) {
|
|
652
|
+
if (!this._active())
|
|
653
|
+
return;
|
|
654
|
+
// The turn in flight is answering the question being replaced.
|
|
655
|
+
if (this._turnActive) {
|
|
656
|
+
this._stopTurn();
|
|
657
|
+
await this._turnDone;
|
|
658
|
+
}
|
|
659
|
+
this._truncateFrom(index);
|
|
660
|
+
this._renderThread();
|
|
661
|
+
this._els.input.value = text;
|
|
662
|
+
this._autosize();
|
|
663
|
+
await this._onSend();
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Drop every item and every message from `index` down, the message itself
|
|
667
|
+
* included: it is about to be sent again.
|
|
668
|
+
*/
|
|
669
|
+
_truncateFrom(index) {
|
|
670
|
+
const conv = this._active();
|
|
671
|
+
if (!conv)
|
|
672
|
+
return;
|
|
673
|
+
let turns = 0;
|
|
674
|
+
for (let i = 0; i < index; i++) {
|
|
675
|
+
const it = conv.items[i];
|
|
676
|
+
if (it.kind === 'msg' && it.role === 'user')
|
|
677
|
+
turns++;
|
|
678
|
+
}
|
|
679
|
+
// Tool results are written into the transcript as user turns too, so the
|
|
680
|
+
// person's own messages are the unbracketed ones.
|
|
681
|
+
let seen = 0;
|
|
682
|
+
let cut = conv.messages.length;
|
|
683
|
+
for (let i = 0; i < conv.messages.length; i++) {
|
|
684
|
+
const m = conv.messages[i];
|
|
685
|
+
if (m.role !== 'user' || m.content.startsWith('['))
|
|
686
|
+
continue;
|
|
687
|
+
if (seen++ === turns) {
|
|
688
|
+
cut = i;
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
conv.messages.length = cut;
|
|
693
|
+
conv.items.length = index;
|
|
694
|
+
this._saveConversations();
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* The receipt for a tool call: one muted line, collapsed.
|
|
698
|
+
*
|
|
699
|
+
* `<details>` rather than a button and a class, because the summary is a
|
|
700
|
+
* real disclosure control, so keyboard, screen reader and find-in-page all
|
|
701
|
+
* work without anything being wired up. Closed by default: this is the row
|
|
702
|
+
* a person scrolls past, and opens only when the answer above it looks
|
|
703
|
+
* wrong.
|
|
704
|
+
*/
|
|
705
|
+
_traceEl(item) {
|
|
706
|
+
const el = document.createElement('details');
|
|
707
|
+
el.className = `trace${item.isError ? ' err' : ''}`;
|
|
708
|
+
const summary = document.createElement('summary');
|
|
709
|
+
const verb = item.isError ? 'failed' : 'ran';
|
|
710
|
+
const outcome = item.isError
|
|
711
|
+
? 'error'
|
|
712
|
+
: typeof item.count === 'number'
|
|
713
|
+
? `${item.count} result${item.count === 1 ? '' : 's'}`
|
|
714
|
+
: 'ok';
|
|
715
|
+
summary.innerHTML =
|
|
716
|
+
`<span class="caret" aria-hidden="true"></span>` +
|
|
717
|
+
`<span>${escapeHtml(humanize(item.name))} ${verb} · ${escapeHtml(outcome)}</span>`;
|
|
718
|
+
el.appendChild(summary);
|
|
719
|
+
const body = document.createElement('div');
|
|
720
|
+
body.className = 'trace-body';
|
|
721
|
+
const args = JSON.stringify(item.args ?? {}, null, 2);
|
|
722
|
+
body.innerHTML =
|
|
723
|
+
`<span class="trace-label">Call</span>` +
|
|
724
|
+
`<pre>${escapeHtml(`${item.name}(${args === '{}' ? '' : args})`)}</pre>` +
|
|
725
|
+
`<span class="trace-label">Response</span>` +
|
|
726
|
+
`<pre class="raw">${escapeHtml(item.text || '(empty)')}</pre>`;
|
|
727
|
+
el.appendChild(body);
|
|
728
|
+
return el;
|
|
729
|
+
}
|
|
730
|
+
_paintToolResult(name, text, isError) {
|
|
731
|
+
this._clearEmpty();
|
|
732
|
+
const el = this._toolEl(name, text, isError);
|
|
733
|
+
this._els.thread.appendChild(el);
|
|
734
|
+
return el;
|
|
735
|
+
}
|
|
736
|
+
/** Append a bubble both to the persisted thread and the DOM. */
|
|
737
|
+
/**
|
|
738
|
+
* Call off the turn in flight.
|
|
739
|
+
*
|
|
740
|
+
* The note left in the thread is presented neutrally: a stop is neither an
|
|
741
|
+
* error nor a refusal, so it is not styled as a warning.
|
|
742
|
+
*/
|
|
743
|
+
_stopTurn() {
|
|
744
|
+
if (!this._stoppable)
|
|
745
|
+
return;
|
|
746
|
+
this._setStoppable(false);
|
|
747
|
+
(this._coordinator ?? this._harness).stop();
|
|
748
|
+
const conv = this._active();
|
|
749
|
+
if (conv) {
|
|
750
|
+
conv.items.push({ kind: 'stopped' });
|
|
751
|
+
this._saveConversations();
|
|
752
|
+
}
|
|
753
|
+
this._paintStopped();
|
|
754
|
+
this._setStatus('');
|
|
755
|
+
this._scroll();
|
|
756
|
+
this._els.input.focus();
|
|
757
|
+
}
|
|
758
|
+
_paintStopped() {
|
|
759
|
+
const el = document.createElement('div');
|
|
760
|
+
el.className = 'stopped-note';
|
|
761
|
+
el.textContent = 'Stopped';
|
|
762
|
+
this._els.thread.appendChild(el);
|
|
763
|
+
return el;
|
|
764
|
+
}
|
|
765
|
+
/** Swap the composer button between sending and stopping. */
|
|
766
|
+
_setStoppable(canStop) {
|
|
767
|
+
if (this._stoppable === canStop)
|
|
768
|
+
return;
|
|
769
|
+
this._stoppable = canStop;
|
|
770
|
+
const go = this._els.go;
|
|
771
|
+
go.classList.toggle('stop', canStop);
|
|
772
|
+
go.innerHTML = canStop ? ICON_STOP : ICON_ARROW;
|
|
773
|
+
go.setAttribute('aria-label', canStop ? 'Stop' : 'Send');
|
|
774
|
+
// Enabled either way: disabling it during a turn would disable the very
|
|
775
|
+
// control needed to stop that turn.
|
|
776
|
+
go.disabled = false;
|
|
777
|
+
}
|
|
778
|
+
_appendMsg(role, text) {
|
|
779
|
+
const conv = this._active();
|
|
780
|
+
conv.items.push({ kind: 'msg', role, text });
|
|
781
|
+
const el = this._paintMsg(role, text, conv.items.length - 1);
|
|
782
|
+
this._saveConversations();
|
|
783
|
+
this._scroll();
|
|
784
|
+
return el;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* The assistant's bubble for one phase of a turn.
|
|
788
|
+
*
|
|
789
|
+
* Starts as an animated thinking indicator rather than a static ellipsis: a
|
|
790
|
+
* turn can be silent for tens of seconds while weights download, and a
|
|
791
|
+
* motionless dot is indistinguishable from a hung page. The first token
|
|
792
|
+
* replaces the indicator with text, and only a non-empty `finalize` persists
|
|
793
|
+
* the bubble into the stored conversation.
|
|
794
|
+
*/
|
|
795
|
+
_appendStreaming() {
|
|
796
|
+
const el = this._paintMsg('assistant', '');
|
|
797
|
+
el.classList.add('thinking');
|
|
798
|
+
el.innerHTML = THINKING_MARKUP;
|
|
799
|
+
// The bubble is a live region so a screen reader hears the phase change
|
|
800
|
+
// without the focus moving to it.
|
|
801
|
+
el.setAttribute('aria-live', 'polite');
|
|
802
|
+
this._scroll();
|
|
803
|
+
const setLabel = (label) => {
|
|
804
|
+
const node = el.querySelector('.think-label');
|
|
805
|
+
if (node)
|
|
806
|
+
node.textContent = label ?? '';
|
|
807
|
+
};
|
|
808
|
+
/**
|
|
809
|
+
* Show byte counts for the current phase, or nothing.
|
|
810
|
+
*
|
|
811
|
+
* Only the weight download reports totals. The runtime fetch and the warm-up
|
|
812
|
+
* do not, and those phases show the dots alone rather than an empty readout.
|
|
813
|
+
*/
|
|
814
|
+
const setProgress = (_pct, detail) => {
|
|
815
|
+
const bytes = el.querySelector('.think-bytes');
|
|
816
|
+
if (!bytes)
|
|
817
|
+
return;
|
|
818
|
+
// Figures rather than a bar: the ring on the header mark already carries
|
|
819
|
+
// the proportion, and what it cannot show is how much is left in bytes.
|
|
820
|
+
bytes.textContent = detail?.total
|
|
821
|
+
? `${formatBytes(detail.loaded)} / ${formatBytes(detail.total)}`
|
|
822
|
+
: '';
|
|
823
|
+
};
|
|
824
|
+
const bubble = {
|
|
825
|
+
// Naming a new phase reuses the same bubble, so a turn that loads, then
|
|
826
|
+
// discovers, then generates remains one element in the thread rather than
|
|
827
|
+
// three that appear and vanish.
|
|
828
|
+
thinking: (label) => {
|
|
829
|
+
if (!el.classList.contains('thinking')) {
|
|
830
|
+
el.classList.add('thinking');
|
|
831
|
+
el.innerHTML = THINKING_MARKUP;
|
|
832
|
+
}
|
|
833
|
+
setLabel(label);
|
|
834
|
+
this._scroll();
|
|
835
|
+
},
|
|
836
|
+
// `t` is the whole answer so far, not the newest fragment; assigning it
|
|
837
|
+
// is correct; appending would repeat the answer on every token.
|
|
838
|
+
update: (t) => {
|
|
839
|
+
if (!t)
|
|
840
|
+
return;
|
|
841
|
+
// First token: drop the indicator and become an ordinary bubble. The
|
|
842
|
+
// live region goes with it, since announcing a streaming answer word by
|
|
843
|
+
// word would talk over the reader.
|
|
844
|
+
if (el.classList.contains('thinking')) {
|
|
845
|
+
el.classList.remove('thinking');
|
|
846
|
+
el.removeAttribute('aria-live');
|
|
847
|
+
el.innerHTML = '';
|
|
848
|
+
if (this._liveBubble === bubble)
|
|
849
|
+
this._liveBubble = null;
|
|
850
|
+
}
|
|
851
|
+
el.textContent = t;
|
|
852
|
+
this._scroll();
|
|
853
|
+
},
|
|
854
|
+
// Empty text means the phase had nothing to leave behind (a finished
|
|
855
|
+
// tool call, a failed load), so the bubble goes rather than lingering as
|
|
856
|
+
// a blank one. Anything else is persisted; a pending indicator never is.
|
|
857
|
+
finalize: (t) => {
|
|
858
|
+
el.classList.remove('thinking');
|
|
859
|
+
el.removeAttribute('aria-live');
|
|
860
|
+
if (this._liveBubble === bubble)
|
|
861
|
+
this._liveBubble = null;
|
|
862
|
+
if (!t) {
|
|
863
|
+
el.remove();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
el.textContent = t;
|
|
867
|
+
this._active().items.push({ kind: 'msg', role: 'assistant', text: t });
|
|
868
|
+
this._saveConversations();
|
|
869
|
+
this._scroll();
|
|
870
|
+
},
|
|
871
|
+
setProgress,
|
|
872
|
+
};
|
|
873
|
+
// The engine reports progress to the element, not through the turn loop,
|
|
874
|
+
// so the element has to know which bubble is currently on screen.
|
|
875
|
+
this._liveBubble = bubble;
|
|
876
|
+
return bubble;
|
|
877
|
+
}
|
|
878
|
+
// ── Turn loop ──────────────────────────────────────────────────────────────
|
|
879
|
+
async _onSend() {
|
|
880
|
+
const goal = this._els.input.value.trim();
|
|
881
|
+
if (!goal || this._turnActive)
|
|
882
|
+
return;
|
|
883
|
+
this._els.input.value = '';
|
|
884
|
+
this._autosize();
|
|
885
|
+
this._appendMsg('user', goal);
|
|
886
|
+
const conv = this._active();
|
|
887
|
+
if (conv && (!conv.title || conv.title === 'New conversation')) {
|
|
888
|
+
conv.title = goal.length > 40 ? goal.slice(0, 40) + '…' : goal;
|
|
889
|
+
this._renderHistory();
|
|
890
|
+
this._saveConversations();
|
|
891
|
+
}
|
|
892
|
+
this._turnActive = true;
|
|
893
|
+
this._setStoppable(true);
|
|
894
|
+
let settle;
|
|
895
|
+
this._turnDone = new Promise((r) => (settle = r));
|
|
896
|
+
try {
|
|
897
|
+
// The toolset has to be complete before the model is asked to choose from
|
|
898
|
+
// it. Discovery normally finished while the weights were downloading; this
|
|
899
|
+
// only waits when a message is sent the instant the panel opens.
|
|
900
|
+
await this._connectTools();
|
|
901
|
+
this._syncProfileContext();
|
|
902
|
+
// One agent, or several with a router in front: the signatures match.
|
|
903
|
+
await (this._coordinator ?? this._harness).turn(goal, conv.messages);
|
|
904
|
+
}
|
|
905
|
+
catch (err) {
|
|
906
|
+
// A cancelled turn has already reported itself in the thread, and is not
|
|
907
|
+
// a failure to report a second time.
|
|
908
|
+
if (!isAbort(err))
|
|
909
|
+
this._reportEngineError(err);
|
|
910
|
+
}
|
|
911
|
+
finally {
|
|
912
|
+
this._turnActive = false;
|
|
913
|
+
this._setStoppable(false);
|
|
914
|
+
this._turnDone = null;
|
|
915
|
+
settle();
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// ── HarnessRenderer ────────────────────────────────────────────────────────
|
|
919
|
+
/**
|
|
920
|
+
* The element's implementation of {@link HarnessRenderer}.
|
|
921
|
+
*
|
|
922
|
+
* Drawing and reporting back only: whether to ask, what to ask for, and when
|
|
923
|
+
* to run a tool are all decided by the harness.
|
|
924
|
+
*/
|
|
925
|
+
_renderer() {
|
|
926
|
+
return {
|
|
927
|
+
message: (role, text) => this._appendMsg(role, text),
|
|
928
|
+
streaming: () => this._appendStreaming(),
|
|
929
|
+
status: (text, isError) => this._setStatus(text, isError),
|
|
930
|
+
ask: (req) => this._renderAsk(req),
|
|
931
|
+
confirm: (req) => this._renderConfirm(req),
|
|
932
|
+
results: (tool, res) => {
|
|
933
|
+
const conv = this._active();
|
|
934
|
+
const item = this._buildResultsItem(tool, res);
|
|
935
|
+
conv?.items.push(item);
|
|
936
|
+
this._els.thread.appendChild(this._resultsEl(item));
|
|
937
|
+
this._upsertMetrics();
|
|
938
|
+
this._saveConversations();
|
|
939
|
+
this._scroll();
|
|
940
|
+
},
|
|
941
|
+
detail: (tool, values, text) => {
|
|
942
|
+
const conv = this._active();
|
|
943
|
+
const item = this._buildDetailItem(tool, values, text);
|
|
944
|
+
conv?.items.push(item);
|
|
945
|
+
this._els.thread.appendChild(this._detailEl(item));
|
|
946
|
+
this._saveConversations();
|
|
947
|
+
this._scroll();
|
|
948
|
+
},
|
|
949
|
+
trace: ({ tool, args, text, isError, count }) => {
|
|
950
|
+
// Not recorded at all when debugging is off. See the `debug` getter.
|
|
951
|
+
if (!this.debug)
|
|
952
|
+
return;
|
|
953
|
+
const item = {
|
|
954
|
+
kind: 'trace',
|
|
955
|
+
name: tool.name,
|
|
956
|
+
args,
|
|
957
|
+
text,
|
|
958
|
+
isError,
|
|
959
|
+
count,
|
|
960
|
+
};
|
|
961
|
+
this._active()?.items.push(item);
|
|
962
|
+
this._els.thread.appendChild(this._traceEl(item));
|
|
963
|
+
this._saveConversations();
|
|
964
|
+
this._scroll();
|
|
965
|
+
},
|
|
966
|
+
toolError: (tool, message) => this._paintToolResult(tool.name, message, true),
|
|
967
|
+
dismissPrompt: () => this._dismissPrompt(),
|
|
968
|
+
// The harness says when stopping stops being possible, because it is the
|
|
969
|
+
// part that knows a tool has been invoked. Once a call is out, offering
|
|
970
|
+
// to stop would be offering to un-send a booking.
|
|
971
|
+
stoppable: (canStop) => this._setStoppable(canStop),
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
_dismissPrompt() {
|
|
975
|
+
if (this._prompt) {
|
|
976
|
+
try {
|
|
977
|
+
this._prompt.dispose();
|
|
978
|
+
}
|
|
979
|
+
catch { /* already gone */ }
|
|
980
|
+
this._prompt = null;
|
|
981
|
+
}
|
|
982
|
+
if (this._formEl && this._formEl.isConnected)
|
|
983
|
+
this._formEl.remove();
|
|
984
|
+
this._formEl = null;
|
|
985
|
+
}
|
|
986
|
+
/** Mount an A2UI prompt surface into the thread. */
|
|
987
|
+
_mountPrompt(messages, onAction) {
|
|
988
|
+
this._clearEmpty();
|
|
989
|
+
this._dismissPrompt();
|
|
990
|
+
const wrap = document.createElement('div');
|
|
991
|
+
wrap.className = 'form a2ui a2ui-scope self-start w-full max-w-[420px]';
|
|
992
|
+
const mounted = mountSurface(wrap, messages, (event) => {
|
|
993
|
+
const name = event?.event?.name ?? event?.name ?? '';
|
|
994
|
+
const ctx = (event?.event?.context ?? event?.context ?? {});
|
|
995
|
+
onAction(String(name), ctx);
|
|
996
|
+
});
|
|
997
|
+
if (!mounted)
|
|
998
|
+
return null;
|
|
999
|
+
this._els.thread.appendChild(wrap);
|
|
1000
|
+
this._formEl = wrap;
|
|
1001
|
+
this._prompt = mounted;
|
|
1002
|
+
this._surfaces.push(mounted);
|
|
1003
|
+
this._scroll();
|
|
1004
|
+
return mounted;
|
|
1005
|
+
}
|
|
1006
|
+
/** Gaps to fill — one question, or a wizard when the plan has several. */
|
|
1007
|
+
_renderAsk(req) {
|
|
1008
|
+
const { tool, plan } = req;
|
|
1009
|
+
if (!plan.steps.length) {
|
|
1010
|
+
req.onComplete({});
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
let step = 0;
|
|
1014
|
+
const mounted = this._mountPrompt(emitAsk(tool.name, plan, req.resolution.values, 'ask'), (name, ctx) => {
|
|
1015
|
+
if (name === 'nxis.cancel') {
|
|
1016
|
+
req.onCancel();
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
if (name === 'nxis.back') {
|
|
1020
|
+
step = Math.max(0, step - 1);
|
|
1021
|
+
this._prompt?.send(emitStep('ask', tool.name, plan, step));
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (name === 'nxis.next') {
|
|
1025
|
+
// Do not advance past an unanswered question: the step exists
|
|
1026
|
+
// precisely because this value is unknown.
|
|
1027
|
+
const form = (this._prompt?.read('form') ?? {});
|
|
1028
|
+
const unanswered = plan.steps[step].slots.filter((sl) => { const v = form[sl.name]; return v === undefined || v === null || String(v).trim() === ''; });
|
|
1029
|
+
if (unanswered.length) {
|
|
1030
|
+
this._setStatus(`Please answer: ${unanswered.map((sl) => humanize(sl.name)).join(', ')}`, true);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
this._setStatus('');
|
|
1034
|
+
step = Math.min(plan.steps.length - 1, step + 1);
|
|
1035
|
+
this._prompt?.send(emitStep('ask', tool.name, plan, step));
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (name === 'nxis.submit') {
|
|
1039
|
+
const form = (this._prompt?.read('form') ?? {});
|
|
1040
|
+
req.onComplete(form);
|
|
1041
|
+
}
|
|
1042
|
+
void ctx;
|
|
1043
|
+
});
|
|
1044
|
+
// A renderer failure must not strand the turn; fall back to the DOM form.
|
|
1045
|
+
if (!mounted)
|
|
1046
|
+
this._renderFormLegacy(tool, req.resolution.values);
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Show what a write will send, and wait for the user to submit it.
|
|
1050
|
+
*
|
|
1051
|
+
* Drawn as DOM rather than an A2UI surface so the confirmation matches the
|
|
1052
|
+
* detail card it becomes once submitted. A2UI's basic catalog cannot express
|
|
1053
|
+
* that styling: each component nests its own shadow root, and the `--a2ui-*`
|
|
1054
|
+
* properties that reach inside are per component type rather than per
|
|
1055
|
+
* instance. `emitConfirm` remains available for renderers that prefer the
|
|
1056
|
+
* surface.
|
|
1057
|
+
*/
|
|
1058
|
+
_renderConfirm(req) {
|
|
1059
|
+
this._clearEmpty();
|
|
1060
|
+
this._dismissPrompt();
|
|
1061
|
+
const rows = req.slots
|
|
1062
|
+
.map((sl) => `<div class="dc-row"><dt>${escapeHtml(sl.schema.description || humanize(sl.name))}</dt><dd>${escapeHtml(String(sl.value ?? ''))}</dd></div>`)
|
|
1063
|
+
.join('');
|
|
1064
|
+
const wrap = document.createElement('div');
|
|
1065
|
+
wrap.className = 'msg detail';
|
|
1066
|
+
wrap.innerHTML = `
|
|
1067
|
+
<div class="detailcard">
|
|
1068
|
+
<div class="dc-badge review">${ICON_ARROW}</div>
|
|
1069
|
+
<div class="dc-eyebrow review">Ready to send</div>
|
|
1070
|
+
<div class="dc-title">${escapeHtml(humanize(req.tool.name))}</div>
|
|
1071
|
+
<div class="dc-sub">Review these details, then submit.</div>
|
|
1072
|
+
<dl class="dc-list">${rows || '<div class="dc-row"><dd>No details captured.</dd></div>'}</dl>
|
|
1073
|
+
<div class="dc-actions">
|
|
1074
|
+
<button type="button" class="btn ghost" data-act="cancel">Cancel</button>
|
|
1075
|
+
<button type="button" class="btn ghost" data-act="edit">Edit</button>
|
|
1076
|
+
<button type="button" class="btn primary" data-act="submit">Submit ${ICON_ARROW}</button>
|
|
1077
|
+
</div>
|
|
1078
|
+
</div>`;
|
|
1079
|
+
wrap.querySelector('[data-act="cancel"]').addEventListener('click', () => req.onCancel());
|
|
1080
|
+
wrap.querySelector('[data-act="edit"]').addEventListener('click', () => req.onEdit());
|
|
1081
|
+
wrap.querySelector('[data-act="submit"]').addEventListener('click', () => {
|
|
1082
|
+
const btn = wrap.querySelector('[data-act="submit"]');
|
|
1083
|
+
btn.disabled = true;
|
|
1084
|
+
req.onSubmit(req.values);
|
|
1085
|
+
});
|
|
1086
|
+
this._els.thread.appendChild(wrap);
|
|
1087
|
+
this._formEl = wrap;
|
|
1088
|
+
this._scroll();
|
|
1089
|
+
}
|
|
1090
|
+
// ── Inline generated form (from JSON-Schema) ───────────────────────────────
|
|
1091
|
+
_renderForm(tool, args) {
|
|
1092
|
+
if (this._useA2ui) {
|
|
1093
|
+
try {
|
|
1094
|
+
this._renderFormA2ui(tool, args);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
catch (err) {
|
|
1098
|
+
// Never strand the user without a form; fall through to the DOM one.
|
|
1099
|
+
console.warn('[a2ui] form surface failed, using inline form', err);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
this._renderFormLegacy(tool, args);
|
|
1103
|
+
}
|
|
1104
|
+
/** The tool form as an A2UI surface rather than hand-built DOM. */
|
|
1105
|
+
_renderFormA2ui(tool, args) {
|
|
1106
|
+
this._clearEmpty();
|
|
1107
|
+
this._tool = tool;
|
|
1108
|
+
const wrap = document.createElement('div');
|
|
1109
|
+
wrap.className = 'form a2ui a2ui-scope self-start w-full max-w-[420px]';
|
|
1110
|
+
const surfaceHost = document.createElement('div');
|
|
1111
|
+
// The status line stays outside the surface: reporting progress and errors
|
|
1112
|
+
// is the host's job, and the basic catalog has no component for it.
|
|
1113
|
+
const result = document.createElement('div');
|
|
1114
|
+
result.className = 'genform-result';
|
|
1115
|
+
wrap.append(surfaceHost, result);
|
|
1116
|
+
const messages = emitToolForm(tool.name, (tool.description || '').trim(), toolSchema(tool), args);
|
|
1117
|
+
const mounted = mountSurface(surfaceHost, messages, (event) => {
|
|
1118
|
+
const name = event?.event?.name ?? event?.name;
|
|
1119
|
+
if (name === 'nxis.cancel') {
|
|
1120
|
+
this._cancelForm();
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if (name !== 'nxis.submit')
|
|
1124
|
+
return;
|
|
1125
|
+
const values = (mounted?.read('form') ?? {});
|
|
1126
|
+
const missing = missingRequired(tool, values);
|
|
1127
|
+
if (missing.length) {
|
|
1128
|
+
this._showFormError(result, `Please fill in: ${missing.map((m) => humanize(m)).join(', ')}.`);
|
|
1129
|
+
this._setStatus('');
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
void this._submitValues(tool, values, result, () => { });
|
|
1133
|
+
});
|
|
1134
|
+
if (!mounted)
|
|
1135
|
+
throw new Error('no form surface produced');
|
|
1136
|
+
this._surfaces.push(mounted);
|
|
1137
|
+
if (this._formEl && this._formEl.isConnected)
|
|
1138
|
+
this._formEl.remove();
|
|
1139
|
+
this._els.thread.appendChild(wrap);
|
|
1140
|
+
this._formEl = wrap;
|
|
1141
|
+
this._scroll();
|
|
1142
|
+
}
|
|
1143
|
+
_renderFormLegacy(tool, args) {
|
|
1144
|
+
this._clearEmpty();
|
|
1145
|
+
this._tool = tool;
|
|
1146
|
+
const schema = toolSchema(tool);
|
|
1147
|
+
const props = schema.properties || {};
|
|
1148
|
+
const required = new Set(schema.required || []);
|
|
1149
|
+
const fields = Object.entries(props)
|
|
1150
|
+
.map(([key, spec]) => {
|
|
1151
|
+
const id = `f_${key}`;
|
|
1152
|
+
const label = `${humanize(key)}${required.has(key) ? ' <span class="req">*</span>' : ''}`;
|
|
1153
|
+
return `<div class="field"><label for="${id}">${label}</label>${this._control(id, key, spec || {}, args[key])}</div>`;
|
|
1154
|
+
})
|
|
1155
|
+
.join('');
|
|
1156
|
+
const wrap = document.createElement('div');
|
|
1157
|
+
wrap.className = 'msg form';
|
|
1158
|
+
wrap.innerHTML = `
|
|
1159
|
+
<form class="genform" novalidate>
|
|
1160
|
+
<div class="genform-head">
|
|
1161
|
+
<div class="gf-tool">${escapeHtml(humanize(tool.name))}</div>
|
|
1162
|
+
<div class="gf-desc">${escapeHtml((tool.description || '').trim())}</div>
|
|
1163
|
+
</div>
|
|
1164
|
+
${fields || '<p class="gf-empty">This tool takes no inputs.</p>'}
|
|
1165
|
+
<div class="genform-actions">
|
|
1166
|
+
<button type="button" class="btn ghost" data-act="cancel">Cancel</button>
|
|
1167
|
+
<button type="submit" class="btn primary">Submit ${ICON_ARROW}</button>
|
|
1168
|
+
</div>
|
|
1169
|
+
<div class="genform-result"></div>
|
|
1170
|
+
</form>`;
|
|
1171
|
+
if (this._formEl && this._formEl.isConnected)
|
|
1172
|
+
this._formEl.remove();
|
|
1173
|
+
this._els.thread.appendChild(wrap);
|
|
1174
|
+
this._formEl = wrap;
|
|
1175
|
+
const form = wrap.querySelector('.genform');
|
|
1176
|
+
form.querySelector('[data-act="cancel"]').addEventListener('click', () => this._cancelForm());
|
|
1177
|
+
form.addEventListener('submit', (e) => {
|
|
1178
|
+
e.preventDefault();
|
|
1179
|
+
this._submitForm(tool, form);
|
|
1180
|
+
});
|
|
1181
|
+
const clearInvalid = (e) => e.target.classList && e.target.classList.remove('invalid');
|
|
1182
|
+
form.addEventListener('input', clearInvalid);
|
|
1183
|
+
form.addEventListener('change', clearInvalid);
|
|
1184
|
+
this._scroll();
|
|
1185
|
+
}
|
|
1186
|
+
_cancelForm() {
|
|
1187
|
+
if (this._formEl)
|
|
1188
|
+
this._formEl.remove();
|
|
1189
|
+
this._formEl = null;
|
|
1190
|
+
this._tool = null;
|
|
1191
|
+
this._appendMsg('assistant', 'Okay — cancelled. Tell me what you’d like to do.');
|
|
1192
|
+
}
|
|
1193
|
+
/** Build one input control from a property schema. */
|
|
1194
|
+
_control(id, name, spec, value) {
|
|
1195
|
+
const enumVals = spec.enum || (spec.items && spec.items.enum);
|
|
1196
|
+
if (enumVals) {
|
|
1197
|
+
const opts = enumVals
|
|
1198
|
+
.map((v) => `<option value="${escapeHtml(String(v))}"${String(v) === String(value) ? ' selected' : ''}>${escapeHtml(humanize(String(v)))}</option>`)
|
|
1199
|
+
.join('');
|
|
1200
|
+
return `<select id="${id}" data-name="${escapeHtml(name)}"><option value="">Select…</option>${opts}</select>`;
|
|
1201
|
+
}
|
|
1202
|
+
if (spec.type === 'boolean') {
|
|
1203
|
+
return `<label class="check"><input id="${id}" data-name="${escapeHtml(name)}" type="checkbox"${value ? ' checked' : ''}> Yes</label>`;
|
|
1204
|
+
}
|
|
1205
|
+
let type = 'text';
|
|
1206
|
+
if (spec.type === 'integer' || spec.type === 'number')
|
|
1207
|
+
type = 'number';
|
|
1208
|
+
else if (spec.format === 'date' || /\bdate\b/.test(name))
|
|
1209
|
+
type = 'date';
|
|
1210
|
+
else if (/email/.test(name))
|
|
1211
|
+
type = 'email';
|
|
1212
|
+
let val = value == null ? '' : String(value);
|
|
1213
|
+
if (type === 'date' && val) {
|
|
1214
|
+
const parts = val.split(/[\/\-]/);
|
|
1215
|
+
if (parts.length === 3 && parts[0].length <= 2) {
|
|
1216
|
+
let y = parts[2];
|
|
1217
|
+
if (y.length === 2)
|
|
1218
|
+
y = (parseInt(y) > 50 ? '19' : '20') + y;
|
|
1219
|
+
val = `${y}-${parts[0].padStart(2, '0')}-${parts[1].padStart(2, '0')}`;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
val = escapeHtml(val);
|
|
1223
|
+
return `<input id="${id}" data-name="${escapeHtml(name)}" type="${type}" value="${val}">`;
|
|
1224
|
+
}
|
|
1225
|
+
_collect(form) {
|
|
1226
|
+
const out = {};
|
|
1227
|
+
form.querySelectorAll('[data-name]').forEach((node) => {
|
|
1228
|
+
const el = node;
|
|
1229
|
+
const name = el.dataset.name;
|
|
1230
|
+
if (el.type === 'checkbox') {
|
|
1231
|
+
out[name] = el.checked;
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
const v = el.value;
|
|
1235
|
+
if (v !== '' && v != null)
|
|
1236
|
+
out[name] = el.type === 'number' ? Number(v) : v;
|
|
1237
|
+
});
|
|
1238
|
+
return out;
|
|
1239
|
+
}
|
|
1240
|
+
async _submitForm(tool, form) {
|
|
1241
|
+
const result = form.querySelector('.genform-result');
|
|
1242
|
+
const submitBtn = form.querySelector('button[type="submit"]');
|
|
1243
|
+
const values = this._collect(form);
|
|
1244
|
+
// Highlighting the offending controls is specific to the DOM form; the
|
|
1245
|
+
// A2UI surface has no equivalent, so it stays here rather than in the
|
|
1246
|
+
// shared path below.
|
|
1247
|
+
form.querySelectorAll('.invalid').forEach((el) => el.classList.remove('invalid'));
|
|
1248
|
+
const missing = missingRequired(tool, values);
|
|
1249
|
+
if (missing.length) {
|
|
1250
|
+
missing.forEach((m) => {
|
|
1251
|
+
let ctrl = null;
|
|
1252
|
+
try {
|
|
1253
|
+
ctrl = form.querySelector(`[data-name="${CSS.escape(m)}"]`);
|
|
1254
|
+
}
|
|
1255
|
+
catch {
|
|
1256
|
+
/* ignore */
|
|
1257
|
+
}
|
|
1258
|
+
if (ctrl)
|
|
1259
|
+
ctrl.classList.add('invalid');
|
|
1260
|
+
});
|
|
1261
|
+
this._showFormError(result, `Please fill in: ${missing.map((m) => humanize(m)).join(', ')}.`);
|
|
1262
|
+
this._setStatus('');
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
await this._submitValues(tool, values, result, (b) => (submitBtn.disabled = b));
|
|
1266
|
+
}
|
|
1267
|
+
_showFormError(result, msg) {
|
|
1268
|
+
result.className = 'genform-result error';
|
|
1269
|
+
result.innerHTML = `${ICON_WARN}<span>${escapeHtml(msg)}</span>`;
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Everything after "we have valid values": run the tool, then swap the form
|
|
1273
|
+
* for a detail card or result cards.
|
|
1274
|
+
*
|
|
1275
|
+
* Shared by the DOM form and the A2UI surface, which differ only in how
|
|
1276
|
+
* values are collected and how invalid fields are flagged, so only that part
|
|
1277
|
+
* stays renderer-specific.
|
|
1278
|
+
*/
|
|
1279
|
+
async _submitValues(tool, values, result, setBusy) {
|
|
1280
|
+
const submitBtn = { set disabled(b) { setBusy(b); } };
|
|
1281
|
+
submitBtn.disabled = true;
|
|
1282
|
+
result.className = 'genform-result';
|
|
1283
|
+
result.textContent = 'Submitting…';
|
|
1284
|
+
try {
|
|
1285
|
+
this._setStatus(`Running ${tool.name}…`);
|
|
1286
|
+
const res = await this.toolSource.execTool(tool, tool.name, values);
|
|
1287
|
+
const isError = !!(res && res.isError);
|
|
1288
|
+
const text = extractResult(res);
|
|
1289
|
+
this._setStatus('');
|
|
1290
|
+
if (isError) {
|
|
1291
|
+
result.className = 'genform-result error';
|
|
1292
|
+
result.innerHTML = `${ICON_WARN}<span>${escapeHtml(text || 'Failed')}</span>`;
|
|
1293
|
+
submitBtn.disabled = false;
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
// Success → the form transitions inline. A write tool (form/action)
|
|
1297
|
+
// becomes a "Submitted" detail card; a read-only tool (typed retrieval)
|
|
1298
|
+
// becomes result cards for the records it returned.
|
|
1299
|
+
const conv = this._active();
|
|
1300
|
+
const readOnly = !!(tool.annotations && tool.annotations.readOnlyHint);
|
|
1301
|
+
let item;
|
|
1302
|
+
let card;
|
|
1303
|
+
if (readOnly) {
|
|
1304
|
+
const results = this._buildResultsItem(tool, res);
|
|
1305
|
+
const summary = results.cards
|
|
1306
|
+
.map((c) => c.title)
|
|
1307
|
+
.filter(Boolean)
|
|
1308
|
+
.slice(0, 8)
|
|
1309
|
+
.join(', ');
|
|
1310
|
+
conv.messages.push({
|
|
1311
|
+
role: 'user',
|
|
1312
|
+
content: `[${tool.name} returned ${results.note}${summary ? ': ' + summary : ''}]`,
|
|
1313
|
+
});
|
|
1314
|
+
// Attribute the typed-retrieval cost to the round that proposed the call.
|
|
1315
|
+
const round = conv.metrics && conv.metrics[conv.metrics.length - 1];
|
|
1316
|
+
if (round) {
|
|
1317
|
+
round.toolCalls += 1;
|
|
1318
|
+
round.results += results.cards.length;
|
|
1319
|
+
round.toolResultTokens += await this.countTokens(text);
|
|
1320
|
+
}
|
|
1321
|
+
item = results;
|
|
1322
|
+
card = this._resultsEl(results);
|
|
1323
|
+
}
|
|
1324
|
+
else {
|
|
1325
|
+
conv.messages.push({
|
|
1326
|
+
role: 'user',
|
|
1327
|
+
content: `[${tool.name} submitted with: ${JSON.stringify(values)}]. Keep these unless I ask to change them.`,
|
|
1328
|
+
});
|
|
1329
|
+
const detail = this._buildDetailItem(tool, values, text);
|
|
1330
|
+
item = detail;
|
|
1331
|
+
card = this._detailEl(detail);
|
|
1332
|
+
}
|
|
1333
|
+
conv.items.push(item);
|
|
1334
|
+
if (this._formEl && this._formEl.isConnected)
|
|
1335
|
+
this._formEl.replaceWith(card);
|
|
1336
|
+
else
|
|
1337
|
+
this._els.thread.appendChild(card);
|
|
1338
|
+
this._formEl = null;
|
|
1339
|
+
this._tool = null;
|
|
1340
|
+
if (readOnly)
|
|
1341
|
+
this._upsertMetrics();
|
|
1342
|
+
this._saveConversations();
|
|
1343
|
+
this._scroll();
|
|
1344
|
+
}
|
|
1345
|
+
catch (e) {
|
|
1346
|
+
result.className = 'genform-result error';
|
|
1347
|
+
result.innerHTML = `${ICON_WARN}<span>${escapeHtml(String(e.message || e))}</span>`;
|
|
1348
|
+
submitBtn.disabled = false;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
_buildDetailItem(tool, values, resultText) {
|
|
1352
|
+
const schema = toolSchema(tool);
|
|
1353
|
+
const props = schema.properties || {};
|
|
1354
|
+
const order = [...Object.keys(props), ...Object.keys(values).filter((k) => !(k in props))];
|
|
1355
|
+
const rows = order
|
|
1356
|
+
.filter((k) => values[k] !== undefined && values[k] !== null && values[k] !== '')
|
|
1357
|
+
.map((k) => {
|
|
1358
|
+
const spec = props[k] || {};
|
|
1359
|
+
let v = values[k];
|
|
1360
|
+
if (spec.type === 'boolean' || typeof v === 'boolean')
|
|
1361
|
+
v = v ? 'Yes' : 'No';
|
|
1362
|
+
else if (spec.enum || (spec.items && spec.items.enum))
|
|
1363
|
+
v = humanize(String(v));
|
|
1364
|
+
return { label: humanize(k), value: String(v) };
|
|
1365
|
+
});
|
|
1366
|
+
const note = resultText && !/^\s*[[{]/.test(resultText) ? resultText : 'Your details were submitted successfully.';
|
|
1367
|
+
return { kind: 'detail', title: humanize(tool.name), note, rows };
|
|
1368
|
+
}
|
|
1369
|
+
// ── Read-tool results → cards ──────────────────────────────────────────────
|
|
1370
|
+
/** Pull the array of records out of a tool response. The WebMCP polyfill's
|
|
1371
|
+
* result normalizer can drop `structuredContent`, so every source is tried:
|
|
1372
|
+
* structuredContent (object or JSON string) and each content text part, and
|
|
1373
|
+
* return the first that yields records. Shapes covered: query → results[],
|
|
1374
|
+
* list_types → types[], get → result{}, or a bare entity/array. */
|
|
1375
|
+
_extractItems(res) {
|
|
1376
|
+
const sources = [];
|
|
1377
|
+
// Collect every candidate payload, recursively unwrapping tool-response
|
|
1378
|
+
// envelopes. `res` can be an object, a JSON string, or (with a WebMCP browser
|
|
1379
|
+
// extension in the loop) the whole `{content, structuredContent}` envelope
|
|
1380
|
+
// serialized to a string, possibly more than once. Diving into both
|
|
1381
|
+
// `structuredContent` and each `content[].text` covers all of them.
|
|
1382
|
+
const visit = (raw, depth = 0) => {
|
|
1383
|
+
if (raw == null || depth > 5)
|
|
1384
|
+
return;
|
|
1385
|
+
const val = raw;
|
|
1386
|
+
if (typeof val === 'string') {
|
|
1387
|
+
const t = val.trim();
|
|
1388
|
+
if (!t)
|
|
1389
|
+
return;
|
|
1390
|
+
let parsed;
|
|
1391
|
+
try {
|
|
1392
|
+
parsed = JSON.parse(t);
|
|
1393
|
+
}
|
|
1394
|
+
catch {
|
|
1395
|
+
return; // plain (non-JSON) text — nothing to extract
|
|
1396
|
+
}
|
|
1397
|
+
// Parsed value may itself be an object/array OR another JSON string
|
|
1398
|
+
// (double-encoded); recurse either way.
|
|
1399
|
+
return visit(parsed, depth + 1);
|
|
1400
|
+
}
|
|
1401
|
+
if (!val || typeof val !== 'object')
|
|
1402
|
+
return;
|
|
1403
|
+
sources.push(val);
|
|
1404
|
+
const v = val;
|
|
1405
|
+
if (v.structuredContent != null)
|
|
1406
|
+
visit(v.structuredContent, depth + 1);
|
|
1407
|
+
if (Array.isArray(v.content)) {
|
|
1408
|
+
for (const c of v.content) {
|
|
1409
|
+
if (c && typeof c === 'object')
|
|
1410
|
+
visit(c.text, depth + 1);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
visit(res);
|
|
1415
|
+
for (const data of sources) {
|
|
1416
|
+
const items = this._itemsFrom(data);
|
|
1417
|
+
if (items.length)
|
|
1418
|
+
return items;
|
|
1419
|
+
}
|
|
1420
|
+
console.warn('[nxis-mcp] no items extracted from tool result', { sourcesTried: sources.length, res });
|
|
1421
|
+
return [];
|
|
1422
|
+
}
|
|
1423
|
+
/** Find the record array within one parsed payload. */
|
|
1424
|
+
_itemsFrom(data) {
|
|
1425
|
+
const objs = (arr) => arr.filter((x) => x && typeof x === 'object');
|
|
1426
|
+
if (Array.isArray(data))
|
|
1427
|
+
return objs(data);
|
|
1428
|
+
if (!data || typeof data !== 'object')
|
|
1429
|
+
return [];
|
|
1430
|
+
const d = data;
|
|
1431
|
+
for (const key of ['results', 'types', 'items', 'entities', 'data']) {
|
|
1432
|
+
if (Array.isArray(d[key]))
|
|
1433
|
+
return objs(d[key]);
|
|
1434
|
+
}
|
|
1435
|
+
if (d.result && typeof d.result === 'object')
|
|
1436
|
+
return [d.result];
|
|
1437
|
+
if (d['@id'] != null || d['@type'] != null || d.name != null)
|
|
1438
|
+
return [d];
|
|
1439
|
+
return [];
|
|
1440
|
+
}
|
|
1441
|
+
/** Build a display card from a single record (a JSON-LD entity or a type row). */
|
|
1442
|
+
_entityCard(entity) {
|
|
1443
|
+
const str = (v) => (v == null ? '' : String(Array.isArray(v) ? v[0] : v));
|
|
1444
|
+
const rawType = entity['@type'];
|
|
1445
|
+
const title = str(entity.name ?? entity.title ?? entity.headline ?? rawType ?? entity['@id'] ?? 'Result');
|
|
1446
|
+
const type = rawType ? humanize(str(rawType)) : '';
|
|
1447
|
+
// Positive outcome (rating) → green tag, per the color rules.
|
|
1448
|
+
let rating;
|
|
1449
|
+
const ar = entity.aggregateRating;
|
|
1450
|
+
if (ar && typeof ar === 'object' && ar.ratingValue != null) {
|
|
1451
|
+
const stars = '★'.repeat(Math.max(1, Math.min(5, Math.round(Number(ar.ratingValue) || 0))));
|
|
1452
|
+
rating = ar.reviewCount != null ? `${stars} ${str(ar.ratingValue)} · ${str(ar.reviewCount)}` : `${stars} ${str(ar.ratingValue)}`;
|
|
1453
|
+
}
|
|
1454
|
+
// 24/7 emergency service is only implied by round-the-clock opening hours.
|
|
1455
|
+
const hours = entity.openingHoursSpecification;
|
|
1456
|
+
const emergency = Array.isArray(hours) &&
|
|
1457
|
+
hours.some((h) => h &&
|
|
1458
|
+
typeof h === 'object' &&
|
|
1459
|
+
String(h.opens) === '00:00' &&
|
|
1460
|
+
String(h.closes) === '23:59');
|
|
1461
|
+
// One supporting line under the headline (feature-card pattern).
|
|
1462
|
+
const desc = str(entity.description).trim();
|
|
1463
|
+
const sub = desc ? (desc.length > 150 ? desc.slice(0, 147) + '…' : desc) : undefined;
|
|
1464
|
+
const rows = [];
|
|
1465
|
+
const push = (label, value, href) => {
|
|
1466
|
+
const v = str(value).trim();
|
|
1467
|
+
if (v && rows.length < 4 && !rows.some((r) => r.label === label)) {
|
|
1468
|
+
rows.push({ label, value: v.length > 90 ? v.slice(0, 87) + '…' : v, ...(href ? { href } : {}) });
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
const addr = entity.address;
|
|
1472
|
+
if (addr && typeof addr === 'object') {
|
|
1473
|
+
const parts = [addr.streetAddress, addr.addressLocality, addr.addressRegion].filter(Boolean).map(str);
|
|
1474
|
+
if (parts.length)
|
|
1475
|
+
push('Address', parts.join(', '));
|
|
1476
|
+
}
|
|
1477
|
+
// Preferred scalar fields, then any remaining scalars as a fallback.
|
|
1478
|
+
const preferred = ['telephone', 'email', 'url', 'count'];
|
|
1479
|
+
const seen = new Set(['name', 'title', 'headline', 'image', 'description', ...preferred]);
|
|
1480
|
+
for (const key of preferred) {
|
|
1481
|
+
const v = entity[key];
|
|
1482
|
+
if (typeof v === 'string' || typeof v === 'number') {
|
|
1483
|
+
const href = key === 'telephone' ? `tel:${String(v).replace(/[^\d+]/g, '')}` : undefined;
|
|
1484
|
+
push(humanize(key === 'url' ? 'website' : key), v, href);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
if (rows.length < 3) {
|
|
1488
|
+
for (const [k, v] of Object.entries(entity)) {
|
|
1489
|
+
if (k.startsWith('@') || seen.has(k))
|
|
1490
|
+
continue;
|
|
1491
|
+
if (typeof v === 'string' || typeof v === 'number')
|
|
1492
|
+
push(humanize(k), v);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
const id = entity['@id'] != null ? str(entity['@id']) : undefined;
|
|
1496
|
+
return { title, type, rating, emergency, sub, rows, id };
|
|
1497
|
+
}
|
|
1498
|
+
_buildResultsItem(tool, res) {
|
|
1499
|
+
const items = this._extractItems(res);
|
|
1500
|
+
const cards = items.slice(0, 12).map((e) => this._entityCard(e));
|
|
1501
|
+
const note = !cards.length
|
|
1502
|
+
? 'No results found.'
|
|
1503
|
+
: `${cards.length} result${cards.length === 1 ? '' : 's'}`;
|
|
1504
|
+
return { kind: 'results', title: humanize(tool.name), note, cards };
|
|
1505
|
+
}
|
|
1506
|
+
/** Feature-card pattern: tinted icon chip, tags, tight Manrope headline, one
|
|
1507
|
+
* supporting line, then mono-labelled detail rows. */
|
|
1508
|
+
/**
|
|
1509
|
+
* ResultCard → A2UI card data.
|
|
1510
|
+
*
|
|
1511
|
+
* Three things do not survive the trip, all catalog limits rather than
|
|
1512
|
+
* mapping choices: the tag colours (basic Text has no tone), the tel: links
|
|
1513
|
+
* on rows (no link component), and the @id line. They are dropped here
|
|
1514
|
+
* rather than faked so the difference is visible when comparing renderers.
|
|
1515
|
+
*/
|
|
1516
|
+
_toA2uiCards(cards) {
|
|
1517
|
+
return cards.map((c) => ({
|
|
1518
|
+
title: c.title,
|
|
1519
|
+
sub: c.sub ?? '',
|
|
1520
|
+
tags: [
|
|
1521
|
+
...(c.type ? [{ label: c.type }] : []),
|
|
1522
|
+
...(c.rating ? [{ label: c.rating }] : []),
|
|
1523
|
+
...(c.emergency ? [{ label: '24/7' }] : []),
|
|
1524
|
+
],
|
|
1525
|
+
rows: c.rows.map((r) => ({ label: r.label, value: r.value })),
|
|
1526
|
+
}));
|
|
1527
|
+
}
|
|
1528
|
+
/** Results rendered through A2UI instead of the bespoke card markup. */
|
|
1529
|
+
_resultsElA2ui(item) {
|
|
1530
|
+
const wrap = document.createElement('div');
|
|
1531
|
+
wrap.className = 'results a2ui a2ui-scope self-stretch w-full';
|
|
1532
|
+
if (!item.cards.length) {
|
|
1533
|
+
wrap.innerHTML = `<div class="rc-empty">${escapeHtml(item.note)}</div>`;
|
|
1534
|
+
return wrap;
|
|
1535
|
+
}
|
|
1536
|
+
try {
|
|
1537
|
+
const messages = emitCardsSurface(item.note, this._toA2uiCards(item.cards));
|
|
1538
|
+
const mounted = mountSurface(wrap, messages);
|
|
1539
|
+
if (!mounted)
|
|
1540
|
+
throw new Error('no surface produced');
|
|
1541
|
+
this._surfaces.push(mounted);
|
|
1542
|
+
}
|
|
1543
|
+
catch (err) {
|
|
1544
|
+
// A renderer failure must not take the conversation with it.
|
|
1545
|
+
console.warn('[a2ui] results surface failed, using inline cards', err);
|
|
1546
|
+
return this._resultsElLegacy(item);
|
|
1547
|
+
}
|
|
1548
|
+
return wrap;
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Typed-retrieval results.
|
|
1552
|
+
*
|
|
1553
|
+
* Drawn as DOM, for the same reason the review card is: the house result card
|
|
1554
|
+
* has a tinted logo chip, blue and green pill tags, a Manrope headline and
|
|
1555
|
+
* tel: links on its rows, and none of that is expressible in A2UI's basic
|
|
1556
|
+
* catalog. Each component nests its own shadow root, so only the global
|
|
1557
|
+
* --a2ui-* properties reach inside, and they are per component type, so there
|
|
1558
|
+
* is no way to say "this tag is a rating" from out here.
|
|
1559
|
+
*
|
|
1560
|
+
* emitCardsSurface remains in a2ui/cards.ts for renderers that want the
|
|
1561
|
+
* surface; a2ui="a2ui-results" opts this element back into it for comparison.
|
|
1562
|
+
*/
|
|
1563
|
+
_resultsEl(item) {
|
|
1564
|
+
return this.getAttribute('a2ui') === 'a2ui-results'
|
|
1565
|
+
? this._resultsElA2ui(item)
|
|
1566
|
+
: this._resultsElLegacy(item);
|
|
1567
|
+
}
|
|
1568
|
+
_resultsElLegacy(item) {
|
|
1569
|
+
const wrap = document.createElement('div');
|
|
1570
|
+
wrap.className = 'msg results';
|
|
1571
|
+
if (!item.cards.length) {
|
|
1572
|
+
wrap.innerHTML = `<div class="rc-empty">${escapeHtml(item.note)}</div>`;
|
|
1573
|
+
return wrap;
|
|
1574
|
+
}
|
|
1575
|
+
const cardsHtml = item.cards
|
|
1576
|
+
.map((c) => {
|
|
1577
|
+
const rows = c.rows
|
|
1578
|
+
.map((r) => `<div class="rc-row"><dt>${escapeHtml(r.label)}</dt><dd>${r.href ? `<a href="${escapeHtml(r.href)}">${escapeHtml(r.value)}</a>` : escapeHtml(r.value)}</dd></div>`)
|
|
1579
|
+
.join('');
|
|
1580
|
+
const tags = (c.type ? `<span class="tag blue"><i class="dot"></i>${escapeHtml(c.type)}</span>` : '') +
|
|
1581
|
+
(c.rating ? `<span class="tag green"><i class="dot"></i>${escapeHtml(c.rating)}</span>` : '') +
|
|
1582
|
+
(c.emergency ? `<span class="tag green"><i class="dot"></i>24/7</span>` : '');
|
|
1583
|
+
return `<div class="resultcard">
|
|
1584
|
+
<div class="rc-top">
|
|
1585
|
+
<div class="rc-chip">${this._logoMarkup()}</div>
|
|
1586
|
+
<div class="rc-tags">${tags}</div>
|
|
1587
|
+
</div>
|
|
1588
|
+
<div class="rc-title">${escapeHtml(c.title)}</div>
|
|
1589
|
+
${c.sub ? `<div class="rc-sub">${escapeHtml(c.sub)}</div>` : ''}
|
|
1590
|
+
${rows ? `<dl class="rc-list">${rows}</dl>` : ''}
|
|
1591
|
+
${c.id ? `<div class="rc-id"><code>${escapeHtml(c.id)}</code></div>` : ''}
|
|
1592
|
+
</div>`;
|
|
1593
|
+
})
|
|
1594
|
+
.join('');
|
|
1595
|
+
wrap.innerHTML = `<div class="rc-head">${escapeHtml(item.note)}</div><div class="rc-cards">${cardsHtml}</div>`;
|
|
1596
|
+
return wrap;
|
|
1597
|
+
}
|
|
1598
|
+
// ── Metrics panel (real Gemma tokenizer counts vs a RAG estimate) ──────────
|
|
1599
|
+
/** Keep one 'metrics' thread item, always at the end of the thread. */
|
|
1600
|
+
_upsertMetrics() {
|
|
1601
|
+
// The comparison needs a tokenizer. A provider that cannot count would
|
|
1602
|
+
// render every figure as zero, which would read as a measurement.
|
|
1603
|
+
if (!this.provider?.countTokens)
|
|
1604
|
+
return;
|
|
1605
|
+
const conv = this._active();
|
|
1606
|
+
if (!conv || !conv.metrics || !conv.metrics.length)
|
|
1607
|
+
return;
|
|
1608
|
+
const idx = conv.items.findIndex((it) => it.kind === 'metrics');
|
|
1609
|
+
if (idx !== -1)
|
|
1610
|
+
conv.items.splice(idx, 1);
|
|
1611
|
+
const item = { kind: 'metrics', rounds: conv.metrics };
|
|
1612
|
+
conv.items.push(item);
|
|
1613
|
+
const existing = this._els.thread.querySelector('.msg.metrics');
|
|
1614
|
+
if (existing)
|
|
1615
|
+
existing.remove();
|
|
1616
|
+
this._els.thread.appendChild(this._metricsEl(item));
|
|
1617
|
+
}
|
|
1618
|
+
_metricsEl(item) {
|
|
1619
|
+
const rounds = item.rounds;
|
|
1620
|
+
const totalInput = rounds.reduce((s, m) => s + m.promptTokens, 0);
|
|
1621
|
+
const totalOutput = rounds.reduce((s, m) => s + m.outputTokens, 0);
|
|
1622
|
+
const totalToolCalls = rounds.reduce((s, m) => s + m.toolCalls, 0);
|
|
1623
|
+
const totalToolTokens = rounds.reduce((s, m) => s + m.toolResultTokens, 0);
|
|
1624
|
+
const mcpTotal = totalInput + totalOutput;
|
|
1625
|
+
const { ragInput, ragOutput, numResults } = estimateRagTokens(rounds);
|
|
1626
|
+
const ragTotal = ragInput + ragOutput;
|
|
1627
|
+
const maxTotal = Math.max(mcpTotal, ragTotal, 1);
|
|
1628
|
+
const savings = ragTotal > 0 ? ((ragTotal - mcpTotal) / ragTotal) * 100 : 0;
|
|
1629
|
+
const rows = rounds
|
|
1630
|
+
.map((m) => `<tr><td>Round ${m.round}</td><td class="num">${fmt(m.promptTokens)}</td><td class="num">${fmt(m.outputTokens)}</td><td class="num">${m.toolCalls}</td><td class="num">${fmt(m.toolResultTokens)}</td></tr>`)
|
|
1631
|
+
.join('');
|
|
1632
|
+
const bar = (label, value, cls) => `<div class="mx-bar-row"><span class="mx-bar-label">${label}</span><div class="mx-bar-track"><i class="${cls}" style="width:${((value / maxTotal) * 100).toFixed(1)}%"></i></div><span class="mx-bar-value">${fmt(value)}</span></div>`;
|
|
1633
|
+
const badge = Math.abs(savings) > 1
|
|
1634
|
+
? `<div class="mx-savings ${savings > 0 ? 'pos' : 'neg'}">${savings > 0
|
|
1635
|
+
? `↓ ${savings.toFixed(0)}% fewer tokens with typed retrieval`
|
|
1636
|
+
: `↑ ${Math.abs(savings).toFixed(0)}% more — multi-round context accumulation adds overhead`}</div>`
|
|
1637
|
+
: '';
|
|
1638
|
+
const el = document.createElement('div');
|
|
1639
|
+
el.className = 'msg metrics';
|
|
1640
|
+
el.innerHTML = `
|
|
1641
|
+
<details class="metricscard">
|
|
1642
|
+
<summary>⚡ ${fmt(mcpTotal)} tokens · ${rounds.length} round${rounds.length === 1 ? '' : 's'} · ${totalToolCalls} tool call${totalToolCalls === 1 ? '' : 's'}</summary>
|
|
1643
|
+
<table class="mx-table">
|
|
1644
|
+
<thead><tr><th>Round</th><th class="num">Input</th><th class="num">Output</th><th class="num">Tools</th><th class="num">Tool tokens</th></tr></thead>
|
|
1645
|
+
<tbody>${rows}<tr class="total"><td>Total</td><td class="num">${fmt(totalInput)}</td><td class="num">${fmt(totalOutput)}</td><td class="num">${totalToolCalls}</td><td class="num">${fmt(totalToolTokens)}</td></tr></tbody>
|
|
1646
|
+
</table>
|
|
1647
|
+
<div class="mx-compare">
|
|
1648
|
+
<div class="mx-compare-title">Typed Agent vs Traditional RAG</div>
|
|
1649
|
+
${bar('Typed', mcpTotal, 'mcp')}
|
|
1650
|
+
${bar('RAG est.', ragTotal, 'rag')}
|
|
1651
|
+
${badge}
|
|
1652
|
+
<div class="mx-note">RAG estimate: ${numResults} result${numResults === 1 ? '' : 's'} × ${CHUNKS_PER_RESULT} HTML chunks × ${AVG_HTML_CHUNK_TOKENS} tokens, on the same base prompt. Token counts are real, measured by the on-device Gemma tokenizer.</div>
|
|
1653
|
+
</div>
|
|
1654
|
+
</details>`;
|
|
1655
|
+
return el;
|
|
1656
|
+
}
|
|
1657
|
+
// ── Preferences (programmatic) ─────────────────────────────────────────────
|
|
1658
|
+
/**
|
|
1659
|
+
* Stored values the agent may use to fill forms.
|
|
1660
|
+
*
|
|
1661
|
+
* Supplied by the host page, which usually knows them already, so a signed-in
|
|
1662
|
+
* visitor is not asked to re-enter their own details.
|
|
1663
|
+
*
|
|
1664
|
+
* Session-scoped by default. Persistence is opt-in, since these values are
|
|
1665
|
+
* personal data and the element offers no UI for a visitor to review or clear
|
|
1666
|
+
* what a page has stored. Set `persist = true` to keep them across reloads.
|
|
1667
|
+
*
|
|
1668
|
+
* @example
|
|
1669
|
+
* ```ts
|
|
1670
|
+
* const agent = document.querySelector('nxis-agent-fullscreen');
|
|
1671
|
+
* agent.preferences.set({ name: 'Jane Doe', email: 'jane@example.com' });
|
|
1672
|
+
* agent.preferences.set('phone', '555-0100');
|
|
1673
|
+
* agent.preferences.persist = true; // survive reloads
|
|
1674
|
+
* agent.preferences.clear(); // forget everything
|
|
1675
|
+
* ```
|
|
1676
|
+
*/
|
|
1677
|
+
get preferences() {
|
|
1678
|
+
return this._prefsApi;
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* The system prompt.
|
|
1682
|
+
*
|
|
1683
|
+
* Set it before the element connects, or call `reset()` afterwards. Two
|
|
1684
|
+
* instructions in {@link DEFAULT_SYSTEM_PROMPT} change how the harness
|
|
1685
|
+
* behaves rather than only how the model reads; see its documentation before
|
|
1686
|
+
* replacing them.
|
|
1687
|
+
*/
|
|
1688
|
+
get systemPrompt() {
|
|
1689
|
+
return this._systemPrompt;
|
|
1690
|
+
}
|
|
1691
|
+
set systemPrompt(prompt) {
|
|
1692
|
+
this._systemPrompt = prompt;
|
|
1693
|
+
// The engine holds the prompt, so a change after connect needs it rebuilt.
|
|
1694
|
+
if (this.provider)
|
|
1695
|
+
this._systemPrompt = prompt;
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* The line shown above the suggestion chips before a conversation starts.
|
|
1699
|
+
*
|
|
1700
|
+
* Only the host knows what this agent can actually do, so the default
|
|
1701
|
+
* describes the mechanism rather than the subject. Set it to say what is
|
|
1702
|
+
* available here.
|
|
1703
|
+
*/
|
|
1704
|
+
get emptyState() {
|
|
1705
|
+
return this._emptyState;
|
|
1706
|
+
}
|
|
1707
|
+
set emptyState(text) {
|
|
1708
|
+
this._emptyState = text;
|
|
1709
|
+
if (this._els?.thread)
|
|
1710
|
+
this._renderThread();
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* The agent's name, used in the header, the launcher's tooltip and the accessible
|
|
1714
|
+
* name of both the launcher and the dialog.
|
|
1715
|
+
*
|
|
1716
|
+
* "Nxis Agent" is a placeholder standing in for a name the host has not
|
|
1717
|
+
* chosen yet. On a customer's own site the agent is theirs, and it should
|
|
1718
|
+
* introduce itself as theirs.
|
|
1719
|
+
*/
|
|
1720
|
+
get name() {
|
|
1721
|
+
return this._name;
|
|
1722
|
+
}
|
|
1723
|
+
set name(value) {
|
|
1724
|
+
this._name = value || DEFAULT_AGENT_NAME;
|
|
1725
|
+
this._applyIdentity();
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* The mark on the launcher, in the headers, above the empty state and on
|
|
1729
|
+
* result cards. Inline SVG markup, or the URL of an image.
|
|
1730
|
+
*
|
|
1731
|
+
* Markup is injected as written, so pass only your own, never anything a
|
|
1732
|
+
* visitor can influence. The footer's "Powered by NxisAI" badge is not
|
|
1733
|
+
* affected by this.
|
|
1734
|
+
*/
|
|
1735
|
+
get logo() {
|
|
1736
|
+
return this._logo;
|
|
1737
|
+
}
|
|
1738
|
+
set logo(value) {
|
|
1739
|
+
this._logo = value || DEFAULT_LOGO;
|
|
1740
|
+
this._applyIdentity();
|
|
1741
|
+
if (this._els?.thread)
|
|
1742
|
+
this._renderThread();
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Brand overrides, as custom properties on the host element.
|
|
1746
|
+
*
|
|
1747
|
+
* Merged over the defaults, not replacing them, so a theme can set one
|
|
1748
|
+
* colour and leave the rest of the system alone. Assigning writes inline
|
|
1749
|
+
* styles, which outrank a stylesheet rule targeting this element, so theme
|
|
1750
|
+
* here or in CSS, but not both.
|
|
1751
|
+
*/
|
|
1752
|
+
get theme() {
|
|
1753
|
+
return { ...this._theme };
|
|
1754
|
+
}
|
|
1755
|
+
set theme(next) {
|
|
1756
|
+
this._theme = { ...next };
|
|
1757
|
+
this._applyTheme();
|
|
1758
|
+
}
|
|
1759
|
+
/** Empty-state chips. Set to [] to show none. */
|
|
1760
|
+
get suggestions() {
|
|
1761
|
+
return [...this._suggestions];
|
|
1762
|
+
}
|
|
1763
|
+
set suggestions(next) {
|
|
1764
|
+
this._suggestions = [...next];
|
|
1765
|
+
if (this._els?.thread)
|
|
1766
|
+
this._renderThread();
|
|
1767
|
+
}
|
|
1768
|
+
_loadProfile() {
|
|
1769
|
+
try {
|
|
1770
|
+
return JSON.parse(localStorage.getItem(PROFILE_KEY) || 'null') || {};
|
|
1771
|
+
}
|
|
1772
|
+
catch {
|
|
1773
|
+
return {};
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
/** Persist only when asked; otherwise leave storage untouched. */
|
|
1777
|
+
_persistProfile() {
|
|
1778
|
+
if (!this._persistPrefs)
|
|
1779
|
+
return;
|
|
1780
|
+
try {
|
|
1781
|
+
localStorage.setItem(PROFILE_KEY, JSON.stringify(this._profile));
|
|
1782
|
+
}
|
|
1783
|
+
catch {
|
|
1784
|
+
/* storage blocked/full */
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Take new values and let the rest of the agent notice.
|
|
1789
|
+
*
|
|
1790
|
+
* Two things depend on this: the system message carries the values so the
|
|
1791
|
+
* model can use them in prose, and the harness reads them as a slot source
|
|
1792
|
+
* so a field the host already supplied is never asked for.
|
|
1793
|
+
*/
|
|
1794
|
+
_applyPreferences(next) {
|
|
1795
|
+
this._profile = next;
|
|
1796
|
+
this._persistProfile();
|
|
1797
|
+
this._syncProfileContext();
|
|
1798
|
+
this._saveConversations();
|
|
1799
|
+
this.dispatchEvent(new CustomEvent('nxis-preferences-change', {
|
|
1800
|
+
detail: { preferences: { ...next } },
|
|
1801
|
+
bubbles: true,
|
|
1802
|
+
composed: true,
|
|
1803
|
+
}));
|
|
1804
|
+
}
|
|
1805
|
+
_buildPreferencesApi() {
|
|
1806
|
+
const self = this;
|
|
1807
|
+
return {
|
|
1808
|
+
get persist() {
|
|
1809
|
+
return self._persistPrefs;
|
|
1810
|
+
},
|
|
1811
|
+
set persist(on) {
|
|
1812
|
+
self._persistPrefs = on;
|
|
1813
|
+
if (on)
|
|
1814
|
+
self._persistProfile();
|
|
1815
|
+
else {
|
|
1816
|
+
try {
|
|
1817
|
+
localStorage.removeItem(PROFILE_KEY);
|
|
1818
|
+
}
|
|
1819
|
+
catch {
|
|
1820
|
+
/* storage blocked */
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
},
|
|
1824
|
+
// Overloaded in the interface; implemented as one function, so the
|
|
1825
|
+
// implementation signature is widened and the cast narrows it back.
|
|
1826
|
+
get: ((key) => key === undefined ? { ...self._profile } : self._profile[key]),
|
|
1827
|
+
set: ((keyOrValues, value) => {
|
|
1828
|
+
const patch = typeof keyOrValues === 'string' ? { [keyOrValues]: value ?? '' } : keyOrValues;
|
|
1829
|
+
const next = { ...self._profile };
|
|
1830
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
1831
|
+
const trimmed = String(v ?? '').trim();
|
|
1832
|
+
// An empty value clears rather than storing "", so set(k, '') and
|
|
1833
|
+
// clear(k) mean the same thing instead of quietly differing.
|
|
1834
|
+
if (trimmed)
|
|
1835
|
+
next[k] = trimmed;
|
|
1836
|
+
else
|
|
1837
|
+
delete next[k];
|
|
1838
|
+
}
|
|
1839
|
+
self._applyPreferences(next);
|
|
1840
|
+
}),
|
|
1841
|
+
clear(key) {
|
|
1842
|
+
if (key === undefined) {
|
|
1843
|
+
self._applyPreferences({});
|
|
1844
|
+
try {
|
|
1845
|
+
localStorage.removeItem(PROFILE_KEY);
|
|
1846
|
+
}
|
|
1847
|
+
catch {
|
|
1848
|
+
/* storage blocked */
|
|
1849
|
+
}
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
const next = { ...self._profile };
|
|
1853
|
+
delete next[key];
|
|
1854
|
+
self._applyPreferences(next);
|
|
1855
|
+
},
|
|
1856
|
+
/** The keys the agent knows how to map onto form fields. */
|
|
1857
|
+
keys() {
|
|
1858
|
+
return Object.keys(PROFILE_ALIASES);
|
|
1859
|
+
},
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
/** Map the saved profile onto a tool's schema property names. */
|
|
1863
|
+
_profileForSchema(schema) {
|
|
1864
|
+
const props = schema && schema.properties ? Object.keys(schema.properties) : [];
|
|
1865
|
+
if (!props.length)
|
|
1866
|
+
return {};
|
|
1867
|
+
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
1868
|
+
const out = {};
|
|
1869
|
+
for (const [pf, val] of Object.entries(this._profile)) {
|
|
1870
|
+
if (!val || !String(val).trim())
|
|
1871
|
+
continue;
|
|
1872
|
+
const cands = (PROFILE_ALIASES[pf] || [pf]).map(norm);
|
|
1873
|
+
const match = props.find((p) => cands.includes(norm(p))) ||
|
|
1874
|
+
props.find((p) => cands.some((c) => norm(p).includes(c) || c.includes(norm(p))));
|
|
1875
|
+
if (match && out[match] === undefined)
|
|
1876
|
+
out[match] = val;
|
|
1877
|
+
}
|
|
1878
|
+
return out;
|
|
1879
|
+
}
|
|
1880
|
+
/** Fold the current profile into the active conversation's system message. */
|
|
1881
|
+
_syncProfileContext() {
|
|
1882
|
+
const conv = this._active();
|
|
1883
|
+
if (!conv || !conv.messages[0])
|
|
1884
|
+
return;
|
|
1885
|
+
const entries = Object.entries(this._profile).filter(([, v]) => v && String(v).trim());
|
|
1886
|
+
const note = entries.length
|
|
1887
|
+
? '\n\nSaved user profile — use these to fill matching fields unless the user gives different values: ' +
|
|
1888
|
+
entries.map(([k, v]) => `${humanize(k)}: ${v}`).join('; ') +
|
|
1889
|
+
'.'
|
|
1890
|
+
: '';
|
|
1891
|
+
const formatReq = '\n\nFor fields that require a specific format (such as dates), explicitly inform the user of the required format (e.g., mm/dd/yyyy).';
|
|
1892
|
+
conv.messages[0] = { role: 'system', content: this._systemPrompt + note + formatReq };
|
|
1893
|
+
}
|
|
1894
|
+
// ── Misc UI ────────────────────────────────────────────────────────────────
|
|
1895
|
+
_setStatus(text, isError = false) {
|
|
1896
|
+
// Errors only.
|
|
1897
|
+
//
|
|
1898
|
+
// The empty state names the phase while loading and the pending bubble
|
|
1899
|
+
// names it during a turn, so echoing the same words above the composer was
|
|
1900
|
+
// the same sentence twice on one screen. An error has nowhere else to go.
|
|
1901
|
+
this._els.status.textContent = isError ? text || '' : '';
|
|
1902
|
+
this._els.status.classList.toggle('err', !!isError);
|
|
1903
|
+
// The engine names its phases through onStatus, and the empty state shows
|
|
1904
|
+
// that name in place of the invitation, so a phase change has to reach it.
|
|
1905
|
+
if (!isError && text && text !== this._stage) {
|
|
1906
|
+
this._stage = text;
|
|
1907
|
+
const stage = this._els?.thread?.querySelector('.stage');
|
|
1908
|
+
if (stage)
|
|
1909
|
+
stage.textContent = text;
|
|
1910
|
+
// A phase with no numbers still changes what the ring should be doing,
|
|
1911
|
+
// and the phase that ends the load is what brings the chips back, so
|
|
1912
|
+
// this repaints either way rather than only while loading.
|
|
1913
|
+
if (!this.provider?.ready)
|
|
1914
|
+
this._loadDetail = undefined;
|
|
1915
|
+
this._paintLoad();
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
_setBar(pct) {
|
|
1919
|
+
this._els.bar.style.width = `${Math.max(0, Math.min(100, pct))}%`;
|
|
1920
|
+
}
|
|
1921
|
+
_scroll() {
|
|
1922
|
+
this._els.thread.scrollTop = this._els.thread.scrollHeight;
|
|
1923
|
+
}
|
|
1924
|
+
_autosize() {
|
|
1925
|
+
const ta = this._els.input;
|
|
1926
|
+
ta.style.height = 'auto';
|
|
1927
|
+
ta.style.height = `${Math.min(ta.scrollHeight, 140)}px`;
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* Rebuild the specialist agents and their coordinator.
|
|
1931
|
+
*
|
|
1932
|
+
* Called on connect and whenever the host changes `agents`, `routes` or
|
|
1933
|
+
* `fallback`. Inexpensive: each agent is a role over the one shared provider,
|
|
1934
|
+
* so no additional model is loaded.
|
|
1935
|
+
*/
|
|
1936
|
+
_buildAgents() {
|
|
1937
|
+
if (!this.provider || !this._agentDefs) {
|
|
1938
|
+
this._coordinator = null;
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
const names = Object.keys(this._agentDefs);
|
|
1942
|
+
const agents = {};
|
|
1943
|
+
for (const name of names) {
|
|
1944
|
+
const def = this._agentDefs[name];
|
|
1945
|
+
agents[name] = new AgentHarness({
|
|
1946
|
+
provider: this.provider,
|
|
1947
|
+
toolSource: this.toolSource,
|
|
1948
|
+
renderer: this._renderer(),
|
|
1949
|
+
profile: (schema) => this._profileForSchema(schema),
|
|
1950
|
+
onMessages: () => this._saveConversations(),
|
|
1951
|
+
systemPrompt: def.systemPrompt,
|
|
1952
|
+
tools: def.tools,
|
|
1953
|
+
name,
|
|
1954
|
+
unavailable: def.unavailable,
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
const descriptions = {};
|
|
1958
|
+
for (const name of names) {
|
|
1959
|
+
const d = this._agentDefs[name].description;
|
|
1960
|
+
if (d)
|
|
1961
|
+
descriptions[name] = d;
|
|
1962
|
+
}
|
|
1963
|
+
const fallback = this._fallbackAgent && agents[this._fallbackAgent] ? this._fallbackAgent : names[0];
|
|
1964
|
+
this._coordinator = new AgentCoordinator({
|
|
1965
|
+
agents,
|
|
1966
|
+
rules: this._routes,
|
|
1967
|
+
fallback,
|
|
1968
|
+
// The model pass is the last resort, and it uses the same weights the
|
|
1969
|
+
// specialists do, so there is no second model to load.
|
|
1970
|
+
provider: this.provider,
|
|
1971
|
+
descriptions,
|
|
1972
|
+
onRoute: (routed) => this._paintRoute(routed.agent, routed.via),
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
/** Zero when the provider cannot count, which hides the token metrics. */
|
|
1976
|
+
async countTokens(text) {
|
|
1977
|
+
return (await this.provider.countTokens?.(text)) ?? 0;
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Add a line to the thread naming the agent that took the turn.
|
|
1981
|
+
*
|
|
1982
|
+
* Behind `debug`, like the tool traces: a misroute is otherwise invisible,
|
|
1983
|
+
* showing only as an unhelpful answer.
|
|
1984
|
+
*/
|
|
1985
|
+
_paintRoute(agent, via) {
|
|
1986
|
+
if (!this.debug)
|
|
1987
|
+
return;
|
|
1988
|
+
const item = { kind: 'route', agent, via };
|
|
1989
|
+
this._active()?.items.push(item);
|
|
1990
|
+
this._els.thread.appendChild(this._routeEl(item));
|
|
1991
|
+
this._saveConversations();
|
|
1992
|
+
this._scroll();
|
|
1993
|
+
}
|
|
1994
|
+
_routeEl(item) {
|
|
1995
|
+
const el = document.createElement('div');
|
|
1996
|
+
el.className = 'routeline';
|
|
1997
|
+
el.textContent = `routed to ${item.agent} · ${item.via}`;
|
|
1998
|
+
return el;
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Bring in the site's own MCP server, once.
|
|
2002
|
+
*
|
|
2003
|
+
* The page's own action tools register themselves. Remote tools do not: they
|
|
2004
|
+
* live on an MCP server that must be discovered through
|
|
2005
|
+
* `/.well-known/ai-catalog.json` and bridged into the page's registry.
|
|
2006
|
+
*
|
|
2007
|
+
* Runs on first open rather than on connect, so an unopened launcher costs no
|
|
2008
|
+
* network request. Failure is neither fatal nor loud: a site with no MCP
|
|
2009
|
+
* server is an ordinary case, and the page's own tools work regardless.
|
|
2010
|
+
*/
|
|
2011
|
+
_connectTools() {
|
|
2012
|
+
if (this._connect)
|
|
2013
|
+
return this._connect;
|
|
2014
|
+
if (this.getAttribute('discover') === 'false') {
|
|
2015
|
+
this._connect = Promise.resolve();
|
|
2016
|
+
return this._connect;
|
|
2017
|
+
}
|
|
2018
|
+
this._connect = connectTools(this.getAttribute('mcp-api-key') || undefined, {
|
|
2019
|
+
// Short, because this blocks the first turn: a provider that has not
|
|
2020
|
+
// appeared within a few seconds is not going to.
|
|
2021
|
+
timeout: 4000,
|
|
2022
|
+
})
|
|
2023
|
+
.then(({ remoteTools, warning }) => {
|
|
2024
|
+
if (warning)
|
|
2025
|
+
console.warn('[nxis-agent]', warning);
|
|
2026
|
+
else if (remoteTools.length) {
|
|
2027
|
+
console.info(`[nxis-agent] discovered remote tools: ${remoteTools.join(', ')}`);
|
|
2028
|
+
}
|
|
2029
|
+
})
|
|
2030
|
+
.catch((err) => console.warn('[nxis-agent] tool discovery failed:', err));
|
|
2031
|
+
return this._connect;
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Report a failure in the thread, where it will be seen.
|
|
2035
|
+
*
|
|
2036
|
+
* Translates the underlying error into a sentence that says what to do next,
|
|
2037
|
+
* rather than surfacing the exception.
|
|
2038
|
+
*/
|
|
2039
|
+
_reportEngineError(err) {
|
|
2040
|
+
const raw = String(err?.message || err || 'Unknown error');
|
|
2041
|
+
let human;
|
|
2042
|
+
if (/webgpu/i.test(raw)) {
|
|
2043
|
+
human =
|
|
2044
|
+
'This browser cannot run the on-device model — it needs WebGPU. ' +
|
|
2045
|
+
'Chrome or Edge 113+, or Safari 18+, will work.';
|
|
2046
|
+
}
|
|
2047
|
+
else if (/cache|quota|storage/i.test(raw)) {
|
|
2048
|
+
human =
|
|
2049
|
+
"The model could not be stored in this browser's cache, so it did not finish " +
|
|
2050
|
+
'loading. Clearing this site\'s data usually fixes it, and the next attempt ' +
|
|
2051
|
+
'will skip the cache.';
|
|
2052
|
+
}
|
|
2053
|
+
else if (/fetch|network|import|load/i.test(raw)) {
|
|
2054
|
+
human =
|
|
2055
|
+
'The model files could not be downloaded. Check the connection and try ' +
|
|
2056
|
+
'again — nothing was lost.';
|
|
2057
|
+
}
|
|
2058
|
+
else {
|
|
2059
|
+
human = `The on-device model could not be started: ${raw}`;
|
|
2060
|
+
}
|
|
2061
|
+
this._setStatus(raw, true);
|
|
2062
|
+
this._appendMsg('assistant', human);
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Draw load progress on whichever mark is currently visible.
|
|
2066
|
+
*
|
|
2067
|
+
* Exactly one ring is ever on screen: the empty state's mark until a
|
|
2068
|
+
* conversation replaces it, then the header's. Phases that report no totals,
|
|
2069
|
+
* such as the runtime fetch and the warm-up, spin rather than fill.
|
|
2070
|
+
*/
|
|
2071
|
+
_paintLoad() {
|
|
2072
|
+
// A provider with nothing to load is ready as soon as it exists, so none
|
|
2073
|
+
// of the progress treatment applies. `load` is optional on the interface so
|
|
2074
|
+
// that this can be detected.
|
|
2075
|
+
const loading = (this.provider?.load ? this.provider.loading : false) ?? false;
|
|
2076
|
+
// The empty state was drawn before this load existed, or before it
|
|
2077
|
+
// finished. Redrawing it calls back here against the right DOM.
|
|
2078
|
+
if (this._emptyLoading !== null && this._emptyLoading !== loading) {
|
|
2079
|
+
this._renderThread();
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
const detail = this._loadDetail;
|
|
2083
|
+
const determinate = loading && !!detail?.total && this._loadPct > 0;
|
|
2084
|
+
const clamped = Math.max(0, Math.min(100, this._loadPct));
|
|
2085
|
+
const ring = this._els?.thread?.querySelector('.ring');
|
|
2086
|
+
const head = this.shadowRoot?.querySelector('.head-ring');
|
|
2087
|
+
for (const [node, show] of [
|
|
2088
|
+
[ring, loading],
|
|
2089
|
+
// The header only takes over once the empty state is gone.
|
|
2090
|
+
[head, loading && !ring],
|
|
2091
|
+
]) {
|
|
2092
|
+
if (!node)
|
|
2093
|
+
continue;
|
|
2094
|
+
node.hidden = !show;
|
|
2095
|
+
node.classList.toggle('indet', show && !determinate);
|
|
2096
|
+
if (show && determinate)
|
|
2097
|
+
node.style.setProperty('--pct', String(clamped));
|
|
2098
|
+
}
|
|
2099
|
+
const byteLine = this._els?.thread?.querySelector('.stage-bytes');
|
|
2100
|
+
if (byteLine) {
|
|
2101
|
+
byteLine.textContent = determinate
|
|
2102
|
+
? `${formatBytes(detail.loaded)} / ${formatBytes(detail.total)}`
|
|
2103
|
+
: '';
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
/**
|
|
2107
|
+
* Split this element into several specialist agents.
|
|
2108
|
+
*
|
|
2109
|
+
* Each definition becomes an {@link AgentHarness} sharing this element's
|
|
2110
|
+
* provider, so one model in memory serves every role. With none set, the
|
|
2111
|
+
* element runs as a single agent with every tool and one prompt.
|
|
2112
|
+
*
|
|
2113
|
+
* @example
|
|
2114
|
+
* ```ts
|
|
2115
|
+
* agent.agents = {
|
|
2116
|
+
* retrieval: { systemPrompt: '…', tools: (t) => t.name.startsWith('nxis_') },
|
|
2117
|
+
* booking: { systemPrompt: '…', tools: (t) => /book/.test(t.name) },
|
|
2118
|
+
* };
|
|
2119
|
+
* agent.routes = [{ route: 'booking', match: /\bbook\b/ }];
|
|
2120
|
+
* agent.fallback = 'retrieval';
|
|
2121
|
+
* ```
|
|
2122
|
+
*/
|
|
2123
|
+
get agents() {
|
|
2124
|
+
return this._agentDefs;
|
|
2125
|
+
}
|
|
2126
|
+
set agents(defs) {
|
|
2127
|
+
this._agentDefs = defs && Object.keys(defs).length ? defs : null;
|
|
2128
|
+
this._buildAgents();
|
|
2129
|
+
}
|
|
2130
|
+
/** The routing table. Ignored unless `agents` is set. */
|
|
2131
|
+
get routes() {
|
|
2132
|
+
return this._routes;
|
|
2133
|
+
}
|
|
2134
|
+
set routes(rules) {
|
|
2135
|
+
this._routes = rules ?? [];
|
|
2136
|
+
this._buildAgents();
|
|
2137
|
+
}
|
|
2138
|
+
/**
|
|
2139
|
+
* Where a turn goes when the rules abstain, nothing is sticky and the model
|
|
2140
|
+
* declined. Defaults to the first agent declared.
|
|
2141
|
+
*/
|
|
2142
|
+
get fallback() {
|
|
2143
|
+
return this._fallbackAgent;
|
|
2144
|
+
}
|
|
2145
|
+
set fallback(name) {
|
|
2146
|
+
this._fallbackAgent = name;
|
|
2147
|
+
this._buildAgents();
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Where completions come from. Defaults to Gemma on WebGPU.
|
|
2151
|
+
*
|
|
2152
|
+
* Set a provider to run the agent on a remote model. Anything a host writes
|
|
2153
|
+
* here should talk to their own origin rather than a vendor directly: a key
|
|
2154
|
+
* in a browser is a key anyone can read from the page.
|
|
2155
|
+
*/
|
|
2156
|
+
get modelProvider() {
|
|
2157
|
+
return this._provider;
|
|
2158
|
+
}
|
|
2159
|
+
set modelProvider(next) {
|
|
2160
|
+
this._provider = next;
|
|
2161
|
+
// Late enough to matter: rebuild so the next turn uses it.
|
|
2162
|
+
if (this._els?.thread) {
|
|
2163
|
+
this.provider = next ?? this.provider;
|
|
2164
|
+
this._paintProvenance();
|
|
2165
|
+
this._buildAgents();
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Show the tool calls behind each answer.
|
|
2170
|
+
*
|
|
2171
|
+
* Off by default, and intended for whoever is building the agent. Traces
|
|
2172
|
+
* carry the arguments a tool was called with, which for a form tool includes
|
|
2173
|
+
* personal data, so while this is off they are neither rendered nor stored
|
|
2174
|
+
* rather than merely hidden.
|
|
2175
|
+
*
|
|
2176
|
+
* Enabling it affects subsequent turns, not those already recorded.
|
|
2177
|
+
*/
|
|
2178
|
+
get debug() {
|
|
2179
|
+
const raw = (this.getAttribute('debug') || '').toLowerCase();
|
|
2180
|
+
if (!this.hasAttribute('debug'))
|
|
2181
|
+
return false;
|
|
2182
|
+
return raw !== 'false' && raw !== '0' && raw !== 'off';
|
|
2183
|
+
}
|
|
2184
|
+
set debug(on) {
|
|
2185
|
+
if (on)
|
|
2186
|
+
this.setAttribute('debug', 'true');
|
|
2187
|
+
else
|
|
2188
|
+
this.removeAttribute('debug');
|
|
2189
|
+
if (this._els?.thread)
|
|
2190
|
+
this._renderThread();
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* How eagerly to fetch the model weights.
|
|
2194
|
+
*
|
|
2195
|
+
* `eager` starts on connect, `idle` waits for the browser to be free (the
|
|
2196
|
+
* default), and `none` waits until the panel is opened or a message is sent.
|
|
2197
|
+
* `preload="false"` is accepted as a synonym for `none`.
|
|
2198
|
+
*/
|
|
2199
|
+
get preload() {
|
|
2200
|
+
const raw = (this.getAttribute('preload') || '').toLowerCase();
|
|
2201
|
+
if (raw === 'eager')
|
|
2202
|
+
return 'eager';
|
|
2203
|
+
if (raw === 'false' || raw === 'none')
|
|
2204
|
+
return 'none';
|
|
2205
|
+
return 'idle';
|
|
2206
|
+
}
|
|
2207
|
+
set preload(mode) {
|
|
2208
|
+
this.setAttribute('preload', mode);
|
|
2209
|
+
// Honoured when set after connect, so a host can start the load mid-session
|
|
2210
|
+
// without a reload.
|
|
2211
|
+
if (mode === 'eager' && this.provider && !this.provider.ready && !this.provider.loading) {
|
|
2212
|
+
this.provider.preload?.('eager');
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
/**
|
|
2216
|
+
* The logo as markup.
|
|
2217
|
+
*
|
|
2218
|
+
* A value starting with `<` is used as written; anything else is treated as
|
|
2219
|
+
* an image URL.
|
|
2220
|
+
*/
|
|
2221
|
+
_logoMarkup() {
|
|
2222
|
+
const logo = this._logo.trim();
|
|
2223
|
+
return logo.startsWith('<') ? logo : `<img src="${escapeHtml(logo)}" alt="" />`;
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* Push the current name and logo into the rendered shadow tree.
|
|
2227
|
+
*
|
|
2228
|
+
* Called on connect and on every set, so hosts can configure the element
|
|
2229
|
+
* before or after it upgrades without having to know which happened first.
|
|
2230
|
+
*/
|
|
2231
|
+
_applyIdentity() {
|
|
2232
|
+
const root = this.shadowRoot;
|
|
2233
|
+
if (!root)
|
|
2234
|
+
return;
|
|
2235
|
+
const logo = this._logoMarkup();
|
|
2236
|
+
root.querySelectorAll('.launcher, .brand').forEach((node) => {
|
|
2237
|
+
node.innerHTML = logo;
|
|
2238
|
+
});
|
|
2239
|
+
const heading = root.querySelector('.main-head h2');
|
|
2240
|
+
if (heading)
|
|
2241
|
+
heading.textContent = this._name;
|
|
2242
|
+
this._paintProvenance();
|
|
2243
|
+
const launcher = root.querySelector('.launcher');
|
|
2244
|
+
launcher?.setAttribute('title', `Open ${this._name}`);
|
|
2245
|
+
launcher?.setAttribute('aria-label', `Open ${this._name}`);
|
|
2246
|
+
root.querySelector('.overlay')?.setAttribute('aria-label', this._name);
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Say where the answers come from.
|
|
2250
|
+
*
|
|
2251
|
+
* The package's claim is that nothing leaves the tab, so the one case where
|
|
2252
|
+
* that stops being true has to be on screen. Painted separately from the rest
|
|
2253
|
+
* of the identity because the provider is built after the shadow tree, and
|
|
2254
|
+
* can be replaced later.
|
|
2255
|
+
*/
|
|
2256
|
+
_paintProvenance() {
|
|
2257
|
+
const badge = this.shadowRoot?.querySelector('.provenance');
|
|
2258
|
+
if (!badge?.lastChild)
|
|
2259
|
+
return;
|
|
2260
|
+
const local = this.provider?.local ?? true;
|
|
2261
|
+
badge.lastChild.textContent = local ? 'On-device' : 'Remote';
|
|
2262
|
+
badge.setAttribute('title', local
|
|
2263
|
+
? 'The model runs in this browser. Nothing you type leaves the device.'
|
|
2264
|
+
: 'Answers come from a model on a server, so this conversation leaves the device.');
|
|
2265
|
+
}
|
|
2266
|
+
/** Write the theme's custom properties onto the host element. */
|
|
2267
|
+
_applyTheme() {
|
|
2268
|
+
for (const props of Object.values(THEME_TOKENS)) {
|
|
2269
|
+
for (const prop of props)
|
|
2270
|
+
this.style.removeProperty(prop);
|
|
2271
|
+
}
|
|
2272
|
+
for (const [key, value] of Object.entries(this._theme)) {
|
|
2273
|
+
const props = THEME_TOKENS[key];
|
|
2274
|
+
if (!props || !value)
|
|
2275
|
+
continue;
|
|
2276
|
+
for (const prop of props)
|
|
2277
|
+
this.style.setProperty(prop, value);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
_template() {
|
|
2281
|
+
return `
|
|
2282
|
+
<style>
|
|
2283
|
+
/* NxisAI Visual Direction — tokens from design.md (drop-in CSS). */
|
|
2284
|
+
:host {
|
|
2285
|
+
all: initial;
|
|
2286
|
+
--bg:#FFFFFF; --surface:#F5F7FB; --surface-2:#EDF1F9;
|
|
2287
|
+
--border:#E4E9F2; --border-strong:#CFD8E8;
|
|
2288
|
+
--ink:#0F172A; --ink-2:#475569; --ink-3:#8A97AC;
|
|
2289
|
+
--blue:#2563EB; --blue-strong:#1D4ED8; --blue-tint:#EEF3FF;
|
|
2290
|
+
--green:#22C55E; --green-strong:#16A34A; --green-tint:#E8FAEF;
|
|
2291
|
+
--inline-bg:#EEF2F8; --red:#B91C1C;
|
|
2292
|
+
--radius:14px; --radius-sm:10px; --radius-pill:999px;
|
|
2293
|
+
--shadow-sm:0 1px 2px rgba(15,23,42,.05);
|
|
2294
|
+
--shadow:0 1px 2px rgba(15,23,42,.04), 0 10px 30px rgba(15,23,42,.06);
|
|
2295
|
+
--display:'Manrope', system-ui, sans-serif;
|
|
2296
|
+
--sans:'Inter', system-ui, sans-serif;
|
|
2297
|
+
--mono:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
2298
|
+
font-family: var(--sans);
|
|
2299
|
+
|
|
2300
|
+
/*
|
|
2301
|
+
* Tailwind v4 composes box-shadow, ring and border-style from --tw-*
|
|
2302
|
+
* custom properties, and "all: initial" above wipes them, so every
|
|
2303
|
+
* child inherits empty values, and shadow/border utilities resolve to
|
|
2304
|
+
* nothing while looking perfectly correct in the stylesheet. Restoring
|
|
2305
|
+
* the defaults here is what makes those utilities work inside a shadow
|
|
2306
|
+
* root that isolates itself from the page.
|
|
2307
|
+
*/
|
|
2308
|
+
--tw-shadow: 0 0 #0000;
|
|
2309
|
+
--tw-inset-shadow: 0 0 #0000;
|
|
2310
|
+
--tw-ring-shadow: 0 0 #0000;
|
|
2311
|
+
--tw-inset-ring-shadow: 0 0 #0000;
|
|
2312
|
+
--tw-ring-offset-shadow: 0 0 #0000;
|
|
2313
|
+
--tw-border-style: solid;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
/* Tailwind, scoped to this shadow root. Utilities resolve to the NxisAI
|
|
2317
|
+
palette because agent.tw.css themes them from design.md. */
|
|
2318
|
+
${TAILWIND_CSS}
|
|
2319
|
+
|
|
2320
|
+
/* A2UI surfaces inherit the tokens above through --a2ui-* bridges. */
|
|
2321
|
+
${A2UI_THEME_CSS}
|
|
2322
|
+
|
|
2323
|
+
/* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
|
|
2324
|
+
.launcher {
|
|
2325
|
+
position: fixed; right: 20px; bottom: 20px; z-index: 9998;
|
|
2326
|
+
width: 56px; height: 56px; border-radius: var(--radius); cursor: pointer; padding: 0;
|
|
2327
|
+
display: flex; align-items: center; justify-content: center;
|
|
2328
|
+
border: 1px solid var(--border); background: var(--surface);
|
|
2329
|
+
box-shadow: var(--shadow-sm);
|
|
2330
|
+
transition: transform .15s ease, box-shadow .15s ease;
|
|
2331
|
+
}
|
|
2332
|
+
.launcher:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
|
|
2333
|
+
.launcher svg, .launcher img { width: 34px; height: auto; display: block; }
|
|
2334
|
+
|
|
2335
|
+
.overlay { position: fixed; inset: 0; z-index: 9999; display: none; background: var(--bg); color: var(--ink); }
|
|
2336
|
+
.overlay.show { display: flex; animation: fade .2s ease; }
|
|
2337
|
+
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
|
2338
|
+
|
|
2339
|
+
/* ── Side panel ─────────────────────────────────────────────── */
|
|
2340
|
+
.scrim { display: none; }
|
|
2341
|
+
.side { width: 280px; flex-shrink: 0; display: flex; flex-direction: column; min-height: 0; background: var(--surface); border-right: 1px solid var(--border); }
|
|
2342
|
+
.side-head { display: flex; align-items: center; gap: 10px; padding: 16px 14px 12px; }
|
|
2343
|
+
.side-head .brand { width: 22px; height: 22px; display: block; }
|
|
2344
|
+
.side-head .brand svg, .side-head .brand img { width: 22px; height: auto; display: block; }
|
|
2345
|
+
.side-title { font-family: var(--display); font-weight: 800; font-size: 14px; }
|
|
2346
|
+
.newchat { margin-left: auto; width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--ink-2); cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
|
2347
|
+
.newchat:hover { color: var(--blue-strong); border-color: var(--blue); }
|
|
2348
|
+
.newchat svg { width: 16px; height: 16px; }
|
|
2349
|
+
.history { flex: 1; overflow-y: auto; padding: 6px 10px; display: flex; flex-direction: column; gap: 3px; }
|
|
2350
|
+
.hist-row { display: flex; align-items: center; border-radius: 8px; }
|
|
2351
|
+
.hist-row:hover { background: var(--surface-2); }
|
|
2352
|
+
.hist-row.active { background: var(--blue-tint); }
|
|
2353
|
+
.hist-item { flex: 1; min-width: 0; text-align: left; background: none; border: none; padding: 9px 10px; font-family: var(--sans); font-size: 13px; color: var(--ink-2); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
2354
|
+
.hist-row.active .hist-item { color: var(--blue-strong); font-weight: 600; }
|
|
2355
|
+
.hist-del { flex-shrink: 0; opacity: 0; width: 26px; height: 26px; margin-right: 4px; border: none; background: none; color: var(--ink-3); cursor: pointer; border-radius: 6px; display: flex; align-items: center; justify-content: center; }
|
|
2356
|
+
.hist-row:hover .hist-del, .hist-row.active .hist-del { opacity: 1; }
|
|
2357
|
+
.hist-del:hover { color: var(--red); background: var(--bg); }
|
|
2358
|
+
.hist-del svg { width: 15px; height: 15px; }
|
|
2359
|
+
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
|
|
2363
|
+
|
|
2364
|
+
|
|
2365
|
+
|
|
2366
|
+
|
|
2367
|
+
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
|
|
2372
|
+
/* ── Main view ──────────────────────────────────────────────── */
|
|
2373
|
+
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
|
2374
|
+
.main-head { display: flex; align-items: center; gap: 12px; padding: 14px 20px; border-bottom: 1px solid var(--border); }
|
|
2375
|
+
.menu { display: none; width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--ink-2); cursor: pointer; align-items: center; justify-content: center; }
|
|
2376
|
+
.menu svg { width: 18px; height: 18px; }
|
|
2377
|
+
.main-head .brand { width: 24px; height: 24px; display: block; }
|
|
2378
|
+
.main-head .brand svg, .main-head .brand img { width: 24px; height: auto; display: block; }
|
|
2379
|
+
.main-head h2 { margin: 0; font-family: var(--display); font-size: 16px; font-weight: 800; letter-spacing: -0.01em; }
|
|
2380
|
+
/* Tags & chips — Inter 500 13px pill, optional leading dot. */
|
|
2381
|
+
.tag { font-family: var(--sans); font-weight: 500; font-size: 13px; padding: 4px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-strong); color: var(--ink-2); background: var(--bg); display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
|
|
2382
|
+
.tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ink-3); }
|
|
2383
|
+
.tag.blue { color: var(--blue-strong); background: var(--blue-tint); border-color: transparent; }
|
|
2384
|
+
.tag.blue .dot { background: var(--blue); }
|
|
2385
|
+
.tag.green { color: var(--green-strong); background: var(--green-tint); border-color: transparent; }
|
|
2386
|
+
.tag.green .dot { background: var(--green); }
|
|
2387
|
+
.main-head .close { margin-left: auto; background: none; border: none; font-size: 24px; line-height: 1; color: var(--ink-3); cursor: pointer; font-family: inherit; }
|
|
2388
|
+
.prog { height: 2px; background: var(--surface-2); }
|
|
2389
|
+
.prog > i { display: block; height: 100%; width: 0; background: var(--blue); transition: width .2s ease; }
|
|
2390
|
+
|
|
2391
|
+
.thread { flex: 1; overflow-y: auto; padding: 22px; display: flex; flex-direction: column; gap: 10px; }
|
|
2392
|
+
.empty { margin: auto; text-align: center; color: var(--ink-3); display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
|
2393
|
+
.empty svg, .empty img { width: 44px; height: auto; opacity: .9; }
|
|
2394
|
+
.empty p { max-width: 320px; font-size: 14px; line-height: 1.5; }
|
|
2395
|
+
.chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; max-width: 460px; }
|
|
2396
|
+
.chip { font-family: var(--sans); font-weight: 500; font-size: 13px; padding: 6px 14px; border-radius: var(--radius-pill); border: 1px solid var(--border-strong); background: var(--bg); color: var(--ink-2); cursor: pointer; transition: border-color .15s ease, color .15s ease, background .15s ease; }
|
|
2397
|
+
.chip:hover { border-color: var(--blue); color: var(--blue-strong); background: var(--blue-tint); }
|
|
2398
|
+
|
|
2399
|
+
/* Thinking indicator — three dots and an optional phase label. */
|
|
2400
|
+
.msg.thinking { display: inline-flex; align-items: center; gap: 10px; }
|
|
2401
|
+
.think-dots { display: inline-flex; align-items: center; gap: 4px; }
|
|
2402
|
+
.think-dots i {
|
|
2403
|
+
width: 6px; height: 6px; border-radius: 50%;
|
|
2404
|
+
background: var(--ink-3); display: block;
|
|
2405
|
+
animation: nxis-think 1.2s ease-in-out infinite;
|
|
2406
|
+
}
|
|
2407
|
+
.think-dots i:nth-child(2) { animation-delay: .15s; }
|
|
2408
|
+
.think-dots i:nth-child(3) { animation-delay: .3s; }
|
|
2409
|
+
@keyframes nxis-think {
|
|
2410
|
+
0%, 80%, 100% { opacity: .28; transform: translateY(0); }
|
|
2411
|
+
40% { opacity: 1; transform: translateY(-3px); }
|
|
2412
|
+
}
|
|
2413
|
+
.think-label {
|
|
2414
|
+
font-family: var(--mono); font-size: 11px; font-weight: 500;
|
|
2415
|
+
letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3);
|
|
2416
|
+
}
|
|
2417
|
+
.think-label:empty { display: none; }
|
|
2418
|
+
|
|
2419
|
+
/* ── The wait, with numbers on it ──────────────────────────────────
|
|
2420
|
+
A first run downloads gigabytes. Dots alone say "working"; they never
|
|
2421
|
+
say "for how long", which is the only question a multi-minute wait
|
|
2422
|
+
raises. The line appears for the phases that have bytes behind them
|
|
2423
|
+
and stays hidden for the ones that do not. */
|
|
2424
|
+
.msg.thinking { flex-direction: column; align-items: stretch; gap: 8px; min-width: 190px; }
|
|
2425
|
+
.think-row { display: inline-flex; align-items: center; gap: 10px; }
|
|
2426
|
+
.think-bytes {
|
|
2427
|
+
margin-left: auto; font-family: var(--mono); font-size: 10px;
|
|
2428
|
+
color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap;
|
|
2429
|
+
}
|
|
2430
|
+
.think-bytes:empty { display: none; }
|
|
2431
|
+
|
|
2432
|
+
/* ── The mark, charging ────────────────────────────────────────────
|
|
2433
|
+
Same progress, drawn on the object already sitting in the empty
|
|
2434
|
+
state. The ring wraps whatever the host set as their logo, so the
|
|
2435
|
+
thing that fills is theirs. */
|
|
2436
|
+
.empty-mark { position: relative; display: grid; place-items: center; }
|
|
2437
|
+
.empty-mark .ring {
|
|
2438
|
+
--pct: 0;
|
|
2439
|
+
position: absolute; inset: -13px;
|
|
2440
|
+
border-radius: 50%;
|
|
2441
|
+
background: conic-gradient(var(--blue) calc(var(--pct) * 1%), var(--surface-2) 0);
|
|
2442
|
+
transition: background .35s linear;
|
|
2443
|
+
}
|
|
2444
|
+
.empty-mark .ring::after {
|
|
2445
|
+
content: ""; position: absolute; inset: 3px;
|
|
2446
|
+
border-radius: 50%; background: var(--bg);
|
|
2447
|
+
}
|
|
2448
|
+
.empty-mark svg, .empty-mark img { position: relative; z-index: 1; }
|
|
2449
|
+
|
|
2450
|
+
/* The stage, standing in for the invitation while weights arrive. */
|
|
2451
|
+
.empty .stage {
|
|
2452
|
+
font-family: var(--mono); font-weight: 500; font-size: 11px;
|
|
2453
|
+
letter-spacing: .05em; color: var(--ink-2); margin: 0;
|
|
2454
|
+
}
|
|
2455
|
+
.empty .stage-bytes {
|
|
2456
|
+
font-family: var(--mono); font-size: 10px; color: var(--ink-3);
|
|
2457
|
+
font-variant-numeric: tabular-nums; margin: -8px 0 0;
|
|
2458
|
+
}
|
|
2459
|
+
.empty .stage-bytes:empty { display: none; }
|
|
2460
|
+
/* The suggestions have waited for the model; let them arrive, rather than
|
|
2461
|
+
appearing as though they had been there all along. */
|
|
2462
|
+
.chips.appear { animation: nxis-chips-in .32s ease both; }
|
|
2463
|
+
@keyframes nxis-chips-in {
|
|
2464
|
+
from { opacity: 0; transform: translateY(6px); }
|
|
2465
|
+
to { opacity: 1; transform: none; }
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
/* The header mark carries what is left of the load once the thread is
|
|
2469
|
+
open: the same ring, smaller, out of the conversation's way. */
|
|
2470
|
+
.main-head .head-mark { position: relative; display: grid; place-items: center; }
|
|
2471
|
+
/* The ring is absolutely positioned, so it paints above a static sibling,
|
|
2472
|
+
and its ::after fills the middle with the panel colour. Without this the
|
|
2473
|
+
disc covers the logo and the header shows an empty circle. */
|
|
2474
|
+
.main-head .brand { position: relative; z-index: 1; }
|
|
2475
|
+
.main-head .head-ring {
|
|
2476
|
+
--pct: 0; position: absolute; inset: -4px; border-radius: 50%;
|
|
2477
|
+
background: conic-gradient(var(--blue) calc(var(--pct) * 1%), var(--surface-2) 0);
|
|
2478
|
+
transition: background .35s linear;
|
|
2479
|
+
}
|
|
2480
|
+
.main-head .head-ring::after {
|
|
2481
|
+
content: ""; position: absolute; inset: 2px; border-radius: 50%; background: var(--bg);
|
|
2482
|
+
}
|
|
2483
|
+
/* No numbers yet: sweep instead of sitting at zero, which reads as hung. */
|
|
2484
|
+
.empty-mark .ring.indet, .main-head .head-ring.indet {
|
|
2485
|
+
background: conic-gradient(from 0deg, transparent 0deg, var(--blue) 300deg, transparent 360deg);
|
|
2486
|
+
animation: nxis-ring-spin 1.1s linear infinite;
|
|
2487
|
+
}
|
|
2488
|
+
@keyframes nxis-ring-spin { to { transform: rotate(360deg); } }
|
|
2489
|
+
/*
|
|
2490
|
+
* Motion carries the signal here, so removing it needs a replacement
|
|
2491
|
+
* rather than a still frame: the dots hold a steady mid-opacity so the
|
|
2492
|
+
* bubble still reads as pending.
|
|
2493
|
+
*/
|
|
2494
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2495
|
+
/* The rings carry information, so they stay; only their easing goes. */
|
|
2496
|
+
.empty-mark .ring, .main-head .head-ring, .caret { transition: none; }
|
|
2497
|
+
.chips.appear { animation: none; }
|
|
2498
|
+
/* A spin with the motion removed has to still say "busy": hold a
|
|
2499
|
+
partial arc rather than an empty track. */
|
|
2500
|
+
.empty-mark .ring.indet, .main-head .head-ring.indet {
|
|
2501
|
+
animation: none;
|
|
2502
|
+
background: conic-gradient(var(--blue) 25%, var(--surface-2) 0);
|
|
2503
|
+
}
|
|
2504
|
+
.think-dots i { animation: none; opacity: .5; }
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
/* Bubble styling now lives in _msgEl as Tailwind utilities. The .msg
|
|
2508
|
+
class is kept only as a hook for the wrappers below, which are layout
|
|
2509
|
+
rather than bubbles — so there is nothing here to undo. */
|
|
2510
|
+
|
|
2511
|
+
|
|
2512
|
+
|
|
2513
|
+
.msg.tool.error { border-color: var(--red); color: var(--red); }
|
|
2514
|
+
.msg.tool b { display: inline-flex; align-items: center; gap: 6px; }
|
|
2515
|
+
.msg.tool svg { width: 14px; height: 14px; }
|
|
2516
|
+
.msg.form, .msg.detail { align-self: flex-start; width: 100%; max-width: 380px; }
|
|
2517
|
+
|
|
2518
|
+
/* ── The receipt ───────────────────────────────────────────────────
|
|
2519
|
+
Muted enough to scroll past, addressable when something looks wrong. */
|
|
2520
|
+
/* The route, when debugging: quieter than a receipt, since it is one
|
|
2521
|
+
word of provenance rather than something to read. */
|
|
2522
|
+
.routeline {
|
|
2523
|
+
align-self: flex-start; font-family: var(--mono); font-size: 10px;
|
|
2524
|
+
letter-spacing: .06em; color: var(--ink-3); opacity: .8; padding: 1px 0;
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
details.trace { align-self: flex-start; max-width: 92%; }
|
|
2528
|
+
details.trace > summary {
|
|
2529
|
+
cursor: pointer; list-style: none; display: inline-flex; align-items: center; gap: 7px;
|
|
2530
|
+
font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em;
|
|
2531
|
+
color: var(--ink-3); padding: 2px 0;
|
|
2532
|
+
}
|
|
2533
|
+
details.trace > summary::-webkit-details-marker { display: none; }
|
|
2534
|
+
details.trace > summary:hover { color: var(--ink-2); }
|
|
2535
|
+
details.trace > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; border-radius: 4px; }
|
|
2536
|
+
details.trace.err > summary { color: var(--red); }
|
|
2537
|
+
.caret {
|
|
2538
|
+
width: 0; height: 0; border-left: 4px solid currentColor;
|
|
2539
|
+
border-top: 3.5px solid transparent; border-bottom: 3.5px solid transparent;
|
|
2540
|
+
transition: transform .16s ease; flex-shrink: 0;
|
|
2541
|
+
}
|
|
2542
|
+
details.trace[open] .caret { transform: rotate(90deg); }
|
|
2543
|
+
.trace-body {
|
|
2544
|
+
margin-top: 7px; background: var(--surface); border: 1px solid var(--border);
|
|
2545
|
+
border-radius: 9px; padding: 9px 11px; display: flex; flex-direction: column; gap: 6px;
|
|
2546
|
+
}
|
|
2547
|
+
.trace-label {
|
|
2548
|
+
font-family: var(--mono); font-size: 9.5px; letter-spacing: .12em;
|
|
2549
|
+
text-transform: uppercase; color: var(--ink-3);
|
|
2550
|
+
}
|
|
2551
|
+
.trace-body pre {
|
|
2552
|
+
margin: 0; font-family: var(--mono); font-size: 10.5px; line-height: 1.5;
|
|
2553
|
+
color: var(--ink-2); white-space: pre-wrap; word-break: break-word;
|
|
2554
|
+
max-height: 160px; overflow-y: auto;
|
|
2555
|
+
}
|
|
2556
|
+
.trace-body pre.raw { color: var(--ink); }
|
|
2557
|
+
|
|
2558
|
+
.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; }
|
|
2559
|
+
.statusline.err { color: var(--red); }
|
|
2560
|
+
.composer { display: flex; align-items: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border); }
|
|
2561
|
+
.composer textarea {
|
|
2562
|
+
flex: 1; font-family: var(--sans); font-size: 14px; line-height: 1.45; color: var(--ink); resize: none;
|
|
2563
|
+
background: var(--bg); border: 1px solid var(--border-strong); border-radius: 10px; padding: 9px 12px; outline: none;
|
|
2564
|
+
max-height: 140px; overflow-y: auto; transition: border-color .15s ease, box-shadow .15s ease;
|
|
2565
|
+
}
|
|
2566
|
+
.composer textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-tint); }
|
|
2567
|
+
.composer textarea::placeholder { color: var(--ink-3); }
|
|
2568
|
+
.composer .go { flex-shrink: 0; width: 40px; height: 40px; border-radius: var(--radius-sm); 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); }
|
|
2569
|
+
.composer .go:hover { background: var(--blue-strong); }
|
|
2570
|
+
.composer .go:disabled { opacity: .45; cursor: not-allowed; }
|
|
2571
|
+
/* Stopping is not a destructive act and does not get a red button. The
|
|
2572
|
+
shape changes (arrow to square) and the fill drops back to a neutral
|
|
2573
|
+
surface, which is enough to say "this does something else now" without
|
|
2574
|
+
implying the turn in progress was a mistake. */
|
|
2575
|
+
.composer .go.stop { background: var(--surface-2); color: var(--ink-2); box-shadow: none; border: 1px solid var(--border-strong); }
|
|
2576
|
+
.composer .go.stop:hover { background: var(--surface); color: var(--ink); }
|
|
2577
|
+
.composer .go.stop svg { width: 15px; height: 15px; }
|
|
2578
|
+
/* A message the person sent, plus the row of things they can do to it.
|
|
2579
|
+
The wrapper carries the alignment the bubble used to carry on its own. */
|
|
2580
|
+
.turn { display: flex; flex-direction: column; align-items: flex-end; }
|
|
2581
|
+
.turn:focus { outline: none; }
|
|
2582
|
+
.turn .msg.user { align-self: flex-end; }
|
|
2583
|
+
.actions { display: flex; gap: 2px; padding: 2px 2px 0; }
|
|
2584
|
+
.act {
|
|
2585
|
+
display: grid; place-items: center; width: 26px; height: 26px; padding: 0;
|
|
2586
|
+
background: none; border: none; border-radius: 7px; cursor: pointer;
|
|
2587
|
+
color: var(--ink-3); transition: color .12s ease, background .12s ease;
|
|
2588
|
+
}
|
|
2589
|
+
.act:hover { color: var(--ink-2); background: var(--surface); }
|
|
2590
|
+
.act:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }
|
|
2591
|
+
.act svg { width: 15px; height: 15px; display: block; }
|
|
2592
|
+
.act.done { color: var(--green-strong); }
|
|
2593
|
+
|
|
2594
|
+
/* Split on hover capability, not on width: a touch laptop needs the row
|
|
2595
|
+
visible and a narrow desktop window does not. Where there is no hover,
|
|
2596
|
+
the actions are simply there, because no gesture will reveal them. */
|
|
2597
|
+
.actions { opacity: .55; transition: opacity .13s ease; }
|
|
2598
|
+
@media (hover: hover) {
|
|
2599
|
+
.actions { opacity: 0; }
|
|
2600
|
+
.turn:hover .actions,
|
|
2601
|
+
.turn:focus-within .actions { opacity: 1; }
|
|
2602
|
+
}
|
|
2603
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2604
|
+
.actions { transition: none; }
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
.editing {
|
|
2608
|
+
align-self: flex-end; max-width: 84%; width: 84%;
|
|
2609
|
+
display: flex; flex-direction: column; gap: 7px;
|
|
2610
|
+
background: var(--bg); border: 1px solid var(--blue); border-radius: 12px; padding: 9px 10px;
|
|
2611
|
+
}
|
|
2612
|
+
.editing textarea {
|
|
2613
|
+
width: 100%; border: none; outline: none; resize: none; background: transparent;
|
|
2614
|
+
font-family: var(--sans); font-size: 13px; line-height: 1.5; color: var(--ink);
|
|
2615
|
+
}
|
|
2616
|
+
.edit-actions { display: flex; gap: 7px; justify-content: flex-end; }
|
|
2617
|
+
.ebtn {
|
|
2618
|
+
font-family: var(--sans); font-size: 12px; font-weight: 500; border-radius: 8px;
|
|
2619
|
+
padding: 5px 11px; cursor: pointer; border: 1px solid var(--border-strong);
|
|
2620
|
+
background: var(--bg); color: var(--ink-2);
|
|
2621
|
+
}
|
|
2622
|
+
.ebtn:hover { border-color: var(--blue); color: var(--blue-strong); }
|
|
2623
|
+
.ebtn.primary { background: var(--blue); border-color: var(--blue); color: #fff; }
|
|
2624
|
+
.ebtn.primary:hover { background: var(--blue-strong); color: #fff; }
|
|
2625
|
+
.edit-hint {
|
|
2626
|
+
font-family: var(--mono); font-size: 10px; letter-spacing: .05em;
|
|
2627
|
+
color: var(--ink-3); align-self: flex-end; padding-top: 2px;
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
.stopped-note {
|
|
2631
|
+
align-self: flex-start;
|
|
2632
|
+
font-size: 12px;
|
|
2633
|
+
color: var(--ink-3);
|
|
2634
|
+
padding: 2px 0 6px;
|
|
2635
|
+
letter-spacing: .01em;
|
|
2636
|
+
}
|
|
2637
|
+
.composer .go svg { width: 17px; height: 17px; }
|
|
2638
|
+
|
|
2639
|
+
/* Attribution. Quiet enough to sit under a customer's own chat surface
|
|
2640
|
+
without competing with it, legible enough to be worth having. */
|
|
2641
|
+
.foot { display: flex; justify-content: center; padding: 0 20px 12px; }
|
|
2642
|
+
.powered {
|
|
2643
|
+
display: inline-flex; align-items: center; gap: 6px; text-decoration: none;
|
|
2644
|
+
font-family: var(--mono); font-weight: 500; font-size: 10px; letter-spacing: 0.1em;
|
|
2645
|
+
text-transform: uppercase; color: var(--ink-3);
|
|
2646
|
+
border-radius: var(--radius-pill); padding: 4px 10px;
|
|
2647
|
+
transition: color .15s ease, background .15s ease;
|
|
2648
|
+
}
|
|
2649
|
+
.powered:hover { color: var(--ink-2); background: var(--surface); }
|
|
2650
|
+
.powered:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
|
2651
|
+
.powered svg { width: 13px; height: auto; display: block; flex-shrink: 0; }
|
|
2652
|
+
|
|
2653
|
+
/* ── Inline generated form ──────────────────────────────────── */
|
|
2654
|
+
.genform { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 18px 16px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 13px; }
|
|
2655
|
+
.genform-head { display: flex; flex-direction: column; gap: 3px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
|
|
2656
|
+
.gf-tool { font-family: var(--display); font-size: 16px; font-weight: 800; letter-spacing: -0.01em; }
|
|
2657
|
+
.gf-desc { font-size: 13px; color: var(--ink-2); }
|
|
2658
|
+
.field { display: flex; flex-direction: column; gap: 5px; }
|
|
2659
|
+
.field label { font-family: var(--mono); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
|
|
2660
|
+
.field .req { color: var(--blue); }
|
|
2661
|
+
.field input, .field select {
|
|
2662
|
+
font-family: var(--sans); font-size: 14px; color: var(--ink);
|
|
2663
|
+
background: var(--bg); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 11px; outline: none;
|
|
2664
|
+
transition: border-color .15s ease, box-shadow .15s ease;
|
|
2665
|
+
}
|
|
2666
|
+
.field input:focus, .field select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-tint); }
|
|
2667
|
+
.field input.invalid, .field select.invalid { border-color: var(--red); box-shadow: 0 0 0 3px rgba(185,28,28,.12); }
|
|
2668
|
+
.field .check { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; color: var(--ink); }
|
|
2669
|
+
.field .check input { width: auto; }
|
|
2670
|
+
.genform-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 2px; }
|
|
2671
|
+
/* Buttons — radius 10, weight 600; Action confirms a positive outcome,
|
|
2672
|
+
Secondary (ghost class) is the alternative path. */
|
|
2673
|
+
.btn { font-family: var(--sans); font-size: 14px; font-weight: 600; border-radius: var(--radius-sm); padding: 11px 20px; cursor: pointer; border: 1px solid transparent; display: inline-flex; align-items: center; gap: 8px; transition: .15s; }
|
|
2674
|
+
.btn svg { width: 16px; height: 16px; }
|
|
2675
|
+
.btn.ghost { background: var(--bg); border-color: var(--border-strong); color: var(--ink); }
|
|
2676
|
+
.btn.ghost:hover { border-color: var(--ink-3); }
|
|
2677
|
+
/*
|
|
2678
|
+
* Signal Blue, not Action Green. design.md §4 gives green to "confirm a
|
|
2679
|
+
* positive outcome", which is the submitted card's badge and eyebrow,
|
|
2680
|
+
* after the fact. The button is the main path before anything has
|
|
2681
|
+
* happened, and that is blue. Using green on both blurred "about to
|
|
2682
|
+
* happen" into "has happened".
|
|
2683
|
+
*
|
|
2684
|
+
* It also has to be blue to stay consistent with the A2UI cards:
|
|
2685
|
+
* --a2ui-color-primary drives their primary buttons AND their
|
|
2686
|
+
* ChoicePicker selection, so repointing it at green would have coloured
|
|
2687
|
+
* "Water Heaters" as a positive outcome rather than a choice.
|
|
2688
|
+
*/
|
|
2689
|
+
.btn.primary { background: var(--blue); color: #fff; }
|
|
2690
|
+
.btn.primary:hover { background: var(--blue-strong); }
|
|
2691
|
+
.btn:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; }
|
|
2692
|
+
.genform-result { display: none; font-size: 13px; align-items: center; gap: 8px; padding: 2px; }
|
|
2693
|
+
.genform-result.error { display: flex; color: var(--red); }
|
|
2694
|
+
.genform-result svg { width: 16px; height: 16px; flex-shrink: 0; }
|
|
2695
|
+
.gf-empty { color: var(--ink-3); font-size: 14px; }
|
|
2696
|
+
|
|
2697
|
+
/* ── Inline detail (confirmation) card ──────────────────────── */
|
|
2698
|
+
.detailcard { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 4px; }
|
|
2699
|
+
.dc-badge { width: 44px; height: 44px; border-radius: 50%; background: var(--green-tint); color: var(--green-strong); display: flex; align-items: center; justify-content: center; margin-bottom: 8px; }
|
|
2700
|
+
.dc-badge svg { width: 22px; height: 22px; }
|
|
2701
|
+
.dc-eyebrow { font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--green-strong); }
|
|
2702
|
+
.dc-title { font-family: var(--display); font-size: 18px; font-weight: 800; letter-spacing: -0.01em; }
|
|
2703
|
+
.dc-sub { font-size: 13px; color: var(--ink-2); margin-bottom: 6px; }
|
|
2704
|
+
.dc-list { margin: 6px 0 0; border-top: 1px solid var(--border); }
|
|
2705
|
+
/* Review is the before-picture of the same card. Green marks a positive
|
|
2706
|
+
outcome (design.md §2), and nothing has happened yet — so the pending
|
|
2707
|
+
state carries Signal Blue, the colour of the action about to be taken. */
|
|
2708
|
+
.dc-badge.review { background: var(--blue-tint); color: var(--blue); }
|
|
2709
|
+
.dc-eyebrow.review { color: var(--blue-strong); }
|
|
2710
|
+
.dc-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
|
2711
|
+
.dc-row { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
|
2712
|
+
.dc-row dt { font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); flex-shrink: 0; padding-top: 2px; }
|
|
2713
|
+
.dc-row dd { margin: 0; font-size: 14px; color: var(--ink); text-align: right; word-break: break-word; }
|
|
2714
|
+
|
|
2715
|
+
/* ── Inline result cards — feature-card pattern: tinted icon chip,
|
|
2716
|
+
tags, tight Manrope headline, one supporting line. ─────── */
|
|
2717
|
+
.msg.results { align-self: stretch; width: 100%; }
|
|
2718
|
+
.rc-head { font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--blue); margin: 2px 0 10px; }
|
|
2719
|
+
.rc-empty { font-size: 13px; color: var(--ink-2); padding: 8px 0; }
|
|
2720
|
+
.rc-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; }
|
|
2721
|
+
.resultcard { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px; display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-sm); transition: box-shadow .15s ease, transform .15s ease; }
|
|
2722
|
+
.resultcard:hover { box-shadow: var(--shadow); transform: translateY(-1px); }
|
|
2723
|
+
.rc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
|
|
2724
|
+
.rc-chip { width: 44px; height: 44px; border-radius: 50%; background: var(--blue-tint); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
2725
|
+
.rc-chip svg, .rc-chip img { width: 21px; height: auto; display: block; }
|
|
2726
|
+
.rc-tags { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
|
|
2727
|
+
.rc-tags .tag { font-size: 12px; padding: 3px 10px; }
|
|
2728
|
+
.rc-title { font-family: var(--display); font-size: 16px; font-weight: 800; color: var(--ink); line-height: 1.25; letter-spacing: -0.01em; }
|
|
2729
|
+
.rc-sub { font-size: 13px; line-height: 1.5; color: var(--ink-2); margin-top: 2px; }
|
|
2730
|
+
.rc-list { margin: 10px 0 0; border-top: 1px solid var(--border); display: flex; flex-direction: column; }
|
|
2731
|
+
.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; }
|
|
2732
|
+
.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-shrink: 0; padding-top: 2px; }
|
|
2733
|
+
.rc-row dd { margin: 0; color: var(--ink); text-align: right; word-break: break-word; }
|
|
2734
|
+
.rc-id { margin-top: 10px; min-width: 0; }
|
|
2735
|
+
.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; }
|
|
2736
|
+
.rc-row dd a { color: var(--blue-strong); text-decoration: none; }
|
|
2737
|
+
.rc-row dd a:hover { text-decoration: underline; }
|
|
2738
|
+
|
|
2739
|
+
/* ── Metrics panel (technical details on demand) ─────────────── */
|
|
2740
|
+
.msg.metrics { align-self: stretch; width: 100%; max-width: 100%; background: none; border: none; padding: 0; white-space: normal; }
|
|
2741
|
+
.metricscard { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); overflow: hidden; }
|
|
2742
|
+
.metricscard summary { cursor: pointer; list-style: none; font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.06em; color: var(--ink-2); padding: 11px 16px; user-select: none; }
|
|
2743
|
+
.metricscard summary::-webkit-details-marker { display: none; }
|
|
2744
|
+
.metricscard summary:hover { color: var(--blue-strong); }
|
|
2745
|
+
.metricscard[open] summary { border-bottom: 1px solid var(--border); }
|
|
2746
|
+
.mx-table { width: calc(100% - 32px); margin: 12px 16px; border-collapse: collapse; font-size: 12px; }
|
|
2747
|
+
.mx-table th { font-family: var(--mono); font-weight: 500; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-3); text-align: left; padding: 4px 8px 6px 0; border-bottom: 1px solid var(--border); }
|
|
2748
|
+
.mx-table td { padding: 5px 8px 5px 0; border-bottom: 1px solid var(--border); color: var(--ink-2); }
|
|
2749
|
+
.mx-table th.num, .mx-table td.num { text-align: right; font-family: var(--mono); font-size: 11px; }
|
|
2750
|
+
.mx-table tr.total td { color: var(--ink); font-weight: 600; border-bottom: none; }
|
|
2751
|
+
.mx-compare { padding: 4px 16px 16px; display: flex; flex-direction: column; gap: 8px; }
|
|
2752
|
+
.mx-compare-title { font-family: var(--mono); font-weight: 500; font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 2px; }
|
|
2753
|
+
.mx-bar-row { display: flex; align-items: center; gap: 10px; font-size: 12px; }
|
|
2754
|
+
.mx-bar-label { font-family: var(--mono); font-size: 10px; color: var(--ink-3); width: 56px; flex-shrink: 0; }
|
|
2755
|
+
.mx-bar-track { flex: 1; height: 8px; border-radius: 4px; background: var(--surface-2); overflow: hidden; }
|
|
2756
|
+
.mx-bar-track i { display: block; height: 100%; border-radius: 4px; }
|
|
2757
|
+
.mx-bar-track i.mcp { background: var(--blue); }
|
|
2758
|
+
.mx-bar-track i.rag { background: var(--ink-3); }
|
|
2759
|
+
.mx-bar-value { font-family: var(--mono); font-size: 11px; color: var(--ink-2); width: 64px; text-align: right; flex-shrink: 0; }
|
|
2760
|
+
.mx-savings { align-self: flex-start; font-size: 12px; font-weight: 600; border-radius: var(--radius-pill); padding: 4px 12px; margin-top: 2px; }
|
|
2761
|
+
.mx-savings.pos { color: var(--green-strong); background: var(--green-tint); }
|
|
2762
|
+
.mx-savings.neg { color: var(--red); background: #FEF2F2; }
|
|
2763
|
+
.mx-note { font-size: 11px; line-height: 1.5; color: var(--ink-3); }
|
|
2764
|
+
|
|
2765
|
+
@media (max-width: 840px) {
|
|
2766
|
+
.side { position: absolute; inset: 0 auto 0 0; z-index: 6; transform: translateX(-100%); transition: transform .2s ease; box-shadow: 0 18px 48px rgba(15,23,42,.18); }
|
|
2767
|
+
.overlay.nav .side { transform: none; }
|
|
2768
|
+
/* Under the drawer, over the conversation: the strip of thread still
|
|
2769
|
+
showing beside the drawer is what a tap to dismiss lands on. */
|
|
2770
|
+
.overlay.nav .scrim {
|
|
2771
|
+
display: block; position: absolute; inset: 0; z-index: 5;
|
|
2772
|
+
background: rgba(15,23,42,.32);
|
|
2773
|
+
animation: nx-scrim-in .2s ease;
|
|
2774
|
+
}
|
|
2775
|
+
@keyframes nx-scrim-in { from { opacity: 0; } to { opacity: 1; } }
|
|
2776
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2777
|
+
.side { transition: none; }
|
|
2778
|
+
.overlay.nav .scrim { animation: none; }
|
|
2779
|
+
}
|
|
2780
|
+
.menu { display: flex; }
|
|
2781
|
+
.msg { max-width: 86%; }
|
|
2782
|
+
.hist-del { opacity: 1; }
|
|
2783
|
+
}
|
|
2784
|
+
</style>
|
|
2785
|
+
<button class="launcher" title="Open ${escapeHtml(this._name)}" aria-label="Open ${escapeHtml(this._name)}">${this._logoMarkup()}</button>
|
|
2786
|
+
<div class="overlay" role="dialog" aria-label="${escapeHtml(this._name)}">
|
|
2787
|
+
<div class="scrim" hidden></div>
|
|
2788
|
+
<aside class="side">
|
|
2789
|
+
<div class="side-head">
|
|
2790
|
+
<span class="brand">${this._logoMarkup()}</span>
|
|
2791
|
+
<span class="side-title">Conversations</span>
|
|
2792
|
+
<button class="newchat" title="New conversation" aria-label="New conversation">${ICON_PLUS}</button>
|
|
2793
|
+
</div>
|
|
2794
|
+
<div class="history"></div>
|
|
2795
|
+
</aside>
|
|
2796
|
+
<main class="main">
|
|
2797
|
+
<div class="main-head">
|
|
2798
|
+
<button class="menu" aria-label="Toggle conversations">${ICON_MENU}</button>
|
|
2799
|
+
<span class="head-mark"><span class="head-ring" hidden></span><span class="brand">${this._logoMarkup()}</span></span>
|
|
2800
|
+
<h2>${escapeHtml(this._name)}</h2>
|
|
2801
|
+
<span class="tag blue provenance"><i class="dot"></i>On-device</span>
|
|
2802
|
+
<button class="close" aria-label="Close">×</button>
|
|
2803
|
+
</div>
|
|
2804
|
+
<div class="prog"><i></i></div>
|
|
2805
|
+
<div class="thread"></div>
|
|
2806
|
+
<div class="statusline"></div>
|
|
2807
|
+
<div class="composer">
|
|
2808
|
+
<textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>
|
|
2809
|
+
<button class="go" aria-label="Send">${ICON_ARROW}</button>
|
|
2810
|
+
</div>
|
|
2811
|
+
<footer class="foot">
|
|
2812
|
+
<a class="powered" href="${NXIS_SITE_URL}" target="_blank" rel="noopener noreferrer">
|
|
2813
|
+
${NXIS_LOGO_SVG}<span>Powered by NxisAI</span>
|
|
2814
|
+
</a>
|
|
2815
|
+
</footer>
|
|
2816
|
+
</main>
|
|
2817
|
+
</div>`;
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
customElements.define('nxis-agent-fullscreen', NxisAgentFullscreen);
|
|
2821
|
+
//# sourceMappingURL=agent-fullscreen.js.map
|