@unoverse-platform/studio 0.1.96 → 0.1.98
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 +3 -3
- package/local/authoring.ts +1 -1
- package/local/catalog.ts +22 -2
- package/local/keys.ts +1 -1
- package/local/nodes.ts +29 -42
- package/local/plugin.ts +75 -13
- package/local/sample-identity/brand.yaml +24 -0
- package/local/sample-identity/organisation.yaml +14 -0
- package/local/sample-identity/purpose.yaml +14 -0
- package/local/sample-identity/story.yaml +18 -0
- package/local/scaffold.mjs +51 -22
- package/local/source.ts +4 -9
- package/package.json +1 -1
- package/screens/AgentsView.tsx +173 -0
- package/screens/ComponentsView.tsx +2 -2
- package/screens/EmptyLibrary.tsx +1 -1
- package/screens/IdentityHeader.tsx +1 -1
- package/screens/IdentityView.tsx +206 -0
- package/screens/PromptBlocksView.tsx +1 -1
- package/screens/SourceView.tsx +1 -1
- package/screens/TemplatesLibraryView.tsx +91 -6
- package/screens/TemplatesView.tsx +24 -9
- package/screens/index.ts +5 -2
- package/src/App.tsx +14 -4
- package/src/NodeKeys.tsx +1 -1
- package/src/host.ts +3 -16
- package/vendor/base/src/agent-mcp/discovery.ts +76 -18
- package/vendor/base/src/agent-mcp/handoff.ts +11 -31
- package/vendor/base/src/agent-mcp/index.ts +5 -5
- package/vendor/base/src/agent-mcp/invoke.ts +37 -19
- package/vendor/base/src/agent-mcp/lean.ts +19 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
- package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
- package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
- package/vendor/base/src/agent-mcp/tools.ts +136 -7
- package/vendor/base/src/agent-mcp/types.ts +47 -10
- package/vendor/base/src/definitions/load/compile/component.ts +59 -4
- package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
- package/vendor/base/src/definitions/load/compile/template.ts +19 -15
- package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
- package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
- package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
- package/vendor/base/src/definitions/load/load.ts +37 -28
- package/vendor/base/src/definitions/model.ts +8 -12
- package/vendor/base/src/definitions/registry/agents.ts +111 -0
- package/vendor/base/src/definitions/registry/catalog.ts +28 -12
- package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
- package/vendor/base/src/definitions/registry/homes.ts +69 -5
- package/vendor/base/src/definitions/registry/objects.ts +413 -0
- package/vendor/base/src/definitions/registry/orgDocuments.ts +107 -0
- package/vendor/base/src/definitions/registry/orgPack.ts +50 -0
- package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
- package/vendor/base/src/definitions/theme.ts +1 -2
- package/vendor/base/src/items/collect.ts +33 -25
- package/vendor/base/src/items/publish.ts +31 -7
- package/vendor/base/src/lint/context.mjs +17 -0
- package/vendor/base/src/lint/design/file.mjs +199 -2
- package/vendor/base/src/lint/design/index.mjs +66 -73
- package/vendor/base/src/lint/design/vocabulary.mjs +6 -3
- package/vendor/base/src/lint/design/walk.mjs +14 -4
- package/vendor/base/src/lint/index.mjs +15 -9
- package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
- package/vendor/base/src/lint/nodes/index.mjs +6 -1
- package/vendor/base/src/lint/nodes/node.mjs +157 -7
- package/vendor/base/src/lint/objects/index.d.mts +32 -0
- package/vendor/base/src/lint/objects/index.mjs +324 -0
- package/vendor/base/src/lint/objects/rules.d.mts +44 -0
- package/vendor/base/src/lint/objects/rules.mjs +446 -0
- package/vendor/base/src/lint/prompts/index.d.mts +34 -0
- package/vendor/base/src/lint/prompts/index.mjs +198 -0
- package/vendor/base/src/manifests/compose.ts +81 -10
- package/vendor/base/src/manifests/executor/callback.ts +15 -4
- package/vendor/base/src/manifests/executor/context.ts +12 -1
- package/vendor/base/src/manifests/executor/memory.ts +83 -3
- package/vendor/base/src/manifests/executor/session.ts +50 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
- package/vendor/base/src/manifests/index.ts +1 -1
- package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
- package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
- package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
- package/vendor/base/src/manifests/runtime/context.ts +18 -6
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
- package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
- package/vendor/base/src/manifests/runtime/events.ts +39 -3
- package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
- package/vendor/base/src/manifests/runtime/index.ts +3 -2
- package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
- package/vendor/base/src/manifests/runtime/usage.ts +10 -0
- package/vendor/base/src/manifests/source.ts +1 -1
- package/vendor/base/src/paths.ts +16 -8
- package/vendor/base/src/platform/audioManager.ts +32 -0
- package/vendor/base/src/platform/executionContext.ts +14 -0
- package/vendor/base/src/platform/liveInput.ts +52 -0
- package/vendor/base/src/plugins/README.md +1 -1
- package/vendor/base/src/plugins/discovery.ts +1 -1
- package/vendor/base/src/template/SafeExpression.ts +6 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
- package/vendor/sdk/appEngine.tsx +31 -12
- package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
- package/vendor/sdk/lib/app.tsx +70 -95
- package/vendor/sdk/lib/connection.tsx +47 -57
- package/vendor/sdk/lib/core/actions.ts +6 -0
- package/vendor/sdk/lib/core/apps.ts +66 -81
- package/vendor/sdk/lib/core/client.ts +5 -14
- package/vendor/sdk/lib/core/connection.ts +30 -0
- package/vendor/sdk/lib/core/index.ts +2 -2
- package/vendor/sdk/lib/core/store.ts +90 -15
- package/vendor/sdk/lib/core/types.ts +9 -8
- package/vendor/sdk/lib/primitives.tsx +113 -4
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
- package/vendor/sdk/lib/render.tsx +3 -3
- package/vendor/sdk/lib/voice.tsx +27 -13
- package/vite.config.ts +1 -1
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
/** A pointer into the data plane — the `${chatId}:${nodeId}` key. */
|
|
28
|
+
import { stateOfUri } from "./apps";
|
|
29
|
+
|
|
28
30
|
export type Pointer = string;
|
|
29
31
|
|
|
30
32
|
/** Structural equality for wire values: identity first, JSON compare for the rest.
|
|
@@ -79,6 +81,7 @@ export type LifecycleState = "idle" | "thinking" | "streaming" | "complete" | "e
|
|
|
79
81
|
|
|
80
82
|
/** Stable empty slice for missing keys — never mutate. */
|
|
81
83
|
const EMPTY_SLICE: Record<string, unknown> = Object.freeze({});
|
|
84
|
+
const EMPTY_POINTERS: readonly Pointer[] = Object.freeze([]);
|
|
82
85
|
|
|
83
86
|
export class ComponentStore {
|
|
84
87
|
// --- data plane ---
|
|
@@ -122,6 +125,13 @@ export class ComponentStore {
|
|
|
122
125
|
private appState: Record<string, unknown> = {};
|
|
123
126
|
// --- history cap (UNOVERSE_STATE_MODEL §6a): hold at most N turns, oldest-out-first. ---
|
|
124
127
|
private maxTurns: number;
|
|
128
|
+
// --- places (docs/unoverse/design/screen/UNOVERSE_SCREEN.md): what the server's screen document puts
|
|
129
|
+
// in each place, in order. An app that declares places draws from here and nothing else. ---
|
|
130
|
+
private places = new Map<string, Pointer[]>();
|
|
131
|
+
/** Interfaces that have been placed outside the conversation at some point (see setPlace). */
|
|
132
|
+
private everOff = new Set<Pointer>();
|
|
133
|
+
/** The state the screen placed each interface in: a place draws it in this face. */
|
|
134
|
+
private placed = new Map<Pointer, string>();
|
|
125
135
|
|
|
126
136
|
/** @param opts.maxTurns history cap (default 100; §6a). Channels may raise/lower it. */
|
|
127
137
|
constructor(opts?: { maxTurns?: number }) {
|
|
@@ -179,7 +189,11 @@ export class ComponentStore {
|
|
|
179
189
|
* marked isApp). Templates are SERVED apps, never streamed components (protocol
|
|
180
190
|
* §0.1); this records WHICH app the channel should load, never a timeline component.
|
|
181
191
|
*/
|
|
182
|
-
setActiveApp(
|
|
192
|
+
setActiveApp(address: string, given: Record<string, unknown> = {}): void {
|
|
193
|
+
// THE HASH NAMES THE STATE (stateOfUri): `unoverse://apps/acme/workspace#review` is the
|
|
194
|
+
// app, opened on review; the hash seeds `state` when the props carry none.
|
|
195
|
+
const { uri: name, state } = stateOfUri(address);
|
|
196
|
+
const props = state && given.state == null ? { state, ...given } : given;
|
|
183
197
|
if (this.activeAppName === name && JSON.stringify(this.activeAppProps) === JSON.stringify(props)) return;
|
|
184
198
|
// TEMPLATE SWAP = the hard refresh boundary: a NEW shell starts with every surface
|
|
185
199
|
// retired — every instance (durable ones included) returns to inline; the new
|
|
@@ -294,13 +308,16 @@ export class ComponentStore {
|
|
|
294
308
|
* declared initial, and a card whose initial IS the lower state would re-claim it on
|
|
295
309
|
* the next walk and never settle.
|
|
296
310
|
*/
|
|
297
|
-
cancelBelow(active: string, order: string[]): void {
|
|
311
|
+
cancelBelow(active: string, order: string[], aliasOf?: Map<string, string>): void {
|
|
298
312
|
const below = new Set(order.slice(order.indexOf(active) + 1));
|
|
299
313
|
if (order.indexOf(active) < 0 || !below.size) return;
|
|
300
314
|
let changed = false;
|
|
301
315
|
for (const [key, slice] of this.data) {
|
|
302
316
|
if (slice.lifetime === "conversation") continue;
|
|
303
|
-
|
|
317
|
+
// A face is read THROUGH the mount claims (`mountAliases`), exactly as the ladder
|
|
318
|
+
// reads it: a card wearing `grid` under a `page` mount sits in `page`.
|
|
319
|
+
const face = (slice.state ?? slice.view ?? slice.defaultState) as string;
|
|
320
|
+
if (!below.has(aliasOf?.get(face) ?? face)) continue;
|
|
304
321
|
this.data.set(key, { ...slice, [this.faceKey(slice)]: "inline", ...("defaultState" in slice ? { defaultState: "inline" } : {}) });
|
|
305
322
|
this.touches.set(key, ++this.touchSeq);
|
|
306
323
|
changed = true;
|
|
@@ -308,11 +325,24 @@ export class ComponentStore {
|
|
|
308
325
|
if (changed) this.bump();
|
|
309
326
|
}
|
|
310
327
|
|
|
328
|
+
/**
|
|
329
|
+
* GUEST CLOSE from template chrome: every surfaced slice retracts to `inline`, the
|
|
330
|
+
* universal fallback face — never to its declared initial (ruled 2026-09-07: a card's
|
|
331
|
+
* declared initial is a claimed state such as `grid`, so retracting there resurrected
|
|
332
|
+
* the rail the guest had just closed; rule 6 forbids it). Durable slices opt out as ever.
|
|
333
|
+
*/
|
|
311
334
|
closeSurfaces(): void {
|
|
312
335
|
let changed = false;
|
|
313
336
|
for (const [key, slice] of this.data) {
|
|
314
337
|
if (slice.lifetime === "conversation") continue;
|
|
315
|
-
|
|
338
|
+
const current = (slice.state ?? slice.view ?? slice.defaultState) as unknown;
|
|
339
|
+
if (typeof current !== "string" || current === "inline") continue;
|
|
340
|
+
const next: Record<string, unknown> = { ...slice };
|
|
341
|
+
next[this.faceKey(next)] = "inline";
|
|
342
|
+
if ("defaultState" in next) next.defaultState = "inline";
|
|
343
|
+
this.data.set(key, next);
|
|
344
|
+
this.touches.set(key, ++this.touchSeq);
|
|
345
|
+
changed = true;
|
|
316
346
|
}
|
|
317
347
|
if (changed) this.bump();
|
|
318
348
|
}
|
|
@@ -327,6 +357,49 @@ export class ComponentStore {
|
|
|
327
357
|
this.bump();
|
|
328
358
|
}
|
|
329
359
|
|
|
360
|
+
/** Replace one place's contents, and the state each interface is placed in, as the server's
|
|
361
|
+
* SCREEN_PLACE says. */
|
|
362
|
+
setPlace(place: string, pointers: Pointer[], states: Record<Pointer, string> = {}): void {
|
|
363
|
+
const current = this.places.get(place) ?? [];
|
|
364
|
+
const sameList = current.length === pointers.length && current.every((p, i) => p === pointers[i]);
|
|
365
|
+
const sameStates = Object.entries(states).every(([p, s]) => this.placed.get(p) === s);
|
|
366
|
+
if (sameList && sameStates) return;
|
|
367
|
+
// STEPPING ASIDE IS AN ARRIVAL IN THE CONVERSATION (owner, 2026-09-16). An interface that
|
|
368
|
+
// opened in another place (focus, main, rail) was placed in its turn when it opened, off
|
|
369
|
+
// the conversation, after the reply bubble that had already been created. When the
|
|
370
|
+
// screen moves it into `chat` it appears in the conversation for the first time, so that
|
|
371
|
+
// is where its turn places it: at the front, above the reply. Placed once, here, and no
|
|
372
|
+
// later data moves it (the 2026-09-08 ruling below, "where it arrived is where it lives",
|
|
373
|
+
// holds: this is an arrival, not an update). A card shown straight into `chat` was never
|
|
374
|
+
// elsewhere and keeps its arrival order.
|
|
375
|
+
// Remembered, not read at the moment: the screen empties the old place and fills `chat`
|
|
376
|
+
// as two messages, in either order, so "is it elsewhere now" can already be false.
|
|
377
|
+
if (place !== "chat") for (const p of pointers) this.everOff.add(p);
|
|
378
|
+
if (place === "chat") {
|
|
379
|
+
const wasInChat = new Set(current);
|
|
380
|
+
for (const p of pointers) {
|
|
381
|
+
if (wasInChat.has(p) || !this.everOff.has(p)) continue;
|
|
382
|
+
// A TURN WITH COMPONENTS IS THE ASSISTANT'S: said in the predicate, because a check
|
|
383
|
+
// inside a callback does not narrow the union outside it, and only the assistant's turn
|
|
384
|
+
// has `components` at all.
|
|
385
|
+
const turn = this.timeline.find((t): t is AssistantTurn => t.role === "assistant" && t.components.includes(p));
|
|
386
|
+
if (!turn) continue;
|
|
387
|
+
turn.components = [p, ...turn.components.filter((c) => c !== p)];
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
this.places.set(place, pointers);
|
|
391
|
+
for (const [p, s] of Object.entries(states)) this.placed.set(p, s);
|
|
392
|
+
this.bump();
|
|
393
|
+
}
|
|
394
|
+
/** The state the screen placed an interface in, or undefined when the screen named none. */
|
|
395
|
+
placedState(pointer: Pointer): string | undefined {
|
|
396
|
+
return this.placed.get(pointer);
|
|
397
|
+
}
|
|
398
|
+
/** What one place holds, in the order the screen document gives. */
|
|
399
|
+
placeItems(place: string): readonly Pointer[] {
|
|
400
|
+
return this.places.get(place) ?? EMPTY_POINTERS;
|
|
401
|
+
}
|
|
402
|
+
|
|
330
403
|
// ════════════════════════════════════════════════════════════════════════════
|
|
331
404
|
// STATE HAS TWO HOMES, BY SCOPE (UNOVERSE_STATE_MODEL §2) — and the SDK hardcodes
|
|
332
405
|
// NO UI concept (no panel / focus / faq / voice). The dev picks the key and the home:
|
|
@@ -404,17 +477,19 @@ export class ComponentStore {
|
|
|
404
477
|
const seeded = event.component.props as Record<string, unknown> | undefined;
|
|
405
478
|
this.supersede(event.chatId, seeded?.state ?? seeded?.view ?? seeded?.defaultState);
|
|
406
479
|
} else {
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
// moves it
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
480
|
+
// WHERE IT ARRIVED IS WHERE IT LIVES (owner ruling 2026-09-08). A pointer's place in
|
|
481
|
+
// its turn is fixed by `placeInTimeline` when it first appears, and no later data
|
|
482
|
+
// moves it. Updates change what a component SAYS, never where it sits.
|
|
483
|
+
//
|
|
484
|
+
// This replaces "new activity reads newest-at-the-bottom", which moved a component to
|
|
485
|
+
// the end of its turn whenever fresh data arrived for it. That was written for one
|
|
486
|
+
// case — answer text resuming after an interactive component placed mid-turn — and it
|
|
487
|
+
// recomputed order on EVERY update, which is stable only while one thing is updating.
|
|
488
|
+
// A held Agent task has two: the task's face ticking through its steps, and the
|
|
489
|
+
// reply resuming once the result lands. They alternate, so the bottom slot alternated
|
|
490
|
+
// with them and the pair visibly swapped places on screen (SAB Account Agent,
|
|
491
|
+
// 2026-09-08). The case it was written for is served by arrival order too: text
|
|
492
|
+
// placed before a form stays above it.
|
|
418
493
|
this.merge(key, this.shieldGuestFace(key, event.data));
|
|
419
494
|
// Rule 5 — a server data-write that PLACES a component into a public state
|
|
420
495
|
// (the host's arrival placement rides COMPONENT_DATA) refreshes that state.
|
|
@@ -72,6 +72,9 @@ export interface UnoverseNode {
|
|
|
72
72
|
* EXISTS only while a component is in its state (§5b: derived, not stored) — e.g. a
|
|
73
73
|
* focus overlay that appears because something is focused and vanishes when it isn't. */
|
|
74
74
|
frame?: UnoverseNode;
|
|
75
|
+
/** Place: how the place holds what the screen shows into it. `one` shows the newest;
|
|
76
|
+
* `many` shows the set the last show named (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §2.2). */
|
|
77
|
+
holds?: "one" | "many";
|
|
75
78
|
/** Skeleton: placeholder variant. */
|
|
76
79
|
variant?: "text" | "card" | "image";
|
|
77
80
|
/** Timeline: data sub-tree rendered for a user turn (binds `text`). ALL bubble UX lives here, not the SDK. */
|
|
@@ -154,6 +157,9 @@ export interface UnoverseNode {
|
|
|
154
157
|
/** Markdown: render links in the surrounding text colour rather than the link colour,
|
|
155
158
|
* for a block that is already inside a coloured surface. */
|
|
156
159
|
inheritLinkColor?: boolean;
|
|
160
|
+
/** Markdown: the action a citation's Open button fires (a `cite:` link, drawn as a marker with
|
|
161
|
+
* a pop-up), with the citation in scope. Undeclared, the pop-up shows without the button. */
|
|
162
|
+
citeAction?: string;
|
|
157
163
|
/** Input: cap the number of characters accepted. */
|
|
158
164
|
maxLength?: number;
|
|
159
165
|
/** Input: render a multi-line field rather than a single line. */
|
|
@@ -206,19 +212,14 @@ export interface UnoverseDefinition {
|
|
|
206
212
|
* state entry that mounts a template (`template:`); the loader stamps the mounted
|
|
207
213
|
* template's own state names as `claims` — the mount state's reaction claim. */
|
|
208
214
|
templates?: Record<string, { template?: string; claims?: string[]; preview?: string[]; director?: Record<string, unknown> } & Record<string, unknown>>;
|
|
215
|
+
/** The places this app declares. An app with places is drawn by the screen document:
|
|
216
|
+
* each interface sits where the server placed it (UNOVERSE_SCREEN.md). */
|
|
217
|
+
places?: { name: string; holds: "one" | "many" }[];
|
|
209
218
|
defaultLayout?: string;
|
|
210
219
|
/** THE DECLARED STATE ORDER (STATE_MODEL v2 §5 rules 3+5) — the template's priority
|
|
211
220
|
* ladder and arrival-scan order, served from the manifest. First entry outranks
|
|
212
221
|
* all below it; no name is special. Absent → legacy v1 recency semantics. */
|
|
213
222
|
stateOrder?: string[];
|
|
214
|
-
/** THE LATCH, PER STATE (docs/MCP_COMPLETE_GUIDE.md §The Component Latch, moved off the
|
|
215
|
-
* manifest 2026-08-31): the states whose wearer the conversation may be ADDRESSED AT,
|
|
216
|
-
* keyed by state name, compiled from each state's own `latch:` block.
|
|
217
|
-
*
|
|
218
|
-
* A state absent from this map can never hold the latch, so the SAME instance retracted
|
|
219
|
-
* from its page state to an inline one drops the pill by construction. `resolveLatch`
|
|
220
|
-
* reads this; nothing about the latch travels on the wire with a render. */
|
|
221
|
-
latches?: Record<string, { title: string; icon?: string }>;
|
|
222
223
|
}
|
|
223
224
|
|
|
224
225
|
// ---- The interaction vocabulary (UNOVERSE_SPEC.md §2e-3) ----
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* Text/Image (see FRAMEWORK.md), never a composite.
|
|
20
20
|
* ════════════════════════════════════════════════════════════════════════════
|
|
21
21
|
*/
|
|
22
|
-
import { createElement, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
22
|
+
import { createContext, createElement, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
23
23
|
import Markdown from "markdown-to-jsx";
|
|
24
24
|
import type { ResolvedTheme, UnoverseNode } from "./core";
|
|
25
25
|
import { styleToCss, cssDecls } from "./style";
|
|
@@ -699,6 +699,58 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
|
|
|
699
699
|
);
|
|
700
700
|
}
|
|
701
701
|
|
|
702
|
+
/**
|
|
703
|
+
* WHAT A CITATION'S OPEN BUTTON FIRES (docs/unoverse/data/objects/citations-in-answers.md): the
|
|
704
|
+
* action the Markdown node declares in `citeAction`, with the render data. Carried by context,
|
|
705
|
+
* not baked into the overrides, so the overrides stay built once per theme and a marker's open
|
|
706
|
+
* pop-up survives the answer streaming on around it.
|
|
707
|
+
*/
|
|
708
|
+
const CiteContext = createContext<{ action?: string; onAction?: ActionHandler; data: Record<string, unknown> }>({ data: {} });
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* A CITATION STILL ARRIVING is held back (citations-in-answers.md): while an answer streams, the
|
|
712
|
+
* tail from an open `[1](cite:` to its closing `)` would draw as a long line of raw link and then
|
|
713
|
+
* collapse into its marker, jolting the text once per citation. Only the tail is touched, and only
|
|
714
|
+
* a numbered label (a citation's), so finished text and web links draw exactly as written.
|
|
715
|
+
*/
|
|
716
|
+
const OPEN_CITATION = /\[\d{0,3}(?:\](?:\((?:c(?:i(?:t(?:e(?::[^)\n]*)?)?)?)?)?)?)?$/;
|
|
717
|
+
export function holdOpenCitation(text: string): string {
|
|
718
|
+
return text.replace(OPEN_CITATION, "");
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* A CITATION'S QUOTE STAYS ONE CELL (citations-in-answers.md, 2026-09-15): a quote of a table row
|
|
723
|
+
* carried its pipes ("Total | 10") into the link's title, and inside an answer's own table the
|
|
724
|
+
* pipe split the row and left the marker raw. Before the markdown is parsed, a pipe inside a
|
|
725
|
+
* `cite:` link's title becomes a broken bar and a line break a space; the words still read.
|
|
726
|
+
*/
|
|
727
|
+
const CITATION_TITLE = /(\]\(cite:[^)\s]*\s+")([^"]*)("\))/g;
|
|
728
|
+
export function keepCitationsWhole(text: string): string {
|
|
729
|
+
return text.replace(CITATION_TITLE, (_, open: string, quote: string, close: string) => `${open}${quote.replace(/\|/g, "\u00a6").replace(/\s*\n\s*/g, " ")}${close}`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* WHERE A POP-UP FITS (citations-in-answers.md): the horizontal shift that keeps a box of this
|
|
734
|
+
* width inside the window from an anchor at `left`, and whether it opens above the anchor
|
|
735
|
+
* because below there is no room and above there is. A margin keeps it off the very edge.
|
|
736
|
+
*/
|
|
737
|
+
export function fitPopover(anchor: { left: number; top: number; bottom: number }, box: { width: number; height: number }, view: { width: number; height: number }, margin = 8) {
|
|
738
|
+
const over = anchor.left + box.width - (view.width - margin);
|
|
739
|
+
const shift = Math.max(over > 0 ? -over : 0, margin - anchor.left);
|
|
740
|
+
const above = anchor.bottom + box.height + margin > view.height && anchor.top - box.height - margin >= 0;
|
|
741
|
+
return { shift, above };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** A `cite:` link's parts: `cite:<document>?part=&page=&doc=`, its title the quoted words. */
|
|
745
|
+
function citationOf(href: string, title?: string) {
|
|
746
|
+
try {
|
|
747
|
+
const u = new URL(href);
|
|
748
|
+
return { document: decodeURIComponent(u.pathname), part: u.searchParams.get("part") ?? "", page: u.searchParams.get("page") ?? "", doc: u.searchParams.get("doc") ?? "", quote: title ?? "" };
|
|
749
|
+
} catch {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
702
754
|
/**
|
|
703
755
|
* markdown-to-jsx overrides for the `Markdown` primitive.
|
|
704
756
|
*
|
|
@@ -734,6 +786,60 @@ function buildMarkdownOverrides(theme: ResolvedTheme, inheritLinkColor = false)
|
|
|
734
786
|
{linkArrow}
|
|
735
787
|
</a>
|
|
736
788
|
);
|
|
789
|
+
// A CITATION (citations-in-answers.md): a `cite:` link is a marker after the fact it supports.
|
|
790
|
+
// Tapped, a pop-up shows the quoted words, the document and the page, and an Open button that
|
|
791
|
+
// fires the node's `citeAction` with the citation. Behaviour only: every look is a served
|
|
792
|
+
// prose key (citeAnchor, citeMark, citePopover, citeQuote, citeSource, citeOpen).
|
|
793
|
+
const CiteMark = ({ href, title, children }: { href: string; title?: string; children?: ReactNode }) => {
|
|
794
|
+
const [open, setOpen] = useState(false);
|
|
795
|
+
const box = useRef<HTMLSpanElement>(null);
|
|
796
|
+
const pop = useRef<HTMLSpanElement>(null);
|
|
797
|
+
const [place, setPlace] = useState({ shift: 0, above: false });
|
|
798
|
+
// ALWAYS ON SCREEN: measured as it opens, and again when the window changes size.
|
|
799
|
+
useLayoutEffect(() => {
|
|
800
|
+
if (!open) return;
|
|
801
|
+
const fit = () => {
|
|
802
|
+
if (!box.current || !pop.current) return;
|
|
803
|
+
const a = box.current.getBoundingClientRect();
|
|
804
|
+
setPlace(fitPopover(a, { width: pop.current.offsetWidth, height: pop.current.offsetHeight }, { width: window.innerWidth, height: window.innerHeight }));
|
|
805
|
+
};
|
|
806
|
+
fit();
|
|
807
|
+
window.addEventListener("resize", fit);
|
|
808
|
+
return () => window.removeEventListener("resize", fit);
|
|
809
|
+
}, [open]);
|
|
810
|
+
const { action, onAction, data } = useContext(CiteContext);
|
|
811
|
+
const c = citationOf(href, title);
|
|
812
|
+
useEffect(() => {
|
|
813
|
+
if (!open) return;
|
|
814
|
+
const away = (e: Event) => { if (box.current && !e.composedPath().includes(box.current)) setOpen(false); };
|
|
815
|
+
const esc = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
|
816
|
+
document.addEventListener("pointerdown", away);
|
|
817
|
+
document.addEventListener("keydown", esc);
|
|
818
|
+
return () => { document.removeEventListener("pointerdown", away); document.removeEventListener("keydown", esc); };
|
|
819
|
+
}, [open]);
|
|
820
|
+
if (!c) return <>{children}</>;
|
|
821
|
+
const source = [c.doc, c.page ? `p. ${c.page}` : ""].filter(Boolean).join(" · ");
|
|
822
|
+
return (
|
|
823
|
+
<span ref={box} style={s("citeAnchor")}>
|
|
824
|
+
<button type="button" aria-expanded={open} aria-label={source ? `Source: ${source}` : "Source"} style={s("citeMark")} onClick={() => setOpen((o) => !o)}>
|
|
825
|
+
{children}
|
|
826
|
+
</button>
|
|
827
|
+
{open && (
|
|
828
|
+
<span ref={pop} role="dialog" style={{ ...s("citePopover"), transform: `translateX(${place.shift}px)`, ...(place.above ? { top: "auto", bottom: "100%" } : {}) }}>
|
|
829
|
+
{c.quote && <span style={{ display: "block", ...s("citeQuote") }}>“{c.quote}”</span>}
|
|
830
|
+
{source && <span style={{ display: "block", ...s("citeSource") }}>{source}</span>}
|
|
831
|
+
{action && onAction && (
|
|
832
|
+
<button type="button" style={s("citeOpen")} onClick={() => { setOpen(false); onAction(action, { ...data, citation: c }); }}>
|
|
833
|
+
Open
|
|
834
|
+
</button>
|
|
835
|
+
)}
|
|
836
|
+
</span>
|
|
837
|
+
)}
|
|
838
|
+
</span>
|
|
839
|
+
);
|
|
840
|
+
};
|
|
841
|
+
const Link = (a: React.AnchorHTMLAttributes<HTMLAnchorElement>) =>
|
|
842
|
+
typeof a.href === "string" && a.href.startsWith("cite:") ? <CiteMark href={a.href} title={a.title}>{a.children}</CiteMark> : <ExternalLink {...a} />;
|
|
737
843
|
// Wide tables scroll instead of crushing columns (legacy ScrollableTable).
|
|
738
844
|
const ScrollableTable = (a: React.TableHTMLAttributes<HTMLTableElement>) => (
|
|
739
845
|
<div style={s("tableWrap")}>
|
|
@@ -751,7 +857,7 @@ function buildMarkdownOverrides(theme: ResolvedTheme, inheritLinkColor = false)
|
|
|
751
857
|
|
|
752
858
|
return {
|
|
753
859
|
p: { props: { style: s("paragraph") } },
|
|
754
|
-
a: { component:
|
|
860
|
+
a: { component: Link },
|
|
755
861
|
table: { component: ScrollableTable },
|
|
756
862
|
img: { component: MarkdownImage },
|
|
757
863
|
// INLINE MARKS and BLOCKS. Authored copy uses all of these (a bolded access code, a
|
|
@@ -785,16 +891,19 @@ function buildMarkdownOverrides(theme: ResolvedTheme, inheritLinkColor = false)
|
|
|
785
891
|
* Container takes node.style (tokens); elements map to served theme tokens via
|
|
786
892
|
* buildMarkdownOverrides. The SDK authors ZERO style values.
|
|
787
893
|
*/
|
|
788
|
-
export function MarkdownView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
|
|
894
|
+
export function MarkdownView({ node, data, theme, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler }): ReactNode {
|
|
789
895
|
const value = (node.bind?.value ? (data[node.bind.value] as string) : "") ?? "";
|
|
790
896
|
// Explicit opt-in ONLY (a CTA pill whose label must match its declared color):
|
|
791
897
|
// `inheritLinkColor: true` on the node. Declaring a body color alone never
|
|
792
898
|
// hijacks anchors — chat prose keeps the served link recipe.
|
|
793
899
|
const inheritLink = (node as { inheritLinkColor?: boolean }).inheritLinkColor === true;
|
|
794
900
|
const overrides = useMemo(() => buildMarkdownOverrides(theme, inheritLink), [theme, inheritLink]);
|
|
901
|
+
const cite = (node as { citeAction?: string }).citeAction;
|
|
795
902
|
return (
|
|
796
903
|
<div style={styleToCss(node.style, theme, data)}>
|
|
797
|
-
<
|
|
904
|
+
<CiteContext.Provider value={{ action: cite, onAction, data }}>
|
|
905
|
+
<Markdown options={{ overrides, forceBlock: true }}>{holdOpenCitation(keepCitationsWhole(value))}</Markdown>
|
|
906
|
+
</CiteContext.Provider>
|
|
798
907
|
</div>
|
|
799
908
|
);
|
|
800
909
|
}
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import { useRef, useCallback, useEffect, useState } from "react";
|
|
12
12
|
|
|
13
|
+
/** How far ahead the first frame is scheduled when nothing is queued. */
|
|
14
|
+
const START_LEAD_SECONDS = 0.2;
|
|
15
|
+
|
|
13
16
|
/** Decode LPCM (Int16, 24 kHz, mono) → AudioBuffer. */
|
|
14
17
|
function decodeLPCM(audioContext: AudioContext, data: ArrayBuffer): AudioBuffer {
|
|
15
18
|
const sampleRate = 24000;
|
|
@@ -69,7 +72,11 @@ export function useAudioPlayback(): UseAudioPlaybackReturn {
|
|
|
69
72
|
try {
|
|
70
73
|
const audioBuffer = decodeLPCM(audioContext, item.audioData);
|
|
71
74
|
const now = audioContext.currentTime;
|
|
72
|
-
|
|
75
|
+
// A CUSHION WHEN THE QUEUE IS EMPTY. A vendor that streams at speaking pace (GPT-Live)
|
|
76
|
+
// sends each frame just as the last one ends, so a frame that is a moment late leaves a
|
|
77
|
+
// gap, and a gap is heard as a click. Starting a little ahead absorbs that lateness; once
|
|
78
|
+
// playing, frames join end to end as before.
|
|
79
|
+
const startTime = nextTimeRef.current > now ? nextTimeRef.current : now + START_LEAD_SECONDS;
|
|
73
80
|
|
|
74
81
|
const source = audioContext.createBufferSource();
|
|
75
82
|
source.buffer = audioBuffer;
|
|
@@ -132,7 +132,7 @@ export function renderNode(
|
|
|
132
132
|
// ComponentSlot AND Template keep their appWidth: both are slot-dispatched places
|
|
133
133
|
// whose width applies to their FRAME (app.tsx), not to a box of their own —
|
|
134
134
|
// consuming it here left the mount frame width-less (measured live 2026-08-28).
|
|
135
|
-
if (panelW && node.type !== "ComponentSlot" && node.type !== "Template") {
|
|
135
|
+
if (panelW && node.type !== "ComponentSlot" && node.type !== "Template" && node.type !== "Place") {
|
|
136
136
|
const w = cssWidth(theme.appSize?.[panelW] ?? panelW);
|
|
137
137
|
if (w) node = { ...node, appWidth: undefined, style: { ...(node.style ?? {}), width: w, flex: "0 0 auto" } } as UnoverseNode;
|
|
138
138
|
}
|
|
@@ -148,8 +148,8 @@ export function renderNode(
|
|
|
148
148
|
// Orb: procedural thinking/working animation — state is a literal or bound (`bind.state`).
|
|
149
149
|
if (node.type === "Orb") return <OrbView key={key} node={node} data={data} theme={theme} />;
|
|
150
150
|
if (node.type === "Input") return <InputView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
|
|
151
|
-
if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} />;
|
|
152
|
-
if (node.type === "ComponentSlot" || node.type === "Timeline" || node.type === "Template") return slot ? slot(node, key) : null;
|
|
151
|
+
if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
|
|
152
|
+
if (node.type === "ComponentSlot" || node.type === "Timeline" || node.type === "Template" || node.type === "Place") return slot ? slot(node, key) : null;
|
|
153
153
|
|
|
154
154
|
// `Ref` — REFERENCE, NOT COPY. The design system's shared pieces travel once and are
|
|
155
155
|
// cached; a definition names one and specialises it here (core/refs.ts: `props` remap,
|
package/vendor/sdk/lib/voice.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import { useAudioCapture } from "./realtime/useAudioCapture";
|
|
|
23
23
|
import { useAudioPlayback } from "./realtime/useAudioPlayback";
|
|
24
24
|
import type { AudioStateEvent } from "./realtime/types";
|
|
25
25
|
import type { ComponentStore } from "./core";
|
|
26
|
+
import { sessionUserId } from "./connection";
|
|
26
27
|
|
|
27
28
|
export type VoiceConnectionStatus = "idle" | "connecting" | "connected" | "ended" | "error";
|
|
28
29
|
|
|
@@ -93,8 +94,14 @@ function formatDuration(totalSeconds: number): string {
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
|
|
96
|
-
const { apiUrl,
|
|
97
|
+
const { apiUrl, getAccessToken } = config;
|
|
97
98
|
const wsUrl = config.wsUrl ?? apiUrl.replace(/^http/, "ws");
|
|
99
|
+
// Same identity as the data plane (connection.tsx): without a token the audio socket is a
|
|
100
|
+
// guest socket, and the server refuses INIT_SESSION unless the id is guest-prefixed.
|
|
101
|
+
const session = useMemo(
|
|
102
|
+
() => ({ ...config.session, userId: sessionUserId(config.session.userId, getAccessToken) }),
|
|
103
|
+
[config.session, getAccessToken],
|
|
104
|
+
);
|
|
98
105
|
|
|
99
106
|
const [connectionStatus, setConnectionStatus] = useState<VoiceConnectionStatus>("idle");
|
|
100
107
|
const [isMuted, setIsMuted] = useState(false);
|
|
@@ -130,6 +137,8 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
|
|
|
130
137
|
setConnectionStatus("ended");
|
|
131
138
|
setAssistantHint(false);
|
|
132
139
|
setUserSpeaking(false);
|
|
140
|
+
setIsLookingUp(false);
|
|
141
|
+
setLookupTool(null);
|
|
133
142
|
playback.markAsLastChunk();
|
|
134
143
|
} else if (state === "TOOL_USE") {
|
|
135
144
|
setIsLookingUp(true);
|
|
@@ -204,6 +213,8 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
|
|
|
204
213
|
} catch (err: unknown) {
|
|
205
214
|
setError(err instanceof Error ? err.message : "Failed to start call");
|
|
206
215
|
setConnectionStatus("error");
|
|
216
|
+
setIsLookingUp(false);
|
|
217
|
+
setLookupTool(null);
|
|
207
218
|
}
|
|
208
219
|
}, [ws, capture, apiUrl, session, getAccessToken]);
|
|
209
220
|
|
|
@@ -219,6 +230,8 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
|
|
|
219
230
|
ws.disconnect();
|
|
220
231
|
setConnectionStatus("ended");
|
|
221
232
|
setIsMuted(false);
|
|
233
|
+
setIsLookingUp(false);
|
|
234
|
+
setLookupTool(null);
|
|
222
235
|
}, [capture, playback, ws, session.workflowId]);
|
|
223
236
|
|
|
224
237
|
const toggleMute = useCallback(() => {
|
|
@@ -259,14 +272,15 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
|
|
|
259
272
|
() => ({
|
|
260
273
|
connectionStatus,
|
|
261
274
|
// The one phase the template branches on — derived from the neutral facts. Order matters:
|
|
262
|
-
// agent speaking
|
|
263
|
-
//
|
|
275
|
+
// a lookup wins, then agent speaking over user (barge-in), and all three win over plain
|
|
276
|
+
// "active".
|
|
264
277
|
//
|
|
265
|
-
// `thinking` sits
|
|
266
|
-
//
|
|
267
|
-
//
|
|
278
|
+
// `thinking` sits ABOVE speaking because a lookup has a true start and a true end (the
|
|
279
|
+
// hand-off and its answer), while speaking may have no end at all: GPT-Live sends no
|
|
280
|
+
// end-of-speech event and streams silence, so speaking can read true for the whole call
|
|
281
|
+
// and would hide the lookup. Thinking clears on the answer, a hang-up or a dropped call.
|
|
268
282
|
//
|
|
269
|
-
// It sits ABOVE "active" because that is the bug it
|
|
283
|
+
// It also sits ABOVE "active" because that is the bug it fixed. A lookup takes 8 to 9 seconds,
|
|
270
284
|
// and for all of it callState read "active" — which every voice template draws as a calm
|
|
271
285
|
// halo meaning "the floor is yours". The orb is the only progress indicator a call has,
|
|
272
286
|
// and it was saying the opposite of what was happening. `isLookingUp` was already right
|
|
@@ -274,12 +288,12 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
|
|
|
274
288
|
// field the templates branch on.
|
|
275
289
|
callState: (!isCallActive
|
|
276
290
|
? "idle"
|
|
277
|
-
:
|
|
278
|
-
? "
|
|
279
|
-
:
|
|
280
|
-
? "
|
|
281
|
-
:
|
|
282
|
-
? "
|
|
291
|
+
: isLookingUp
|
|
292
|
+
? "thinking"
|
|
293
|
+
: isAssistantSpeaking
|
|
294
|
+
? "speaking"
|
|
295
|
+
: isUserSpeaking
|
|
296
|
+
? "listening"
|
|
283
297
|
: "active") as VoiceCallState,
|
|
284
298
|
isIdle: !isCallActive,
|
|
285
299
|
isCallActive,
|
package/vite.config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local Studio — the standalone developer tool (docs/architecture/LOCAL_STUDIO.md).
|
|
2
|
+
* Local Studio — the standalone developer tool (docs/architecture/authoring/LOCAL_STUDIO.md).
|
|
3
3
|
*
|
|
4
4
|
* Runs entirely offline against the developer's own project folder. No Postgres, no
|
|
5
5
|
* Redis, no engine, no Docker: the catalog routes are served straight off disk by the
|