@bytexbyte/nxtlinq-ai-agent-web-development 0.1.1 → 0.1.2
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/legacy/core/lib/useVoiceMode.d.ts.map +1 -1
- package/dist/legacy/core/lib/useVoiceMode.js +5 -3
- package/dist/voice/useVoiceTranscriptMessages.d.ts +6 -4
- package/dist/voice/useVoiceTranscriptMessages.d.ts.map +1 -1
- package/dist/voice/useVoiceTranscriptMessages.js +82 -76
- package/dist/voice/voiceUserBubble.d.ts +10 -0
- package/dist/voice/voiceUserBubble.d.ts.map +1 -0
- package/dist/voice/voiceUserBubble.js +52 -0
- package/package.json +3 -3
- package/src/legacy/core/lib/useVoiceMode.ts +11 -3
- package/src/voice/useVoiceTranscriptMessages.ts +88 -71
- package/src/voice/voiceUserBubble.ts +71 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useVoiceMode.d.ts","sourceRoot":"","sources":["../../../../src/legacy/core/lib/useVoiceMode.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,8CAA8C,CAAC;AACpG,OAAO,EAKL,KAAK,WAAW,EAEjB,MAAM,iBAAiB,CAAC;AAgBzB,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,OAAO,EAAE,CAAC;IAC7B,WAAW,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;IAC7D,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC,CAAC;AAEF,wBAAgB,YAAY,CAAC,EAC3B,MAAM,EACN,SAAS,EACT,QAAQ,EACR,UAAU,EACV,KAAK,EACL,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,UAAU,EACV,WAAW,EACX,WAAW,EACX,OAAO,EACP,aAAa,EACb,gBAAgB,EAChB,cAA8B,GAC/B,EAAE,mBAAmB;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"useVoiceMode.d.ts","sourceRoot":"","sources":["../../../../src/legacy/core/lib/useVoiceMode.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,8CAA8C,CAAC;AACpG,OAAO,EAKL,KAAK,WAAW,EAEjB,MAAM,iBAAiB,CAAC;AAgBzB,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,OAAO,EAAE,CAAC;IAC7B,WAAW,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;IAC7D,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC,CAAC;AAEF,wBAAgB,YAAY,CAAC,EAC3B,MAAM,EACN,SAAS,EACT,QAAQ,EACR,UAAU,EACV,KAAK,EACL,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,UAAU,EACV,WAAW,EACX,WAAW,EACX,OAAO,EACP,aAAa,EACb,gBAAgB,EAChB,cAA8B,GAC/B,EAAE,mBAAmB;;;;;;;;;;EAkWrB"}
|
|
@@ -53,10 +53,12 @@ export function useVoiceMode({ apiKey, apiSecret, pseudoId, externalId, aitId, w
|
|
|
53
53
|
const voiceSessionIdRef = React.useRef(null);
|
|
54
54
|
const transcriptApi = React.useMemo(() => ({
|
|
55
55
|
getMessages,
|
|
56
|
-
|
|
56
|
+
updateMessages: (updater) => {
|
|
57
|
+
setMessages(updater);
|
|
58
|
+
},
|
|
57
59
|
syncVoiceTurnHistory: (opts) => syncVoiceTurnHistory(opts?.last),
|
|
58
60
|
}), [getMessages, setMessages, syncVoiceTurnHistory]);
|
|
59
|
-
const { handleTranscript: handleTranscriptUi, handleDone: handleDoneUi, clearVoiceStream } = useVoiceTranscriptMessages(transcriptApi, 'voice', voiceSessionId, () => voiceSessionIdRef.current);
|
|
61
|
+
const { handleTranscript: handleTranscriptUi, handleDone: handleDoneUi, clearVoiceStream } = useVoiceTranscriptMessages(transcriptApi, 'voice', voiceSessionId, () => voiceSessionIdRef.current, () => turnUserTextRef.current.trim());
|
|
60
62
|
const resetMicAfterTurn = React.useCallback(() => {
|
|
61
63
|
lastCommittedUserRef.current = turnUserTextRef.current.trim();
|
|
62
64
|
turnUserTextRef.current = '';
|
|
@@ -161,7 +163,7 @@ export function useVoiceMode({ apiKey, apiSecret, pseudoId, externalId, aitId, w
|
|
|
161
163
|
onError?.(new Error(event.error));
|
|
162
164
|
return;
|
|
163
165
|
}
|
|
164
|
-
handleDoneUi(event);
|
|
166
|
+
handleDoneUi(event, { pendingUserText: turnUserTextRef.current.trim() });
|
|
165
167
|
resetMicAfterTurn();
|
|
166
168
|
}, [handleDoneUi, onError, resetMicAfterTurn]);
|
|
167
169
|
const voiceHandlersRef = React.useRef({
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import type { Message, VoiceDoneEvent, VoiceTranscriptEvent } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
2
|
type InteractionMode = 'text' | 'voice';
|
|
3
|
-
type VoiceTranscriptAgentApi = {
|
|
3
|
+
export type VoiceTranscriptAgentApi = {
|
|
4
4
|
getMessages: () => Message[];
|
|
5
|
-
|
|
5
|
+
updateMessages: (updater: (prev: Message[]) => Message[]) => void;
|
|
6
6
|
syncVoiceTurnHistory: (options?: {
|
|
7
7
|
last?: number;
|
|
8
8
|
}) => Promise<void>;
|
|
9
9
|
};
|
|
10
|
-
export declare function useVoiceTranscriptMessages(api: VoiceTranscriptAgentApi, interactionMode: InteractionMode, voiceSessionId: string | null, getVoiceSessionId?: () => string | null): {
|
|
10
|
+
export declare function useVoiceTranscriptMessages(api: VoiceTranscriptAgentApi, interactionMode: InteractionMode, voiceSessionId: string | null, getVoiceSessionId?: () => string | null, getPendingUserText?: () => string): {
|
|
11
11
|
handleTranscript: (event: VoiceTranscriptEvent) => void;
|
|
12
|
-
handleDone: (event: VoiceDoneEvent
|
|
12
|
+
handleDone: (event: VoiceDoneEvent, options?: {
|
|
13
|
+
pendingUserText?: string;
|
|
14
|
+
}) => void;
|
|
13
15
|
clearVoiceStream: () => void;
|
|
14
16
|
};
|
|
15
17
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useVoiceTranscriptMessages.d.ts","sourceRoot":"","sources":["../../src/voice/useVoiceTranscriptMessages.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EACP,cAAc,EACd,oBAAoB,EACrB,MAAM,8CAA8C,CAAC;
|
|
1
|
+
{"version":3,"file":"useVoiceTranscriptMessages.d.ts","sourceRoot":"","sources":["../../src/voice/useVoiceTranscriptMessages.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EACP,cAAc,EACd,oBAAoB,EACrB,MAAM,8CAA8C,CAAC;AAMtD,KAAK,eAAe,GAAG,MAAM,GAAG,OAAO,CAAC;AAExC,MAAM,MAAM,uBAAuB,GAAG;IACpC,WAAW,EAAE,MAAM,OAAO,EAAE,CAAC;IAC7B,cAAc,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,EAAE,KAAK,IAAI,CAAC;IAClE,oBAAoB,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACtE,CAAC;AAWF,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,uBAAuB,EAC5B,eAAe,EAAE,eAAe,EAChC,cAAc,EAAE,MAAM,GAAG,IAAI,EAC7B,iBAAiB,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,EACvC,kBAAkB,CAAC,EAAE,MAAM,MAAM;8BA0GvB,oBAAoB;wBA4BpB,cAAc,YAAY;QAAE,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE;;EAkCjE"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mergeStreamingTranscript } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
2
|
import { useCallback, useRef } from 'react';
|
|
3
3
|
import { flushSync } from 'react-dom';
|
|
4
|
+
import { ensureUserBubbleForVoiceTurn } from './voiceUserBubble';
|
|
4
5
|
const STREAM_PREFIX = 'voice-stream-';
|
|
5
6
|
function voiceMeta(sessionId) {
|
|
6
7
|
return {
|
|
@@ -8,108 +9,110 @@ function voiceMeta(sessionId) {
|
|
|
8
9
|
voiceSessionId: sessionId ?? undefined,
|
|
9
10
|
};
|
|
10
11
|
}
|
|
11
|
-
export function useVoiceTranscriptMessages(api, interactionMode, voiceSessionId, getVoiceSessionId) {
|
|
12
|
+
export function useVoiceTranscriptMessages(api, interactionMode, voiceSessionId, getVoiceSessionId, getPendingUserText) {
|
|
12
13
|
const streamIdRef = useRef(null);
|
|
13
14
|
const sessionIdRef = useRef(voiceSessionId);
|
|
14
15
|
sessionIdRef.current = voiceSessionId;
|
|
15
16
|
const resolveSessionId = useCallback(() => getVoiceSessionId?.() ?? sessionIdRef.current, [getVoiceSessionId]);
|
|
16
17
|
const isVoiceUiActive = useCallback(() => interactionMode === 'voice', [interactionMode]);
|
|
17
18
|
const upsertStreaming = useCallback((text) => {
|
|
18
|
-
const messages = api.getMessages();
|
|
19
|
-
let streamId = streamIdRef.current;
|
|
20
|
-
if (!streamId) {
|
|
21
|
-
streamId = `${STREAM_PREFIX}${Date.now()}`;
|
|
22
|
-
streamIdRef.current = streamId;
|
|
23
|
-
}
|
|
24
|
-
const idx = messages.findIndex((m) => m.id === streamId);
|
|
25
|
-
const partialContent = idx >= 0
|
|
26
|
-
? mergeStreamingTranscript(messages[idx]?.partialContent ?? '', text)
|
|
27
|
-
: text;
|
|
28
|
-
const meta = voiceMeta(resolveSessionId());
|
|
29
19
|
const apply = () => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
20
|
+
api.updateMessages((prev) => {
|
|
21
|
+
let streamId = streamIdRef.current;
|
|
22
|
+
if (!streamId) {
|
|
23
|
+
streamId = `${STREAM_PREFIX}${Date.now()}`;
|
|
24
|
+
streamIdRef.current = streamId;
|
|
25
|
+
}
|
|
26
|
+
const idx = prev.findIndex((m) => m.id === streamId);
|
|
27
|
+
const partialContent = idx >= 0
|
|
28
|
+
? mergeStreamingTranscript(prev[idx]?.partialContent ?? '', text)
|
|
29
|
+
: text;
|
|
30
|
+
const meta = voiceMeta(resolveSessionId());
|
|
31
|
+
if (idx >= 0) {
|
|
32
|
+
return prev.map((m, i) => i === idx
|
|
33
|
+
? { ...m, partialContent, isStreaming: true, metadata: { ...m.metadata, ...meta } }
|
|
34
|
+
: m);
|
|
35
|
+
}
|
|
36
|
+
const withUser = ensureUserBubbleForVoiceTurn(prev, getPendingUserText?.() ?? '', undefined, meta);
|
|
37
|
+
const base = withUser;
|
|
38
|
+
return [
|
|
39
|
+
...base,
|
|
40
|
+
{
|
|
41
|
+
id: streamId,
|
|
42
|
+
role: 'assistant',
|
|
43
|
+
content: '',
|
|
44
|
+
partialContent,
|
|
45
|
+
isStreaming: true,
|
|
46
|
+
timestamp: new Date().toISOString(),
|
|
47
|
+
metadata: meta,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
});
|
|
48
51
|
};
|
|
49
52
|
flushSync(apply);
|
|
50
|
-
}, [api, resolveSessionId]);
|
|
53
|
+
}, [api, getPendingUserText, resolveSessionId]);
|
|
51
54
|
const finalizeAssistant = useCallback((text, messageId) => {
|
|
52
55
|
const trimmed = text.trim();
|
|
53
56
|
streamIdRef.current = null;
|
|
54
57
|
if (!trimmed)
|
|
55
58
|
return;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
api.updateMessages((prev) => {
|
|
60
|
+
const streamIdx = prev.findIndex((m) => m.isStreaming && m.role === 'assistant');
|
|
61
|
+
if (streamIdx >= 0) {
|
|
62
|
+
return prev.map((m, i) => i === streamIdx
|
|
63
|
+
? {
|
|
64
|
+
...m,
|
|
65
|
+
id: messageId ?? m.id,
|
|
66
|
+
content: trimmed,
|
|
67
|
+
partialContent: undefined,
|
|
68
|
+
isStreaming: false,
|
|
69
|
+
metadata: { ...m.metadata, ...voiceMeta(resolveSessionId()) },
|
|
70
|
+
}
|
|
71
|
+
: m);
|
|
72
|
+
}
|
|
73
|
+
const last = prev[prev.length - 1];
|
|
74
|
+
if (last?.role === 'assistant' && last.content === trimmed)
|
|
75
|
+
return prev;
|
|
76
|
+
return [
|
|
77
|
+
...prev,
|
|
78
|
+
{
|
|
79
|
+
id: messageId ?? `voice-asst-${Date.now()}`,
|
|
80
|
+
role: 'assistant',
|
|
63
81
|
content: trimmed,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
const last = messages[messages.length - 1];
|
|
72
|
-
if (last?.role === 'assistant' && last.content === trimmed)
|
|
73
|
-
return;
|
|
74
|
-
api.setMessages([
|
|
75
|
-
...messages,
|
|
76
|
-
{
|
|
77
|
-
id: messageId ?? `voice-asst-${Date.now()}`,
|
|
78
|
-
role: 'assistant',
|
|
79
|
-
content: trimmed,
|
|
80
|
-
timestamp: new Date().toISOString(),
|
|
81
|
-
metadata: voiceMeta(resolveSessionId()),
|
|
82
|
-
},
|
|
83
|
-
]);
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
metadata: voiceMeta(resolveSessionId()),
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
});
|
|
84
87
|
}, [api, resolveSessionId]);
|
|
85
88
|
const handleTranscript = useCallback((event) => {
|
|
86
89
|
if (!isVoiceUiActive())
|
|
87
90
|
return;
|
|
88
91
|
const text = event.text?.trim() ?? '';
|
|
89
92
|
if (event.role === 'assistant') {
|
|
90
|
-
// Keep one streaming bubble for the whole turn; finalize only in handleDone.
|
|
91
93
|
if (text)
|
|
92
94
|
upsertStreaming(text);
|
|
93
95
|
return;
|
|
94
96
|
}
|
|
95
97
|
if (event.role === 'user' && !event.interim && text) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
98
|
+
api.updateMessages((prev) => {
|
|
99
|
+
const last = prev[prev.length - 1];
|
|
100
|
+
if (last?.role === 'user' && last.content === text)
|
|
101
|
+
return prev;
|
|
102
|
+
return [
|
|
103
|
+
...prev,
|
|
104
|
+
{
|
|
105
|
+
id: `voice-user-${Date.now()}`,
|
|
106
|
+
role: 'user',
|
|
107
|
+
content: text,
|
|
108
|
+
timestamp: new Date().toISOString(),
|
|
109
|
+
metadata: voiceMeta(resolveSessionId()),
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
});
|
|
110
113
|
}
|
|
111
|
-
}, [api,
|
|
112
|
-
const handleDone = useCallback((event) => {
|
|
114
|
+
}, [api, isVoiceUiActive, resolveSessionId, upsertStreaming]);
|
|
115
|
+
const handleDone = useCallback((event, options) => {
|
|
113
116
|
if (!isVoiceUiActive())
|
|
114
117
|
return;
|
|
115
118
|
if (event.guardrailsBlocked || event.billingBlocked || event.error) {
|
|
@@ -118,6 +121,9 @@ export function useVoiceTranscriptMessages(api, interactionMode, voiceSessionId,
|
|
|
118
121
|
}
|
|
119
122
|
const reply = event.replyText?.trim() ?? '';
|
|
120
123
|
if (reply) {
|
|
124
|
+
flushSync(() => {
|
|
125
|
+
api.updateMessages((prev) => ensureUserBubbleForVoiceTurn(prev, options?.pendingUserText ?? getPendingUserText?.() ?? '', event.userMessageId, voiceMeta(resolveSessionId())));
|
|
126
|
+
});
|
|
121
127
|
finalizeAssistant(reply, event.assistantMessageId ?? undefined);
|
|
122
128
|
}
|
|
123
129
|
else {
|
|
@@ -126,7 +132,7 @@ export function useVoiceTranscriptMessages(api, interactionMode, voiceSessionId,
|
|
|
126
132
|
void api.syncVoiceTurnHistory({ last: 20 }).catch((err) => {
|
|
127
133
|
console.warn('[nxtlinq] syncVoiceTurnHistory after voice turn failed', err);
|
|
128
134
|
});
|
|
129
|
-
}, [api, finalizeAssistant, isVoiceUiActive]);
|
|
135
|
+
}, [api, finalizeAssistant, getPendingUserText, isVoiceUiActive, resolveSessionId]);
|
|
130
136
|
const clearVoiceStream = useCallback(() => {
|
|
131
137
|
streamIdRef.current = null;
|
|
132
138
|
}, []);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Message } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
export declare const VOICE_USER_INPUT_PLACEHOLDER = "(Voice input)";
|
|
3
|
+
type VoiceMeta = {
|
|
4
|
+
voiceRealtime: true;
|
|
5
|
+
voiceSessionId?: string;
|
|
6
|
+
};
|
|
7
|
+
/** Ensure a user bubble exists before the in-flight assistant reply for this turn. */
|
|
8
|
+
export declare function ensureUserBubbleForVoiceTurn(messages: Message[], userText: string, userMessageId: string | null | undefined, metaForPlaceholder: VoiceMeta | undefined): Message[];
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=voiceUserBubble.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voiceUserBubble.d.ts","sourceRoot":"","sources":["../../src/voice/voiceUserBubble.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8CAA8C,CAAC;AAE5E,eAAO,MAAM,4BAA4B,kBAAkB,CAAC;AAE5D,KAAK,SAAS,GAAG;IACf,aAAa,EAAE,IAAI,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,sFAAsF;AACtF,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,OAAO,EAAE,EACnB,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,kBAAkB,EAAE,SAAS,GAAG,SAAS,GACxC,OAAO,EAAE,CAuDX"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const VOICE_USER_INPUT_PLACEHOLDER = '(Voice input)';
|
|
2
|
+
/** Ensure a user bubble exists before the in-flight assistant reply for this turn. */
|
|
3
|
+
export function ensureUserBubbleForVoiceTurn(messages, userText, userMessageId, metaForPlaceholder) {
|
|
4
|
+
const streamIdx = messages.findIndex((m) => m.isStreaming && m.role === 'assistant');
|
|
5
|
+
const insertAt = streamIdx >= 0 ? streamIdx : messages.length;
|
|
6
|
+
const before = messages.slice(0, insertAt);
|
|
7
|
+
const lastAsstIdx = (() => {
|
|
8
|
+
for (let i = before.length - 1; i >= 0; i -= 1) {
|
|
9
|
+
const m = before[i];
|
|
10
|
+
if (m.role === 'assistant' && !m.isStreaming && Boolean(m.content?.trim()))
|
|
11
|
+
return i;
|
|
12
|
+
}
|
|
13
|
+
return -1;
|
|
14
|
+
})();
|
|
15
|
+
const lastUserIdx = (() => {
|
|
16
|
+
for (let i = before.length - 1; i >= 0; i -= 1) {
|
|
17
|
+
if (before[i].role === 'user')
|
|
18
|
+
return i;
|
|
19
|
+
}
|
|
20
|
+
return -1;
|
|
21
|
+
})();
|
|
22
|
+
const hasUserForTurn = lastUserIdx >= 0 && lastUserIdx > lastAsstIdx;
|
|
23
|
+
const trimmed = userText.trim();
|
|
24
|
+
const displayText = trimmed || VOICE_USER_INPUT_PLACEHOLDER;
|
|
25
|
+
if (hasUserForTurn) {
|
|
26
|
+
const existing = before[lastUserIdx];
|
|
27
|
+
const shouldUpgrade = trimmed &&
|
|
28
|
+
existing.content !== trimmed &&
|
|
29
|
+
(existing.content === VOICE_USER_INPUT_PLACEHOLDER || !existing.content.trim());
|
|
30
|
+
if (!shouldUpgrade)
|
|
31
|
+
return messages;
|
|
32
|
+
const upgraded = before.map((m, i) => i === lastUserIdx
|
|
33
|
+
? {
|
|
34
|
+
...m,
|
|
35
|
+
content: trimmed,
|
|
36
|
+
id: userMessageId ?? m.id,
|
|
37
|
+
metadata: userMessageId ? undefined : m.metadata,
|
|
38
|
+
}
|
|
39
|
+
: m);
|
|
40
|
+
return [...upgraded, ...messages.slice(insertAt)];
|
|
41
|
+
}
|
|
42
|
+
const userMsg = {
|
|
43
|
+
id: userMessageId ?? `voice-user-${Date.now()}`,
|
|
44
|
+
role: 'user',
|
|
45
|
+
content: displayText,
|
|
46
|
+
timestamp: new Date().toISOString(),
|
|
47
|
+
metadata: userMessageId ? undefined : metaForPlaceholder,
|
|
48
|
+
};
|
|
49
|
+
const next = [...messages];
|
|
50
|
+
next.splice(insertAt, 0, userMsg);
|
|
51
|
+
return next;
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytexbyte/nxtlinq-ai-agent-web-development",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "React Web headless SDK for nxtlinq AI Agent — hooks and browser ports",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"react-dom": ">=18.0.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@bytexbyte/nxtlinq-ai-agent-core-development": "0.3.
|
|
40
|
+
"@bytexbyte/nxtlinq-ai-agent-core-development": "0.3.9",
|
|
41
41
|
"ethers": "^6.16.0",
|
|
42
42
|
"fast-json-stable-stringify": "^2.1.0",
|
|
43
43
|
"metakeep": "^2.2.8",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"universal-cookie": "^8.0.1"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@bytexbyte/nxtlinq-ai-agent-core-development": "
|
|
48
|
+
"@bytexbyte/nxtlinq-ai-agent-core-development": "0.3.9",
|
|
49
49
|
"@types/react": "^18.2.64",
|
|
50
50
|
"@types/react-dom": "^18.2.25",
|
|
51
51
|
"react": "^18.2.0",
|
|
@@ -102,12 +102,20 @@ export function useVoiceMode({
|
|
|
102
102
|
|
|
103
103
|
const transcriptApi = React.useMemo(() => ({
|
|
104
104
|
getMessages,
|
|
105
|
-
|
|
105
|
+
updateMessages: (updater: (prev: Message[]) => Message[]) => {
|
|
106
|
+
setMessages(updater);
|
|
107
|
+
},
|
|
106
108
|
syncVoiceTurnHistory: (opts?: { last?: number }) => syncVoiceTurnHistory(opts?.last),
|
|
107
109
|
}), [getMessages, setMessages, syncVoiceTurnHistory]);
|
|
108
110
|
|
|
109
111
|
const { handleTranscript: handleTranscriptUi, handleDone: handleDoneUi, clearVoiceStream } =
|
|
110
|
-
useVoiceTranscriptMessages(
|
|
112
|
+
useVoiceTranscriptMessages(
|
|
113
|
+
transcriptApi,
|
|
114
|
+
'voice',
|
|
115
|
+
voiceSessionId,
|
|
116
|
+
() => voiceSessionIdRef.current,
|
|
117
|
+
() => turnUserTextRef.current.trim(),
|
|
118
|
+
);
|
|
111
119
|
|
|
112
120
|
const resetMicAfterTurn = React.useCallback(() => {
|
|
113
121
|
lastCommittedUserRef.current = turnUserTextRef.current.trim();
|
|
@@ -206,7 +214,7 @@ export function useVoiceMode({
|
|
|
206
214
|
onError?.(new Error(event.error));
|
|
207
215
|
return;
|
|
208
216
|
}
|
|
209
|
-
handleDoneUi(event);
|
|
217
|
+
handleDoneUi(event, { pendingUserText: turnUserTextRef.current.trim() });
|
|
210
218
|
resetMicAfterTurn();
|
|
211
219
|
}, [handleDoneUi, onError, resetMicAfterTurn]);
|
|
212
220
|
|
|
@@ -6,12 +6,13 @@ import type {
|
|
|
6
6
|
import { mergeStreamingTranscript } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
7
7
|
import { useCallback, useRef } from 'react';
|
|
8
8
|
import { flushSync } from 'react-dom';
|
|
9
|
+
import { ensureUserBubbleForVoiceTurn } from './voiceUserBubble';
|
|
9
10
|
|
|
10
11
|
type InteractionMode = 'text' | 'voice';
|
|
11
12
|
|
|
12
|
-
type VoiceTranscriptAgentApi = {
|
|
13
|
+
export type VoiceTranscriptAgentApi = {
|
|
13
14
|
getMessages: () => Message[];
|
|
14
|
-
|
|
15
|
+
updateMessages: (updater: (prev: Message[]) => Message[]) => void;
|
|
15
16
|
syncVoiceTurnHistory: (options?: { last?: number }) => Promise<void>;
|
|
16
17
|
};
|
|
17
18
|
|
|
@@ -29,6 +30,7 @@ export function useVoiceTranscriptMessages(
|
|
|
29
30
|
interactionMode: InteractionMode,
|
|
30
31
|
voiceSessionId: string | null,
|
|
31
32
|
getVoiceSessionId?: () => string | null,
|
|
33
|
+
getPendingUserText?: () => string,
|
|
32
34
|
) {
|
|
33
35
|
const streamIdRef = useRef<string | null>(null);
|
|
34
36
|
const sessionIdRef = useRef(voiceSessionId);
|
|
@@ -46,45 +48,52 @@ export function useVoiceTranscriptMessages(
|
|
|
46
48
|
|
|
47
49
|
const upsertStreaming = useCallback(
|
|
48
50
|
(text: string) => {
|
|
49
|
-
const messages = api.getMessages();
|
|
50
|
-
let streamId = streamIdRef.current;
|
|
51
|
-
if (!streamId) {
|
|
52
|
-
streamId = `${STREAM_PREFIX}${Date.now()}`;
|
|
53
|
-
streamIdRef.current = streamId;
|
|
54
|
-
}
|
|
55
|
-
const idx = messages.findIndex((m) => m.id === streamId);
|
|
56
|
-
const partialContent =
|
|
57
|
-
idx >= 0
|
|
58
|
-
? mergeStreamingTranscript(messages[idx]?.partialContent ?? '', text)
|
|
59
|
-
: text;
|
|
60
|
-
const meta = voiceMeta(resolveSessionId());
|
|
61
51
|
const apply = () => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
52
|
+
api.updateMessages((prev) => {
|
|
53
|
+
let streamId = streamIdRef.current;
|
|
54
|
+
if (!streamId) {
|
|
55
|
+
streamId = `${STREAM_PREFIX}${Date.now()}`;
|
|
56
|
+
streamIdRef.current = streamId;
|
|
57
|
+
}
|
|
58
|
+
const idx = prev.findIndex((m) => m.id === streamId);
|
|
59
|
+
const partialContent =
|
|
60
|
+
idx >= 0
|
|
61
|
+
? mergeStreamingTranscript(prev[idx]?.partialContent ?? '', text)
|
|
62
|
+
: text;
|
|
63
|
+
const meta = voiceMeta(resolveSessionId());
|
|
64
|
+
|
|
65
|
+
if (idx >= 0) {
|
|
66
|
+
return prev.map((m, i) =>
|
|
65
67
|
i === idx
|
|
66
68
|
? { ...m, partialContent, isStreaming: true, metadata: { ...m.metadata, ...meta } }
|
|
67
69
|
: m,
|
|
68
|
-
)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const withUser = ensureUserBubbleForVoiceTurn(
|
|
74
|
+
prev,
|
|
75
|
+
getPendingUserText?.() ?? '',
|
|
76
|
+
undefined,
|
|
77
|
+
meta,
|
|
69
78
|
);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
const base = withUser;
|
|
80
|
+
return [
|
|
81
|
+
...base,
|
|
82
|
+
{
|
|
83
|
+
id: streamId,
|
|
84
|
+
role: 'assistant' as const,
|
|
85
|
+
content: '',
|
|
86
|
+
partialContent,
|
|
87
|
+
isStreaming: true,
|
|
88
|
+
timestamp: new Date().toISOString(),
|
|
89
|
+
metadata: meta,
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
});
|
|
84
93
|
};
|
|
85
94
|
flushSync(apply);
|
|
86
95
|
},
|
|
87
|
-
[api, resolveSessionId],
|
|
96
|
+
[api, getPendingUserText, resolveSessionId],
|
|
88
97
|
);
|
|
89
98
|
|
|
90
99
|
const finalizeAssistant = useCallback(
|
|
@@ -93,11 +102,10 @@ export function useVoiceTranscriptMessages(
|
|
|
93
102
|
streamIdRef.current = null;
|
|
94
103
|
if (!trimmed) return;
|
|
95
104
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
messages.map((m, i) =>
|
|
105
|
+
api.updateMessages((prev) => {
|
|
106
|
+
const streamIdx = prev.findIndex((m) => m.isStreaming && m.role === 'assistant');
|
|
107
|
+
if (streamIdx >= 0) {
|
|
108
|
+
return prev.map((m, i) =>
|
|
101
109
|
i === streamIdx
|
|
102
110
|
? {
|
|
103
111
|
...m,
|
|
@@ -108,22 +116,21 @@ export function useVoiceTranscriptMessages(
|
|
|
108
116
|
metadata: { ...m.metadata, ...voiceMeta(resolveSessionId()) },
|
|
109
117
|
}
|
|
110
118
|
: m,
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
]);
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const last = prev[prev.length - 1];
|
|
122
|
+
if (last?.role === 'assistant' && last.content === trimmed) return prev;
|
|
123
|
+
return [
|
|
124
|
+
...prev,
|
|
125
|
+
{
|
|
126
|
+
id: messageId ?? `voice-asst-${Date.now()}`,
|
|
127
|
+
role: 'assistant' as const,
|
|
128
|
+
content: trimmed,
|
|
129
|
+
timestamp: new Date().toISOString(),
|
|
130
|
+
metadata: voiceMeta(resolveSessionId()),
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
});
|
|
127
134
|
},
|
|
128
135
|
[api, resolveSessionId],
|
|
129
136
|
);
|
|
@@ -133,31 +140,31 @@ export function useVoiceTranscriptMessages(
|
|
|
133
140
|
if (!isVoiceUiActive()) return;
|
|
134
141
|
const text = event.text?.trim() ?? '';
|
|
135
142
|
if (event.role === 'assistant') {
|
|
136
|
-
// Keep one streaming bubble for the whole turn; finalize only in handleDone.
|
|
137
143
|
if (text) upsertStreaming(text);
|
|
138
144
|
return;
|
|
139
145
|
}
|
|
140
146
|
if (event.role === 'user' && !event.interim && text) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
147
|
+
api.updateMessages((prev) => {
|
|
148
|
+
const last = prev[prev.length - 1];
|
|
149
|
+
if (last?.role === 'user' && last.content === text) return prev;
|
|
150
|
+
return [
|
|
151
|
+
...prev,
|
|
152
|
+
{
|
|
153
|
+
id: `voice-user-${Date.now()}`,
|
|
154
|
+
role: 'user' as const,
|
|
155
|
+
content: text,
|
|
156
|
+
timestamp: new Date().toISOString(),
|
|
157
|
+
metadata: voiceMeta(resolveSessionId()),
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
});
|
|
154
161
|
}
|
|
155
162
|
},
|
|
156
|
-
[api,
|
|
163
|
+
[api, isVoiceUiActive, resolveSessionId, upsertStreaming],
|
|
157
164
|
);
|
|
158
165
|
|
|
159
166
|
const handleDone = useCallback(
|
|
160
|
-
(event: VoiceDoneEvent) => {
|
|
167
|
+
(event: VoiceDoneEvent, options?: { pendingUserText?: string }) => {
|
|
161
168
|
if (!isVoiceUiActive()) return;
|
|
162
169
|
if (event.guardrailsBlocked || event.billingBlocked || event.error) {
|
|
163
170
|
streamIdRef.current = null;
|
|
@@ -165,6 +172,16 @@ export function useVoiceTranscriptMessages(
|
|
|
165
172
|
}
|
|
166
173
|
const reply = event.replyText?.trim() ?? '';
|
|
167
174
|
if (reply) {
|
|
175
|
+
flushSync(() => {
|
|
176
|
+
api.updateMessages((prev) =>
|
|
177
|
+
ensureUserBubbleForVoiceTurn(
|
|
178
|
+
prev,
|
|
179
|
+
options?.pendingUserText ?? getPendingUserText?.() ?? '',
|
|
180
|
+
event.userMessageId,
|
|
181
|
+
voiceMeta(resolveSessionId()),
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
});
|
|
168
185
|
finalizeAssistant(reply, event.assistantMessageId ?? undefined);
|
|
169
186
|
} else {
|
|
170
187
|
streamIdRef.current = null;
|
|
@@ -173,7 +190,7 @@ export function useVoiceTranscriptMessages(
|
|
|
173
190
|
console.warn('[nxtlinq] syncVoiceTurnHistory after voice turn failed', err);
|
|
174
191
|
});
|
|
175
192
|
},
|
|
176
|
-
[api, finalizeAssistant, isVoiceUiActive],
|
|
193
|
+
[api, finalizeAssistant, getPendingUserText, isVoiceUiActive, resolveSessionId],
|
|
177
194
|
);
|
|
178
195
|
|
|
179
196
|
const clearVoiceStream = useCallback(() => {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Message } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
|
|
3
|
+
export const VOICE_USER_INPUT_PLACEHOLDER = '(Voice input)';
|
|
4
|
+
|
|
5
|
+
type VoiceMeta = {
|
|
6
|
+
voiceRealtime: true;
|
|
7
|
+
voiceSessionId?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Ensure a user bubble exists before the in-flight assistant reply for this turn. */
|
|
11
|
+
export function ensureUserBubbleForVoiceTurn(
|
|
12
|
+
messages: Message[],
|
|
13
|
+
userText: string,
|
|
14
|
+
userMessageId: string | null | undefined,
|
|
15
|
+
metaForPlaceholder: VoiceMeta | undefined,
|
|
16
|
+
): Message[] {
|
|
17
|
+
const streamIdx = messages.findIndex((m) => m.isStreaming && m.role === 'assistant');
|
|
18
|
+
const insertAt = streamIdx >= 0 ? streamIdx : messages.length;
|
|
19
|
+
const before = messages.slice(0, insertAt);
|
|
20
|
+
|
|
21
|
+
const lastAsstIdx = (() => {
|
|
22
|
+
for (let i = before.length - 1; i >= 0; i -= 1) {
|
|
23
|
+
const m = before[i];
|
|
24
|
+
if (m.role === 'assistant' && !m.isStreaming && Boolean(m.content?.trim())) return i;
|
|
25
|
+
}
|
|
26
|
+
return -1;
|
|
27
|
+
})();
|
|
28
|
+
const lastUserIdx = (() => {
|
|
29
|
+
for (let i = before.length - 1; i >= 0; i -= 1) {
|
|
30
|
+
if (before[i].role === 'user') return i;
|
|
31
|
+
}
|
|
32
|
+
return -1;
|
|
33
|
+
})();
|
|
34
|
+
const hasUserForTurn = lastUserIdx >= 0 && lastUserIdx > lastAsstIdx;
|
|
35
|
+
|
|
36
|
+
const trimmed = userText.trim();
|
|
37
|
+
const displayText = trimmed || VOICE_USER_INPUT_PLACEHOLDER;
|
|
38
|
+
|
|
39
|
+
if (hasUserForTurn) {
|
|
40
|
+
const existing = before[lastUserIdx];
|
|
41
|
+
const shouldUpgrade =
|
|
42
|
+
trimmed &&
|
|
43
|
+
existing.content !== trimmed &&
|
|
44
|
+
(existing.content === VOICE_USER_INPUT_PLACEHOLDER || !existing.content.trim());
|
|
45
|
+
if (!shouldUpgrade) return messages;
|
|
46
|
+
|
|
47
|
+
const upgraded = before.map((m, i) =>
|
|
48
|
+
i === lastUserIdx
|
|
49
|
+
? {
|
|
50
|
+
...m,
|
|
51
|
+
content: trimmed,
|
|
52
|
+
id: userMessageId ?? m.id,
|
|
53
|
+
metadata: userMessageId ? undefined : m.metadata,
|
|
54
|
+
}
|
|
55
|
+
: m,
|
|
56
|
+
);
|
|
57
|
+
return [...upgraded, ...messages.slice(insertAt)];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const userMsg: Message = {
|
|
61
|
+
id: userMessageId ?? `voice-user-${Date.now()}`,
|
|
62
|
+
role: 'user',
|
|
63
|
+
content: displayText,
|
|
64
|
+
timestamp: new Date().toISOString(),
|
|
65
|
+
metadata: userMessageId ? undefined : metaForPlaceholder,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const next = [...messages];
|
|
69
|
+
next.splice(insertAt, 0, userMsg);
|
|
70
|
+
return next;
|
|
71
|
+
}
|