@bytexbyte/nxtlinq-ai-agent-ui-react-development 0.1.1 → 0.1.3

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.
@@ -5,11 +5,13 @@ import type {
5
5
  } from '@bytexbyte/nxtlinq-ai-agent-core-development';
6
6
  import { mergeStreamingTranscript } from '@bytexbyte/nxtlinq-ai-agent-core-development';
7
7
  import { useCallback, useRef } from 'react';
8
- import type { InteractionMode } from '../context/AgentAssistantContext';
8
+ import { ensureUserBubbleForVoiceTurn } from './voiceUserBubble';
9
9
 
10
- type VoiceTranscriptAgentApi = {
10
+ type InteractionMode = 'text' | 'voice';
11
+
12
+ export type VoiceTranscriptAgentApi = {
11
13
  getMessages: () => Message[];
12
- setMessages: (messages: Message[]) => void;
14
+ updateMessages: (updater: (prev: Message[]) => Message[]) => void;
13
15
  syncVoiceTurnHistory: (options?: { last?: number }) => Promise<void>;
14
16
  };
15
17
 
@@ -26,6 +28,7 @@ export function useVoiceTranscriptMessages(
26
28
  api: VoiceTranscriptAgentApi,
27
29
  interactionMode: InteractionMode,
28
30
  voiceSessionId: string | null,
31
+ getPendingUserText?: () => string,
29
32
  ) {
30
33
  const streamIdRef = useRef<string | null>(null);
31
34
  const sessionIdRef = useRef(voiceSessionId);
@@ -38,42 +41,48 @@ export function useVoiceTranscriptMessages(
38
41
 
39
42
  const upsertStreaming = useCallback(
40
43
  (text: string) => {
41
- const messages = api.getMessages();
42
- let streamId = streamIdRef.current;
43
- if (!streamId) {
44
- streamId = `${STREAM_PREFIX}${Date.now()}`;
45
- streamIdRef.current = streamId;
46
- }
47
- const idx = messages.findIndex((m) => m.id === streamId);
48
- const partialContent =
49
- idx >= 0
50
- ? mergeStreamingTranscript(messages[idx]?.partialContent ?? '', text)
51
- : text;
52
- const meta = voiceMeta(sessionIdRef.current);
53
- if (idx >= 0) {
54
- api.setMessages(
55
- messages.map((m, i) =>
44
+ api.updateMessages((prev) => {
45
+ let streamId = streamIdRef.current;
46
+ if (!streamId) {
47
+ streamId = `${STREAM_PREFIX}${Date.now()}`;
48
+ streamIdRef.current = streamId;
49
+ }
50
+ const idx = prev.findIndex((m) => m.id === streamId);
51
+ const partialContent =
52
+ idx >= 0
53
+ ? mergeStreamingTranscript(prev[idx]?.partialContent ?? '', text)
54
+ : text;
55
+ const meta = voiceMeta(sessionIdRef.current);
56
+
57
+ if (idx >= 0) {
58
+ return prev.map((m, i) =>
56
59
  i === idx
57
60
  ? { ...m, partialContent, isStreaming: true, metadata: { ...m.metadata, ...meta } }
58
61
  : m,
59
- ),
62
+ );
63
+ }
64
+
65
+ const withUser = ensureUserBubbleForVoiceTurn(
66
+ prev,
67
+ getPendingUserText?.() ?? '',
68
+ undefined,
69
+ meta,
60
70
  );
61
- return;
62
- }
63
- api.setMessages([
64
- ...messages,
65
- {
66
- id: streamId,
67
- role: 'assistant',
68
- content: '',
69
- partialContent,
70
- isStreaming: true,
71
- timestamp: new Date().toISOString(),
72
- metadata: meta,
73
- },
74
- ]);
71
+ return [
72
+ ...withUser,
73
+ {
74
+ id: streamId,
75
+ role: 'assistant' as const,
76
+ content: '',
77
+ partialContent,
78
+ isStreaming: true,
79
+ timestamp: new Date().toISOString(),
80
+ metadata: meta,
81
+ },
82
+ ];
83
+ });
75
84
  },
76
- [api],
85
+ [api, getPendingUserText],
77
86
  );
78
87
 
79
88
  const finalizeAssistant = useCallback(
@@ -82,11 +91,10 @@ export function useVoiceTranscriptMessages(
82
91
  streamIdRef.current = null;
83
92
  if (!trimmed) return;
84
93
 
85
- const messages = api.getMessages();
86
- const streamIdx = messages.findIndex((m) => m.isStreaming && m.role === 'assistant');
87
- if (streamIdx >= 0) {
88
- api.setMessages(
89
- messages.map((m, i) =>
94
+ api.updateMessages((prev) => {
95
+ const streamIdx = prev.findIndex((m) => m.isStreaming && m.role === 'assistant');
96
+ if (streamIdx >= 0) {
97
+ return prev.map((m, i) =>
90
98
  i === streamIdx
91
99
  ? {
92
100
  ...m,
@@ -97,22 +105,21 @@ export function useVoiceTranscriptMessages(
97
105
  metadata: { ...m.metadata, ...voiceMeta(sessionIdRef.current) },
98
106
  }
99
107
  : m,
100
- ),
101
- );
102
- return;
103
- }
104
- const last = messages[messages.length - 1];
105
- if (last?.role === 'assistant' && last.content === trimmed) return;
106
- api.setMessages([
107
- ...messages,
108
- {
109
- id: messageId ?? `voice-asst-${Date.now()}`,
110
- role: 'assistant',
111
- content: trimmed,
112
- timestamp: new Date().toISOString(),
113
- metadata: voiceMeta(sessionIdRef.current),
114
- },
115
- ]);
108
+ );
109
+ }
110
+ const last = prev[prev.length - 1];
111
+ if (last?.role === 'assistant' && last.content === trimmed) return prev;
112
+ return [
113
+ ...prev,
114
+ {
115
+ id: messageId ?? `voice-asst-${Date.now()}`,
116
+ role: 'assistant' as const,
117
+ content: trimmed,
118
+ timestamp: new Date().toISOString(),
119
+ metadata: voiceMeta(sessionIdRef.current),
120
+ },
121
+ ];
122
+ });
116
123
  },
117
124
  [api],
118
125
  );
@@ -122,31 +129,31 @@ export function useVoiceTranscriptMessages(
122
129
  if (!isVoiceUiActive()) return;
123
130
  const text = event.text?.trim() ?? '';
124
131
  if (event.role === 'assistant') {
125
- // Keep one streaming bubble for the whole turn; finalize only in handleDone.
126
132
  if (text) upsertStreaming(text);
127
133
  return;
128
134
  }
129
135
  if (event.role === 'user' && !event.interim && text) {
130
- const messages = api.getMessages();
131
- const last = messages[messages.length - 1];
132
- if (last?.role === 'user' && last.content === text) return;
133
- api.setMessages([
134
- ...messages,
135
- {
136
- id: `voice-user-${Date.now()}`,
137
- role: 'user',
138
- content: text,
139
- timestamp: new Date().toISOString(),
140
- metadata: voiceMeta(sessionIdRef.current),
141
- },
142
- ]);
136
+ api.updateMessages((prev) => {
137
+ const last = prev[prev.length - 1];
138
+ if (last?.role === 'user' && last.content === text) return prev;
139
+ return [
140
+ ...prev,
141
+ {
142
+ id: `voice-user-${Date.now()}`,
143
+ role: 'user' as const,
144
+ content: text,
145
+ timestamp: new Date().toISOString(),
146
+ metadata: voiceMeta(sessionIdRef.current),
147
+ },
148
+ ];
149
+ });
143
150
  }
144
151
  },
145
- [api, finalizeAssistant, isVoiceUiActive, upsertStreaming],
152
+ [api, isVoiceUiActive, upsertStreaming],
146
153
  );
147
154
 
148
155
  const handleDone = useCallback(
149
- (event: VoiceDoneEvent) => {
156
+ (event: VoiceDoneEvent, options?: { pendingUserText?: string }) => {
150
157
  if (!isVoiceUiActive()) return;
151
158
  if (event.guardrailsBlocked || event.billingBlocked || event.error) {
152
159
  streamIdRef.current = null;
@@ -154,6 +161,14 @@ export function useVoiceTranscriptMessages(
154
161
  }
155
162
  const reply = event.replyText?.trim() ?? '';
156
163
  if (reply) {
164
+ api.updateMessages((prev) =>
165
+ ensureUserBubbleForVoiceTurn(
166
+ prev,
167
+ options?.pendingUserText ?? getPendingUserText?.() ?? '',
168
+ event.userMessageId,
169
+ voiceMeta(sessionIdRef.current),
170
+ ),
171
+ );
157
172
  finalizeAssistant(reply, event.assistantMessageId ?? undefined);
158
173
  } else {
159
174
  streamIdRef.current = null;
@@ -162,7 +177,7 @@ export function useVoiceTranscriptMessages(
162
177
  console.warn('[nxtlinq] syncVoiceTurnHistory after voice turn failed', err);
163
178
  });
164
179
  },
165
- [api, finalizeAssistant, isVoiceUiActive],
180
+ [api, finalizeAssistant, getPendingUserText, isVoiceUiActive],
166
181
  );
167
182
 
168
183
  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
+ }