@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.
@@ -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;;;;;;;;;;EA0VrB"}
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
- setMessages: (messages) => setMessages(messages),
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
- setMessages: (messages: Message[]) => void;
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) => void;
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;AAKtD,KAAK,eAAe,GAAG,MAAM,GAAG,OAAO,CAAC;AAExC,KAAK,uBAAuB,GAAG;IAC7B,WAAW,EAAE,MAAM,OAAO,EAAE,CAAC;IAC7B,WAAW,EAAE,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;IAC3C,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;8BAqG7B,oBAAoB;wBA4BpB,cAAc;;EAwBzB"}
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
- if (idx >= 0) {
31
- api.setMessages(messages.map((m, i) => i === idx
32
- ? { ...m, partialContent, isStreaming: true, metadata: { ...m.metadata, ...meta } }
33
- : m));
34
- return;
35
- }
36
- api.setMessages([
37
- ...messages,
38
- {
39
- id: streamId,
40
- role: 'assistant',
41
- content: '',
42
- partialContent,
43
- isStreaming: true,
44
- timestamp: new Date().toISOString(),
45
- metadata: meta,
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
- const messages = api.getMessages();
57
- const streamIdx = messages.findIndex((m) => m.isStreaming && m.role === 'assistant');
58
- if (streamIdx >= 0) {
59
- api.setMessages(messages.map((m, i) => i === streamIdx
60
- ? {
61
- ...m,
62
- id: messageId ?? m.id,
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
- partialContent: undefined,
65
- isStreaming: false,
66
- metadata: { ...m.metadata, ...voiceMeta(resolveSessionId()) },
67
- }
68
- : m));
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
- const messages = api.getMessages();
97
- const last = messages[messages.length - 1];
98
- if (last?.role === 'user' && last.content === text)
99
- return;
100
- api.setMessages([
101
- ...messages,
102
- {
103
- id: `voice-user-${Date.now()}`,
104
- role: 'user',
105
- content: text,
106
- timestamp: new Date().toISOString(),
107
- metadata: voiceMeta(resolveSessionId()),
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, finalizeAssistant, isVoiceUiActive, resolveSessionId, upsertStreaming]);
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.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.8",
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": "workspace:^",
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
- setMessages: (messages: Message[]) => setMessages(messages),
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(transcriptApi, 'voice', voiceSessionId, () => voiceSessionIdRef.current);
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
- setMessages: (messages: Message[]) => void;
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
- if (idx >= 0) {
63
- api.setMessages(
64
- messages.map((m, i) =>
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
- return;
71
- }
72
- api.setMessages([
73
- ...messages,
74
- {
75
- id: streamId,
76
- role: 'assistant',
77
- content: '',
78
- partialContent,
79
- isStreaming: true,
80
- timestamp: new Date().toISOString(),
81
- metadata: meta,
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
- const messages = api.getMessages();
97
- const streamIdx = messages.findIndex((m) => m.isStreaming && m.role === 'assistant');
98
- if (streamIdx >= 0) {
99
- api.setMessages(
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
- return;
114
- }
115
- const last = messages[messages.length - 1];
116
- if (last?.role === 'assistant' && last.content === trimmed) return;
117
- api.setMessages([
118
- ...messages,
119
- {
120
- id: messageId ?? `voice-asst-${Date.now()}`,
121
- role: 'assistant',
122
- content: trimmed,
123
- timestamp: new Date().toISOString(),
124
- metadata: voiceMeta(resolveSessionId()),
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
- const messages = api.getMessages();
142
- const last = messages[messages.length - 1];
143
- if (last?.role === 'user' && last.content === text) return;
144
- api.setMessages([
145
- ...messages,
146
- {
147
- id: `voice-user-${Date.now()}`,
148
- role: 'user',
149
- content: text,
150
- timestamp: new Date().toISOString(),
151
- metadata: voiceMeta(resolveSessionId()),
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, finalizeAssistant, isVoiceUiActive, resolveSessionId, upsertStreaming],
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
+ }