@unoverse-platform/studio 0.1.102 → 0.1.104
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 +4 -2
- package/screens/AtomsView.tsx +2 -1
- package/screens/ComponentsView.tsx +2 -1
- package/screens/NativeAppHost.tsx +1 -2
- package/screens/TemplatesLibraryView.tsx +14 -1
- package/screens/TemplatesView.tsx +2 -1
- package/screens/index.ts +4 -0
- package/screens/react/unoverse.tsx +281 -0
- package/vendor/base/src/agent-mcp/index.ts +1 -1
- package/vendor/base/src/agent-mcp/invoke.ts +6 -2
- package/vendor/base/src/agent-mcp/tools.ts +14 -5
- package/vendor/base/src/agent-mcp/types.ts +6 -0
- package/vendor/base/src/auth/auth.ts +90 -23
- package/vendor/base/src/definitions/load/compile/sections.ts +79 -0
- package/vendor/base/src/definitions/load/compile/template.ts +23 -2
- package/vendor/base/src/definitions/model.ts +9 -0
- package/vendor/base/src/definitions/registry/catalog.ts +6 -7
- package/vendor/base/src/executor.ts +25 -1
- package/vendor/base/src/manifests/executor/toolBridge.ts +25 -19
- package/vendor/base/src/manifests/runtime/channels/final.ts +4 -1
- package/vendor/base/src/manifests/runtime/duplex/session.ts +3 -9
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +28 -25
- package/vendor/base/src/platform/serviceCalls.ts +13 -0
- package/vendor/sdk/appEngine.ts +363 -0
- package/vendor/sdk/lib/actions.ts +36 -0
- package/vendor/sdk/lib/citations.ts +48 -0
- package/vendor/sdk/lib/core/apps.ts +18 -0
- package/vendor/sdk/lib/core/client.ts +3 -3
- package/vendor/sdk/lib/core/store.ts +6 -0
- package/vendor/sdk/lib/dom/app.ts +546 -0
- package/vendor/sdk/lib/dom/chrome.ts +125 -0
- package/vendor/sdk/lib/dom/component.ts +333 -0
- package/vendor/sdk/lib/dom/connection.ts +309 -0
- package/vendor/sdk/lib/dom/element.ts +69 -0
- package/vendor/sdk/lib/dom/isolate.ts +37 -0
- package/vendor/sdk/lib/dom/markdown.ts +388 -0
- package/vendor/sdk/lib/dom/markdownParse.ts +440 -0
- package/vendor/sdk/lib/dom/mounted.ts +29 -0
- package/vendor/sdk/lib/dom/primitives.ts +689 -0
- package/vendor/sdk/lib/dom/region.ts +195 -0
- package/vendor/sdk/lib/dom/render.ts +177 -0
- package/vendor/sdk/lib/dom/theme.ts +38 -0
- package/vendor/sdk/lib/dom/types.ts +36 -0
- package/vendor/sdk/lib/index.ts +26 -17
- package/vendor/sdk/lib/orb/canvas.ts +139 -0
- package/vendor/sdk/lib/orb/index.ts +8 -18
- package/vendor/sdk/lib/orb/presets.ts +1 -1
- package/vendor/sdk/lib/orb/{types.ts → states.ts} +0 -24
- package/vendor/sdk/lib/realtime/capture.ts +152 -0
- package/vendor/sdk/lib/realtime/playback.ts +129 -0
- package/vendor/sdk/lib/realtime/socket.ts +117 -0
- package/vendor/sdk/lib/style.ts +45 -37
- package/vendor/sdk/lib/voiceService.ts +344 -0
- package/vendor/sdk/main.ts +107 -0
- package/vendor/sdk/mcpAppBridge.ts +125 -102
- package/vendor/sdk/pageInterfaces.ts +452 -0
- package/vendor/sdk/responseView.ts +193 -0
- package/vite.config.ts +3 -3
- package/vendor/sdk/appEngine.tsx +0 -393
- package/vendor/sdk/lib/UnoverseComponent.tsx +0 -325
- package/vendor/sdk/lib/app.tsx +0 -730
- package/vendor/sdk/lib/connection.tsx +0 -433
- package/vendor/sdk/lib/isolate.tsx +0 -70
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +0 -131
- package/vendor/sdk/lib/orb/theme.ts +0 -83
- package/vendor/sdk/lib/primitives.tsx +0 -943
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +0 -237
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +0 -157
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +0 -164
- package/vendor/sdk/lib/render.tsx +0 -244
- package/vendor/sdk/lib/streamed.tsx +0 -30
- package/vendor/sdk/lib/theme.ts +0 -55
- package/vendor/sdk/lib/transition/MountArrangement.tsx +0 -71
- package/vendor/sdk/lib/transition/index.ts +0 -9
- package/vendor/sdk/lib/transition/viewTransition.ts +0 -29
- package/vendor/sdk/lib/voice.tsx +0 -422
- package/vendor/sdk/main.tsx +0 -125
- package/vendor/sdk/pageInterfaces.tsx +0 -383
- package/vendor/sdk/responseView.tsx +0 -82
|
@@ -47,12 +47,6 @@ export interface ToolBridge {
|
|
|
47
47
|
): Promise<{ content: string; minted: any[]; skills?: Array<{ name: string; title?: string; instructions: string; regionId?: string; regionName?: string | null; ms?: number }> }>;
|
|
48
48
|
/** Does this exchange end the turn? Depends on which tools were WIRED. */
|
|
49
49
|
endsTurn(calls: { name: string; resultContent: string }[]): boolean;
|
|
50
|
-
/**
|
|
51
|
-
* WHAT SORT OF STEP a call to this tool is on the timeline. The bridge minted the tool,
|
|
52
|
-
* so the bridge knows whether it came from a task row; the loop only relays the answer
|
|
53
|
-
* to the trace. Optional, because a core-tools-only bridge has nothing but tools.
|
|
54
|
-
*/
|
|
55
|
-
kindOf?(toolName: string): "tool" | "task";
|
|
56
50
|
}
|
|
57
51
|
|
|
58
52
|
/**
|
|
@@ -100,8 +94,27 @@ export function recordToolTrace(
|
|
|
100
94
|
*/
|
|
101
95
|
kind: "tool" | "skill" | "task" = "tool",
|
|
102
96
|
): void {
|
|
103
|
-
|
|
104
|
-
|
|
97
|
+
recordCall({ executionId: (ctx as any)?.scope?.executionId, parentNodeId: (ctx as any)?.scope?.nodeId, toolName, args, result, startTime, success, kind, who: node.type });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* ONE CALL ON THE TIMELINE, by whoever ran it (packages/docs/nodes/mcp-services.md, "Who
|
|
102
|
+
* records a call"): a tool records its own call, nested under the node that called it. The
|
|
103
|
+
* service channel (executor.ts) records a node's methods; the harness records only its own
|
|
104
|
+
* steps through `recordToolTrace`.
|
|
105
|
+
*/
|
|
106
|
+
export function recordCall(call: {
|
|
107
|
+
executionId?: string;
|
|
108
|
+
parentNodeId?: string;
|
|
109
|
+
toolName: string;
|
|
110
|
+
args: unknown;
|
|
111
|
+
result: unknown;
|
|
112
|
+
startTime: number;
|
|
113
|
+
success: boolean;
|
|
114
|
+
kind?: "tool" | "skill" | "task";
|
|
115
|
+
who: string;
|
|
116
|
+
}): void {
|
|
117
|
+
const { executionId, parentNodeId, toolName, args, result, startTime, success, kind = "tool" } = call;
|
|
105
118
|
if (!executionId || !parentNodeId) return;
|
|
106
119
|
|
|
107
120
|
/**
|
|
@@ -134,7 +147,7 @@ export function recordToolTrace(
|
|
|
134
147
|
duration: endTime - startTime,
|
|
135
148
|
success,
|
|
136
149
|
kind,
|
|
137
|
-
}).catch((err: any) => console.warn(`[manifests] ${
|
|
150
|
+
}).catch((err: any) => console.warn(`[manifests] ${call.who}: could not save MCP trace: ${err?.message ?? err}`));
|
|
138
151
|
}
|
|
139
152
|
|
|
140
153
|
export async function runToolLoop(
|
|
@@ -339,29 +352,19 @@ export async function runToolLoop(
|
|
|
339
352
|
|
|
340
353
|
let content: string;
|
|
341
354
|
/**
|
|
342
|
-
* THE TIMELINE BAR
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
* vanished the day `openai` migrated
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
* `success` is the part that matters most. A tool that fails still returns a string to
|
|
350
|
-
* the model (see the catch below), so from the outside a broken tool and an empty
|
|
351
|
-
* result are identical: the workflow completes, four green bars, and the model answers
|
|
352
|
-
* from its own knowledge. That happened three times in one session before anyone could
|
|
353
|
-
* see it. This flag is what turns it red.
|
|
355
|
+
* THE TIMELINE BAR is the TOOL's to record, never the loop's (owner, 2026-10-01;
|
|
356
|
+
* packages/docs/nodes/mcp-services.md, "Who records a call"). Every tool records its
|
|
357
|
+
* own call wherever it runs (a node's method, a row's function, a platform tool), with
|
|
358
|
+
* `success` false on a failure, so a call reads the same from an Agent, a voice session
|
|
359
|
+
* or an outside host. The bar once vanished the day `openai` migrated, because it lived
|
|
360
|
+
* in each Agent family; it lives with the tool now.
|
|
354
361
|
*/
|
|
355
|
-
const startedAt = Date.now();
|
|
356
|
-
let ok = true;
|
|
357
362
|
try {
|
|
358
363
|
content = await bridge.call(call.name, args);
|
|
359
364
|
} catch (err: any) {
|
|
360
365
|
// A failed tool is information for the model, not a crash: it can try another.
|
|
361
|
-
ok = false;
|
|
362
366
|
content = JSON.stringify({ error: err?.message ?? String(err) });
|
|
363
367
|
}
|
|
364
|
-
recordToolTrace(node, ctx, call.name, args, content, startedAt, ok, bridge.kindOf?.(call.name) ?? "tool");
|
|
365
368
|
exchanges.push({ name: call.name, resultContent: content });
|
|
366
369
|
// The RESULT, and only now that it exists. It is never in the HTTP stream: our loop
|
|
367
370
|
// produced it, which is why it cannot be a `from: response` row. PARSED for the
|
|
@@ -153,6 +153,19 @@ export async function saveMCPTraceToWorkflow(trace: any): Promise<string> {
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* THE TOOLS A CALLER HOLDS NOW, so each call's row on the trace lists the choice the model
|
|
158
|
+
* really had: the run's starting tools plus every one a search has minted since. Fire and
|
|
159
|
+
* forget, like the trace itself.
|
|
160
|
+
*/
|
|
161
|
+
export function saveAvailableTools(executionId: string, nodeId: string, tools: string[]): void {
|
|
162
|
+
void fetch(`${WORKFLOW_SERVICE_URL}/analytics/available-tools`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "Content-Type": "application/json" },
|
|
165
|
+
body: JSON.stringify({ executionId, nodeId, tools }),
|
|
166
|
+
}).catch(() => {});
|
|
167
|
+
}
|
|
168
|
+
|
|
156
169
|
/**
|
|
157
170
|
* Persist plugin state to PostgreSQL via workflow-service
|
|
158
171
|
*/
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The native app ENGINE (§6d), plain DOM, surface-agnostic. It:
|
|
3
|
+
* - reads its OWN workflow binding from its manifest (§4b: "App supplies workflow, trigger"),
|
|
4
|
+
* - opens the SDK's native connection (`/stream` register_session → store),
|
|
5
|
+
* - fires the workflow DIRECTLY over that connection with the USER's token (§5a: "direct
|
|
6
|
+
* channel → Gravity backend, never brokered").
|
|
7
|
+
*
|
|
8
|
+
* The server tool only LOADS this app; it never fires or brokers a token.
|
|
9
|
+
*/
|
|
10
|
+
import { postToEmbedHost, handAnswerToHost } from "./mcpAppBridge";
|
|
11
|
+
import { mountPageInterfaces, type Binding } from "./pageInterfaces";
|
|
12
|
+
import { UnoverseClient } from "./lib/core/client";
|
|
13
|
+
import { ComponentStore } from "./lib/core/store";
|
|
14
|
+
import { computeAppWidth, resolveActiveLayout } from "./lib/core/apps";
|
|
15
|
+
import { setAnalyticsSink, setAnalyticsContext, setAnalyticsDebug } from "./lib/core/analytics";
|
|
16
|
+
import type { ResolvedTheme, UnoverseDefinition } from "./lib/core/types";
|
|
17
|
+
import type { ActionHandler, ActionMeta } from "./lib/actions";
|
|
18
|
+
import { mountApp, type AppMount } from "./lib/dom/app";
|
|
19
|
+
import { openConnection, type UnoverseConnection } from "./lib/dom/connection";
|
|
20
|
+
import { fetchTheme } from "./lib/dom/theme";
|
|
21
|
+
import { createVoiceService, type VoiceService } from "./lib/voiceService";
|
|
22
|
+
|
|
23
|
+
export interface AppConfig {
|
|
24
|
+
serverUrl: string;
|
|
25
|
+
apiUrl?: string;
|
|
26
|
+
appId: string;
|
|
27
|
+
getAccessToken?: () => Promise<string | null> | string | null;
|
|
28
|
+
userId?: string;
|
|
29
|
+
conversationId: string;
|
|
30
|
+
/** Where this app runs, reported on every analytics event (UNOVERSE_ANALYTICS.md §surfaces). */
|
|
31
|
+
surface?: string;
|
|
32
|
+
/** Log each analytics event as it fires (server-delivered, never a client flag). */
|
|
33
|
+
analyticsDebug?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AppEngine {
|
|
37
|
+
update(cfg: AppConfig): void;
|
|
38
|
+
destroy(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Manifest = { workflow?: string; trigger?: string; service?: string };
|
|
42
|
+
|
|
43
|
+
/** Mount the named app into `el`. */
|
|
44
|
+
export function mountAppEngine(el: HTMLElement, first: AppConfig): AppEngine {
|
|
45
|
+
const doc = el.ownerDocument;
|
|
46
|
+
let cfg = first;
|
|
47
|
+
// ONE CLIENT PER SERVER, ONE STORE PER CONVERSATION: a conversation swap must not carry the
|
|
48
|
+
// old timeline into another's shell (seen live 2026-08-08).
|
|
49
|
+
let client = new UnoverseClient(`${cfg.serverUrl}/mcp`, { getAccessToken: cfg.getAccessToken as never });
|
|
50
|
+
let storeKey = `${cfg.userId}|${cfg.conversationId}`;
|
|
51
|
+
let store = new ComponentStore();
|
|
52
|
+
// WHICH APP IS RUNNING: the host's config names it, and the SERVER may swap it mid
|
|
53
|
+
// conversation (an app discovered and called IS the surface). A swap resets the binding at
|
|
54
|
+
// once, so no session opens on the old app's trigger against the new app.
|
|
55
|
+
let appId = cfg.appId;
|
|
56
|
+
let binding: Manifest = {};
|
|
57
|
+
let def: UnoverseDefinition | null = null;
|
|
58
|
+
let theme: ResolvedTheme | null = null;
|
|
59
|
+
let inTask = false;
|
|
60
|
+
let alive = true;
|
|
61
|
+
let loadToken = 0;
|
|
62
|
+
let conn: UnoverseConnection | null = null;
|
|
63
|
+
let connKey = "";
|
|
64
|
+
let voice: VoiceService = inertVoice();
|
|
65
|
+
let voiceKey = "";
|
|
66
|
+
let app: AppMount | null = null;
|
|
67
|
+
let page: { destroy(): void } | null = null;
|
|
68
|
+
let lastWidth: string | undefined;
|
|
69
|
+
let stopStore = () => {};
|
|
70
|
+
let stopVoice = () => {};
|
|
71
|
+
let stopConn = () => {};
|
|
72
|
+
|
|
73
|
+
function inertVoice(): VoiceService {
|
|
74
|
+
// No `store`: not a producer, so it writes no callState into ordinary templates.
|
|
75
|
+
return createVoiceService({ apiUrl: "", session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" } });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ANALYTICS (UNOVERSE_ANALYTICS.md): events cross to the HOST REALM, where the page's own tag
|
|
79
|
+
// lives with the visitor's consent; with no parent the post is a silent no-op.
|
|
80
|
+
const analytics = () => {
|
|
81
|
+
setAnalyticsDebug(!!cfg.analyticsDebug);
|
|
82
|
+
setAnalyticsContext({ surface: cfg.surface, conversation_id: cfg.conversationId, user_id: cfg.userId });
|
|
83
|
+
setAnalyticsSink((event) => postToEmbedHost({ type: "unoverse:event", ...event }));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// The app's width, reported UP to the embedding host whenever a panel opens or closes; a new
|
|
87
|
+
// app always re-announces (the host resets its frame on a switch).
|
|
88
|
+
const reportWidth = () => {
|
|
89
|
+
const w = computeAppWidth(resolveActiveLayout(def, store), store, theme?.appSize, def);
|
|
90
|
+
if (w && w !== lastWidth) {
|
|
91
|
+
lastWidth = w;
|
|
92
|
+
postToEmbedHost({ type: "unoverse:size", width: w });
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// STATUS TO THE HOST: ready, connected, the server's refusal, and the last run's failure.
|
|
97
|
+
let lastStatus = "";
|
|
98
|
+
const reportStatus = () => {
|
|
99
|
+
const s = conn?.status() ?? { isConnected: false, isReady: false, refusal: null };
|
|
100
|
+
const msg = { type: "unoverse:status", ready: !!s.isReady, connected: !!s.isConnected, refused: s.refusal ?? undefined, failure: store.getLastFailure() ?? undefined };
|
|
101
|
+
const key = JSON.stringify(msg);
|
|
102
|
+
if (key === lastStatus) return;
|
|
103
|
+
lastStatus = key;
|
|
104
|
+
postToEmbedHost(msg);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** The voice service for this app: real for a `service: "voice"` app, else inert. A call
|
|
108
|
+
* names the workflow it runs; the app lends its binding as the default. */
|
|
109
|
+
const syncVoice = () => {
|
|
110
|
+
const wanted = binding.service === "voice";
|
|
111
|
+
const key = wanted ? JSON.stringify([cfg.apiUrl ?? cfg.serverUrl, cfg.userId, cfg.conversationId, binding.workflow, binding.trigger, storeKey]) : "inert";
|
|
112
|
+
if (key === voiceKey) return;
|
|
113
|
+
voiceKey = key;
|
|
114
|
+
stopVoice();
|
|
115
|
+
voice.destroy();
|
|
116
|
+
voice = wanted
|
|
117
|
+
? createVoiceService({
|
|
118
|
+
apiUrl: cfg.apiUrl ?? cfg.serverUrl,
|
|
119
|
+
// The audio lane is derived from the client's own server origin, never the host's.
|
|
120
|
+
wsUrl: client.serverOrigin.replace(/^http/, "ws"),
|
|
121
|
+
getAccessToken: cfg.getAccessToken,
|
|
122
|
+
session: { userId: cfg.userId ?? "", conversationId: cfg.conversationId, chatId: cfg.conversationId, workflowId: binding.workflow ?? "", targetTriggerNode: binding.trigger ?? "" },
|
|
123
|
+
// Producer: writes callState/isMuted into TEMPLATE STATE so the voice states switch.
|
|
124
|
+
store,
|
|
125
|
+
})
|
|
126
|
+
: inertVoice();
|
|
127
|
+
stopVoice = voice.subscribe(syncConnection);
|
|
128
|
+
app?.update({ voice });
|
|
129
|
+
mountPage();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* THE CONNECTION, open once the user is known AND there is something to hear from: the app's
|
|
134
|
+
* own workflow, a call, or a task the page is in. Nothing is held open while a visitor reads.
|
|
135
|
+
*/
|
|
136
|
+
const syncConnection = () => {
|
|
137
|
+
const enabled = !!cfg.userId && (!!binding.workflow || voice.state.isCallActive || inTask);
|
|
138
|
+
const key = enabled ? JSON.stringify([cfg.serverUrl, binding.workflow, binding.trigger, cfg.userId, cfg.conversationId, storeKey, !!cfg.getAccessToken]) : "";
|
|
139
|
+
if (key === connKey) return;
|
|
140
|
+
connKey = key;
|
|
141
|
+
stopConn();
|
|
142
|
+
conn?.close();
|
|
143
|
+
conn = null;
|
|
144
|
+
if (enabled) {
|
|
145
|
+
conn = openConnection(
|
|
146
|
+
{ apiUrl: cfg.apiUrl ?? cfg.serverUrl, streamUrl: cfg.serverUrl, getAccessToken: cfg.getAccessToken as never },
|
|
147
|
+
{ workflowId: binding.workflow ?? "", targetTriggerNode: binding.trigger ?? "", userId: cfg.userId ?? "", conversationId: cfg.conversationId, chatId: cfg.conversationId, appId },
|
|
148
|
+
store,
|
|
149
|
+
{
|
|
150
|
+
appId: () => appId,
|
|
151
|
+
onAppSwitch: switchApp,
|
|
152
|
+
// A voice in the running workflow asks the page in: join its call, never start one.
|
|
153
|
+
onJoinCall: (workflowId) => void voice.joinCall(workflowId),
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
stopConn = conn.subscribe(reportStatus);
|
|
157
|
+
}
|
|
158
|
+
reportStatus();
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const switchApp = (next: string) => {
|
|
162
|
+
if (next === appId) return;
|
|
163
|
+
appId = next;
|
|
164
|
+
load();
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Read the app's manifest (its binding, its service) and its definition (its width). */
|
|
168
|
+
const load = () => {
|
|
169
|
+
const token = ++loadToken;
|
|
170
|
+
binding = {};
|
|
171
|
+
def = null;
|
|
172
|
+
lastWidth = undefined;
|
|
173
|
+
syncConnection();
|
|
174
|
+
syncVoice();
|
|
175
|
+
const orgFromId = appId.includes("/") ? appId.split("/")[0] : null;
|
|
176
|
+
if (orgFromId) loadTheme(`${orgFromId}/light`, token);
|
|
177
|
+
client
|
|
178
|
+
.connect()
|
|
179
|
+
.then(() => (client as unknown as { client: { readResource(a: { uri: string }): Promise<{ contents?: { text?: string }[] }> } }).client.readResource({ uri: `unoverse://apps/${appId.toLowerCase()}` }))
|
|
180
|
+
.then((res) => {
|
|
181
|
+
if (!alive || token !== loadToken) return;
|
|
182
|
+
const parsed = res?.contents?.[0]?.text ? JSON.parse(res.contents[0].text) : null;
|
|
183
|
+
const m = parsed?.manifest ?? parsed ?? {};
|
|
184
|
+
const b = m.binding ?? {};
|
|
185
|
+
binding = { workflow: b.workflow, trigger: b.trigger, service: m.service };
|
|
186
|
+
syncVoice();
|
|
187
|
+
syncConnection();
|
|
188
|
+
})
|
|
189
|
+
.catch((e) => console.error("[app] manifest read failed:", e));
|
|
190
|
+
client
|
|
191
|
+
.connect()
|
|
192
|
+
.then(() => client.readDefinition(`unoverse://apps/${appId}/definition`))
|
|
193
|
+
.then((d) => {
|
|
194
|
+
if (!alive || token !== loadToken) return;
|
|
195
|
+
def = d ?? null;
|
|
196
|
+
if (!orgFromId) loadTheme(def?.org ? `${def.org}/light` : "light", token);
|
|
197
|
+
reportWidth();
|
|
198
|
+
})
|
|
199
|
+
.catch((e) => console.error("[app] template read failed:", e));
|
|
200
|
+
mountAppView();
|
|
201
|
+
};
|
|
202
|
+
const loadTheme = (name: string, token: number) =>
|
|
203
|
+
fetchTheme(client, name)
|
|
204
|
+
.then((t) => {
|
|
205
|
+
if (!alive || token !== loadToken) return;
|
|
206
|
+
theme = t;
|
|
207
|
+
reportWidth();
|
|
208
|
+
})
|
|
209
|
+
.catch(() => {});
|
|
210
|
+
|
|
211
|
+
// THE SPEC'S ANSWERS, AND NOTHING ELSE: while the server is ELICITING, a `submit` answers
|
|
212
|
+
// `accept` with its slice and a `cancel` answers `cancel`. Otherwise a submit inside an outside
|
|
213
|
+
// host goes to the host's model; everything else is the data-plane `user_action`.
|
|
214
|
+
const toServer = (t: string, data: Record<string, unknown>, meta?: ActionMeta) => {
|
|
215
|
+
const state = (meta?.state ?? data) as Record<string, unknown>;
|
|
216
|
+
if ((t === "submit" || t === "cancel") && conn?.resolveElicitation(state, t === "cancel" ? "cancel" : "accept")) return;
|
|
217
|
+
if (t === "submit" && handAnswerToHost(state)) return;
|
|
218
|
+
conn?.sendAction(t, {
|
|
219
|
+
componentNodeId: meta?.nodeId,
|
|
220
|
+
componentType: meta?.type,
|
|
221
|
+
// The component's OWN chatId is the TURN id the waiting node execution is keyed on.
|
|
222
|
+
componentChatId: meta?.chatId,
|
|
223
|
+
componentState: state,
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// A PAGE TEMPLATE'S TASK, called at its own workflow's trigger with the user's token.
|
|
228
|
+
// `metadata.task` names the task its Task Runner loads; the submit carries the answers.
|
|
229
|
+
const startTask = async (b: Binding, task: string, submit?: Record<string, unknown>) => {
|
|
230
|
+
if (!b.workflow) return;
|
|
231
|
+
inTask = true;
|
|
232
|
+
syncConnection();
|
|
233
|
+
const token = cfg.getAccessToken ? await cfg.getAccessToken() : null;
|
|
234
|
+
fetch(`${cfg.apiUrl ?? cfg.serverUrl}/api/workflows/${b.workflow}/execute`, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
237
|
+
body: JSON.stringify({
|
|
238
|
+
input: {
|
|
239
|
+
message: submit ? "Submit" : "Start",
|
|
240
|
+
chatId: cfg.conversationId,
|
|
241
|
+
conversationId: cfg.conversationId,
|
|
242
|
+
userId: cfg.userId,
|
|
243
|
+
providerId: "gravity-ds",
|
|
244
|
+
metadata: { targetTriggerNode: b.trigger, workflowId: b.workflow, task, ...(submit ? { submit } : {}) },
|
|
245
|
+
},
|
|
246
|
+
conversationId: cfg.conversationId,
|
|
247
|
+
}),
|
|
248
|
+
}).catch((e) => console.error("[unoverse] task start failed", e));
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const onAction: ActionHandler = (action, data, meta) => {
|
|
252
|
+
const t = typeof action === "string" ? action : (action as { type?: string })?.type;
|
|
253
|
+
const d = (data ?? {}) as Record<string, unknown>;
|
|
254
|
+
if (t === "close") {
|
|
255
|
+
// The app owns its close chrome; the embedding host decides what "close" means.
|
|
256
|
+
postToEmbedHost({ type: "unoverse:close" });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (t === "sendMessage") {
|
|
260
|
+
// A suggestion that NAMES AN APP launches it: read that app's manifest (the binding lives
|
|
261
|
+
// there) and fire its trigger on the bound workflow.
|
|
262
|
+
const launch = typeof d.app === "string" ? d.app : "";
|
|
263
|
+
if (launch) {
|
|
264
|
+
(client as unknown as { client: { readResource(a: { uri: string }): Promise<{ contents?: { text?: string }[] }> } }).client
|
|
265
|
+
.readResource({ uri: `unoverse://apps/${launch.toLowerCase()}` })
|
|
266
|
+
.then((res) => {
|
|
267
|
+
const parsed = res?.contents?.[0]?.text ? JSON.parse(res.contents[0].text) : null;
|
|
268
|
+
const b = parsed?.manifest?.binding ?? parsed?.binding ?? {};
|
|
269
|
+
if (!b.trigger) return console.error("[app] launch failed: no binding on", launch);
|
|
270
|
+
if (b.workflow && binding.workflow && b.workflow !== binding.workflow)
|
|
271
|
+
console.warn("[app] launch:", launch, "binds a different workflow — firing its trigger on the bound workflow");
|
|
272
|
+
// LOADING STATE: the app's declared defaultState pre-opens its surface.
|
|
273
|
+
const declared = parsed?.manifest?.defaultState ?? parsed?.manifest?.mode ?? parsed?.defaultState ?? parsed?.mode;
|
|
274
|
+
if (declared && declared !== "app") store.mergeAppState({ defaultState: declared });
|
|
275
|
+
conn?.trigger({ app: launch, metadata: { targetTriggerNode: b.trigger, ...(def?.org ? { org: def.org } : {}) } });
|
|
276
|
+
})
|
|
277
|
+
.catch((e) => console.error("[app] launch failed:", e));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// Composer submit: Enter carries `text`; a send BUTTON carries none, so the shared draft.
|
|
281
|
+
const text = (typeof d.text === "string" && d.text) || store.getDraft();
|
|
282
|
+
if (text.trim()) {
|
|
283
|
+
conn?.sendMessage(text);
|
|
284
|
+
store.setDraft("");
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// Controlled composer: every keystroke is the shared draft (local, never a round trip).
|
|
289
|
+
if (t === "input") {
|
|
290
|
+
store.setDraft(typeof d.text === "string" ? d.text : "");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (t) toServer(t, d, meta);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const mountAppView = () => {
|
|
297
|
+
const uri = `unoverse://apps/${appId}/definition`;
|
|
298
|
+
if (app) return app.update({ uri, voice });
|
|
299
|
+
app = mountApp({ client, store, uri, voice, onAction, doc });
|
|
300
|
+
el.append(...app.nodes);
|
|
301
|
+
};
|
|
302
|
+
// The page's own templates, drawn into its elements by this one runtime.
|
|
303
|
+
const mountPage = () => {
|
|
304
|
+
page?.destroy();
|
|
305
|
+
page = mountPageInterfaces({ org: appId.split("/")[0], client, voice, appStore: store, toServer, startTask });
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const watchStore = () => {
|
|
309
|
+
stopStore();
|
|
310
|
+
stopStore = store.subscribe(() => {
|
|
311
|
+
reportWidth();
|
|
312
|
+
reportStatus();
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
analytics();
|
|
317
|
+
watchStore();
|
|
318
|
+
load();
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
update(next) {
|
|
322
|
+
const prev = cfg;
|
|
323
|
+
cfg = next;
|
|
324
|
+
analytics();
|
|
325
|
+
let reset = false;
|
|
326
|
+
if (next.serverUrl !== prev.serverUrl) {
|
|
327
|
+
client = new UnoverseClient(`${next.serverUrl}/mcp`, { getAccessToken: next.getAccessToken as never });
|
|
328
|
+
reset = true;
|
|
329
|
+
}
|
|
330
|
+
const key = `${next.userId}|${next.conversationId}`;
|
|
331
|
+
if (key !== storeKey) {
|
|
332
|
+
storeKey = key;
|
|
333
|
+
store = new ComponentStore();
|
|
334
|
+
watchStore();
|
|
335
|
+
reset = true;
|
|
336
|
+
}
|
|
337
|
+
if (reset) {
|
|
338
|
+
app?.destroy();
|
|
339
|
+
app?.nodes.forEach((n) => n.parentNode?.removeChild(n));
|
|
340
|
+
app = null;
|
|
341
|
+
}
|
|
342
|
+
// The host's own switch WINS and resets: a new config is a new intent.
|
|
343
|
+
if (reset || next.appId !== prev.appId) {
|
|
344
|
+
appId = next.appId;
|
|
345
|
+
load();
|
|
346
|
+
} else {
|
|
347
|
+
syncVoice();
|
|
348
|
+
syncConnection();
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
destroy() {
|
|
352
|
+
alive = false;
|
|
353
|
+
stopStore();
|
|
354
|
+
stopVoice();
|
|
355
|
+
stopConn();
|
|
356
|
+
conn?.close();
|
|
357
|
+
voice.destroy();
|
|
358
|
+
page?.destroy();
|
|
359
|
+
app?.destroy();
|
|
360
|
+
setAnalyticsSink(null);
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a definition's interaction fires, and the handler it reaches. Plain TypeScript: both
|
|
3
|
+
* renderers (the legacy one in render.tsx and the plain-DOM one in ./dom) use it.
|
|
4
|
+
*/
|
|
5
|
+
import type { ActionSpec, UnoverseNode } from "./core/types";
|
|
6
|
+
|
|
7
|
+
// An action is a bare string (→ workflow) or a declarative envelope (§2e-3). The
|
|
8
|
+
// binding layer (streamed.tsx) interprets it; primitives just forward node.action.
|
|
9
|
+
/** Instance context riding with a bubbled server action — WHICH component fired and its
|
|
10
|
+
* current slice, so the channel can forward the submitted state (e.g. a wizard's answers)
|
|
11
|
+
* without reaching into the store. Pure transport metadata; the SDK interprets none of it. */
|
|
12
|
+
export interface ActionMeta {
|
|
13
|
+
chatId?: string;
|
|
14
|
+
nodeId?: string;
|
|
15
|
+
/** Which component this instance is — so the server needs no registry to find out. */
|
|
16
|
+
type?: string;
|
|
17
|
+
state?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ActionHandler = (action: string | ActionSpec, data: Record<string, unknown>, meta?: ActionMeta) => void;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A leaf's action, with the node's declared `analytics` attached (UNOVERSE_ANALYTICS.md).
|
|
24
|
+
*
|
|
25
|
+
* Authors write `analytics:` beside `action:` on the node, where the data it reports is
|
|
26
|
+
* already in scope. The two travel together from here so `dispatchAction` remains the ONE
|
|
27
|
+
* place an interaction is understood: leaves stay dumb, and no leaf can forget to report.
|
|
28
|
+
* A node with no `analytics` block passes its action through untouched, unallocated.
|
|
29
|
+
*/
|
|
30
|
+
export function nodeAction(node: UnoverseNode, fallback: string): string | ActionSpec {
|
|
31
|
+
const action = node.action ?? fallback;
|
|
32
|
+
if (!node.analytics?.event) return action;
|
|
33
|
+
return typeof action === "string"
|
|
34
|
+
? { type: action, analytics: node.analytics }
|
|
35
|
+
: { ...action, analytics: node.analytics };
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Citations in answers (docs/unoverse/data/objects/citations-in-answers.md): the text rules
|
|
3
|
+
* and the pop-up placement. Plain TypeScript, shared by both renderers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A CITATION STILL ARRIVING is held back (citations-in-answers.md): while an answer streams, the
|
|
8
|
+
* tail from an open `[1](cite:` to its closing `)` would draw as a long line of raw link and then
|
|
9
|
+
* collapse into its marker, jolting the text once per citation. Only the tail is touched, and only
|
|
10
|
+
* a numbered label (a citation's), so finished text and web links draw exactly as written.
|
|
11
|
+
*/
|
|
12
|
+
const OPEN_CITATION = /\[\d{0,3}(?:\](?:\((?:c(?:i(?:t(?:e(?::[^)\n]*)?)?)?)?)?)?)?$/;
|
|
13
|
+
export function holdOpenCitation(text: string): string {
|
|
14
|
+
return text.replace(OPEN_CITATION, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A CITATION'S QUOTE STAYS ONE CELL (citations-in-answers.md, 2026-09-15): a quote of a table row
|
|
19
|
+
* carried its pipes ("Total | 10") into the link's title, and inside an answer's own table the
|
|
20
|
+
* pipe split the row and left the marker raw. Before the markdown is parsed, a pipe inside a
|
|
21
|
+
* `cite:` link's title becomes a broken bar and a line break a space; the words still read.
|
|
22
|
+
*/
|
|
23
|
+
const CITATION_TITLE = /(\]\(cite:[^)\s]*\s+")([^"]*)("\))/g;
|
|
24
|
+
export function keepCitationsWhole(text: string): string {
|
|
25
|
+
return text.replace(CITATION_TITLE, (_, open: string, quote: string, close: string) => `${open}${quote.replace(/\|/g, "\u00a6").replace(/\s*\n\s*/g, " ")}${close}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* WHERE A POP-UP FITS (citations-in-answers.md): the horizontal shift that keeps a box of this
|
|
30
|
+
* width inside the window from an anchor at `left`, and whether it opens above the anchor
|
|
31
|
+
* because below there is no room and above there is. A margin keeps it off the very edge.
|
|
32
|
+
*/
|
|
33
|
+
export function fitPopover(anchor: { left: number; top: number; bottom: number }, box: { width: number; height: number }, view: { width: number; height: number }, margin = 8) {
|
|
34
|
+
const over = anchor.left + box.width - (view.width - margin);
|
|
35
|
+
const shift = Math.max(over > 0 ? -over : 0, margin - anchor.left);
|
|
36
|
+
const above = anchor.bottom + box.height + margin > view.height && anchor.top - box.height - margin >= 0;
|
|
37
|
+
return { shift, above };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A `cite:` link's parts: `cite:<document>?part=&page=&doc=`, its title the quoted words. */
|
|
41
|
+
export function citationOf(href: string, title?: string) {
|
|
42
|
+
try {
|
|
43
|
+
const u = new URL(href);
|
|
44
|
+
return { document: decodeURIComponent(u.pathname), part: u.searchParams.get("part") ?? "", page: u.searchParams.get("page") ?? "", doc: u.searchParams.get("doc") ?? "", quote: title ?? "" };
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -308,6 +308,24 @@ export function resolveActiveView(
|
|
|
308
308
|
|
|
309
309
|
|
|
310
310
|
/** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
|
|
311
|
+
/**
|
|
312
|
+
* THE INSTANCE A HELD SLOT DRAWS (docs/design/templates.md "Holding a component"). A template
|
|
313
|
+
* holds a component inside an `Each`: each item is its own instance, keyed by the item's id
|
|
314
|
+
* under the template's own node id, the same id the server sent it under
|
|
315
|
+
* (server runtime/screen/held.ts `heldNodeId`). Nothing when it has not arrived, or when its own
|
|
316
|
+
* state has lifted it into a place: it is drawn there, not here.
|
|
317
|
+
*/
|
|
318
|
+
export function heldPointer(store: ComponentStore, node: UnoverseNode, item: Record<string, unknown>, host: { chatId: string; nodeId: string }): string | undefined {
|
|
319
|
+
const type = (node as { hold?: { type?: unknown } }).hold?.type;
|
|
320
|
+
if (typeof type !== "string") return undefined;
|
|
321
|
+
const id = item.universal_id ?? item.ref ?? item.id ?? item.index;
|
|
322
|
+
if (typeof id !== "string" && typeof id !== "number") return undefined;
|
|
323
|
+
const nodeId = `${host.nodeId}:${type}:${String(id)}`;
|
|
324
|
+
if (!store.getType(host.chatId, nodeId)) return undefined;
|
|
325
|
+
const pointer = `${host.chatId}:${nodeId}`;
|
|
326
|
+
return store.isPlaced(pointer) ? undefined : pointer;
|
|
327
|
+
}
|
|
328
|
+
|
|
311
329
|
export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?: Set<string>): string[] {
|
|
312
330
|
const sel = (node.select ?? {}) as { from?: string; type?: string; where?: { field?: string; eq?: unknown; ne?: unknown }; limit?: number };
|
|
313
331
|
// A SCOPED selection (the mount's hold: latest) spans ALL turns and the SCOPE is
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* (resources/read), with a per-request bearer for secured servers. Caches both.
|
|
6
6
|
* No UI framework here.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/
|
|
8
|
+
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
|
|
9
|
+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
10
|
+
|
|
10
11
|
// CSP-safe (no `new Function`) validator — the default Ajv provider's eval is blocked by
|
|
11
12
|
// ChatGPT's widget CSP. See connection.tsx for the full rationale.
|
|
12
|
-
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
|
|
13
13
|
import type { UnoverseDefinition, ResolvedTheme } from "./types";
|
|
14
14
|
import { stateOfUri } from "./apps";
|
|
15
15
|
|
|
@@ -395,6 +395,12 @@ export class ComponentStore {
|
|
|
395
395
|
placedState(pointer: Pointer): string | undefined {
|
|
396
396
|
return this.placed.get(pointer);
|
|
397
397
|
}
|
|
398
|
+
/** Whether the screen has put this interface in any place. A held interface its own state
|
|
399
|
+
* lifted into a place is drawn there, never in its template too (one instance, one place). */
|
|
400
|
+
isPlaced(pointer: Pointer): boolean {
|
|
401
|
+
for (const items of this.places.values()) if (items.includes(pointer)) return true;
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
398
404
|
/** What one place holds, in the order the screen document gives. */
|
|
399
405
|
placeItems(place: string): readonly Pointer[] {
|
|
400
406
|
return this.places.get(place) ?? EMPTY_POINTERS;
|