@unoverse-platform/studio 0.1.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/bin/studio.mjs +124 -0
- package/index.html +12 -0
- package/local/catalog.ts +105 -0
- package/local/keys.ts +118 -0
- package/local/nodes.ts +77 -0
- package/local/plugin.ts +401 -0
- package/local/scaffold.d.mts +13 -0
- package/local/scaffold.mjs +85 -0
- package/package.json +43 -0
- package/public/logo.png +0 -0
- package/screens/AtomsView.tsx +206 -0
- package/screens/ComponentsView.tsx +551 -0
- package/screens/ConfigForm.tsx +217 -0
- package/screens/DesignSystemView.tsx +324 -0
- package/screens/IdentityHeader.tsx +88 -0
- package/screens/PromptBlocksView.tsx +137 -0
- package/screens/SkillsView.tsx +167 -0
- package/screens/TemplatesView.tsx +1005 -0
- package/screens/index.ts +26 -0
- package/screens/states.ts +44 -0
- package/src/App.tsx +252 -0
- package/src/ConnectUniverse.tsx +232 -0
- package/src/NewProject.tsx +89 -0
- package/src/NodeKeys.tsx +98 -0
- package/src/NodesView.tsx +443 -0
- package/src/PublishDialog.tsx +309 -0
- package/src/UniverseSession.tsx +121 -0
- package/src/host.ts +165 -0
- package/src/index.css +7 -0
- package/src/main.tsx +11 -0
- package/src/universes.ts +210 -0
- package/vendor/sdk/appEngine.tsx +219 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
- package/vendor/sdk/lib/connection.tsx +302 -0
- package/vendor/sdk/lib/core/actions.ts +84 -0
- package/vendor/sdk/lib/core/client.ts +134 -0
- package/vendor/sdk/lib/core/conditions.ts +43 -0
- package/vendor/sdk/lib/core/connection.ts +142 -0
- package/vendor/sdk/lib/core/index.ts +32 -0
- package/vendor/sdk/lib/core/store.ts +455 -0
- package/vendor/sdk/lib/core/types.ts +194 -0
- package/vendor/sdk/lib/index.ts +59 -0
- package/vendor/sdk/lib/isolate.tsx +70 -0
- package/vendor/sdk/lib/primitives.tsx +453 -0
- package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
- package/vendor/sdk/lib/realtime/types.ts +45 -0
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
- package/vendor/sdk/lib/render.tsx +178 -0
- package/vendor/sdk/lib/streamed.tsx +65 -0
- package/vendor/sdk/lib/style.ts +227 -0
- package/vendor/sdk/lib/template.tsx +521 -0
- package/vendor/sdk/lib/theme.ts +55 -0
- package/vendor/sdk/lib/voice.tsx +311 -0
- package/vendor/sdk/main.tsx +96 -0
- package/vite.config.ts +70 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data-plane translation layer (pure, no React, no DOM).
|
|
3
|
+
*
|
|
4
|
+
* The CHANNEL owns the live workflow connection (WS + REST execute); this file
|
|
5
|
+
* is the thin mapping it feeds: one inbound server message → one mutation of the
|
|
6
|
+
* single `ComponentStore`. Keeping it pure makes it unit-testable and lets the
|
|
7
|
+
* React hook (react/src/connection.tsx) own only socket lifecycle.
|
|
8
|
+
*
|
|
9
|
+
* The Unoverse MCP server is NOT involved here — it's the control plane
|
|
10
|
+
* (definitions + theme). See UNOVERSE_MCP_TEMPLATE_PROTOCOL.md §5a.
|
|
11
|
+
*/
|
|
12
|
+
import type { ComponentStore } from "./store";
|
|
13
|
+
|
|
14
|
+
// ---- Inbound server messages (the subset this rung handles) ----
|
|
15
|
+
// Mirrors the legacy gravity-client contract (core/types.ts). nodeId is
|
|
16
|
+
// SERVER-ASSIGNED per emitted component — never minted by the client.
|
|
17
|
+
|
|
18
|
+
export interface ServerComponentInit {
|
|
19
|
+
type: "COMPONENT_INIT";
|
|
20
|
+
chatId: string;
|
|
21
|
+
nodeId: string;
|
|
22
|
+
component: { type: string; componentUrl?: string; props?: Record<string, unknown> };
|
|
23
|
+
/** Marks a TEMPLATE directive (legacy emits a fake COMPONENT_INIT with nodeId
|
|
24
|
+
* `<name>_template`). Templates are SERVED apps, loaded via resources/read — never
|
|
25
|
+
* rendered as components — so we route these to the template plane, not the timeline. */
|
|
26
|
+
metadata?: { isTemplate?: boolean; [k: string]: unknown };
|
|
27
|
+
}
|
|
28
|
+
export interface ServerComponentData {
|
|
29
|
+
type: "COMPONENT_DATA" | "OBJECT_DATA";
|
|
30
|
+
chatId: string;
|
|
31
|
+
nodeId: string;
|
|
32
|
+
data: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
export interface ServerWorkflowState {
|
|
35
|
+
type: "WORKFLOW_STATE";
|
|
36
|
+
state: string; // WORKFLOW_STARTED | WORKFLOW_COMPLETED | ...
|
|
37
|
+
chatId: string;
|
|
38
|
+
workflowId?: string;
|
|
39
|
+
workflowRunId?: string | null;
|
|
40
|
+
/** Template SELECTION rides here (SPEC §5b: `{ template, templateMode }`) — the
|
|
41
|
+
* OFFICIAL signal for which MCP-app shell to load via resources/read. */
|
|
42
|
+
metadata?: { template?: string; templateMode?: string; [k: string]: unknown };
|
|
43
|
+
}
|
|
44
|
+
export interface ServerSessionReady {
|
|
45
|
+
type: "SESSION_READY";
|
|
46
|
+
}
|
|
47
|
+
/** Generic write into TEMPLATE STATE — the template-state twin of COMPONENT_DATA. The
|
|
48
|
+
* producer names the keys (e.g. the Suggestions node sends `{ faqs }`); core stays generic. */
|
|
49
|
+
export interface ServerTemplateData {
|
|
50
|
+
type: "TEMPLATE_DATA";
|
|
51
|
+
data: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
export type ServerMessage =
|
|
54
|
+
| ServerComponentInit
|
|
55
|
+
| ServerComponentData
|
|
56
|
+
| ServerWorkflowState
|
|
57
|
+
| ServerSessionReady
|
|
58
|
+
| ServerTemplateData
|
|
59
|
+
| { type: string; [k: string]: unknown }; // forward-compat: unknown types ignored
|
|
60
|
+
|
|
61
|
+
// Track first-seen components per `chatId:nodeId` so a repeated COMPONENT_INIT
|
|
62
|
+
// is applied as a props merge (legacy behaviour), not a second timeline placement.
|
|
63
|
+
// Caller passes a Set it owns (per connection), so this stays pure.
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Apply one inbound server message to the single store. Out-of-scope message types
|
|
67
|
+
* (NODE_EXECUTION, audio) are ignored here; audio-state rides the WS lane, not this stream.
|
|
68
|
+
*
|
|
69
|
+
* @param seen per-connection set of already-initialised `chatId:nodeId` keys.
|
|
70
|
+
*/
|
|
71
|
+
export function applyServerMessage(store: ComponentStore, msg: ServerMessage, seen: Set<string>): void {
|
|
72
|
+
switch (msg.type) {
|
|
73
|
+
case "COMPONENT_INIT": {
|
|
74
|
+
const m = msg as ServerComponentInit;
|
|
75
|
+
if (!m.chatId || !m.nodeId) return;
|
|
76
|
+
// TEMPLATE DIRECTIVE — a template is a SERVED MCP app (protocol §0.1), loaded via
|
|
77
|
+
// resources/read unoverse://templates/{name}; it is NEVER a streamed component. Record
|
|
78
|
+
// WHICH app to load (the channel reads it); do not place it in the component timeline
|
|
79
|
+
// (that produced "Unknown Unoverse component: <Template>"). The legacy componentUrl
|
|
80
|
+
// .js bundle is ignored — Unoverse loads the neutral definition, not a React bundle.
|
|
81
|
+
if (m.metadata?.isTemplate === true || m.nodeId.endsWith("_template")) {
|
|
82
|
+
if (m.component?.type) store.setActiveTemplate(m.component.type, m.component.props ?? {});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const key = `${m.chatId}:${m.nodeId}`;
|
|
86
|
+
if (seen.has(key)) {
|
|
87
|
+
// Already placed — treat a repeat INIT as a props merge (no re-placement).
|
|
88
|
+
const props = m.component?.props;
|
|
89
|
+
if (props && Object.keys(props).length > 0) {
|
|
90
|
+
store.apply({ type: "COMPONENT_DATA", chatId: m.chatId, nodeId: m.nodeId, data: props });
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
seen.add(key);
|
|
95
|
+
store.apply({ type: "COMPONENT_INIT", chatId: m.chatId, nodeId: m.nodeId, component: m.component });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
case "COMPONENT_DATA":
|
|
99
|
+
case "OBJECT_DATA": {
|
|
100
|
+
const m = msg as ServerComponentData;
|
|
101
|
+
if (!m.chatId || !m.nodeId) return;
|
|
102
|
+
store.apply({ type: "COMPONENT_DATA", chatId: m.chatId, nodeId: m.nodeId, data: m.data ?? {} });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
case "WORKFLOW_STATE": {
|
|
106
|
+
const m = msg as ServerWorkflowState;
|
|
107
|
+
// OFFICIAL template selection (SPEC §5b): the workflow names the app shell in
|
|
108
|
+
// metadata.template; the channel loads it via resources/read. (templateMode
|
|
109
|
+
// switch/stack/replace is future — default "switch".)
|
|
110
|
+
if (m.metadata?.template) store.setActiveTemplate(m.metadata.template);
|
|
111
|
+
// Open an EMPTY streaming turn so the timeline shows a thinking state BEFORE the
|
|
112
|
+
// first component (legacy processWorkflowState.ts; aiContext treats these as
|
|
113
|
+
// streaming). COMPONENT_INIT then reuses it. Without this, the assistant turn is
|
|
114
|
+
// born WITH its first component, so the streaming-and-empty window never exists.
|
|
115
|
+
if (m.state === "WORKFLOW_STARTED" || m.state === "THINKING" || m.state === "RESPONDING" || m.state === "WAITING") {
|
|
116
|
+
if (m.chatId) store.startResponse(m.chatId);
|
|
117
|
+
} else if (m.state === "WORKFLOW_COMPLETED" || m.state === "COMPLETE") {
|
|
118
|
+
store.completeResponse(m.chatId);
|
|
119
|
+
}
|
|
120
|
+
// Lifecycle machine — the global turn status (projected for visibleWhen).
|
|
121
|
+
if (m.state === "WORKFLOW_STARTED" || m.state === "THINKING") store.setLifecycle("thinking");
|
|
122
|
+
else if (m.state === "RESPONDING" || m.state === "WAITING") store.setLifecycle("streaming");
|
|
123
|
+
else if (m.state === "WORKFLOW_COMPLETED" || m.state === "COMPLETE") store.setLifecycle("complete");
|
|
124
|
+
else if (m.state === "ERROR" || m.state === "WORKFLOW_ERROR") store.setLifecycle("error");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// No AUDIO_STATE case: voice is a NATIVE SERVICE on the WS audio lane (useVoiceService),
|
|
128
|
+
// NOT the structured MCP stream. Audio-state events never arrive here (UNOVERSE_SPEC §2e-1).
|
|
129
|
+
case "TEMPLATE_DATA": {
|
|
130
|
+
// Generic write into TEMPLATE STATE (the template-state twin of COMPONENT_DATA). The
|
|
131
|
+
// PRODUCER names the keys (the Suggestions node sends `{ faqs }`); the template picks
|
|
132
|
+
// them up. Core knows NO key names — fully generic (UNOVERSE_STATE_MODEL §2/§8).
|
|
133
|
+
const data = ((msg as { data?: Record<string, unknown> }).data ?? {}) as Record<string, unknown>;
|
|
134
|
+
store.mergeTemplateState(data);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
default:
|
|
138
|
+
// SESSION_READY is handled by the hook (it may send initialQuery); everything
|
|
139
|
+
// else (NODE_EXECUTION, audio) is out of scope this rung.
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @unoverse/core — framework-agnostic brain. Public barrel.
|
|
3
|
+
*
|
|
4
|
+
* The implementation is split into focused modules:
|
|
5
|
+
* - ./types neutral definition shape + interaction vocab + resolved-theme shape
|
|
6
|
+
* - ./client the MCP client (reads definitions + theme)
|
|
7
|
+
* - ./store the single shared state + interaction state machines
|
|
8
|
+
* - ./connection data-plane translation (server message → store)
|
|
9
|
+
* - ./actions the action interpreter (§2e-3)
|
|
10
|
+
* No UI framework here; the React binding lives in @unoverse/sdk.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export * from "./types";
|
|
14
|
+
export * from "./client";
|
|
15
|
+
export * from "./store";
|
|
16
|
+
|
|
17
|
+
// Data-plane translation layer (pure event→store mapping; channel-owned connection).
|
|
18
|
+
export {
|
|
19
|
+
applyServerMessage,
|
|
20
|
+
type ServerMessage,
|
|
21
|
+
type ServerComponentInit,
|
|
22
|
+
type ServerComponentData,
|
|
23
|
+
type ServerWorkflowState,
|
|
24
|
+
type ServerSessionReady,
|
|
25
|
+
type ServerTemplateData,
|
|
26
|
+
} from "./connection";
|
|
27
|
+
|
|
28
|
+
// The action interpreter (§2e-3) — local `setValue` store write + server route.
|
|
29
|
+
export { dispatchAction, resolveValue, type ActionContext } from "./actions";
|
|
30
|
+
|
|
31
|
+
// The shared "react to state" predicate (visibleWhen / Switch / style.when).
|
|
32
|
+
export { matches, visible, selectCase } from "./conditions";
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @unoverse/core — the single shared state (UNOVERSE_SPEC.md §2e-0).
|
|
3
|
+
*
|
|
4
|
+
* ONE store, ONE subscribe. Framework-agnostic (NO Zustand/XState) so it ports to
|
|
5
|
+
* every platform; the React binding is `useSyncExternalStore` in the react package.
|
|
6
|
+
* Two facets of the same object:
|
|
7
|
+
*
|
|
8
|
+
* 1. DATA — every component instance keyed `${chatId}:${nodeId}`,
|
|
9
|
+
* MERGE-not-replace. The live, high-frequency plane.
|
|
10
|
+
* 2. TIMELINE — the conversation: an ordered list of turns. An assistant
|
|
11
|
+
* turn holds POINTERS (the `chatId:nodeId` keys) into the data
|
|
12
|
+
* plane — never copies. This is what templates read.
|
|
13
|
+
*
|
|
14
|
+
* Plus a generic TEMPLATE-STATE bag (`templateState`) + `lifecycle` (derived from the
|
|
15
|
+
* conversation) + `draft` (the shared composer buffer). The SDK hardcodes NO UI concept —
|
|
16
|
+
* disclosure/focus/etc. are keys the dev writes via the `setValue` (component) or
|
|
17
|
+
* `setTemplateValue` (template) actions and reads via `visibleWhen`. (Voice is a native
|
|
18
|
+
* SERVICE — WS audio lane, react package — whose call state a producer merges into
|
|
19
|
+
* template state, per UNOVERSE_SPEC §2e-1. The store holds structured state only.)
|
|
20
|
+
*
|
|
21
|
+
* The split that makes template-swapping safe: COMPONENT_INIT *places a pointer* in
|
|
22
|
+
* the timeline (structure changes once); COMPONENT_DATA only merges into the data
|
|
23
|
+
* plane (the timeline never moves). A template reads structure, never the stream — so
|
|
24
|
+
* you can swap templates mid-stream and the conversation + live data are untouched.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** A pointer into the data plane — the `${chatId}:${nodeId}` key. */
|
|
28
|
+
export type Pointer = string;
|
|
29
|
+
|
|
30
|
+
export interface UserTurn {
|
|
31
|
+
role: "user";
|
|
32
|
+
id: string;
|
|
33
|
+
chatId: string;
|
|
34
|
+
text: string;
|
|
35
|
+
/** Epoch ms when the turn was created — the renderer formats it (e.g. "just now"). */
|
|
36
|
+
createdAt: number;
|
|
37
|
+
}
|
|
38
|
+
export interface AssistantTurn {
|
|
39
|
+
role: "assistant";
|
|
40
|
+
id: string;
|
|
41
|
+
chatId: string;
|
|
42
|
+
streamingState: "streaming" | "complete";
|
|
43
|
+
/** Pointers into the data plane (`chatId:nodeId`), in arrival order. NOT copies. */
|
|
44
|
+
components: Pointer[];
|
|
45
|
+
/** Epoch ms when the response opened — the renderer formats it (e.g. "just now"). */
|
|
46
|
+
createdAt: number;
|
|
47
|
+
}
|
|
48
|
+
export type Turn = UserTurn | AssistantTurn;
|
|
49
|
+
|
|
50
|
+
export interface ComponentInitEvent {
|
|
51
|
+
type: "COMPONENT_INIT";
|
|
52
|
+
chatId: string;
|
|
53
|
+
nodeId: string;
|
|
54
|
+
component: { type: string; componentUrl?: string; props?: Record<string, unknown> };
|
|
55
|
+
}
|
|
56
|
+
export interface ComponentDataEvent {
|
|
57
|
+
type: "COMPONENT_DATA";
|
|
58
|
+
chatId: string;
|
|
59
|
+
nodeId: string;
|
|
60
|
+
data: Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
export type UnoverseEvent = ComponentInitEvent | ComponentDataEvent;
|
|
63
|
+
|
|
64
|
+
/** Lifecycle machine states (the workflow turn's status). */
|
|
65
|
+
export type LifecycleState = "idle" | "thinking" | "streaming" | "complete" | "error";
|
|
66
|
+
|
|
67
|
+
/** Stable empty slice for missing keys — never mutate. */
|
|
68
|
+
const EMPTY_SLICE: Record<string, unknown> = Object.freeze({});
|
|
69
|
+
|
|
70
|
+
export class ComponentStore {
|
|
71
|
+
// --- data plane ---
|
|
72
|
+
private data = new Map<string, Record<string, unknown>>();
|
|
73
|
+
private types = new Map<string, string>();
|
|
74
|
+
// --- timeline plane (pointers only) ---
|
|
75
|
+
private timeline: Turn[] = [];
|
|
76
|
+
// --- write recency per slice (monotonic seq stamped on every merge) — powers
|
|
77
|
+
// ComponentSlot.select.where's "most recent state-write wins" ordering (§5b).
|
|
78
|
+
// Generic bookkeeping: core stamps WHEN a slice changed, never reads WHAT. ---
|
|
79
|
+
private touches = new Map<string, number>();
|
|
80
|
+
/** Slices whose LATEST write was a guest interaction (`mergeComponentState`) — value is
|
|
81
|
+
* that write's touch seq. Powers `pinned` layouts: guest writes move them, arrivals don't. */
|
|
82
|
+
private interactionTouches = new Map<string, number>();
|
|
83
|
+
private touchSeq = 0;
|
|
84
|
+
// --- shared ---
|
|
85
|
+
private listeners = new Set<() => void>();
|
|
86
|
+
private version = 0;
|
|
87
|
+
private structureVersion = 0;
|
|
88
|
+
private notifyScheduled = false;
|
|
89
|
+
private seq = 0;
|
|
90
|
+
// --- active template (the workflow-selected MCP app shell; §0.1) ---
|
|
91
|
+
private activeTemplateName: string | null = null;
|
|
92
|
+
private activeTemplateProps: Record<string, unknown> = {};
|
|
93
|
+
// --- template state: the active template's OWN generic bag (UNOVERSE_STATE_MODEL §2).
|
|
94
|
+
// draft / suggestions / openPanel / focusedId / voice call state all live here as
|
|
95
|
+
// plain keys — core knows NO key names. Replace/merge only (O(1), never append). ---
|
|
96
|
+
private templateState: Record<string, unknown> = {};
|
|
97
|
+
// --- history cap (UNOVERSE_STATE_MODEL §6a): hold at most N turns, oldest-out-first. ---
|
|
98
|
+
private maxTurns: number;
|
|
99
|
+
|
|
100
|
+
/** @param opts.maxTurns history cap (default 100; §6a). Channels may raise/lower it. */
|
|
101
|
+
constructor(opts?: { maxTurns?: number }) {
|
|
102
|
+
this.maxTurns = opts?.maxTurns ?? 100;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ============ writes ============
|
|
106
|
+
|
|
107
|
+
/** The current composer draft (a key in template state). */
|
|
108
|
+
getDraft(): string {
|
|
109
|
+
return (this.templateState.draft as string) ?? "";
|
|
110
|
+
}
|
|
111
|
+
/** Set the composer draft (input onChange writes here; a send button reads it). */
|
|
112
|
+
setDraft(text: string): void {
|
|
113
|
+
if (this.getDraft() === text) return;
|
|
114
|
+
this.mergeTemplateState({ draft: text });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The workflow-selected template (MCP app shell) + its props, or null if the channel picks. */
|
|
118
|
+
getActiveTemplate(): { name: string | null; props: Record<string, unknown> } {
|
|
119
|
+
return { name: this.activeTemplateName, props: this.activeTemplateProps };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Switch the active template (a workflow "render this app" directive — a COMPONENT_INIT
|
|
123
|
+
* marked isTemplate). Templates are SERVED apps, never streamed components (protocol
|
|
124
|
+
* §0.1); this records WHICH app the channel should load, never a timeline component.
|
|
125
|
+
*/
|
|
126
|
+
setActiveTemplate(name: string, props: Record<string, unknown> = {}): void {
|
|
127
|
+
if (this.activeTemplateName === name && JSON.stringify(this.activeTemplateProps) === JSON.stringify(props)) return;
|
|
128
|
+
// TEMPLATE SWAP = the hard refresh boundary: a NEW shell starts with every surface
|
|
129
|
+
// retired — every instance (durable ones included) returns to inline; the new
|
|
130
|
+
// template derives its base state. Conversation data stays untouched (turns +
|
|
131
|
+
// slices persist; only the active VIEW resets) — swapping back re-presents nothing
|
|
132
|
+
// until something surfaces again.
|
|
133
|
+
if (this.activeTemplateName !== null && this.activeTemplateName !== name) {
|
|
134
|
+
for (const [key, slice] of this.data) {
|
|
135
|
+
if (typeof slice.defaultState === "string" && slice.defaultState !== "inline") {
|
|
136
|
+
this.data.set(key, { ...slice, defaultState: "inline" });
|
|
137
|
+
this.touches.set(key, ++this.touchSeq);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
this.activeTemplateName = name;
|
|
142
|
+
this.activeTemplateProps = props;
|
|
143
|
+
this.bump();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* GUEST CLOSE — un-surface the active view: every non-inline slice returns to `inline`,
|
|
148
|
+
* so a name-synced layout retracts to its base (the ✕ on a rail/panel the guest didn't
|
|
149
|
+
* open a single card from). This is EXACTLY the new-turn reset (`beginExchange`), just
|
|
150
|
+
* triggered by a template close button instead of an arriving turn — same durable
|
|
151
|
+
* (`lifetime: "conversation"`) opt-out, no new vocabulary, nothing stored. The renderer
|
|
152
|
+
* calls this when a template-chrome `setValue defaultState:"inline"` fires (a card that
|
|
153
|
+
* closes ITSELF still uses the plain component write; template chrome has no card to hit).
|
|
154
|
+
*/
|
|
155
|
+
closeSurfaces(): void {
|
|
156
|
+
let changed = false;
|
|
157
|
+
for (const [key, slice] of this.data) {
|
|
158
|
+
if (slice.lifetime === "conversation") continue;
|
|
159
|
+
if (typeof slice.defaultState === "string" && slice.defaultState !== "inline") {
|
|
160
|
+
this.data.set(key, { ...slice, defaultState: "inline" });
|
|
161
|
+
this.touches.set(key, ++this.touchSeq);
|
|
162
|
+
changed = true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (changed) this.bump();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The active template's state bag (templates read this into their root scope). */
|
|
169
|
+
getTemplateState(): Readonly<Record<string, unknown>> {
|
|
170
|
+
return this.templateState;
|
|
171
|
+
}
|
|
172
|
+
/** Merge a patch into template state (replace/merge — never append, stays O(1)). */
|
|
173
|
+
mergeTemplateState(patch: Record<string, unknown>): void {
|
|
174
|
+
this.templateState = { ...this.templateState, ...patch };
|
|
175
|
+
this.bump();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
179
|
+
// STATE HAS TWO HOMES, BY SCOPE (UNOVERSE_STATE_MODEL §2) — and the SDK hardcodes
|
|
180
|
+
// NO UI concept (no panel / focus / faq / voice). The dev picks the key and the home:
|
|
181
|
+
// • COMPONENT state — a component's own data + view state (tab / edit / wizard-step /
|
|
182
|
+
// displayState / …), keyed by its id, written via the `setValue` action (data plane).
|
|
183
|
+
// • TEMPLATE state — the active template's own bag (`templateState`), written via the
|
|
184
|
+
// `setTemplateValue` action (or `mergeTemplateState` from a producer). Just keys.
|
|
185
|
+
// `lifecycle` is the one thing that is NEITHER: it's READ OFF the conversation (the turn's
|
|
186
|
+
// streaming/complete status), so it stays a tiny derived machine here. `draft` is the one
|
|
187
|
+
// named convenience (the shared composer buffer the channel reads to send).
|
|
188
|
+
// (Voice is a NATIVE SERVICE — WS audio lane, react package — whose call state a producer
|
|
189
|
+
// merges into template state. Not a store machine.)
|
|
190
|
+
// ⛔ Do NOT add a UI-pattern method here (togglePanel/openFocus/…). It's a key the dev
|
|
191
|
+
// writes via setValue / setTemplateValue and reads via `visibleWhen`. Removed twice.
|
|
192
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
193
|
+
|
|
194
|
+
// --- lifecycle: the workflow turn's status (derived from WORKFLOW_STATE) ---
|
|
195
|
+
private lifecycleState: LifecycleState = "idle";
|
|
196
|
+
|
|
197
|
+
/** The conversation lifecycle status. */
|
|
198
|
+
getLifecycle(): LifecycleState {
|
|
199
|
+
return this.lifecycleState;
|
|
200
|
+
}
|
|
201
|
+
/** Move the lifecycle machine to a status (idempotent). */
|
|
202
|
+
setLifecycle(s: LifecycleState): void {
|
|
203
|
+
if (this.lifecycleState === s) return;
|
|
204
|
+
this.lifecycleState = s;
|
|
205
|
+
this.bump();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Apply a streamed event (COMPONENT_INIT or COMPONENT_DATA). */
|
|
209
|
+
apply(event: UnoverseEvent): void {
|
|
210
|
+
const key = `${event.chatId}:${event.nodeId}`;
|
|
211
|
+
if (event.type === "COMPONENT_INIT") {
|
|
212
|
+
this.types.set(key, event.component.type);
|
|
213
|
+
this.placeInTimeline(event.chatId, key); // structure: place the pointer ONCE
|
|
214
|
+
this.merge(key, event.component.props ?? {}); // data: seed
|
|
215
|
+
} else {
|
|
216
|
+
// NEW ACTIVITY reads newest-at-the-bottom: if this component is no longer the
|
|
217
|
+
// LAST pointer in its turn (something was placed after it), fresh server data
|
|
218
|
+
// moves it back to the end — e.g. answer text resuming AFTER an interactive
|
|
219
|
+
// component the workflow placed mid-turn. At most ONE structure bump per
|
|
220
|
+
// overtake; the streaming hot path (already last) stays data-only.
|
|
221
|
+
const resp = this.responseFor(event.chatId);
|
|
222
|
+
if (resp && resp.components.length > 1 && resp.components[resp.components.length - 1] !== key && resp.components.includes(key)) {
|
|
223
|
+
resp.components.splice(resp.components.indexOf(key), 1);
|
|
224
|
+
resp.components.push(key);
|
|
225
|
+
this.bump();
|
|
226
|
+
}
|
|
227
|
+
this.merge(key, event.data);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Append a user message turn. Response identity = its chatId. */
|
|
232
|
+
addUserMessage(chatId: string, text: string): void {
|
|
233
|
+
this.timeline.push({ role: "user", id: `u${this.seq++}`, chatId, text, createdAt: Date.now() });
|
|
234
|
+
this.beginExchange();
|
|
235
|
+
this.evict();
|
|
236
|
+
this.bump();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* TWO LIFETIMES, ONE STORE (docs/design/04): CONVERSATION state (turns + each
|
|
241
|
+
* instance's data — durable, append-only, the stream owns it) vs CHAT state (the
|
|
242
|
+
* present interaction: each instance's active VIEW, the template's chrome). A new
|
|
243
|
+
* user turn advances the conversation, so the chat layer returns to its universal
|
|
244
|
+
* default: every instance goes back to `inline` — surfaces empty, the template
|
|
245
|
+
* derives its base state, panels retract. Data is untouched; history keeps every
|
|
246
|
+
* card. (A future durable, conversation-scoped component — a cart, an itinerary —
|
|
247
|
+
* will opt out of this explicitly; today nothing does.)
|
|
248
|
+
*/
|
|
249
|
+
private beginExchange(): void {
|
|
250
|
+
for (const [key, slice] of this.data) {
|
|
251
|
+
// DURABLE conversation-scoped surface (docs/design/04 §Two lifetimes): the
|
|
252
|
+
// producer marked the slice `lifetime: "conversation"` (a cart, an itinerary,
|
|
253
|
+
// a composed page) — it opted out of the new-turn reset and stays surfaced
|
|
254
|
+
// until replaced, closed, or the template swaps. Generic: a key the producer
|
|
255
|
+
// wrote, not a UI concept.
|
|
256
|
+
if (slice.lifetime === "conversation") continue;
|
|
257
|
+
if (typeof slice.defaultState === "string" && slice.defaultState !== "inline") {
|
|
258
|
+
this.data.set(key, { ...slice, defaultState: "inline" });
|
|
259
|
+
this.touches.set(key, ++this.touchSeq);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Open (or re-open) the assistant response for chatId in streaming state, WITHOUT
|
|
266
|
+
* attaching a component. Mirrors legacy WORKFLOW_STARTED (processWorkflowState.ts):
|
|
267
|
+
* it creates the EMPTY streaming turn so the timeline shows a "thinking" state in
|
|
268
|
+
* the window BEFORE the first component arrives. The first COMPONENT_INIT then
|
|
269
|
+
* reuses this same turn (responseFor matches by chatId). Idempotent.
|
|
270
|
+
*/
|
|
271
|
+
startResponse(chatId: string): void {
|
|
272
|
+
const resp = this.responseFor(chatId);
|
|
273
|
+
if (!resp) {
|
|
274
|
+
this.timeline.push({ role: "assistant", id: `a${this.seq++}`, chatId, streamingState: "streaming", components: [], createdAt: Date.now() });
|
|
275
|
+
this.evict();
|
|
276
|
+
this.bump();
|
|
277
|
+
} else if (resp.streamingState !== "streaming") {
|
|
278
|
+
resp.streamingState = "streaming";
|
|
279
|
+
this.bump();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Mark the assistant response for this chatId complete (e.g. on workflow-completed). */
|
|
284
|
+
completeResponse(chatId: string): void {
|
|
285
|
+
const resp = this.responseFor(chatId);
|
|
286
|
+
if (resp && resp.streamingState !== "complete") {
|
|
287
|
+
resp.streamingState = "complete";
|
|
288
|
+
this.bump();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Place a component pointer into the open assistant turn for chatId (lazily creating it). */
|
|
293
|
+
private placeInTimeline(chatId: string, key: Pointer): void {
|
|
294
|
+
let resp = this.responseFor(chatId);
|
|
295
|
+
if (!resp) {
|
|
296
|
+
// Born COMPLETE: only lifecycle events (startResponse) open a STREAMING turn. A
|
|
297
|
+
// turn lazily created just to hold an arriving component (e.g. a conversation-keyed
|
|
298
|
+
// durable render) is not a spoken reply — no completion event will ever match its
|
|
299
|
+
// chatId, so born-streaming left it as an eternal thinking bubble. If a real
|
|
300
|
+
// lifecycle start for this id follows, startResponse flips it to streaming.
|
|
301
|
+
resp = { role: "assistant", id: `a${this.seq++}`, chatId, streamingState: "complete", components: [], createdAt: Date.now() };
|
|
302
|
+
this.timeline.push(resp);
|
|
303
|
+
this.evict();
|
|
304
|
+
}
|
|
305
|
+
if (!resp.components.includes(key)) resp.components.push(key);
|
|
306
|
+
this.bump();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private responseFor(chatId: string): AssistantTurn | undefined {
|
|
310
|
+
// Response identity is the chatId — all components of one turn share it.
|
|
311
|
+
for (let i = this.timeline.length - 1; i >= 0; i--) {
|
|
312
|
+
const t = this.timeline[i];
|
|
313
|
+
if (t.role === "assistant" && t.chatId === chatId) return t;
|
|
314
|
+
}
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Trim the timeline to the last `maxTurns`, deleting the component slices the dropped
|
|
319
|
+
* turns pointed at so the data/types maps can't leak (UNOVERSE_STATE_MODEL §6a). */
|
|
320
|
+
private evict(): void {
|
|
321
|
+
while (this.timeline.length > this.maxTurns) {
|
|
322
|
+
const dropped = this.timeline.shift();
|
|
323
|
+
if (dropped?.role === "assistant") {
|
|
324
|
+
for (const ptr of dropped.components) {
|
|
325
|
+
this.data.delete(ptr);
|
|
326
|
+
this.types.delete(ptr);
|
|
327
|
+
this.touches.delete(ptr);
|
|
328
|
+
this.interactionTouches.delete(ptr);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private merge(key: string, patch: Record<string, unknown>): void {
|
|
335
|
+
// merge-not-replace. Fresh object identity per merge = the per-slice snapshot
|
|
336
|
+
// React bindings key on (useSyncExternalStore bails out for untouched slices).
|
|
337
|
+
this.data.set(key, { ...(this.data.get(key) ?? {}), ...patch });
|
|
338
|
+
this.touches.set(key, ++this.touchSeq);
|
|
339
|
+
this.bumpData();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* LOCAL interaction write into a component's own slice (the `setValue` action).
|
|
344
|
+
* Same merge as inbound COMPONENT_DATA, but STRUCTURE-bumps: slot selection
|
|
345
|
+
* (`ComponentSlot.select.where`) reads component state, and templates subscribe to
|
|
346
|
+
* structure only — a click that changes a component's state must re-resolve the
|
|
347
|
+
* template's slots. Low-frequency (a click, not a token stream), so the synchronous
|
|
348
|
+
* notify is fine.
|
|
349
|
+
*/
|
|
350
|
+
mergeComponentState(chatId: string, nodeId: string, patch: Record<string, unknown>): void {
|
|
351
|
+
const key = `${chatId}:${nodeId}`;
|
|
352
|
+
this.data.set(key, { ...(this.data.get(key) ?? {}), ...patch });
|
|
353
|
+
this.touches.set(key, ++this.touchSeq);
|
|
354
|
+
// Remember that this slice's LATEST write came through the guest's hand — the one
|
|
355
|
+
// fact `pinned` layouts need: a pinned layout yields to guest writes, never to
|
|
356
|
+
// background arrivals (which land via `merge` and don't stamp this).
|
|
357
|
+
this.interactionTouches.set(key, this.touchSeq);
|
|
358
|
+
this.bump();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** The touch seq of this slice's last GUEST write (`setValue`), or -1 if the latest
|
|
362
|
+
* activity was a server arrival. Compare with `touchOf`: equal ⇒ the guest wrote last. */
|
|
363
|
+
interactionTouchOf(pointer: string): number {
|
|
364
|
+
return this.interactionTouches.get(pointer) ?? -1;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* STRUCTURE bump — timeline placement, template state, lifecycle. Low-frequency
|
|
369
|
+
* and often interaction-driven (composer draft), so listeners fire synchronously.
|
|
370
|
+
*/
|
|
371
|
+
private bump(): void {
|
|
372
|
+
this.version++;
|
|
373
|
+
this.structureVersion++;
|
|
374
|
+
this.notify();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* DATA bump — COMPONENT_DATA merges, the per-chunk streaming hot path. A token
|
|
379
|
+
* stream can deliver hundreds of events/sec; notifying per event drives one React
|
|
380
|
+
* render pass each. State is updated synchronously (reads are always fresh) but
|
|
381
|
+
* listener notification coalesces to once per animation frame (display rate),
|
|
382
|
+
* falling back to a macrotask outside the DOM. useSyncExternalStore is safe with
|
|
383
|
+
* deferred notify — it re-reads the snapshot when the notification lands.
|
|
384
|
+
*/
|
|
385
|
+
private bumpData(): void {
|
|
386
|
+
this.version++;
|
|
387
|
+
if (this.notifyScheduled) return;
|
|
388
|
+
this.notifyScheduled = true;
|
|
389
|
+
const flush = () => {
|
|
390
|
+
this.notifyScheduled = false;
|
|
391
|
+
this.notify();
|
|
392
|
+
};
|
|
393
|
+
if (typeof requestAnimationFrame === "function") requestAnimationFrame(flush);
|
|
394
|
+
else setTimeout(flush, 0);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private notify(): void {
|
|
398
|
+
this.listeners.forEach((l) => l());
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ============ reads — data plane (unchanged API; streamed.tsx depends on these) ============
|
|
402
|
+
|
|
403
|
+
get(chatId: string, nodeId: string): Record<string, unknown> {
|
|
404
|
+
// EMPTY (module-stable) for missing keys — a fresh {} per call would make a
|
|
405
|
+
// snapshot-based useSyncExternalStore loop forever.
|
|
406
|
+
return this.data.get(`${chatId}:${nodeId}`) ?? EMPTY_SLICE;
|
|
407
|
+
}
|
|
408
|
+
getType(chatId: string, nodeId: string): string | undefined {
|
|
409
|
+
return this.types.get(`${chatId}:${nodeId}`);
|
|
410
|
+
}
|
|
411
|
+
getVersion(): number {
|
|
412
|
+
return this.version;
|
|
413
|
+
}
|
|
414
|
+
/** Bumped only by timeline/template-state/lifecycle changes — what templates subscribe to.
|
|
415
|
+
* COMPONENT_DATA merges don't touch it, so streaming chunks never re-render the template. */
|
|
416
|
+
getStructureVersion(): number {
|
|
417
|
+
return this.structureVersion;
|
|
418
|
+
}
|
|
419
|
+
subscribe(fn: () => void): () => void {
|
|
420
|
+
this.listeners.add(fn);
|
|
421
|
+
return () => {
|
|
422
|
+
this.listeners.delete(fn);
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ============ reads — timeline plane (what templates iterate) ============
|
|
427
|
+
|
|
428
|
+
/** The full conversation, in order. */
|
|
429
|
+
getTimeline(): readonly Turn[] {
|
|
430
|
+
return this.timeline;
|
|
431
|
+
}
|
|
432
|
+
/** Just the assistant responses, in order. */
|
|
433
|
+
getResponses(): AssistantTurn[] {
|
|
434
|
+
return this.timeline.filter((t): t is AssistantTurn => t.role === "assistant");
|
|
435
|
+
}
|
|
436
|
+
/** The most recent assistant response (KeyService-style single-response layouts). */
|
|
437
|
+
latestResponse(): AssistantTurn | undefined {
|
|
438
|
+
const r = this.getResponses();
|
|
439
|
+
return r[r.length - 1];
|
|
440
|
+
}
|
|
441
|
+
/** Resolve a pointer back to its parts (for handing to the leaf renderer). */
|
|
442
|
+
split(pointer: Pointer): { chatId: string; nodeId: string } {
|
|
443
|
+
const i = pointer.indexOf(":");
|
|
444
|
+
return { chatId: pointer.slice(0, i), nodeId: pointer.slice(i + 1) };
|
|
445
|
+
}
|
|
446
|
+
/** Type of the component a pointer refers to (for filter-by-type in templates). */
|
|
447
|
+
typeOf(pointer: Pointer): string | undefined {
|
|
448
|
+
return this.types.get(pointer);
|
|
449
|
+
}
|
|
450
|
+
/** Write-recency of a slice (monotonic seq; 0 = never written). Powers the
|
|
451
|
+
* `select.where` most-recent-wins ordering — last writer wins, like any store. */
|
|
452
|
+
touchOf(pointer: Pointer): number {
|
|
453
|
+
return this.touches.get(pointer) ?? 0;
|
|
454
|
+
}
|
|
455
|
+
}
|