@bytexbyte/nxtlinq-ai-agent-ui-react-native-development 0.2.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.
Files changed (98) hide show
  1. package/dist/NxtlinqAgentAssistant.d.ts +29 -0
  2. package/dist/NxtlinqAgentAssistant.d.ts.map +1 -0
  3. package/dist/NxtlinqAgentAssistant.js +32 -0
  4. package/dist/components/AgentAssistantShell.d.ts +7 -0
  5. package/dist/components/AgentAssistantShell.d.ts.map +1 -0
  6. package/dist/components/AgentAssistantShell.js +77 -0
  7. package/dist/components/AgentComposer.d.ts +3 -0
  8. package/dist/components/AgentComposer.d.ts.map +1 -0
  9. package/dist/components/AgentComposer.js +56 -0
  10. package/dist/components/AgentMessageList.d.ts +3 -0
  11. package/dist/components/AgentMessageList.d.ts.map +1 -0
  12. package/dist/components/AgentMessageList.js +91 -0
  13. package/dist/components/AgentRemoteAudio.d.ts +14 -0
  14. package/dist/components/AgentRemoteAudio.d.ts.map +1 -0
  15. package/dist/components/AgentRemoteAudio.js +62 -0
  16. package/dist/components/AgentVoiceBar.d.ts +3 -0
  17. package/dist/components/AgentVoiceBar.d.ts.map +1 -0
  18. package/dist/components/AgentVoiceBar.js +133 -0
  19. package/dist/components/PresetMessageChips.d.ts +3 -0
  20. package/dist/components/PresetMessageChips.d.ts.map +1 -0
  21. package/dist/components/PresetMessageChips.js +39 -0
  22. package/dist/components/VoiceGreetTrigger.d.ts +10 -0
  23. package/dist/components/VoiceGreetTrigger.d.ts.map +1 -0
  24. package/dist/components/VoiceGreetTrigger.js +99 -0
  25. package/dist/components/VoiceIcons.d.ts +12 -0
  26. package/dist/components/VoiceIcons.d.ts.map +1 -0
  27. package/dist/components/VoiceIcons.js +17 -0
  28. package/dist/components/VoiceImageInput.d.ts +10 -0
  29. package/dist/components/VoiceImageInput.d.ts.map +1 -0
  30. package/dist/components/VoiceImageInput.js +100 -0
  31. package/dist/components/VoiceWaveform.d.ts +7 -0
  32. package/dist/components/VoiceWaveform.d.ts.map +1 -0
  33. package/dist/components/VoiceWaveform.js +64 -0
  34. package/dist/context/AgentAssistantContext.d.ts +45 -0
  35. package/dist/context/AgentAssistantContext.d.ts.map +1 -0
  36. package/dist/context/AgentAssistantContext.js +244 -0
  37. package/dist/index.d.ts +16 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +14 -0
  40. package/dist/theme/defaultTheme.d.ts +3 -0
  41. package/dist/theme/defaultTheme.d.ts.map +1 -0
  42. package/dist/theme/defaultTheme.js +33 -0
  43. package/dist/types.d.ts +103 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +1 -0
  46. package/dist/voice/AudioSessionWaker.d.ts +18 -0
  47. package/dist/voice/AudioSessionWaker.d.ts.map +1 -0
  48. package/dist/voice/AudioSessionWaker.js +49 -0
  49. package/dist/voice/TextTtsPlayer.d.ts +21 -0
  50. package/dist/voice/TextTtsPlayer.d.ts.map +1 -0
  51. package/dist/voice/TextTtsPlayer.js +91 -0
  52. package/dist/voice/VoiceAutoGreetBinder.d.ts +6 -0
  53. package/dist/voice/VoiceAutoGreetBinder.d.ts.map +1 -0
  54. package/dist/voice/VoiceAutoGreetBinder.js +25 -0
  55. package/dist/voice/useVoiceAutoGreet.d.ts +24 -0
  56. package/dist/voice/useVoiceAutoGreet.d.ts.map +1 -0
  57. package/dist/voice/useVoiceAutoGreet.js +64 -0
  58. package/dist/voice/useVoiceMicState.d.ts +24 -0
  59. package/dist/voice/useVoiceMicState.d.ts.map +1 -0
  60. package/dist/voice/useVoiceMicState.js +84 -0
  61. package/dist/voice/voiceMicConstants.d.ts +5 -0
  62. package/dist/voice/voiceMicConstants.d.ts.map +1 -0
  63. package/dist/voice/voiceMicConstants.js +11 -0
  64. package/dist/voice/voiceWaveformConstants.d.ts +6 -0
  65. package/dist/voice/voiceWaveformConstants.d.ts.map +1 -0
  66. package/dist/voice/voiceWaveformConstants.js +7 -0
  67. package/dist/voice/webrtcAudioGain.d.ts +6 -0
  68. package/dist/voice/webrtcAudioGain.d.ts.map +1 -0
  69. package/dist/voice/webrtcAudioGain.js +11 -0
  70. package/dist/voice/writeTtsCacheFile.d.ts +9 -0
  71. package/dist/voice/writeTtsCacheFile.d.ts.map +1 -0
  72. package/dist/voice/writeTtsCacheFile.js +37 -0
  73. package/package.json +64 -0
  74. package/src/NxtlinqAgentAssistant.tsx +103 -0
  75. package/src/components/AgentAssistantShell.tsx +167 -0
  76. package/src/components/AgentComposer.tsx +117 -0
  77. package/src/components/AgentMessageList.tsx +187 -0
  78. package/src/components/AgentRemoteAudio.tsx +105 -0
  79. package/src/components/AgentVoiceBar.tsx +232 -0
  80. package/src/components/PresetMessageChips.tsx +64 -0
  81. package/src/components/VoiceGreetTrigger.tsx +158 -0
  82. package/src/components/VoiceIcons.tsx +32 -0
  83. package/src/components/VoiceImageInput.tsx +178 -0
  84. package/src/components/VoiceWaveform.tsx +84 -0
  85. package/src/context/AgentAssistantContext.tsx +369 -0
  86. package/src/index.ts +59 -0
  87. package/src/react-native.d.ts +42 -0
  88. package/src/theme/defaultTheme.ts +35 -0
  89. package/src/types.ts +107 -0
  90. package/src/voice/AudioSessionWaker.tsx +94 -0
  91. package/src/voice/TextTtsPlayer.tsx +151 -0
  92. package/src/voice/VoiceAutoGreetBinder.tsx +38 -0
  93. package/src/voice/useVoiceAutoGreet.ts +95 -0
  94. package/src/voice/useVoiceMicState.ts +116 -0
  95. package/src/voice/voiceMicConstants.ts +14 -0
  96. package/src/voice/voiceWaveformConstants.ts +10 -0
  97. package/src/voice/webrtcAudioGain.ts +21 -0
  98. package/src/voice/writeTtsCacheFile.ts +47 -0
@@ -0,0 +1,105 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import { useAgentAssistant } from '../context/AgentAssistantContext';
4
+ import {
5
+ applyRemoteAudioPlaybackGain,
6
+ DEFAULT_REMOTE_AUDIO_GAIN,
7
+ } from '@bytexbyte/nxtlinq-ai-agent-core-development';
8
+
9
+ type RTCViewComponent = React.ComponentType<{
10
+ streamURL: string;
11
+ objectFit?: string;
12
+ style?: object;
13
+ zOrder?: number;
14
+ }>;
15
+
16
+ let RTCView: RTCViewComponent | null = null;
17
+ try {
18
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
19
+ RTCView = require('react-native-webrtc').RTCView as RTCViewComponent;
20
+ } catch {
21
+ RTCView = null;
22
+ }
23
+
24
+ type StreamWithUrl = {
25
+ toURL?: () => string;
26
+ getAudioTracks?: () => Array<{ enabled: boolean; kind?: string }>;
27
+ };
28
+
29
+ export type AgentRemoteAudioProps = {
30
+ /**
31
+ * Playback gain for assistant WebRTC audio (`MediaStreamTrack._setVolume`, 0–10).
32
+ * Text TTS uses react-native-video at volume 1 and sounds louder by default.
33
+ */
34
+ remoteAudioGain?: number;
35
+ };
36
+
37
+ /**
38
+ * Plays remote WebRTC assistant audio. On iOS/Android, {@link RTCView} is required
39
+ * (enabling tracks alone is not enough).
40
+ */
41
+ export function AgentRemoteAudio({
42
+ remoteAudioGain = DEFAULT_REMOTE_AUDIO_GAIN,
43
+ }: AgentRemoteAudioProps): React.ReactElement | null {
44
+ const { getRemoteAudioStream, interactionMode, voiceSessionId, voiceStatus } =
45
+ useAgentAssistant();
46
+ const [streamUrl, setStreamUrl] = useState<string | null>(null);
47
+
48
+ useEffect(() => {
49
+ if (interactionMode !== 'voice' || !voiceSessionId) {
50
+ setStreamUrl(null);
51
+ return;
52
+ }
53
+
54
+ const sync = () => {
55
+ const stream = getRemoteAudioStream() as StreamWithUrl | null;
56
+ applyRemoteAudioPlaybackGain(
57
+ stream as Parameters<typeof applyRemoteAudioPlaybackGain>[0],
58
+ remoteAudioGain,
59
+ );
60
+ const tracks = stream?.getAudioTracks?.() ?? [];
61
+ for (const track of tracks) {
62
+ if (track.kind === 'audio' || track.kind == null) {
63
+ track.enabled = true;
64
+ }
65
+ }
66
+ const url = stream?.toURL?.() ?? null;
67
+ setStreamUrl((prev) => (prev === url ? prev : url));
68
+ };
69
+
70
+ sync();
71
+ const id = setInterval(sync, 150);
72
+ return () => clearInterval(id);
73
+ }, [
74
+ getRemoteAudioStream,
75
+ interactionMode,
76
+ voiceSessionId,
77
+ voiceStatus,
78
+ remoteAudioGain,
79
+ ]);
80
+
81
+ if (!RTCView || !streamUrl || interactionMode !== 'voice') {
82
+ return null;
83
+ }
84
+
85
+ return (
86
+ <RTCView
87
+ key={streamUrl}
88
+ streamURL={streamUrl}
89
+ objectFit="cover"
90
+ zOrder={-1}
91
+ style={styles.hidden}
92
+ />
93
+ );
94
+ }
95
+
96
+ const styles = StyleSheet.create({
97
+ hidden: {
98
+ position: 'absolute',
99
+ width: 1,
100
+ height: 1,
101
+ opacity: 0.01,
102
+ left: -1000,
103
+ top: -1000,
104
+ },
105
+ });
@@ -0,0 +1,232 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Pressable,
5
+ StyleSheet,
6
+ Text,
7
+ View,
8
+ } from 'react-native';
9
+ import {
10
+ useAgentAssistant,
11
+ voiceStatusLabel,
12
+ } from '../context/AgentAssistantContext';
13
+ import { SPEAKER_ACTIVE_STATUSES } from '../voice/voiceMicConstants';
14
+ import { MicIcon, MicOffIcon, SpeakerIcon, StopIcon } from './VoiceIcons';
15
+
16
+ function statusDotColor(
17
+ status: ReturnType<typeof useAgentAssistant>['voiceStatus'],
18
+ theme: ReturnType<typeof useAgentAssistant>['theme'],
19
+ ): string {
20
+ switch (status) {
21
+ case 'listening':
22
+ return theme.colors.voiceActive;
23
+ case 'speaking':
24
+ return theme.colors.voiceSpeaking;
25
+ case 'idle':
26
+ return theme.colors.mutedText;
27
+ default:
28
+ return theme.colors.primary;
29
+ }
30
+ }
31
+
32
+ export function AgentVoiceBar(): React.ReactElement | null {
33
+ const {
34
+ theme,
35
+ interactionMode,
36
+ voiceStatus,
37
+ voiceSessionId,
38
+ isVoiceConnecting,
39
+ isVoiceChannelReady,
40
+ stopVoice,
41
+ interrupt,
42
+ isVoiceAvailable,
43
+ isMicMuted,
44
+ isMicHeldForAssistant,
45
+ toggleVoiceMicMute,
46
+ } = useAgentAssistant();
47
+
48
+ const [speakerPulseOpacity, setSpeakerPulseOpacity] = useState(1);
49
+ const isSpeakerActive = SPEAKER_ACTIVE_STATUSES.has(voiceStatus);
50
+
51
+ useEffect(() => {
52
+ if (!isSpeakerActive) {
53
+ setSpeakerPulseOpacity(1);
54
+ return;
55
+ }
56
+ let high = true;
57
+ const id = setInterval(() => {
58
+ setSpeakerPulseOpacity(high ? 0.35 : 1);
59
+ high = !high;
60
+ }, 600);
61
+ return () => clearInterval(id);
62
+ }, [isSpeakerActive]);
63
+
64
+ const returnToTextMode = useCallback(() => {
65
+ void stopVoice();
66
+ }, [stopVoice]);
67
+
68
+ if (!isVoiceAvailable) return null;
69
+ if (interactionMode !== 'voice' && !isVoiceConnecting) return null;
70
+
71
+ const showConnecting = isVoiceConnecting;
72
+ const awaitingChannel =
73
+ Boolean(voiceSessionId) &&
74
+ !isVoiceConnecting &&
75
+ !isVoiceChannelReady &&
76
+ voiceStatus === 'idle';
77
+
78
+ const statusHint = showConnecting
79
+ ? 'Tap Back to text mode below to cancel'
80
+ : awaitingChannel
81
+ ? 'Waiting for voice channel…'
82
+ : isMicHeldForAssistant
83
+ ? 'Mic paused while assistant responds (use Interrupt to speak)'
84
+ : isMicMuted
85
+ ? 'Mic is off — tap the mic when ready to speak'
86
+ : voiceStatus === 'listening'
87
+ ? 'Start speaking'
88
+ : voiceStatus === 'speaking'
89
+ ? 'Assistant is speaking…'
90
+ : voiceStatus === 'thinking'
91
+ ? 'Thinking…'
92
+ : '';
93
+
94
+ return (
95
+ <View
96
+ style={[
97
+ styles.container,
98
+ {
99
+ borderTopColor: theme.colors.border,
100
+ backgroundColor: theme.colors.surface,
101
+ padding: theme.spacing.md,
102
+ },
103
+ ]}
104
+ >
105
+ <View style={styles.mainRow}>
106
+ <View
107
+ style={[
108
+ styles.statusPill,
109
+ { borderColor: theme.colors.border, backgroundColor: theme.colors.background },
110
+ ]}
111
+ >
112
+ <View style={styles.statusPillHeader}>
113
+ <View
114
+ style={[
115
+ styles.dot,
116
+ { backgroundColor: statusDotColor(voiceStatus, theme) },
117
+ ]}
118
+ />
119
+ <Text
120
+ style={{
121
+ color: theme.colors.assistantText,
122
+ fontSize: theme.typography.captionSize,
123
+ fontWeight: '600',
124
+ flexShrink: 1,
125
+ }}
126
+ numberOfLines={1}
127
+ >
128
+ {showConnecting
129
+ ? 'Connecting'
130
+ : awaitingChannel
131
+ ? 'Connecting'
132
+ : voiceStatusLabel(voiceStatus)}
133
+ </Text>
134
+ {showConnecting ? (
135
+ <ActivityIndicator color={theme.colors.primary} size="small" />
136
+ ) : null}
137
+ </View>
138
+ {statusHint ? (
139
+ <Text
140
+ style={{
141
+ color: theme.colors.mutedText,
142
+ fontSize: theme.typography.captionSize - 1,
143
+ flexShrink: 1,
144
+ alignSelf: 'stretch',
145
+ }}
146
+ numberOfLines={2}
147
+ >
148
+ {statusHint}
149
+ </Text>
150
+ ) : null}
151
+ </View>
152
+
153
+ <View style={styles.iconRow}>
154
+ <View style={{ opacity: isSpeakerActive ? speakerPulseOpacity : 0.35 }}>
155
+ <SpeakerIcon size={24} color={theme.colors.voiceSpeaking} />
156
+ </View>
157
+
158
+ <Pressable
159
+ onPress={toggleVoiceMicMute}
160
+ disabled={showConnecting || awaitingChannel || isMicHeldForAssistant}
161
+ style={({ pressed }) => [
162
+ styles.iconButton,
163
+ {
164
+ opacity:
165
+ pressed || showConnecting || awaitingChannel || isMicHeldForAssistant
166
+ ? 0.45
167
+ : 1,
168
+ },
169
+ ]}
170
+ >
171
+ {isMicMuted ? (
172
+ <MicOffIcon size={24} color="#ef4444" />
173
+ ) : (
174
+ <MicIcon size={24} color={theme.colors.assistantText} />
175
+ )}
176
+ </Pressable>
177
+
178
+ <Pressable
179
+ onPress={() => interrupt()}
180
+ disabled={showConnecting || awaitingChannel}
181
+ style={({ pressed }) => [
182
+ styles.iconButton,
183
+ { opacity: pressed || showConnecting || awaitingChannel ? 0.45 : 1 },
184
+ ]}
185
+ >
186
+ <StopIcon size={24} color={theme.colors.assistantText} />
187
+ </Pressable>
188
+ </View>
189
+ </View>
190
+
191
+ <Pressable onPress={returnToTextMode} style={styles.textLink}>
192
+ <Text style={{ color: theme.colors.primary, fontSize: theme.typography.captionSize }}>
193
+ Back to text mode
194
+ </Text>
195
+ </Pressable>
196
+ </View>
197
+ );
198
+ }
199
+
200
+ const styles = StyleSheet.create({
201
+ container: { borderTopWidth: StyleSheet.hairlineWidth },
202
+ mainRow: {
203
+ flexDirection: 'row',
204
+ alignItems: 'center',
205
+ justifyContent: 'space-between',
206
+ gap: 10,
207
+ },
208
+ statusPill: {
209
+ flex: 1,
210
+ minWidth: 0,
211
+ flexDirection: 'column',
212
+ alignItems: 'flex-start',
213
+ gap: 4,
214
+ paddingHorizontal: 10,
215
+ paddingVertical: 8,
216
+ borderRadius: 12,
217
+ borderWidth: 1,
218
+ },
219
+ statusPillHeader: {
220
+ flexDirection: 'row',
221
+ alignItems: 'center',
222
+ gap: 6,
223
+ alignSelf: 'stretch',
224
+ },
225
+ dot: { width: 8, height: 8, borderRadius: 4 },
226
+ iconRow: { flexDirection: 'row', alignItems: 'center', gap: 2 },
227
+ iconButton: {
228
+ padding: 8,
229
+ borderRadius: 8,
230
+ },
231
+ textLink: { marginTop: 10, alignSelf: 'center' },
232
+ });
@@ -0,0 +1,64 @@
1
+ import React from 'react';
2
+ import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
3
+ import { useAgentAssistant } from '../context/AgentAssistantContext';
4
+
5
+ export function PresetMessageChips(): React.ReactElement | null {
6
+ const { presetMessages, selectPreset, theme, interactionMode } = useAgentAssistant();
7
+
8
+ if (!presetMessages.length || interactionMode === 'voice') {
9
+ return null;
10
+ }
11
+
12
+ return (
13
+ <View
14
+ style={[
15
+ styles.container,
16
+ {
17
+ borderBottomColor: theme.colors.border,
18
+ backgroundColor: theme.colors.surface,
19
+ },
20
+ ]}
21
+ >
22
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scroll}>
23
+ {presetMessages.map((preset, index) => (
24
+ <Pressable
25
+ key={`${preset.text}-${index}`}
26
+ onPress={() => void selectPreset(preset)}
27
+ style={({ pressed }: { pressed: boolean }) => [
28
+ styles.chip,
29
+ {
30
+ borderColor: theme.colors.border,
31
+ backgroundColor: pressed ? theme.colors.border : theme.colors.background,
32
+ borderRadius: theme.radius.button,
33
+ },
34
+ ]}
35
+ >
36
+ <Text
37
+ style={{
38
+ color: theme.colors.assistantText,
39
+ fontSize: theme.typography.captionSize,
40
+ }}
41
+ numberOfLines={1}
42
+ >
43
+ {preset.text}
44
+ </Text>
45
+ </Pressable>
46
+ ))}
47
+ </ScrollView>
48
+ </View>
49
+ );
50
+ }
51
+
52
+ const styles = StyleSheet.create({
53
+ container: {
54
+ borderBottomWidth: StyleSheet.hairlineWidth,
55
+ paddingVertical: 8,
56
+ },
57
+ scroll: { paddingHorizontal: 12, gap: 8 },
58
+ chip: {
59
+ borderWidth: 1,
60
+ paddingHorizontal: 12,
61
+ paddingVertical: 8,
62
+ marginRight: 8,
63
+ },
64
+ });
@@ -0,0 +1,158 @@
1
+ import { uriToVoiceImageAttachment } from '@bytexbyte/nxtlinq-ai-agent-react-native-development';
2
+ import React, { useCallback, useState } from 'react';
3
+ import {
4
+ ActivityIndicator,
5
+ Pressable,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ } from 'react-native';
10
+ import { useAgentAssistant } from '../context/AgentAssistantContext';
11
+ import type { VoiceAutoGreetConfig } from '../voice/useVoiceAutoGreet';
12
+
13
+ function buildGreetingText(config: VoiceAutoGreetConfig): string {
14
+ if (config.text?.trim()) return config.text.trim();
15
+ const product = config.productName?.trim();
16
+ return product
17
+ ? `Hi! Please give me a brief introduction to ${product}.`
18
+ : 'Hi! Please say hello and introduce yourself.';
19
+ }
20
+
21
+ export type VoiceGreetTriggerProps = {
22
+ config: VoiceAutoGreetConfig;
23
+ };
24
+
25
+ /**
26
+ * P0 / QA: manually fire the same payload as {@link useVoiceAutoGreet} (product image + intro text).
27
+ */
28
+ export function VoiceGreetTrigger({ config }: VoiceGreetTriggerProps): React.ReactElement | null {
29
+ const {
30
+ theme,
31
+ interactionMode,
32
+ isVoiceActive,
33
+ isVoiceChannelReady,
34
+ triggerVoiceGreeting,
35
+ } = useAgentAssistant();
36
+ const [busy, setBusy] = useState(false);
37
+ const [error, setError] = useState<string | null>(null);
38
+ const [hint, setHint] = useState<string | null>(null);
39
+
40
+ const sendGreet = useCallback(async () => {
41
+ if (!isVoiceActive) {
42
+ setError('Start voice mode first');
43
+ return;
44
+ }
45
+ if (!isVoiceChannelReady) {
46
+ setError('Voice channel not ready — wait for Listening');
47
+ return;
48
+ }
49
+ setBusy(true);
50
+ setError(null);
51
+ setHint(null);
52
+ try {
53
+ const imageUrl = config.productImageUrl?.trim();
54
+ let attachments = config.attachments;
55
+ if (!attachments?.length && imageUrl) {
56
+ try {
57
+ attachments = [await uriToVoiceImageAttachment(imageUrl, 'product.jpg')];
58
+ } catch (imgErr) {
59
+ console.warn('[nxtlinq] greet image skipped:', imgErr);
60
+ }
61
+ }
62
+ await triggerVoiceGreeting(
63
+ {
64
+ text: buildGreetingText(config),
65
+ attachments,
66
+ skipUserMessage: config.skipUserMessage ?? true,
67
+ },
68
+ { waitForChannel: true, timeoutMs: 12000 },
69
+ );
70
+ setHint('Opening greet sent');
71
+ } catch (e) {
72
+ const message = e instanceof Error ? e.message : String(e);
73
+ setError(message);
74
+ console.warn('[nxtlinq] triggerVoiceGreeting failed:', message);
75
+ } finally {
76
+ setBusy(false);
77
+ }
78
+ }, [
79
+ config,
80
+ isVoiceActive,
81
+ isVoiceChannelReady,
82
+ triggerVoiceGreeting,
83
+ ]);
84
+
85
+ if (interactionMode !== 'voice') return null;
86
+
87
+ const imageNote = config.productImageUrl?.trim()
88
+ ? '(含產品圖)'
89
+ : '(僅文字)';
90
+
91
+ return (
92
+ <View
93
+ style={[
94
+ styles.box,
95
+ {
96
+ borderTopColor: theme.colors.border,
97
+ backgroundColor: theme.colors.background,
98
+ padding: theme.spacing.sm,
99
+ },
100
+ ]}
101
+ >
102
+ <Text style={{ color: theme.colors.mutedText, fontSize: theme.typography.captionSize }}>
103
+ Voice opening greet (manual test){imageNote}
104
+ </Text>
105
+ {!isVoiceChannelReady ? (
106
+ <Text
107
+ style={{
108
+ color: theme.colors.mutedText,
109
+ fontSize: theme.typography.captionSize,
110
+ marginTop: theme.spacing.xs,
111
+ }}
112
+ >
113
+ Waiting for voice channel…
114
+ </Text>
115
+ ) : null}
116
+ {error ? (
117
+ <Text style={{ color: theme.colors.error, fontSize: theme.typography.captionSize }}>
118
+ {error}
119
+ </Text>
120
+ ) : null}
121
+ {hint ? (
122
+ <Text style={{ color: theme.colors.primary, fontSize: theme.typography.captionSize }}>
123
+ {hint}
124
+ </Text>
125
+ ) : null}
126
+ <Pressable
127
+ onPress={() => void sendGreet()}
128
+ disabled={busy || !isVoiceChannelReady}
129
+ style={({ pressed }) => [
130
+ styles.btn,
131
+ {
132
+ backgroundColor: theme.colors.voiceSpeaking,
133
+ marginTop: theme.spacing.xs,
134
+ opacity: pressed || busy || !isVoiceChannelReady ? 0.6 : 1,
135
+ },
136
+ ]}
137
+ >
138
+ {busy ? (
139
+ <ActivityIndicator color={theme.colors.primaryText} size="small" />
140
+ ) : (
141
+ <Text style={{ color: theme.colors.primaryText, fontWeight: '600' }}>
142
+ Send opening greet
143
+ </Text>
144
+ )}
145
+ </Pressable>
146
+ </View>
147
+ );
148
+ }
149
+
150
+ const styles = StyleSheet.create({
151
+ box: { borderTopWidth: StyleSheet.hairlineWidth },
152
+ btn: {
153
+ paddingHorizontal: 14,
154
+ paddingVertical: 10,
155
+ borderRadius: 8,
156
+ alignItems: 'center',
157
+ },
158
+ });
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
3
+
4
+ type IconProps = {
5
+ size?: number;
6
+ color?: string;
7
+ };
8
+
9
+ type MaterialIconProps = IconProps & { name: string };
10
+
11
+ /** Typed wrapper — avoids React 18 JSX/classic Component mismatch in package `tsc`. */
12
+ const MaterialIcon = MaterialIcons as unknown as React.ComponentType<MaterialIconProps>;
13
+
14
+ export function MicIcon({ size = 24, color = '#4b5563' }: IconProps): React.ReactElement {
15
+ return <MaterialIcon name="mic" size={size} color={color} />;
16
+ }
17
+
18
+ export function MicOffIcon({ size = 24, color = '#ef4444' }: IconProps): React.ReactElement {
19
+ return <MaterialIcon name="mic-off" size={size} color={color} />;
20
+ }
21
+
22
+ export function StopIcon({ size = 24, color = '#4b5563' }: IconProps): React.ReactElement {
23
+ return <MaterialIcon name="stop" size={size} color={color} />;
24
+ }
25
+
26
+ /** Loudspeaker — assistant audio output (Material volume-up). */
27
+ export function SpeakerIcon({
28
+ size = 24,
29
+ color = '#ec4899',
30
+ }: IconProps): React.ReactElement {
31
+ return <MaterialIcon name="volume-up" size={size} color={color} />;
32
+ }