@unoverse-platform/studio 0.1.25 → 0.1.26
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/package.json +1 -1
- package/screens/SourceView.tsx +1 -1
- package/screens/TemplatesView.tsx +2 -2
- package/src/PublishDialog.tsx +1 -1
- package/vendor/sdk/appEngine.tsx +26 -1
- package/vendor/sdk/lib/UnoverseComponent.tsx +1 -1
- package/vendor/sdk/lib/core/actions.ts +10 -0
- package/vendor/sdk/lib/core/analytics.ts +89 -0
- package/vendor/sdk/lib/core/client.ts +1 -1
- package/vendor/sdk/lib/core/index.ts +12 -0
- package/vendor/sdk/lib/core/types.ts +20 -0
- package/vendor/sdk/lib/index.ts +9 -0
- package/vendor/sdk/lib/primitives.tsx +3 -3
- package/vendor/sdk/lib/render.tsx +16 -0
- package/vendor/sdk/main.tsx +7 -0
package/package.json
CHANGED
package/screens/SourceView.tsx
CHANGED
|
@@ -432,7 +432,7 @@ export function SourceView({
|
|
|
432
432
|
/**
|
|
433
433
|
* TWO FILE SHAPES, TWO WAYS IN.
|
|
434
434
|
*
|
|
435
|
-
* An ENVELOPE (`
|
|
435
|
+
* An ENVELOPE (`course-card.yaml`) holds its tree under `root:`, and that tree IS
|
|
436
436
|
* the composed root, so the file path maps by dropping one level. A PARTIAL (a
|
|
437
437
|
* layout) is a bare node included somewhere inside the composed tree, so it has to
|
|
438
438
|
* be found first. Treating everything as a partial meant an envelope never matched
|
|
@@ -112,7 +112,7 @@ interface Def {
|
|
|
112
112
|
autoTrigger?: boolean;
|
|
113
113
|
default?: boolean;
|
|
114
114
|
inputSchema?: Record<string, unknown>;
|
|
115
|
-
// Per-STATE mock seeding: `{ "<state>": ["
|
|
115
|
+
// Per-STATE mock seeding: `{ "<state>": ["course-card", "course-card"] }` — the
|
|
116
116
|
// author decides what each state's preview shows (a repeated name = several
|
|
117
117
|
// instances). Supersedes the legacy flat `previewComponents`.
|
|
118
118
|
// Per-state mock: an ARRAY seeds components; an OBJECT is authored template-state
|
|
@@ -468,7 +468,7 @@ export function TemplatesView({
|
|
|
468
468
|
// what a real arrival-in-focus or a component's own expand button would do.
|
|
469
469
|
function seed(comps: Def[]) {
|
|
470
470
|
// nodeId is per-INSTANCE (`name-i`), so previewComponents may repeat a name to
|
|
471
|
-
// seed several instances — e.g. ["
|
|
471
|
+
// seed several instances — e.g. ["course-card", "course-card"] previews a rail of cards.
|
|
472
472
|
comps.forEach((c, i) =>
|
|
473
473
|
store.apply({
|
|
474
474
|
type: "COMPONENT_INIT",
|
package/src/PublishDialog.tsx
CHANGED
|
@@ -122,7 +122,7 @@ export function PublishDialog({
|
|
|
122
122
|
* own status, and the statuses sort so the ones that will move sit at the top.
|
|
123
123
|
*
|
|
124
124
|
* The kind is singular here, because a row is one thing. `bpp` is a style set and
|
|
125
|
-
* `
|
|
125
|
+
* `bpp-chat-layout` is an app; without that word they read as five apps, which is how a
|
|
126
126
|
* developer concludes their components were never collected when they were.
|
|
127
127
|
*/
|
|
128
128
|
const KIND_LABEL: Record<string, string> = {
|
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
useUnoverseTheme,
|
|
18
18
|
computeAppWidth,
|
|
19
19
|
resolveActiveLayout,
|
|
20
|
+
setAnalyticsSink,
|
|
21
|
+
setAnalyticsContext,
|
|
22
|
+
setAnalyticsDebug,
|
|
20
23
|
type UseVoiceServiceConfig,
|
|
21
24
|
} from "./lib";
|
|
22
25
|
|
|
@@ -27,10 +30,17 @@ export interface AppConfig {
|
|
|
27
30
|
getAccessToken?: () => Promise<string | null> | string | null;
|
|
28
31
|
userId?: string;
|
|
29
32
|
conversationId: string;
|
|
33
|
+
/** Where this app is running, reported on every analytics event
|
|
34
|
+
* (UNOVERSE_ANALYTICS.md §surfaces). Host-supplied: the app cannot know which frame
|
|
35
|
+
* it was placed in. */
|
|
36
|
+
surface?: string;
|
|
37
|
+
/** Log each analytics event as it fires. Server-delivered with the rest of the tenant
|
|
38
|
+
* config, never a client-side flag someone can turn on against a live site. */
|
|
39
|
+
analyticsDebug?: boolean;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
export function AppEngine(cfg: AppConfig) {
|
|
33
|
-
const { serverUrl, apiUrl, templateId, getAccessToken, userId, conversationId } = cfg;
|
|
43
|
+
const { serverUrl, apiUrl, templateId, getAccessToken, userId, conversationId, surface, analyticsDebug } = cfg;
|
|
34
44
|
|
|
35
45
|
const store = useMemo(() => new ComponentStore(), []);
|
|
36
46
|
const client = useMemo(
|
|
@@ -80,6 +90,21 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
80
90
|
// inbound `unoverse:config` channel), re-reporting whenever a panel surface opens or
|
|
81
91
|
// closes: the app grows by the panel's declared width, then shrinks back. The host
|
|
82
92
|
// animates the resize. Only posts on an ACTUAL width change (guard).
|
|
93
|
+
// ANALYTICS (UNOVERSE_ANALYTICS.md). The SDK produces events and knows no destination;
|
|
94
|
+
// this is the pipe out. Events cross to the HOST REALM, where the page's own analytics
|
|
95
|
+
// tag lives with the visitor's cookie and consent state already resolved — which is the
|
|
96
|
+
// whole reason measurement runs client-side rather than from a server that cannot read
|
|
97
|
+
// either. With no parent (a first-party page, an MCP app host) the post is a silent
|
|
98
|
+
// no-op, so a surface with nothing to report to costs nothing.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
setAnalyticsDebug(!!analyticsDebug);
|
|
101
|
+
setAnalyticsContext({ surface, conversation_id: conversationId, user_id: userId });
|
|
102
|
+
setAnalyticsSink((event) => {
|
|
103
|
+
window.parent?.postMessage({ type: "unoverse:event", ...event }, "*");
|
|
104
|
+
});
|
|
105
|
+
return () => setAnalyticsSink(null);
|
|
106
|
+
}, [surface, conversationId, userId, analyticsDebug]);
|
|
107
|
+
|
|
83
108
|
useEffect(() => {
|
|
84
109
|
const report = () => {
|
|
85
110
|
// NAME-SYNC: width is computed off the ACTIVE layout (multi-layout templates
|
|
@@ -38,7 +38,7 @@ export function useUnoverseInstance(store?: ComponentStore, chatId = "", nodeId
|
|
|
38
38
|
|
|
39
39
|
export interface UnoverseComponentProps {
|
|
40
40
|
client: UnoverseClient;
|
|
41
|
-
/** STATIC source: the definition to render, e.g. "unoverse://components/
|
|
41
|
+
/** STATIC source: the definition to render, e.g. "unoverse://components/card". */
|
|
42
42
|
uri?: string;
|
|
43
43
|
/** STATIC source: instance data bound into the definition. */
|
|
44
44
|
data?: Record<string, unknown>;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* `{{binding}}` resolution, an optional chained `then`, and a hard split between
|
|
10
10
|
* LOCAL `setValue` and a server route for anything computed/transactional.
|
|
11
11
|
*/
|
|
12
|
+
import { emitAnalytics, resolveAnalyticsParams } from "./analytics";
|
|
12
13
|
import type { ComponentStore } from "./store";
|
|
13
14
|
import type { ActionSpec } from "./types";
|
|
14
15
|
|
|
@@ -44,6 +45,15 @@ export function dispatchAction(
|
|
|
44
45
|
if (action == null) return;
|
|
45
46
|
const spec: ActionSpec = typeof action === "string" ? { type: action } : action;
|
|
46
47
|
|
|
48
|
+
// ANALYTICS FIRST, and unconditionally. Reporting must not depend on what the action
|
|
49
|
+
// then does, nor on it succeeding: an interaction that happened is news whether or not
|
|
50
|
+
// the write, the route or the chained `then` works out. This is also why it is here and
|
|
51
|
+
// not in a leaf — one interpreter sees every interaction, so an event cannot be missed
|
|
52
|
+
// by an author writing an unusual definition.
|
|
53
|
+
if (spec.analytics?.event) {
|
|
54
|
+
emitAnalytics(spec.analytics.event, resolveAnalyticsParams(spec.analytics, scope, resolveValue));
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
if (spec.type === "setValue") {
|
|
48
58
|
// Write the dev's chosen keys into THIS COMPONENT's OWN slice (chatId:nodeId) — its own
|
|
49
59
|
// view state: tab / edit / wizard-step / displayState / anything. Read back via
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Experience analytics — the emit side (docs/unoverse/UNOVERSE_ANALYTICS.md).
|
|
3
|
+
*
|
|
4
|
+
* The SDK produces events and knows NOTHING about where they go. No GA, no gtag, no
|
|
5
|
+
* dataLayer, no vendor of any kind appears here. A host registers a sink; without one
|
|
6
|
+
* every emit is a no-op, which is what makes a preview, a test, or an MCP app host with
|
|
7
|
+
* no page to report to cost nothing.
|
|
8
|
+
*
|
|
9
|
+
* Events are NEWS, never instruction: nothing downstream may branch on one. The trigger
|
|
10
|
+
* originates in a browser the visitor controls, so a path that could act on an event
|
|
11
|
+
* would be a path a visitor could drive.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { AnalyticsSpec } from "./types";
|
|
15
|
+
|
|
16
|
+
/** What a sink receives. `context` is the platform's own vocabulary, kept separate from
|
|
17
|
+
* the author's `params` so the two can never collide on a key. */
|
|
18
|
+
export interface AnalyticsEvent {
|
|
19
|
+
event: string;
|
|
20
|
+
params: Record<string, unknown>;
|
|
21
|
+
context: AnalyticsContext;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AnalyticsContext {
|
|
25
|
+
/** Where this is running: `unoverse_assistant` | `unoverse_client` | `mcp_app_host`. */
|
|
26
|
+
surface?: string;
|
|
27
|
+
/** The journey key. The SAME id the chat, the session and agent memory use. */
|
|
28
|
+
conversation_id?: string;
|
|
29
|
+
user_id?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type AnalyticsSink = (event: AnalyticsEvent) => void;
|
|
33
|
+
|
|
34
|
+
let sink: AnalyticsSink | null = null;
|
|
35
|
+
let context: AnalyticsContext = {};
|
|
36
|
+
let debug = false;
|
|
37
|
+
|
|
38
|
+
/** Registered by the host (the channel), which owns identity and the destination.
|
|
39
|
+
* Passing null removes it, so a torn-down host stops receiving. */
|
|
40
|
+
export function setAnalyticsSink(next: AnalyticsSink | null): void {
|
|
41
|
+
sink = next;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Session facts, supplied once by the channel. The SDK never invents them. */
|
|
45
|
+
export function setAnalyticsContext(next: AnalyticsContext): void {
|
|
46
|
+
context = { ...context, ...next };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Server-delivered, never a client-side flag: log each event as it fires. */
|
|
50
|
+
export function setAnalyticsDebug(next: boolean): void {
|
|
51
|
+
debug = next;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Emit one event. Failure is SILENT and CLOSED: a throwing sink never breaks a render
|
|
56
|
+
* and never blocks the interaction that produced it.
|
|
57
|
+
*/
|
|
58
|
+
export function emitAnalytics(event: string, params?: Record<string, unknown>): void {
|
|
59
|
+
if (!event) return;
|
|
60
|
+
const payload: AnalyticsEvent = { event, params: params ?? {}, context: { ...context } };
|
|
61
|
+
// Matches the platform's existing log convention (`[unoverse:lifecycle] …`), so one
|
|
62
|
+
// console filter catches it among a host page's own noise.
|
|
63
|
+
if (debug) console.log(`[unoverse:analytics] ${event}`, payload.params, payload.context);
|
|
64
|
+
if (!sink) return;
|
|
65
|
+
try {
|
|
66
|
+
sink(payload);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.warn(`[unoverse:analytics] sink failed for ${event}: ${(err as Error)?.message ?? err}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a declared `params` block against the data scope at the interaction site,
|
|
74
|
+
* using the SAME `{{path}}` binding an action's values already use. Keys resolving to
|
|
75
|
+
* `undefined` are dropped rather than sent as empty, so an absent field is absent in the
|
|
76
|
+
* report instead of arriving as a misleading blank.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveAnalyticsParams(
|
|
79
|
+
spec: AnalyticsSpec | undefined,
|
|
80
|
+
scope: Record<string, unknown>,
|
|
81
|
+
resolve: (value: unknown, scope: Record<string, unknown>) => unknown,
|
|
82
|
+
): Record<string, unknown> {
|
|
83
|
+
const out: Record<string, unknown> = {};
|
|
84
|
+
for (const [key, value] of Object.entries(spec?.params ?? {})) {
|
|
85
|
+
const v = resolve(value, scope);
|
|
86
|
+
if (v !== undefined && v !== null && v !== "") out[key] = v;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
@@ -114,7 +114,7 @@ export class UnoverseClient {
|
|
|
114
114
|
return this.withSession(() => this.client.callTool({ name, arguments: args }));
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/** resources/read a definition by URI (e.g. unoverse://components/
|
|
117
|
+
/** resources/read a definition by URI (e.g. unoverse://components/card). Cached. */
|
|
118
118
|
async readDefinition(uri: string): Promise<UnoverseDefinition> {
|
|
119
119
|
const cached = this.cache.get(uri);
|
|
120
120
|
if (cached) return cached;
|
|
@@ -28,5 +28,17 @@ export {
|
|
|
28
28
|
// The action interpreter (§2e-3) — local `setValue` store write + server route.
|
|
29
29
|
export { dispatchAction, resolveValue, type ActionContext } from "./actions";
|
|
30
30
|
|
|
31
|
+
// Experience analytics (UNOVERSE_ANALYTICS.md) — the SDK emits; the host decides where to.
|
|
32
|
+
export {
|
|
33
|
+
emitAnalytics,
|
|
34
|
+
setAnalyticsSink,
|
|
35
|
+
setAnalyticsContext,
|
|
36
|
+
setAnalyticsDebug,
|
|
37
|
+
resolveAnalyticsParams,
|
|
38
|
+
type AnalyticsEvent,
|
|
39
|
+
type AnalyticsContext,
|
|
40
|
+
type AnalyticsSink,
|
|
41
|
+
} from "./analytics";
|
|
42
|
+
|
|
31
43
|
// The shared "react to state" predicate (visibleWhen / Switch / style.when).
|
|
32
44
|
export { matches, visible, selectCase } from "./conditions";
|
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
* SHAPE (values live on the server). Pure types — no runtime, no imports.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/** Report an interaction to the host's analytics (UNOVERSE_ANALYTICS.md). Declared on a
|
|
9
|
+
* node beside its `action`. The SDK produces the event and knows nothing about where it
|
|
10
|
+
* goes: no vendor, no destination, no identity appears in this shape. */
|
|
11
|
+
export interface AnalyticsSpec {
|
|
12
|
+
/** The event name. A GA4 standard name where one fits, else `unoverse_`-prefixed. */
|
|
13
|
+
event: string;
|
|
14
|
+
/** Sent with it. `{{path}}` resolves against the data scope at the interaction site,
|
|
15
|
+
* the same binding an action's `values` already use. */
|
|
16
|
+
params?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
// ---- The neutral definition shape (mirrors apps/unoverse/definitions/*.json) ----
|
|
9
20
|
|
|
10
21
|
export interface UnoverseNode {
|
|
@@ -13,6 +24,11 @@ export interface UnoverseNode {
|
|
|
13
24
|
bind?: Record<string, string>; // target prop -> data field
|
|
14
25
|
visibleWhen?: VisibleWhen; // render only when the condition holds (truthy field, or {field,eq/ne})
|
|
15
26
|
action?: string | ActionSpec; // dispatched on interaction (bare string → server, or a setValue envelope)
|
|
27
|
+
/** Report this interaction to the host's analytics (UNOVERSE_ANALYTICS.md). Opt-in per
|
|
28
|
+
* node: no block, no event. Sits beside `action` because each interaction carries its
|
|
29
|
+
* own params, drawn from the data already in scope right here. The SDK only produces
|
|
30
|
+
* the event; where it goes is the channel's business. */
|
|
31
|
+
analytics?: AnalyticsSpec;
|
|
16
32
|
placeholder?: string; // Input: empty-field copy (content, not style)
|
|
17
33
|
/** Box: keep the scroll pinned to the newest content — smooth-scroll to the end when
|
|
18
34
|
* content grows (new turn or streaming text), unless the user has scrolled up. Generic
|
|
@@ -136,6 +152,10 @@ export interface ActionSpec {
|
|
|
136
152
|
values?: ActionValue[]; // setValue / setTemplateValue patches (the dev's chosen keys)
|
|
137
153
|
send?: string[]; // server route: which scope fields to include (default: all)
|
|
138
154
|
then?: ActionSpec | string; // optional chained follow-up
|
|
155
|
+
/** Carried here by the renderer, NOT authored on the action: a node declares
|
|
156
|
+
* `analytics:` beside `action:`, and the leaf attaches it so the ONE interpreter
|
|
157
|
+
* (`dispatchAction`) stays the only place an interaction is understood. */
|
|
158
|
+
analytics?: AnalyticsSpec;
|
|
139
159
|
}
|
|
140
160
|
|
|
141
161
|
/** A data condition, evaluated against the render data scope. Pick ONE test:
|
package/vendor/sdk/lib/index.ts
CHANGED
|
@@ -30,6 +30,11 @@ export {
|
|
|
30
30
|
matches,
|
|
31
31
|
visible,
|
|
32
32
|
selectCase,
|
|
33
|
+
emitAnalytics,
|
|
34
|
+
setAnalyticsSink,
|
|
35
|
+
setAnalyticsContext,
|
|
36
|
+
setAnalyticsDebug,
|
|
37
|
+
resolveAnalyticsParams,
|
|
33
38
|
} from "./core";
|
|
34
39
|
export type {
|
|
35
40
|
ResolvedTheme,
|
|
@@ -56,4 +61,8 @@ export type {
|
|
|
56
61
|
ServerWorkflowState,
|
|
57
62
|
ServerTemplateData,
|
|
58
63
|
ServerSessionReady,
|
|
64
|
+
AnalyticsSpec,
|
|
65
|
+
AnalyticsEvent,
|
|
66
|
+
AnalyticsContext,
|
|
67
|
+
AnalyticsSink,
|
|
59
68
|
} from "./core";
|
|
@@ -23,7 +23,7 @@ import { createElement, useEffect, useMemo, useRef, useState, type CSSProperties
|
|
|
23
23
|
import Markdown from "markdown-to-jsx";
|
|
24
24
|
import type { ResolvedTheme, UnoverseNode } from "./core";
|
|
25
25
|
import { styleToCss, cssDecls } from "./style";
|
|
26
|
-
import type
|
|
26
|
+
import { nodeAction, type ActionHandler } from "./render";
|
|
27
27
|
|
|
28
28
|
/** Read a bound data field as a string (for Text/Image/Button content). */
|
|
29
29
|
function bound(field: string | undefined, data: Record<string, unknown>): string | undefined {
|
|
@@ -332,7 +332,7 @@ export function ImageView({ node, data, theme }: { node: UnoverseNode; data: Rec
|
|
|
332
332
|
export function ButtonView({ node, data, theme, onAction, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler; children?: ReactNode }): ReactNode {
|
|
333
333
|
const { className, styleEls, style, disabled } = nodeChrome(node, data, theme);
|
|
334
334
|
return (
|
|
335
|
-
<button className={className} style={style} disabled={disabled} data-unoverse-id={inspectId(node)} onClick={() => onAction?.(node
|
|
335
|
+
<button className={className} style={style} disabled={disabled} data-unoverse-id={inspectId(node)} onClick={() => onAction?.(nodeAction(node, "click"), data)}>
|
|
336
336
|
{styleEls}
|
|
337
337
|
{node.children?.length ? children : bound(node.bind?.label, data)}
|
|
338
338
|
</button>
|
|
@@ -507,7 +507,7 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
|
|
|
507
507
|
const submit = () => {
|
|
508
508
|
const t = value.trim();
|
|
509
509
|
if (!t) return;
|
|
510
|
-
onAction?.(node
|
|
510
|
+
onAction?.(nodeAction(node, "submit"), { text: t });
|
|
511
511
|
if (field == null) setLocal("");
|
|
512
512
|
};
|
|
513
513
|
const { style, disabled } = nodeChrome(node, data, theme);
|
|
@@ -57,6 +57,22 @@ export interface ActionMeta {
|
|
|
57
57
|
|
|
58
58
|
export type ActionHandler = (action: string | ActionSpec, data: Record<string, unknown>, meta?: ActionMeta) => void;
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A leaf's action, with the node's declared `analytics` attached (UNOVERSE_ANALYTICS.md).
|
|
62
|
+
*
|
|
63
|
+
* Authors write `analytics:` beside `action:` on the node, where the data it reports is
|
|
64
|
+
* already in scope. The two travel together from here so `dispatchAction` remains the ONE
|
|
65
|
+
* place an interaction is understood: leaves stay dumb, and no leaf can forget to report.
|
|
66
|
+
* A node with no `analytics` block passes its action through untouched, unallocated.
|
|
67
|
+
*/
|
|
68
|
+
export function nodeAction(node: UnoverseNode, fallback: string): string | ActionSpec {
|
|
69
|
+
const action = node.action ?? fallback;
|
|
70
|
+
if (!node.analytics?.event) return action;
|
|
71
|
+
return typeof action === "string"
|
|
72
|
+
? { type: action, analytics: node.analytics }
|
|
73
|
+
: { ...action, analytics: node.analytics };
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
/**
|
|
61
77
|
* Resolves a `ComponentSlot` node against the store (provided by the template
|
|
62
78
|
* renderer). Returns the rendered leaves (or the slot's fallback). Components
|
package/vendor/sdk/main.tsx
CHANGED
|
@@ -29,6 +29,13 @@ function render() {
|
|
|
29
29
|
templateId={current.templateId}
|
|
30
30
|
userId={current.userId}
|
|
31
31
|
conversationId={current.conversationId}
|
|
32
|
+
// ANALYTICS (UNOVERSE_ANALYTICS.md). Host-supplied: the same bundle boots inside a
|
|
33
|
+
// customer's page, our own client, and an MCP app host, and it cannot tell which
|
|
34
|
+
// from in here. A host that says nothing gets `mcp_app_host`, the one surface with
|
|
35
|
+
// no parent page to report to, so an unconfigured boot reports as the surface where
|
|
36
|
+
// events go nowhere rather than mislabelling itself as a customer embed.
|
|
37
|
+
surface={current.surface ?? "mcp_app_host"}
|
|
38
|
+
analyticsDebug={current.analyticsDebug}
|
|
32
39
|
// Only claim auth when the host actually sent a token. A fabricated accessor on a
|
|
33
40
|
// tokenless boot made the SDK skip its guest-id substitution (connection.tsx), so
|
|
34
41
|
// the session registered a non-guest id and the public-entry gate refused it.
|