@runtypelabs/persona 4.26.1 → 4.28.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/dist/approval-ui.d.cts +18 -0
- package/dist/approval-ui.d.ts +18 -0
- package/dist/artifacts-ui.d.cts +18 -0
- package/dist/artifacts-ui.d.ts +18 -0
- package/dist/chunk-AALCT54S.js +1 -0
- package/dist/{client-history-GOZWBDA5.js → client-history-LV3KHEFA.js} +1 -1
- package/dist/client-history.js +1 -1
- package/dist/client-stream-N22JHTLE.js +4 -0
- package/dist/client-stream.js +3 -3
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +18 -0
- package/dist/context-mentions.d.ts +18 -0
- package/dist/event-stream-view.d.cts +18 -0
- package/dist/event-stream-view.d.ts +18 -0
- package/dist/forms-ui.d.cts +26 -0
- package/dist/forms-ui.d.ts +26 -0
- package/dist/index.cjs +57 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +58 -58
- package/dist/index.js.map +1 -1
- package/dist/session-reconnect.d.cts +18 -0
- package/dist/session-reconnect.d.ts +18 -0
- package/dist/smart-dom-reader.d.cts +18 -0
- package/dist/smart-dom-reader.d.ts +18 -0
- package/dist/theme-editor-preview.cjs +66 -66
- package/dist/theme-editor-preview.d.cts +18 -0
- package/dist/theme-editor-preview.d.ts +18 -0
- package/dist/theme-editor-preview.js +31 -31
- package/dist/theme-editor.d.cts +18 -0
- package/dist/theme-editor.d.ts +18 -0
- package/dist/voice-runtime-LVUMPUDS.js +17 -0
- package/dist/voice-runtime.cjs +7 -7
- package/dist/voice-runtime.d.cts +33 -0
- package/dist/voice-runtime.d.ts +33 -0
- package/dist/voice-runtime.js +7 -7
- package/package.json +1 -1
- package/src/client-stream.ts +10 -3
- package/src/client.interrupt.test.ts +18 -0
- package/src/client.test.ts +20 -0
- package/src/client.ts +7 -0
- package/src/session.ts +16 -3
- package/src/session.voice.test.ts +199 -3
- package/src/types.ts +24 -0
- package/src/ui.composer-defer-one.test.ts +17 -0
- package/src/ui.ts +49 -12
- package/src/voice/__fixtures__/voice-wire/server/session_end-provider-error.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/session_end-unknown-reason.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/session_end.json +5 -0
- package/src/voice/runtype-voice-provider.ts +205 -21
- package/src/voice/voice-clause-buffer.test.ts +143 -0
- package/src/voice/voice-clause-buffer.ts +187 -0
- package/src/voice/voice-delegation.ts +111 -13
- package/src/voice/voice.test.ts +433 -2
- package/dist/chunk-GKPCIQPB.js +0 -1
- package/dist/client-stream-42FOI3EC.js +0 -4
- package/dist/voice-runtime-XLU6YK2N.js +0 -17
|
@@ -36,7 +36,16 @@ const h = vi.hoisted(() => {
|
|
|
36
36
|
errorCb: ((e: Error) => void) | null;
|
|
37
37
|
bridge: VoiceSessionBridge | null;
|
|
38
38
|
prewarms: number;
|
|
39
|
-
|
|
39
|
+
notice: string | null;
|
|
40
|
+
} = {
|
|
41
|
+
transcriptCb: null,
|
|
42
|
+
metricsCb: null,
|
|
43
|
+
statusCb: null,
|
|
44
|
+
errorCb: null,
|
|
45
|
+
bridge: null,
|
|
46
|
+
prewarms: 0,
|
|
47
|
+
notice: null,
|
|
48
|
+
};
|
|
40
49
|
|
|
41
50
|
const fakeProvider = {
|
|
42
51
|
type: 'runtype' as const,
|
|
@@ -64,6 +73,11 @@ const h = vi.hoisted(() => {
|
|
|
64
73
|
setSessionBridge: (bridge: VoiceSessionBridge) => {
|
|
65
74
|
state.bridge = bridge;
|
|
66
75
|
},
|
|
76
|
+
takeNotice: () => {
|
|
77
|
+
const notice = state.notice;
|
|
78
|
+
state.notice = null;
|
|
79
|
+
return notice;
|
|
80
|
+
},
|
|
67
81
|
};
|
|
68
82
|
|
|
69
83
|
return { state, fakeProvider };
|
|
@@ -513,12 +527,31 @@ describe('AgentWidgetSession - voice client delegation bridge', () => {
|
|
|
513
527
|
});
|
|
514
528
|
|
|
515
529
|
it('hands the provider a bridge whose history is the visible settled messages', () => {
|
|
516
|
-
|
|
517
|
-
//
|
|
530
|
+
// An earlier call's captions (e.g. on the previous page) are conversation
|
|
531
|
+
// the new voice session never heard, so they carry over.
|
|
518
532
|
h.state.transcriptCb!('assistant', 'Let me check.', true, { turnId: 'f0', caption: true });
|
|
519
533
|
h.state.transcriptCb!('user', 'hi there', true, { turnId: 'u0', caption: true });
|
|
534
|
+
drive('user', 'still talk', false, 'u1'); // interim: not part of the context
|
|
535
|
+
expect(h.state.bridge!.getHistory()).toEqual([
|
|
536
|
+
{ role: 'assistant', content: 'Welcome! How can I help?' },
|
|
537
|
+
{ role: 'assistant', content: 'Let me check.' },
|
|
538
|
+
{ role: 'user', content: 'hi there' },
|
|
539
|
+
]);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("leaves a delegated turn's spoken filler out of the history, keeping the answer", async () => {
|
|
543
|
+
// Full duplex: the voice model's words arrive as captions.
|
|
544
|
+
h.state.transcriptCb!('user', 'whats the weather in paris', true, { turnId: 'u1', caption: true });
|
|
545
|
+
h.state.transcriptCb!('assistant', 'Let me check that.', true, { turnId: 'f1', caption: true });
|
|
546
|
+
dispatch.mockImplementation(async (_options, onEvent) => reply(onEvent, 'It is sunny in Paris.'));
|
|
547
|
+
await h.state.bridge!.runDelegatedTurn(req({ delegationId: 'd1', userText: 'whats the weather in paris' }));
|
|
548
|
+
h.state.transcriptCb!('assistant', 'Anything else?', true, { turnId: 'a2', caption: true });
|
|
549
|
+
|
|
520
550
|
expect(h.state.bridge!.getHistory()).toEqual([
|
|
521
551
|
{ role: 'assistant', content: 'Welcome! How can I help?' },
|
|
552
|
+
{ role: 'user', content: 'whats the weather in paris' },
|
|
553
|
+
{ role: 'assistant', content: 'It is sunny in Paris.' },
|
|
554
|
+
{ role: 'assistant', content: 'Anything else?' },
|
|
522
555
|
]);
|
|
523
556
|
});
|
|
524
557
|
|
|
@@ -748,6 +781,146 @@ describe('AgentWidgetSession - voice client delegation bridge', () => {
|
|
|
748
781
|
expect(result.followUp).toBeTypeOf('function');
|
|
749
782
|
});
|
|
750
783
|
|
|
784
|
+
describe('streaming the answer (delegation_stream)', () => {
|
|
785
|
+
/** Streams `chunks` into one assistant message, growing it like a text stream. */
|
|
786
|
+
const streamReply = (onEvent: (event: AgentWidgetEvent) => void, chunks: string[], id = 'assistant-s1') => {
|
|
787
|
+
const base = { id, role: 'assistant' as const, createdAt: new Date().toISOString() };
|
|
788
|
+
let content = '';
|
|
789
|
+
onEvent({ type: 'status', status: 'connecting' });
|
|
790
|
+
for (const chunk of chunks) {
|
|
791
|
+
content += chunk;
|
|
792
|
+
onEvent({ type: 'message', message: { ...base, content, streaming: true } });
|
|
793
|
+
}
|
|
794
|
+
onEvent({ type: 'message', message: { ...base, content, streaming: false } });
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
it('streams the answer in clause pieces while it renders, then reports streamedChars', async () => {
|
|
798
|
+
drive('user', 'what are your hours', true, 'u1');
|
|
799
|
+
const pieces: string[] = [];
|
|
800
|
+
const seenBeforeEnd: string[] = [];
|
|
801
|
+
dispatch.mockImplementation(async (_options, onEvent) => {
|
|
802
|
+
streamReply(onEvent, ['We open at ', 'eight, and close ', 'at six. On Sundays ', 'we are closed.']);
|
|
803
|
+
seenBeforeEnd.push(...pieces);
|
|
804
|
+
// A tool bubble between two answer messages never streams.
|
|
805
|
+
onEvent({
|
|
806
|
+
type: 'message',
|
|
807
|
+
message: { id: 'tool-1', role: 'assistant', content: '', createdAt: new Date().toISOString(), variant: 'tool' },
|
|
808
|
+
});
|
|
809
|
+
streamReply(onEvent, ['Anything else?'], 'assistant-s2');
|
|
810
|
+
onEvent({ type: 'status', status: 'idle' });
|
|
811
|
+
});
|
|
812
|
+
const result = await h.state.bridge!.runDelegatedTurn(
|
|
813
|
+
req({ delegationId: 'd1', userText: 'what are your hours', stream: (text) => pieces.push(text) }),
|
|
814
|
+
);
|
|
815
|
+
expect(result.text).toBe('We open at eight, and close at six. On Sundays we are closed.\n\nAnything else?');
|
|
816
|
+
// The first pieces went out while the stream was still running.
|
|
817
|
+
expect(seenBeforeEnd).toEqual(['We open at eight, ', 'and close at six. ']);
|
|
818
|
+
expect(pieces.join('')).toBe(result.text);
|
|
819
|
+
expect(result.streamedChars).toBe(result.text.length);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('streams the answer of an approval-parked turn; the approval script is the unstreamed suffix', async () => {
|
|
823
|
+
drive('user', 'order two croissants', true, 'u1');
|
|
824
|
+
parkOnApproval();
|
|
825
|
+
const pieces: string[] = [];
|
|
826
|
+
const result = await h.state.bridge!.runDelegatedTurn(
|
|
827
|
+
req({ delegationId: 'd1', userText: 'order two croissants', stream: (text) => pieces.push(text) }),
|
|
828
|
+
);
|
|
829
|
+
expect(result.status).toBe('pending_approval');
|
|
830
|
+
expect(pieces).toEqual(['I can do that.']);
|
|
831
|
+
expect(result.streamedChars).toBe('I can do that.'.length);
|
|
832
|
+
expect(result.text.startsWith('I can do that.\n\n')).toBe(true);
|
|
833
|
+
expect(result.text.slice(result.streamedChars)).toContain("This action needs the user's approval");
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('stops streaming when the answer is rewritten, and counts only the shared prefix', async () => {
|
|
837
|
+
drive('user', 'hours', true, 'u1');
|
|
838
|
+
const pieces: string[] = [];
|
|
839
|
+
dispatch.mockImplementation(async (_options, onEvent) => {
|
|
840
|
+
const base = { id: 'assistant-rw', role: 'assistant' as const, createdAt: new Date().toISOString() };
|
|
841
|
+
onEvent({ type: 'message', message: { ...base, content: 'We open at eight. Sun', streaming: true } });
|
|
842
|
+
onEvent({ type: 'message', message: { ...base, content: 'We open at 8am. Closed Sundays.', streaming: false } });
|
|
843
|
+
onEvent({ type: 'status', status: 'idle' });
|
|
844
|
+
});
|
|
845
|
+
const result = await h.state.bridge!.runDelegatedTurn(
|
|
846
|
+
req({ delegationId: 'd1', userText: 'hours', stream: (text) => pieces.push(text) }),
|
|
847
|
+
);
|
|
848
|
+
expect(pieces).toEqual(['We open at eight. ']);
|
|
849
|
+
expect(result.text).toBe('We open at 8am. Closed Sundays.');
|
|
850
|
+
expect(result.streamedChars).toBe('We open at '.length);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it('streams a held sentence end after 200 ms of quiet, and stops on cancellation', async () => {
|
|
854
|
+
vi.useFakeTimers();
|
|
855
|
+
try {
|
|
856
|
+
drive('user', 'hours', true, 'u1');
|
|
857
|
+
const pieces: string[] = [];
|
|
858
|
+
let resume!: () => void;
|
|
859
|
+
const paused = new Promise<void>((resolve) => (resume = resolve));
|
|
860
|
+
const base = { id: 'assistant-idle', role: 'assistant' as const, createdAt: new Date().toISOString() };
|
|
861
|
+
dispatch.mockImplementation(async (_options, onEvent) => {
|
|
862
|
+
onEvent({ type: 'message', message: { ...base, content: 'We open at 8 a.m.', streaming: true } });
|
|
863
|
+
await paused;
|
|
864
|
+
onEvent({ type: 'message', message: { ...base, content: 'We open at 8 a.m. Closed Sundays.', streaming: false } });
|
|
865
|
+
onEvent({ type: 'status', status: 'idle' });
|
|
866
|
+
});
|
|
867
|
+
const turn = h.state.bridge!.runDelegatedTurn(
|
|
868
|
+
req({ delegationId: 'd1', userText: 'hours', stream: (text) => pieces.push(text) }),
|
|
869
|
+
);
|
|
870
|
+
await vi.advanceTimersByTimeAsync(199);
|
|
871
|
+
expect(pieces).toEqual([]);
|
|
872
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
873
|
+
expect(pieces).toEqual(['We open at 8 a.m.']);
|
|
874
|
+
resume();
|
|
875
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
876
|
+
const result = await turn;
|
|
877
|
+
expect(pieces).toEqual(['We open at 8 a.m.', ' Closed Sundays.']);
|
|
878
|
+
expect(pieces.join('')).toBe(result.text);
|
|
879
|
+
expect(result.streamedChars).toBe(result.text.length);
|
|
880
|
+
|
|
881
|
+
// Cancelled while a sentence end is held: the idle flush never fires.
|
|
882
|
+
const later: string[] = [];
|
|
883
|
+
let resume2!: () => void;
|
|
884
|
+
const paused2 = new Promise<void>((resolve) => (resume2 = resolve));
|
|
885
|
+
const base2 = { ...base, id: 'assistant-idle-2' };
|
|
886
|
+
dispatch.mockImplementation(async (_options, onEvent) => {
|
|
887
|
+
onEvent({ type: 'message', message: { ...base2, content: 'Sure thing.', streaming: true } });
|
|
888
|
+
await paused2;
|
|
889
|
+
onEvent({ type: 'message', message: { ...base2, content: 'Sure thing. Done.', streaming: false } });
|
|
890
|
+
onEvent({ type: 'status', status: 'idle' });
|
|
891
|
+
});
|
|
892
|
+
const turn2 = h.state.bridge!.runDelegatedTurn(
|
|
893
|
+
req({ delegationId: 'd2', userText: 'more', stream: (text) => later.push(text) }),
|
|
894
|
+
);
|
|
895
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
896
|
+
h.state.bridge!.dropDelegation!('d2');
|
|
897
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
898
|
+
expect(later).toEqual([]);
|
|
899
|
+
resume2();
|
|
900
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
901
|
+
await turn2;
|
|
902
|
+
expect(later).toEqual([]);
|
|
903
|
+
} finally {
|
|
904
|
+
vi.useRealTimers();
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it('streams nothing and omits streamedChars without a stream callback', async () => {
|
|
909
|
+
drive('user', 'hours', true, 'u1');
|
|
910
|
+
dispatch.mockImplementation(async (_options, onEvent) => reply(onEvent, 'We open at nine. Sundays closed.'));
|
|
911
|
+
const result = await h.state.bridge!.runDelegatedTurn(req({ delegationId: 'd1', userText: 'hours' }));
|
|
912
|
+
expect(result).not.toHaveProperty('streamedChars');
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it('marks only the delegated voice turn as spoken (voice: { spoken: true })', async () => {
|
|
917
|
+
dispatch.mockImplementation(async (_options, onEvent) => reply(onEvent, 'Nine to five.'));
|
|
918
|
+
await h.state.bridge!.runDelegatedTurn(req({ delegationId: 'd1', userText: 'what are your hours' }));
|
|
919
|
+
await session.sendMessage('typed question');
|
|
920
|
+
const options = dispatch.mock.calls.map(([o]) => (o as { voiceSpoken?: boolean }).voiceSpoken);
|
|
921
|
+
expect(options).toEqual([true, false]);
|
|
922
|
+
});
|
|
923
|
+
|
|
751
924
|
it('follows up with the answer once the visitor approves, kept off browser TTS', async () => {
|
|
752
925
|
drive('user', 'order two croissants', true, 'u1');
|
|
753
926
|
parkOnApproval();
|
|
@@ -1449,3 +1622,26 @@ describe('AgentWidgetSession - prewarmVoice', () => {
|
|
|
1449
1622
|
expect(h.state.prewarms).toBe(0);
|
|
1450
1623
|
});
|
|
1451
1624
|
});
|
|
1625
|
+
|
|
1626
|
+
describe('AgentWidgetSession - voice session end (Amendment 6)', () => {
|
|
1627
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
1628
|
+
const tokenSession = () =>
|
|
1629
|
+
new AgentWidgetSession(
|
|
1630
|
+
{
|
|
1631
|
+
clientToken: 'ct_live_demo',
|
|
1632
|
+
apiUrl: 'https://api.runtype.com',
|
|
1633
|
+
voiceRecognition: { enabled: true, provider: { type: 'runtype', runtype: { agentId: 'a1' } } },
|
|
1634
|
+
},
|
|
1635
|
+
{ onMessagesChanged: () => {}, onStatusChanged: () => {}, onStreamingChanged: () => {}, onError: () => {} },
|
|
1636
|
+
);
|
|
1637
|
+
|
|
1638
|
+
it("hands the UI the provider's status-line notice once", async () => {
|
|
1639
|
+
const session = tokenSession();
|
|
1640
|
+
session.setupVoice();
|
|
1641
|
+
await flush();
|
|
1642
|
+
h.state.notice = 'This voice call reached its time limit.';
|
|
1643
|
+
expect(session.getVoiceNotice()).toBe('This voice call reached its time limit.');
|
|
1644
|
+
expect(session.getVoiceNotice()).toBeNull();
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -5230,6 +5230,11 @@ export interface VoiceProvider {
|
|
|
5230
5230
|
setSessionBridge?(bridge: VoiceSessionBridge): void;
|
|
5231
5231
|
/** The AI-disclosure notice for the live call, if one should show (speech-to-speech). */
|
|
5232
5232
|
getDisclosure?(): string | null;
|
|
5233
|
+
/**
|
|
5234
|
+
* Status-line text for the call, returned once: why the server ended it
|
|
5235
|
+
* (`session_end`) or that it is reconnecting. `null` when there is none.
|
|
5236
|
+
*/
|
|
5237
|
+
takeNotice?(): string | null;
|
|
5233
5238
|
}
|
|
5234
5239
|
|
|
5235
5240
|
/** Metadata on a {@link VoiceProvider.onTranscript} update. */
|
|
@@ -5260,6 +5265,13 @@ export type VoiceDelegationRequest = {
|
|
|
5260
5265
|
userUtteranceIds: string[];
|
|
5261
5266
|
/** The voice model's view of the conversation it delegated from. */
|
|
5262
5267
|
messages: Array<{ role: string; content: unknown }>;
|
|
5268
|
+
/**
|
|
5269
|
+
* Set when the server streams delegated answers (`delegation_stream`): called
|
|
5270
|
+
* with each speakable piece of the turn's answer, in order, while it renders.
|
|
5271
|
+
* The pieces, concatenated, are a prefix of the result's `text`; the result
|
|
5272
|
+
* says how much of it with `streamedChars`.
|
|
5273
|
+
*/
|
|
5274
|
+
stream?: (text: string) => void;
|
|
5263
5275
|
};
|
|
5264
5276
|
|
|
5265
5277
|
/**
|
|
@@ -5300,6 +5312,12 @@ export type VoiceDelegationResult = {
|
|
|
5300
5312
|
* @default true
|
|
5301
5313
|
*/
|
|
5302
5314
|
inChat?: boolean;
|
|
5315
|
+
/**
|
|
5316
|
+
* How many leading characters of `text` already went out through
|
|
5317
|
+
* `request.stream` (the rest, such as an approval script, is unstreamed).
|
|
5318
|
+
* Omitted or 0: nothing was streamed.
|
|
5319
|
+
*/
|
|
5320
|
+
streamedChars?: number;
|
|
5303
5321
|
/**
|
|
5304
5322
|
* Set when the turn parked on approvals (`text` then asks for the decision).
|
|
5305
5323
|
* Resolves with the terminal result once the visitor decides (or the
|
|
@@ -6261,6 +6279,12 @@ export type ClientChatRequest = {
|
|
|
6261
6279
|
turnId?: RuntypeClientChatRequest['turnId'];
|
|
6262
6280
|
/** Whether this turn should interrupt a prior in-flight response (from Core public OpenAPI). */
|
|
6263
6281
|
submitMode?: RuntypeClientChatRequest['submitMode'];
|
|
6282
|
+
/**
|
|
6283
|
+
* Spoken-reply hint, set on a delegated voice turn whose reply a voice model
|
|
6284
|
+
* reads aloud: the server asks the agent for a speakable answer (the direct
|
|
6285
|
+
* answer first, in plain sentences). Not persisted into history.
|
|
6286
|
+
*/
|
|
6287
|
+
voice?: { spoken: true };
|
|
6264
6288
|
};
|
|
6265
6289
|
|
|
6266
6290
|
/**
|
|
@@ -159,6 +159,23 @@ describe("streamingSubmitBehavior: defer-one", () => {
|
|
|
159
159
|
expect(status?.textContent).toContain("already queued");
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
it("keeps a notice in the status line when the chat status changes under it", async () => {
|
|
163
|
+
const { mount } = makeController();
|
|
164
|
+
await startStreaming(mount);
|
|
165
|
+
type(mount, "queued message");
|
|
166
|
+
pressEnter(mount);
|
|
167
|
+
await flush();
|
|
168
|
+
type(mount, "second attempt");
|
|
169
|
+
pressEnter(mount);
|
|
170
|
+
await flush();
|
|
171
|
+
|
|
172
|
+
stream.push(unifiedFrames());
|
|
173
|
+
stream.close();
|
|
174
|
+
await flush(50);
|
|
175
|
+
const status = mount.querySelector<HTMLElement>("[data-persona-composer-notice]");
|
|
176
|
+
expect(status?.textContent).toContain("already queued");
|
|
177
|
+
});
|
|
178
|
+
|
|
162
179
|
it("card Edit restores the snapshot into the draft and drops the card", async () => {
|
|
163
180
|
const { mount, controller } = makeController();
|
|
164
181
|
await startStreaming(mount);
|
package/src/ui.ts
CHANGED
|
@@ -353,11 +353,14 @@ import {
|
|
|
353
353
|
// Default localStorage key for chat history (automatically cleared on clear chat)
|
|
354
354
|
const DEFAULT_CHAT_HISTORY_STORAGE_KEY = "persona-chat-history";
|
|
355
355
|
const VOICE_STATE_RESTORE_WINDOW = 30 * 1000;
|
|
356
|
+
const VOICE_RESUME_NOTICE = "Tap the mic to resume your voice call";
|
|
356
357
|
// Split desktop boundary; must match widget.css artifact media queries (min-width:641px).
|
|
357
358
|
const ARTIFACT_SPLIT_DESKTOP_MIN = 641;
|
|
358
359
|
|
|
359
360
|
/** Marks the status region while a composer lock reason owns it. */
|
|
360
361
|
const COMPOSER_REASON_ATTR = "data-persona-composer-reason";
|
|
362
|
+
/** Marks the status region while a transient composer notice owns it. */
|
|
363
|
+
const COMPOSER_NOTICE_ATTR = "data-persona-composer-notice";
|
|
361
364
|
|
|
362
365
|
const IMAGE_FILE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
|
|
363
366
|
"image/png": "png",
|
|
@@ -1384,8 +1387,9 @@ export const createAgentExperience = (
|
|
|
1384
1387
|
placeStatusIndicator(el, composerForm, statusCfg);
|
|
1385
1388
|
applyStatusIndicatorState(el, statusCfg, status);
|
|
1386
1389
|
}
|
|
1387
|
-
// A composer lock reason owns the status region until the lock clears
|
|
1388
|
-
|
|
1390
|
+
// A composer lock reason owns the status region until the lock clears, and
|
|
1391
|
+
// a notice until its timer ends (which re-applies the status).
|
|
1392
|
+
if (el.hasAttribute(COMPOSER_REASON_ATTR) || el.hasAttribute(COMPOSER_NOTICE_ATTR)) return;
|
|
1389
1393
|
if (status === "idle" && statusCfg.idleLink) {
|
|
1390
1394
|
el.textContent = "";
|
|
1391
1395
|
const link = document.createElement("a");
|
|
@@ -5083,6 +5087,15 @@ export const createAgentExperience = (
|
|
|
5083
5087
|
// would otherwise not appear until the first keystroke.
|
|
5084
5088
|
syncComposerCompact();
|
|
5085
5089
|
const voiceAutoResumeMode = config.voiceRecognition?.autoResume ?? false;
|
|
5090
|
+
// A session-voice call is live while listening OR while its continuous
|
|
5091
|
+
// (barge-in) mic is hot: a full-duplex call cycles listening → processing →
|
|
5092
|
+
// speaking, and `isVoiceActive()` alone (status === listening) would record a
|
|
5093
|
+
// live call as hung up and never restore it on the next page.
|
|
5094
|
+
const isSessionVoiceLive = () => session.isVoiceActive() || session.isBargeInActive();
|
|
5095
|
+
// Set on `pagehide`: the unload tears the call down without the visitor
|
|
5096
|
+
// hanging up, so its trailing status changes must not overwrite the
|
|
5097
|
+
// persisted "call was live" record the next page restores from.
|
|
5098
|
+
let pageHiding = false;
|
|
5086
5099
|
const emitVoiceState = (source: AgentWidgetVoiceStateEvent["source"]) => {
|
|
5087
5100
|
eventBus.emit("voice:state", {
|
|
5088
5101
|
active: voiceState.active,
|
|
@@ -5112,17 +5125,37 @@ export const createAgentExperience = (
|
|
|
5112
5125
|
voiceState.manuallyDeactivated = false;
|
|
5113
5126
|
if (usesSessionVoice(config.voiceRecognition?.provider)) {
|
|
5114
5127
|
session.toggleVoice().then(() => {
|
|
5115
|
-
voiceState.active =
|
|
5128
|
+
voiceState.active = isSessionVoiceLive();
|
|
5116
5129
|
emitVoiceState("restore");
|
|
5117
|
-
if (
|
|
5130
|
+
if (voiceState.active) {
|
|
5131
|
+
applyRuntypeMicRecordingStyles();
|
|
5132
|
+
} else {
|
|
5133
|
+
// Typically the autoplay policy: this page has no user gesture
|
|
5134
|
+
// yet, so the audio graph can't start. A mic tap resumes it.
|
|
5135
|
+
persistVoiceMetadata();
|
|
5136
|
+
showComposerNotice(VOICE_RESUME_NOTICE, 10_000);
|
|
5137
|
+
}
|
|
5118
5138
|
});
|
|
5119
5139
|
} else {
|
|
5120
5140
|
startVoiceRecognition("restore");
|
|
5121
5141
|
}
|
|
5122
5142
|
}
|
|
5123
|
-
|
|
5143
|
+
// Session voice redials at once (a navigation mid-call should feel
|
|
5144
|
+
// seamless); toggleVoice() already waits on the provider setup.
|
|
5145
|
+
}, usesSessionVoice(config.voiceRecognition?.provider) ? 0 : 1000);
|
|
5124
5146
|
}
|
|
5125
5147
|
};
|
|
5148
|
+
// Re-stamp a live call as the page unloads: the restore window counts from
|
|
5149
|
+
// the navigation, not from when the (possibly long) call was started. A
|
|
5150
|
+
// back/forward-cache hide (`persisted`) isn't an unload: the page lives on.
|
|
5151
|
+
const handleVoicePageHide = (event: PageTransitionEvent) => {
|
|
5152
|
+
pageHiding = !event.persisted;
|
|
5153
|
+
if (voiceState.active) persistVoiceMetadata();
|
|
5154
|
+
};
|
|
5155
|
+
if (typeof window !== "undefined") {
|
|
5156
|
+
window.addEventListener("pagehide", handleVoicePageHide);
|
|
5157
|
+
destroyCallbacks.push(() => window.removeEventListener("pagehide", handleVoicePageHide));
|
|
5158
|
+
}
|
|
5126
5159
|
|
|
5127
5160
|
const getMessagesForPersistence = () =>
|
|
5128
5161
|
session
|
|
@@ -7916,6 +7949,7 @@ export const createAgentExperience = (
|
|
|
7916
7949
|
statusText.setAttribute("role", "status");
|
|
7917
7950
|
statusText.setAttribute("aria-live", "polite");
|
|
7918
7951
|
statusText.setAttribute(COMPOSER_REASON_ATTR, "");
|
|
7952
|
+
statusText.removeAttribute(COMPOSER_NOTICE_ATTR); // the reason outranks a notice
|
|
7919
7953
|
statusText.textContent = reason;
|
|
7920
7954
|
applyStatusIndicatorState(statusText, config.statusIndicator ?? {}, "idle");
|
|
7921
7955
|
return;
|
|
@@ -8803,6 +8837,9 @@ export const createAgentExperience = (
|
|
|
8803
8837
|
// provider; the mic-button styling below is runtype-specific.
|
|
8804
8838
|
eventBus.emit("voice:status", { status, timestamp: Date.now() });
|
|
8805
8839
|
if (status !== 'disconnected' && !usesSessionVoice(config.voiceRecognition?.provider)) return;
|
|
8840
|
+
// Why the server ended the call, or that it is reconnecting.
|
|
8841
|
+
const voiceNotice = session.getVoiceNotice();
|
|
8842
|
+
if (voiceNotice) showComposerNotice(voiceNotice, 10_000);
|
|
8806
8843
|
|
|
8807
8844
|
switch (status) {
|
|
8808
8845
|
case 'listening':
|
|
@@ -8852,7 +8889,7 @@ export const createAgentExperience = (
|
|
|
8852
8889
|
}
|
|
8853
8890
|
removeRuntypeMicStateStyles();
|
|
8854
8891
|
emitVoiceState("system");
|
|
8855
|
-
persistVoiceMetadata();
|
|
8892
|
+
if (!pageHiding) persistVoiceMetadata();
|
|
8856
8893
|
}
|
|
8857
8894
|
break;
|
|
8858
8895
|
}
|
|
@@ -9507,7 +9544,6 @@ export const createAgentExperience = (
|
|
|
9507
9544
|
resetHistoryNavigation();
|
|
9508
9545
|
};
|
|
9509
9546
|
|
|
9510
|
-
const COMPOSER_NOTICE_ATTR = "data-persona-composer-notice";
|
|
9511
9547
|
let composerNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
9512
9548
|
|
|
9513
9549
|
/** Transient message in the status region; a lock reason always outranks it. */
|
|
@@ -10497,11 +10533,11 @@ export const createAgentExperience = (
|
|
|
10497
10533
|
}
|
|
10498
10534
|
|
|
10499
10535
|
session.toggleVoice().then(() => {
|
|
10500
|
-
voiceState.active =
|
|
10501
|
-
voiceState.manuallyDeactivated = !
|
|
10536
|
+
voiceState.active = isSessionVoiceLive();
|
|
10537
|
+
voiceState.manuallyDeactivated = !voiceState.active;
|
|
10502
10538
|
persistVoiceMetadata();
|
|
10503
10539
|
emitVoiceState("user");
|
|
10504
|
-
if (
|
|
10540
|
+
if (voiceState.active) {
|
|
10505
10541
|
applyRuntypeMicRecordingStyles();
|
|
10506
10542
|
} else {
|
|
10507
10543
|
removeRuntypeMicStateStyles();
|
|
@@ -12956,9 +12992,10 @@ export const createAgentExperience = (
|
|
|
12956
12992
|
voiceState.manuallyDeactivated = false;
|
|
12957
12993
|
persistVoiceMetadata();
|
|
12958
12994
|
session.toggleVoice().then(() => {
|
|
12959
|
-
voiceState.active =
|
|
12995
|
+
voiceState.active = isSessionVoiceLive();
|
|
12996
|
+
persistVoiceMetadata();
|
|
12960
12997
|
emitVoiceState("user");
|
|
12961
|
-
if (
|
|
12998
|
+
if (voiceState.active) applyRuntypeMicRecordingStyles();
|
|
12962
12999
|
});
|
|
12963
13000
|
return true;
|
|
12964
13001
|
}
|