@runtypelabs/persona 4.25.0 → 4.26.1
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/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
- package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +14 -0
- package/dist/animations-extra.d.ts +14 -0
- package/dist/approval-ui-QAKWNFNM.js +1 -0
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +186 -8
- package/dist/approval-ui.d.ts +186 -8
- package/dist/approval-ui.js +2 -2
- package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
- package/dist/artifacts-ui.cjs +4 -4
- package/dist/artifacts-ui.d.cts +186 -8
- package/dist/artifacts-ui.d.ts +186 -8
- package/dist/artifacts-ui.js +5 -5
- package/dist/chunk-2ANTRKTV.js +1 -0
- package/dist/chunk-4HPTPUC6.js +1 -0
- package/dist/chunk-6V3MNEKB.js +1 -0
- package/dist/chunk-GKPCIQPB.js +1 -0
- package/dist/chunk-HGUCGM3K.js +9 -0
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-J4J3Y2P2.js +1 -0
- package/dist/chunk-K6HKIP5K.js +1 -0
- package/dist/chunk-K7NA7G7J.js +1 -0
- package/dist/chunk-MXUTS3LB.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-QRKGQPU7.js +1 -0
- package/dist/chunk-S6PBWE75.js +1 -0
- package/dist/chunk-T7QM7YMF.js +1 -0
- package/dist/chunk-TR2H475E.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/chunk-ZC66NAIR.js +1 -0
- package/dist/client-history-GOZWBDA5.js +1 -0
- package/dist/client-history.js +1 -0
- package/dist/client-stream-42FOI3EC.js +4 -0
- package/dist/client-stream.js +5 -0
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions-E7HJYWO6.js +161 -0
- package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
- package/dist/context-mentions.cjs +7 -7
- package/dist/context-mentions.d.cts +186 -8
- package/dist/context-mentions.d.ts +186 -8
- package/dist/context-mentions.js +8 -8
- package/dist/event-stream-view-FOIJHFQO.js +1 -0
- package/dist/event-stream-view.cjs +1 -1
- package/dist/event-stream-view.d.cts +189 -31
- package/dist/event-stream-view.d.ts +189 -31
- package/dist/event-stream-view.js +1 -1
- package/dist/forms-ui.d.cts +227 -667
- package/dist/forms-ui.d.ts +227 -667
- package/dist/history-shell-QDQ44PSE.js +3 -0
- package/dist/history-shell.js +3 -0
- package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
- package/dist/history-view.cjs +2 -2
- package/dist/history-view.d.cts +35 -1
- package/dist/history-view.d.ts +35 -1
- package/dist/history-view.js +2 -2
- package/dist/index.cjs +80 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +233 -738
- package/dist/index.d.ts +233 -738
- package/dist/index.global.js +3 -215
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +81 -75
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -67
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +22 -22
- package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-actions-ISSKSI66.js +1 -0
- package/dist/session-actions.js +1 -0
- package/dist/session-reconnect.d.cts +186 -8
- package/dist/session-reconnect.d.ts +186 -8
- package/dist/smart-dom-reader.cjs +13 -13
- package/dist/smart-dom-reader.d.cts +186 -8
- package/dist/smart-dom-reader.d.ts +186 -8
- package/dist/smart-dom-reader.js +11 -11
- package/dist/theme-editor-preview.cjs +116 -98
- package/dist/theme-editor-preview.d.cts +188 -8
- package/dist/theme-editor-preview.d.ts +188 -8
- package/dist/theme-editor-preview.js +49 -54
- package/dist/theme-editor.cjs +2 -2
- package/dist/theme-editor.d.cts +187 -23
- package/dist/theme-editor.d.ts +187 -23
- package/dist/theme-editor.js +2 -2
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
- package/dist/ui-extras.js +72 -0
- package/dist/voice-runtime-XLU6YK2N.js +17 -0
- package/dist/voice-runtime.cjs +17 -1
- package/dist/voice-runtime.d.cts +11656 -232
- package/dist/voice-runtime.d.ts +11656 -232
- package/dist/voice-runtime.js +17 -1
- package/dist/voice-worklet-player.cjs +19 -4
- package/dist/voice-worklet-player.d.cts +9 -10
- package/dist/voice-worklet-player.d.ts +9 -10
- package/dist/voice-worklet-player.js +19 -4
- package/dist/webmcp-polyfill.js +1 -1
- package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
- package/dist/webmcp-runtime.cjs +2 -2
- package/dist/webmcp-runtime.d.cts +1 -33
- package/dist/webmcp-runtime.d.ts +1 -33
- package/dist/webmcp-runtime.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +11 -5
- package/src/approval-paused-tool-settle.test.ts +325 -0
- package/src/client-approve.test.ts +311 -0
- package/src/client-history-eager.ts +9 -0
- package/src/client-history-loader.ts +23 -0
- package/src/client-history.ts +579 -0
- package/src/client-resume.ts +177 -0
- package/src/client-stream-eager.ts +12 -0
- package/src/client-stream-loader.ts +25 -0
- package/src/client-stream.ts +1880 -0
- package/src/client.test.ts +31 -1
- package/src/client.ts +293 -2607
- package/src/components/approval-actions.test.ts +27 -0
- package/src/components/approval-actions.ts +27 -3
- package/src/components/message-bubble.test.ts +19 -0
- package/src/components/message-bubble.ts +2 -0
- package/src/components/registry.ts +6 -6
- package/src/components/tool-bubble.test.ts +9 -0
- package/src/components/tool-bubble.ts +1 -0
- package/src/history-shell-bundle.test.ts +54 -0
- package/src/history-shell-eager.ts +12 -0
- package/src/history-shell-loader.ts +27 -0
- package/src/history-shell.ts +2362 -0
- package/src/history-view-loader.ts +2 -0
- package/src/history-view.ts +3 -0
- package/src/index-core.ts +6 -0
- package/src/index-global.ts +90 -248
- package/src/index.ts +13 -0
- package/src/install.ts +6 -4
- package/src/internal/runtype-history-provider.test.ts +1 -0
- package/src/internal/runtype-history-provider.ts +28 -13
- package/src/plugins/registry.ts +8 -8
- package/src/session-actions-chunk.test.ts +55 -0
- package/src/session-actions-eager.ts +11 -0
- package/src/session-actions-loader.ts +37 -0
- package/src/session-actions.ts +909 -0
- package/src/session-history.test.ts +3 -1
- package/src/session.ts +895 -1538
- package/src/session.voice.test.ts +1171 -5
- package/src/styles/widget.css +58 -0
- package/src/theme-editor/state.ts +91 -91
- package/src/types.ts +204 -5
- package/src/ui-ask-user-sheet.ts +382 -0
- package/src/ui-extras-eager.ts +10 -0
- package/src/ui-extras-entry.ts +9 -0
- package/src/ui-extras-loader.ts +26 -0
- package/src/ui.artifact-rail-resize.test.ts +2 -1
- package/src/ui.ask-user-chunk.test.ts +93 -0
- package/src/ui.history-chunk.test.ts +2 -1
- package/src/ui.history-config-provider.test.ts +2 -1
- package/src/ui.history-confirm-fallback.test.ts +142 -0
- package/src/ui.history-controller.test.ts +2 -1
- package/src/ui.history-lifecycle.test.ts +2 -1
- package/src/ui.history-render-hooks.test.ts +2 -1
- package/src/ui.history-shell.test.ts +2 -1
- package/src/ui.rail-overlay.test.ts +2 -1
- package/src/ui.rail-resize.test.ts +2 -1
- package/src/ui.rail-trigger-pending.test.ts +2 -1
- package/src/ui.ts +340 -2770
- package/src/utils/attachment-manager.ts +67 -67
- package/src/utils/constants.ts +3 -0
- package/src/utils/context-mention-controller.ts +323 -323
- package/src/utils/context-mention-manager.ts +40 -40
- package/src/utils/context-mention-orchestrator.ts +28 -0
- package/src/utils/event-stream-buffer.ts +50 -50
- package/src/utils/event-stream-store.ts +43 -43
- package/src/utils/throughput-tracker.ts +34 -34
- package/src/utils/virtual-scroller.ts +67 -67
- package/src/utils/visitor-store.test.ts +50 -0
- package/src/utils/visitor-store.ts +23 -5
- package/src/voice/__fixtures__/voice-wire/README.md +1 -0
- package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
- package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
- package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
- package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
- package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
- package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
- package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
- package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
- package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
- package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
- package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
- package/src/voice/audio-playback-manager.test.ts +57 -0
- package/src/voice/audio-playback-manager.ts +120 -98
- package/src/voice/browser-voice-provider.ts +37 -37
- package/src/voice/fallback-speech-engine.ts +22 -16
- package/src/voice/keyed-voice-transcript.ts +314 -0
- package/src/voice/mic-state-styles.ts +182 -0
- package/src/voice/read-aloud-controller.ts +46 -44
- package/src/voice/runtype-speech-engine.ts +35 -33
- package/src/voice/runtype-voice-provider.ts +680 -189
- package/src/voice/session-voice-wiring.ts +266 -0
- package/src/voice/voice-delegation.test.ts +71 -0
- package/src/voice/voice-delegation.ts +364 -0
- package/src/voice/voice-turn-tracker.ts +6 -6
- package/src/voice/voice.test.ts +1060 -8
- package/src/voice/worklet-playback-engine.test.ts +81 -0
- package/src/voice/worklet-playback-engine.ts +21 -3
- package/src/voice-runtime-loader.ts +4 -1
- package/src/voice-runtime.ts +7 -0
- package/src/webmcp-runtime-entry.ts +41 -39
- package/dist/approval-ui-3NMYNXBD.js +0 -1
- package/dist/chunk-5EIIHQLQ.js +0 -1
- package/dist/chunk-7CO4T5KB.js +0 -1
- package/dist/chunk-EFJBUH5F.js +0 -1
- package/dist/chunk-R2TMIMDI.js +0 -3
- package/dist/chunk-UTVLOTWB.js +0 -9
- package/dist/context-mentions-45MODWOY.js +0 -161
- package/dist/event-stream-view-GL3ADRY2.js +0 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
- package/dist/voice-runtime-7DA7MJH4.js +0 -1
- package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
|
@@ -30,27 +30,33 @@ export class FallbackSpeechEngine implements SpeechEngine {
|
|
|
30
30
|
|
|
31
31
|
// Whichever engine is currently driving playback, so pause/resume/stop route
|
|
32
32
|
// to the right one after a fallback has (or hasn't) kicked in.
|
|
33
|
-
|
|
33
|
+
#active: SpeechEngine;
|
|
34
34
|
|
|
35
|
+
readonly #primary: SpeechEngine;
|
|
36
|
+
readonly #fallback: SpeechEngine;
|
|
37
|
+
readonly #options: FallbackSpeechEngineOptions;
|
|
35
38
|
constructor(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
primary: SpeechEngine,
|
|
40
|
+
fallback: SpeechEngine,
|
|
41
|
+
options: FallbackSpeechEngineOptions = {},
|
|
39
42
|
) {
|
|
40
|
-
this
|
|
43
|
+
this.#primary = primary;
|
|
44
|
+
this.#fallback = fallback;
|
|
45
|
+
this.#options = options;
|
|
46
|
+
this.#active = primary;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
// Pause/resume only matters once something is playing, and both built-in
|
|
44
50
|
// engines support it; report the active engine's capability.
|
|
45
51
|
get supportsPause(): boolean {
|
|
46
|
-
return this
|
|
52
|
+
return this.#active.supportsPause;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
speak(request: SpeechRequest, callbacks: SpeechCallbacks): void {
|
|
50
|
-
this
|
|
56
|
+
this.#active = this.#primary;
|
|
51
57
|
let started = false;
|
|
52
58
|
|
|
53
|
-
this
|
|
59
|
+
this.#primary.speak(request, {
|
|
54
60
|
onStart: () => {
|
|
55
61
|
started = true;
|
|
56
62
|
callbacks.onStart?.();
|
|
@@ -63,27 +69,27 @@ export class FallbackSpeechEngine implements SpeechEngine {
|
|
|
63
69
|
return;
|
|
64
70
|
}
|
|
65
71
|
// Pre-start failure: silently hand the utterance to the fallback.
|
|
66
|
-
this
|
|
67
|
-
this
|
|
68
|
-
this
|
|
72
|
+
this.#options.onFallback?.(error);
|
|
73
|
+
this.#active = this.#fallback;
|
|
74
|
+
this.#fallback.speak(request, callbacks);
|
|
69
75
|
},
|
|
70
76
|
});
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
pause(): void {
|
|
74
|
-
this
|
|
80
|
+
this.#active.pause();
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
resume(): void {
|
|
78
|
-
this
|
|
84
|
+
this.#active.resume();
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
stop(): void {
|
|
82
|
-
this
|
|
88
|
+
this.#active.stop();
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
destroy(): void {
|
|
86
|
-
this
|
|
87
|
-
this
|
|
92
|
+
this.#primary.destroy?.();
|
|
93
|
+
this.#fallback.destroy?.();
|
|
88
94
|
}
|
|
89
95
|
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import type { AgentWidgetMessage, InjectMessageOptions } from "../types";
|
|
2
|
+
|
|
3
|
+
/** The session surface the keyed reconciler drives. */
|
|
4
|
+
export interface KeyedVoiceTranscriptHost {
|
|
5
|
+
find(id: string): AgentWidgetMessage | undefined;
|
|
6
|
+
inject(options: InjectMessageOptions): AgentWidgetMessage;
|
|
7
|
+
upsert(message: AgentWidgetMessage): void;
|
|
8
|
+
/** Clear `streaming` / `voiceProcessing` on these messages in one update. */
|
|
9
|
+
settle(ids: Set<string>): void;
|
|
10
|
+
setStreaming(streaming: boolean): void;
|
|
11
|
+
/** The provider already played this reply's audio: browser TTS must skip it. */
|
|
12
|
+
markSpoken(id: string): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Bubbles owned by one provider turnId. */
|
|
16
|
+
type KeyedTurn = {
|
|
17
|
+
userId?: string;
|
|
18
|
+
assistantId?: string;
|
|
19
|
+
userFinal?: boolean;
|
|
20
|
+
assistantFinal?: boolean;
|
|
21
|
+
userStartMs?: number;
|
|
22
|
+
assistantStartMs?: number;
|
|
23
|
+
/** Submitted as a chat turn: the chat pipeline renders its reply. */
|
|
24
|
+
claimed?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Loose text match between a transcript and the delegated request text. */
|
|
28
|
+
const normalize = (text: string) => text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reconciles turn-keyed voice transcripts (full-duplex providers such as
|
|
32
|
+
* GPT-Live) into chat bubbles: each `(turnId, role)` owns one bubble that later
|
|
33
|
+
* frames replace in place, so overlapping turns stay separate and may arrive in
|
|
34
|
+
* any order. No empty assistant placeholder is injected; the session's
|
|
35
|
+
* `streaming` flag drives the standalone typing indicator instead. A new bubble
|
|
36
|
+
* with a `startMs` sorts above any keyed bubble of the call that started later,
|
|
37
|
+
* so a late user transcript still renders above the reply it prompted.
|
|
38
|
+
*
|
|
39
|
+
* Cancelled output is the provider's to drop (until the server acknowledges
|
|
40
|
+
* the cancel): every reply that reaches `apply` after a stop renders, which
|
|
41
|
+
* keeps a later answer (e.g. a delegation result) visible while its audio plays.
|
|
42
|
+
*
|
|
43
|
+
* Ships in the lazy voice-runtime chunk: only sessions with a voice provider
|
|
44
|
+
* ever construct one.
|
|
45
|
+
*/
|
|
46
|
+
export class KeyedVoiceTranscript {
|
|
47
|
+
private turns = new Map<string, KeyedTurn>();
|
|
48
|
+
private latestId: string | null = null;
|
|
49
|
+
private cancelled = new Set<string>();
|
|
50
|
+
|
|
51
|
+
constructor(private host: KeyedVoiceTranscriptHost) {}
|
|
52
|
+
|
|
53
|
+
apply(
|
|
54
|
+
role: "user" | "assistant",
|
|
55
|
+
text: string,
|
|
56
|
+
isFinal: boolean,
|
|
57
|
+
turnId: string,
|
|
58
|
+
startMs?: number,
|
|
59
|
+
caption?: boolean,
|
|
60
|
+
): void {
|
|
61
|
+
const host = this.host;
|
|
62
|
+
// Speech-to-speech transcripts can carry a leading space (" What are…").
|
|
63
|
+
text = text.trim();
|
|
64
|
+
let turn = this.turns.get(turnId);
|
|
65
|
+
if (!turn) {
|
|
66
|
+
turn = {};
|
|
67
|
+
this.turns.set(turnId, turn);
|
|
68
|
+
this.latestId = turnId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (role === "user") {
|
|
72
|
+
turn.userFinal = isFinal;
|
|
73
|
+
const existing = turn.userId ? host.find(turn.userId) : undefined;
|
|
74
|
+
if (existing) {
|
|
75
|
+
const moved = turn.userStartMs === undefined && startMs !== undefined;
|
|
76
|
+
if (moved) turn.userStartMs = startMs;
|
|
77
|
+
host.upsert({
|
|
78
|
+
...existing,
|
|
79
|
+
content: text,
|
|
80
|
+
voiceProcessing: !isFinal,
|
|
81
|
+
...(moved && this.placeBefore(this.startedAfter(startMs))),
|
|
82
|
+
});
|
|
83
|
+
} else {
|
|
84
|
+
// A user transcript that lands after its own turn's reply started still
|
|
85
|
+
// renders above that reply: borrow the reply's timestamp, sort just ahead.
|
|
86
|
+
turn.userStartMs = startMs;
|
|
87
|
+
const reply = turn.assistantId ? host.find(turn.assistantId) : undefined;
|
|
88
|
+
turn.userId = host.inject({
|
|
89
|
+
role: "user",
|
|
90
|
+
content: text,
|
|
91
|
+
streaming: false,
|
|
92
|
+
voiceProcessing: !isFinal,
|
|
93
|
+
voiceCaption: caption,
|
|
94
|
+
...this.placeBefore(this.startedAfter(startMs) ?? reply),
|
|
95
|
+
}).id;
|
|
96
|
+
}
|
|
97
|
+
} else if (!this.cancelled.has(turnId)) {
|
|
98
|
+
turn.assistantFinal = isFinal;
|
|
99
|
+
const existing = turn.assistantId ? host.find(turn.assistantId) : undefined;
|
|
100
|
+
if (existing) {
|
|
101
|
+
// A timestamp that arrives after the bubble did re-positions it.
|
|
102
|
+
const moved = turn.assistantStartMs === undefined && startMs !== undefined;
|
|
103
|
+
if (moved) turn.assistantStartMs = startMs;
|
|
104
|
+
host.upsert({
|
|
105
|
+
...existing,
|
|
106
|
+
content: text,
|
|
107
|
+
streaming: !isFinal,
|
|
108
|
+
voiceProcessing: !isFinal,
|
|
109
|
+
...(moved && this.placeBefore(this.startedAfter(startMs))),
|
|
110
|
+
});
|
|
111
|
+
} else if (text) {
|
|
112
|
+
turn.assistantStartMs = startMs;
|
|
113
|
+
turn.assistantId = host.inject({
|
|
114
|
+
role: "assistant",
|
|
115
|
+
content: text,
|
|
116
|
+
streaming: !isFinal,
|
|
117
|
+
voiceProcessing: !isFinal,
|
|
118
|
+
voiceCaption: caption,
|
|
119
|
+
...this.placeBefore(this.startedAfter(startMs)),
|
|
120
|
+
}).id;
|
|
121
|
+
}
|
|
122
|
+
if (isFinal && turn.assistantId) host.markSpoken(turn.assistantId);
|
|
123
|
+
}
|
|
124
|
+
this.sync();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The user bubble a delegated request came from, claimed so it is submitted
|
|
129
|
+
* once. With utterance ids it is the last one's bubble; one that hasn't been
|
|
130
|
+
* transcribed yet is created now (from `userText`), so its transcript later
|
|
131
|
+
* fills it instead of adding a second bubble. Without an id it is the newest
|
|
132
|
+
* unclaimed bubble whose text matches exactly, else one where either text
|
|
133
|
+
* is a prefix of the other (the request can beat the final transcript).
|
|
134
|
+
* `null` when nothing matches: never someone else's bubble.
|
|
135
|
+
*
|
|
136
|
+
* `userUtteranceIds` lists every utterance a split request joins (the last
|
|
137
|
+
* is the one submitted). The others are claimed too: they stay displayed as
|
|
138
|
+
* captions, never sent and never claimed again. One not transcribed yet
|
|
139
|
+
* is reserved, so its transcript later renders as a caption.
|
|
140
|
+
*/
|
|
141
|
+
claimUserTurn(userText: string, userUtteranceIds: string[] = []): string | null {
|
|
142
|
+
const requestId = userUtteranceIds[userUtteranceIds.length - 1];
|
|
143
|
+
for (const id of userUtteranceIds) {
|
|
144
|
+
if (id === requestId) continue;
|
|
145
|
+
if (!this.turns.has(id)) this.turns.set(id, {});
|
|
146
|
+
this.turns.get(id)!.claimed = true;
|
|
147
|
+
}
|
|
148
|
+
let pick: KeyedTurn | undefined;
|
|
149
|
+
if (requestId) {
|
|
150
|
+
pick = this.turns.get(requestId);
|
|
151
|
+
if (!pick) {
|
|
152
|
+
pick = {};
|
|
153
|
+
this.turns.set(requestId, pick);
|
|
154
|
+
}
|
|
155
|
+
if (pick.claimed) return null;
|
|
156
|
+
if (!pick.userId || !this.host.find(pick.userId)) {
|
|
157
|
+
pick.userFinal = true;
|
|
158
|
+
pick.userId = this.host.inject({ role: "user", content: userText.trim() }).id;
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
const want = normalize(userText);
|
|
162
|
+
const open = [...this.turns.values()]
|
|
163
|
+
.reverse()
|
|
164
|
+
.flatMap((turn) => {
|
|
165
|
+
const bubble = turn.userId && !turn.claimed ? this.host.find(turn.userId) : undefined;
|
|
166
|
+
return bubble ? [{ turn, text: normalize(bubble.content) }] : [];
|
|
167
|
+
})
|
|
168
|
+
.filter(({ text }) => text && want);
|
|
169
|
+
pick = (
|
|
170
|
+
open.find(({ text }) => text === want) ??
|
|
171
|
+
open.find(({ text }) => text.startsWith(want) || want.startsWith(text))
|
|
172
|
+
)?.turn;
|
|
173
|
+
}
|
|
174
|
+
if (!pick) return null;
|
|
175
|
+
// No sync: the chat turn about to start owns the streaming flag.
|
|
176
|
+
pick.claimed = true;
|
|
177
|
+
return pick.userId!;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Explicit stop: drop the rest of every in-flight reply, else the awaited one. */
|
|
181
|
+
cancel(): void {
|
|
182
|
+
let targets = this.inFlight();
|
|
183
|
+
if (targets.length === 0) {
|
|
184
|
+
const awaiting = this.awaiting();
|
|
185
|
+
if (!awaiting) return;
|
|
186
|
+
targets = [awaiting];
|
|
187
|
+
}
|
|
188
|
+
for (const id of targets) this.cancelled.add(id);
|
|
189
|
+
this.closeReplies(targets);
|
|
190
|
+
this.sync();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Voice error: close every in-flight reply as-is and show the error text (as
|
|
195
|
+
* the awaited turn's reply, when one is waiting), so `streaming` clears.
|
|
196
|
+
*/
|
|
197
|
+
fail(errorText: string): void {
|
|
198
|
+
const awaitingId = this.awaiting();
|
|
199
|
+
const inFlight = this.inFlight();
|
|
200
|
+
if (!awaitingId && inFlight.length === 0) return;
|
|
201
|
+
for (const id of inFlight) this.turns.get(id)!.assistantFinal = true;
|
|
202
|
+
this.closeReplies(inFlight);
|
|
203
|
+
const msg = this.host.inject({
|
|
204
|
+
role: "assistant",
|
|
205
|
+
content: errorText,
|
|
206
|
+
streaming: false,
|
|
207
|
+
voiceProcessing: false,
|
|
208
|
+
});
|
|
209
|
+
const awaiting = awaitingId ? this.turns.get(awaitingId) : undefined;
|
|
210
|
+
if (awaiting) {
|
|
211
|
+
awaiting.assistantId = msg.id;
|
|
212
|
+
awaiting.assistantFinal = true;
|
|
213
|
+
}
|
|
214
|
+
this.sync();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Call ended: freeze every keyed bubble as-is and forget the turns. */
|
|
218
|
+
settle(): void {
|
|
219
|
+
if (this.turns.size === 0) return;
|
|
220
|
+
const wasPending = this.pending();
|
|
221
|
+
const ids = new Set<string>();
|
|
222
|
+
for (const turn of this.turns.values()) {
|
|
223
|
+
if (turn.userId) ids.add(turn.userId);
|
|
224
|
+
if (turn.assistantId) {
|
|
225
|
+
ids.add(turn.assistantId);
|
|
226
|
+
this.host.markSpoken(turn.assistantId);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.turns.clear();
|
|
230
|
+
this.latestId = null;
|
|
231
|
+
this.host.settle(ids);
|
|
232
|
+
if (wasPending) this.host.setStreaming(false);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The call's earliest-starting keyed bubble that began after `startMs`. */
|
|
236
|
+
private startedAfter(startMs: number | undefined): AgentWidgetMessage | undefined {
|
|
237
|
+
if (startMs === undefined) return undefined;
|
|
238
|
+
let best: { start: number; id: string } | undefined;
|
|
239
|
+
for (const turn of this.turns.values()) {
|
|
240
|
+
for (const [id, start] of [
|
|
241
|
+
[turn.userId, turn.userStartMs],
|
|
242
|
+
[turn.assistantId, turn.assistantStartMs],
|
|
243
|
+
] as const) {
|
|
244
|
+
if (id && start !== undefined && start > startMs && (!best || start < best.start)) {
|
|
245
|
+
best = { start, id };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return best && this.host.find(best.id);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Sort a bubble just ahead of `next` (messages order by time, then
|
|
254
|
+
* sequence): halfway between `next` and the keyed bubbles already placed
|
|
255
|
+
* ahead of it. Those all started earlier (else one of them would be
|
|
256
|
+
* `next`), so bubbles placed ahead of the same reply stay in `startMs`
|
|
257
|
+
* order. Sequences are timestamp-sized, so halving, not a tiny fixed
|
|
258
|
+
* offset, keeps them distinct.
|
|
259
|
+
*/
|
|
260
|
+
private placeBefore(next: AgentWidgetMessage | undefined) {
|
|
261
|
+
if (!next) return undefined;
|
|
262
|
+
const top = next.sequence ?? 0;
|
|
263
|
+
let low = top - 1;
|
|
264
|
+
for (const turn of this.turns.values()) {
|
|
265
|
+
for (const id of [turn.userId, turn.assistantId]) {
|
|
266
|
+
const placed = id ? this.host.find(id) : undefined;
|
|
267
|
+
const seq = placed?.sequence;
|
|
268
|
+
if (placed?.createdAt === next.createdAt && seq !== undefined && seq > low && seq < top) {
|
|
269
|
+
low = seq;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { createdAt: next.createdAt, sequence: (low + top) / 2 };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private closeReplies(turnIds: string[]): void {
|
|
277
|
+
const ids = new Set<string>();
|
|
278
|
+
for (const id of turnIds) {
|
|
279
|
+
const assistantId = this.turns.get(id)?.assistantId;
|
|
280
|
+
if (assistantId) {
|
|
281
|
+
ids.add(assistantId);
|
|
282
|
+
this.host.markSpoken(assistantId);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (ids.size > 0) this.host.settle(ids);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Turns whose assistant bubble is visible but not yet final. */
|
|
289
|
+
private inFlight(): string[] {
|
|
290
|
+
const ids: string[] = [];
|
|
291
|
+
for (const [id, turn] of this.turns) {
|
|
292
|
+
if (turn.assistantId && !turn.assistantFinal && !this.cancelled.has(id)) ids.push(id);
|
|
293
|
+
}
|
|
294
|
+
return ids;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The newest turn has a final user utterance and no reply text yet. */
|
|
298
|
+
private awaiting(): string | null {
|
|
299
|
+
const id = this.latestId;
|
|
300
|
+
if (!id) return null;
|
|
301
|
+
const turn = this.turns.get(id);
|
|
302
|
+
return turn?.userFinal && !turn.assistantId && !turn.claimed && !this.cancelled.has(id)
|
|
303
|
+
? id
|
|
304
|
+
: null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private pending(): boolean {
|
|
308
|
+
return this.awaiting() !== null || this.inFlight().length > 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private sync(): void {
|
|
312
|
+
this.host.setStreaming(this.pending());
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mic-button state styling for session-owned voice providers (Runtype /
|
|
3
|
+
* custom): recording, processing, speaking, and the reset back to idle.
|
|
4
|
+
*
|
|
5
|
+
* Ships in the lazy voice-runtime chunk (exported from `voice-runtime.ts`):
|
|
6
|
+
* these states only arise from a provider, which `setupVoice` constructs from
|
|
7
|
+
* that same chunk, so pages without session voice never download it. The UI
|
|
8
|
+
* passes its live closure state through `MicStateContext`; the icon renderer
|
|
9
|
+
* comes from core so this chunk never carries its own icon registry.
|
|
10
|
+
*/
|
|
11
|
+
import type { AgentWidgetConfig } from "../types";
|
|
12
|
+
import type { AgentWidgetSession } from "../session";
|
|
13
|
+
|
|
14
|
+
/** Snapshot of the idle mic, shared with the UI's browser-dictation path. */
|
|
15
|
+
export type OriginalMicStyles = {
|
|
16
|
+
backgroundColor: string;
|
|
17
|
+
color: string;
|
|
18
|
+
borderColor: string;
|
|
19
|
+
iconName: string;
|
|
20
|
+
iconSize: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type MicStateContext = {
|
|
24
|
+
mic: () => HTMLButtonElement | null;
|
|
25
|
+
config: () => AgentWidgetConfig;
|
|
26
|
+
session: () => AgentWidgetSession;
|
|
27
|
+
original: OriginalMicStyles | null;
|
|
28
|
+
setMicState: (state: "idle" | "recording" | "processing" | "speaking") => void;
|
|
29
|
+
renderIcon: (name: string, size: number, color: string, strokeWidth: number) => SVGElement | null;
|
|
30
|
+
iconFallbackPx: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type MicStateStyles = {
|
|
34
|
+
recording: () => void;
|
|
35
|
+
processing: () => void;
|
|
36
|
+
speaking: () => void;
|
|
37
|
+
reset: () => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const createMicStateStyles = (ctx: MicStateContext): MicStateStyles => {
|
|
41
|
+
const storeOriginalMicStyles = (micButton: HTMLButtonElement) => {
|
|
42
|
+
if (ctx.original) return; // Already stored
|
|
43
|
+
const voiceConfig = ctx.config().voiceRecognition ?? {};
|
|
44
|
+
ctx.original = {
|
|
45
|
+
backgroundColor: micButton.style.backgroundColor,
|
|
46
|
+
color: micButton.style.color,
|
|
47
|
+
borderColor: micButton.style.borderColor,
|
|
48
|
+
iconName: voiceConfig.iconName ?? "mic",
|
|
49
|
+
iconSize: parseFloat(voiceConfig.iconSize ?? "") || ctx.iconFallbackPx,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Swap the mic button's SVG icon */
|
|
54
|
+
const swapMicIcon = (micButton: HTMLButtonElement, iconName: string, color: string) => {
|
|
55
|
+
const existingSvg = micButton.querySelector("svg");
|
|
56
|
+
if (existingSvg) existingSvg.remove();
|
|
57
|
+
const size =
|
|
58
|
+
ctx.original?.iconSize ??
|
|
59
|
+
(parseFloat(ctx.config().voiceRecognition?.iconSize ?? "") || ctx.iconFallbackPx);
|
|
60
|
+
const newSvg = ctx.renderIcon(iconName, size, color, 1.5);
|
|
61
|
+
if (newSvg) micButton.appendChild(newSvg);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Remove all voice state CSS classes */
|
|
65
|
+
const removeAllVoiceStateClasses = (micButton: HTMLButtonElement) => {
|
|
66
|
+
micButton.classList.remove("persona-voice-recording", "persona-voice-processing", "persona-voice-speaking");
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const recording = () => {
|
|
70
|
+
const micButton = ctx.mic();
|
|
71
|
+
if (!micButton) return;
|
|
72
|
+
storeOriginalMicStyles(micButton);
|
|
73
|
+
const voiceConfig = ctx.config().voiceRecognition ?? {};
|
|
74
|
+
const recordingBackgroundColor = voiceConfig.recordingBackgroundColor;
|
|
75
|
+
const recordingIconColor = voiceConfig.recordingIconColor;
|
|
76
|
+
const recordingBorderColor = voiceConfig.recordingBorderColor;
|
|
77
|
+
removeAllVoiceStateClasses(micButton);
|
|
78
|
+
micButton.classList.add("persona-voice-recording");
|
|
79
|
+
ctx.setMicState("recording");
|
|
80
|
+
micButton.style.backgroundColor = recordingBackgroundColor ?? "var(--persona-voice-recording-bg, #ef4444)";
|
|
81
|
+
micButton.style.color = recordingIconColor ?? "var(--persona-voice-recording-indicator, #ffffff)";
|
|
82
|
+
if (recordingIconColor) {
|
|
83
|
+
const svg = micButton.querySelector("svg");
|
|
84
|
+
if (svg) svg.setAttribute("stroke", recordingIconColor);
|
|
85
|
+
}
|
|
86
|
+
if (recordingBorderColor) micButton.style.borderColor = recordingBorderColor;
|
|
87
|
+
micButton.setAttribute("aria-label", "Stop voice recognition");
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const processing = () => {
|
|
91
|
+
const micButton = ctx.mic();
|
|
92
|
+
if (!micButton) return;
|
|
93
|
+
storeOriginalMicStyles(micButton);
|
|
94
|
+
const voiceConfig = ctx.config().voiceRecognition ?? {};
|
|
95
|
+
const interruptionMode = ctx.session().getVoiceInterruptionMode();
|
|
96
|
+
const originalMicStyles = ctx.original;
|
|
97
|
+
const iconName = voiceConfig.processingIconName ?? "loader";
|
|
98
|
+
const iconColor = voiceConfig.processingIconColor ?? originalMicStyles?.color ?? "";
|
|
99
|
+
const bgColor = voiceConfig.processingBackgroundColor ?? originalMicStyles?.backgroundColor ?? "";
|
|
100
|
+
const borderColor = voiceConfig.processingBorderColor ?? originalMicStyles?.borderColor ?? "";
|
|
101
|
+
|
|
102
|
+
removeAllVoiceStateClasses(micButton);
|
|
103
|
+
micButton.classList.add("persona-voice-processing");
|
|
104
|
+
ctx.setMicState("processing");
|
|
105
|
+
micButton.style.backgroundColor = bgColor;
|
|
106
|
+
micButton.style.borderColor = borderColor;
|
|
107
|
+
const resolvedColor = iconColor || "currentColor";
|
|
108
|
+
micButton.style.color = resolvedColor;
|
|
109
|
+
swapMicIcon(micButton, iconName, resolvedColor);
|
|
110
|
+
micButton.setAttribute("aria-label", "Processing voice input");
|
|
111
|
+
// In "none" mode the button is not actionable during processing
|
|
112
|
+
if (interruptionMode === "none") {
|
|
113
|
+
micButton.style.cursor = "default";
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const speaking = () => {
|
|
118
|
+
const micButton = ctx.mic();
|
|
119
|
+
if (!micButton) return;
|
|
120
|
+
storeOriginalMicStyles(micButton);
|
|
121
|
+
const voiceConfig = ctx.config().voiceRecognition ?? {};
|
|
122
|
+
const interruptionMode = ctx.session().getVoiceInterruptionMode();
|
|
123
|
+
const originalMicStyles = ctx.original;
|
|
124
|
+
// Default icon depends on interruption mode:
|
|
125
|
+
// "square" for cancel, "mic" for barge-in (hot mic), "volume-2" otherwise
|
|
126
|
+
const defaultSpeakingIcon = interruptionMode === "cancel" ? "square"
|
|
127
|
+
: interruptionMode === "barge-in" ? "mic"
|
|
128
|
+
: "volume-2";
|
|
129
|
+
const iconName = voiceConfig.speakingIconName ?? defaultSpeakingIcon;
|
|
130
|
+
const iconColor = voiceConfig.speakingIconColor
|
|
131
|
+
?? (interruptionMode === "barge-in" ? (voiceConfig.recordingIconColor ?? originalMicStyles?.color ?? "") : (originalMicStyles?.color ?? ""));
|
|
132
|
+
const bgColor = voiceConfig.speakingBackgroundColor
|
|
133
|
+
?? (interruptionMode === "barge-in" ? (voiceConfig.recordingBackgroundColor ?? "var(--persona-voice-recording-bg, #ef4444)") : (originalMicStyles?.backgroundColor ?? ""));
|
|
134
|
+
const borderColor = voiceConfig.speakingBorderColor
|
|
135
|
+
?? (interruptionMode === "barge-in" ? (voiceConfig.recordingBorderColor ?? "") : (originalMicStyles?.borderColor ?? ""));
|
|
136
|
+
|
|
137
|
+
removeAllVoiceStateClasses(micButton);
|
|
138
|
+
micButton.classList.add("persona-voice-speaking");
|
|
139
|
+
ctx.setMicState("speaking");
|
|
140
|
+
micButton.style.backgroundColor = bgColor;
|
|
141
|
+
micButton.style.borderColor = borderColor;
|
|
142
|
+
const resolvedColor = iconColor || "currentColor";
|
|
143
|
+
micButton.style.color = resolvedColor;
|
|
144
|
+
swapMicIcon(micButton, iconName, resolvedColor);
|
|
145
|
+
|
|
146
|
+
// aria-label varies by interruption mode
|
|
147
|
+
const ariaLabel = interruptionMode === "cancel"
|
|
148
|
+
? "Stop playback and re-record"
|
|
149
|
+
: interruptionMode === "barge-in"
|
|
150
|
+
? "Speak to interrupt"
|
|
151
|
+
: "Agent is speaking";
|
|
152
|
+
micButton.setAttribute("aria-label", ariaLabel);
|
|
153
|
+
// In "none" mode the button is not actionable during speaking
|
|
154
|
+
if (interruptionMode === "none") {
|
|
155
|
+
micButton.style.cursor = "default";
|
|
156
|
+
}
|
|
157
|
+
// In "barge-in" mode, add recording class to show mic is hot
|
|
158
|
+
if (interruptionMode === "barge-in") {
|
|
159
|
+
micButton.classList.add("persona-voice-recording");
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** Restore mic button to idle state (icon, colors, aria-label, cursor) */
|
|
164
|
+
const reset = () => {
|
|
165
|
+
const micButton = ctx.mic();
|
|
166
|
+
if (!micButton) return;
|
|
167
|
+
removeAllVoiceStateClasses(micButton);
|
|
168
|
+
ctx.setMicState("idle");
|
|
169
|
+
const originalMicStyles = ctx.original;
|
|
170
|
+
if (originalMicStyles) {
|
|
171
|
+
micButton.style.backgroundColor = originalMicStyles.backgroundColor ?? "";
|
|
172
|
+
micButton.style.color = originalMicStyles.color ?? "";
|
|
173
|
+
micButton.style.borderColor = originalMicStyles.borderColor ?? "";
|
|
174
|
+
swapMicIcon(micButton, originalMicStyles.iconName, originalMicStyles.color || "currentColor");
|
|
175
|
+
ctx.original = null;
|
|
176
|
+
}
|
|
177
|
+
micButton.style.cursor = "";
|
|
178
|
+
micButton.setAttribute("aria-label", "Start voice recognition");
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return { recording, processing, speaking, reset };
|
|
182
|
+
};
|