@bytexbyte/nxtlinq-ai-agent-ui-react-development 0.1.1
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/NxtlinqAgentChat.d.ts +26 -0
- package/dist/NxtlinqAgentChat.d.ts.map +1 -0
- package/dist/NxtlinqAgentChat.js +28 -0
- package/dist/components/AgentAssistantShell.d.ts +5 -0
- package/dist/components/AgentAssistantShell.d.ts.map +1 -0
- package/dist/components/AgentAssistantShell.js +52 -0
- package/dist/components/AgentComposer.d.ts +3 -0
- package/dist/components/AgentComposer.d.ts.map +1 -0
- package/dist/components/AgentComposer.js +60 -0
- package/dist/components/AgentMessageList.d.ts +3 -0
- package/dist/components/AgentMessageList.d.ts.map +1 -0
- package/dist/components/AgentMessageList.js +37 -0
- package/dist/components/AgentRemoteAudio.d.ts +4 -0
- package/dist/components/AgentRemoteAudio.d.ts.map +1 -0
- package/dist/components/AgentRemoteAudio.js +34 -0
- package/dist/components/AgentVoiceBar.d.ts +3 -0
- package/dist/components/AgentVoiceBar.d.ts.map +1 -0
- package/dist/components/AgentVoiceBar.js +91 -0
- package/dist/components/PresetMessageChips.d.ts +3 -0
- package/dist/components/PresetMessageChips.d.ts.map +1 -0
- package/dist/components/PresetMessageChips.js +23 -0
- package/dist/context/AgentAssistantContext.d.ts +32 -0
- package/dist/context/AgentAssistantContext.d.ts.map +1 -0
- package/dist/context/AgentAssistantContext.js +159 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/legacy/assets/images/adiSideItalicDataUri.d.ts +2 -0
- package/dist/legacy/assets/images/adiSideItalicDataUri.d.ts.map +1 -0
- package/dist/legacy/assets/images/adiSideItalicDataUri.js +1 -0
- package/dist/legacy/chatbot/ChatBot.d.ts +5 -0
- package/dist/legacy/chatbot/ChatBot.d.ts.map +1 -0
- package/dist/legacy/chatbot/ChatBot.js +35 -0
- package/dist/legacy/chatbot/context/ChatBotContext.d.ts +5 -0
- package/dist/legacy/chatbot/context/ChatBotContext.d.ts.map +1 -0
- package/dist/legacy/chatbot/context/ChatBotContext.js +2908 -0
- package/dist/legacy/chatbot/types/ChatBotTypes.d.ts +166 -0
- package/dist/legacy/chatbot/types/ChatBotTypes.d.ts.map +1 -0
- package/dist/legacy/chatbot/types/ChatBotTypes.js +1 -0
- package/dist/legacy/chatbot/ui/BerifyMeModal.d.ts +17 -0
- package/dist/legacy/chatbot/ui/BerifyMeModal.d.ts.map +1 -0
- package/dist/legacy/chatbot/ui/BerifyMeModal.js +110 -0
- package/dist/legacy/chatbot/ui/ChatBotUI.d.ts +3 -0
- package/dist/legacy/chatbot/ui/ChatBotUI.d.ts.map +1 -0
- package/dist/legacy/chatbot/ui/ChatBotUI.js +625 -0
- package/dist/legacy/chatbot/ui/MessageInput.d.ts +3 -0
- package/dist/legacy/chatbot/ui/MessageInput.d.ts.map +1 -0
- package/dist/legacy/chatbot/ui/MessageInput.js +321 -0
- package/dist/legacy/chatbot/ui/MessageList.d.ts +4 -0
- package/dist/legacy/chatbot/ui/MessageList.d.ts.map +1 -0
- package/dist/legacy/chatbot/ui/MessageList.js +455 -0
- package/dist/legacy/chatbot/ui/ModelSelector.d.ts +4 -0
- package/dist/legacy/chatbot/ui/ModelSelector.d.ts.map +1 -0
- package/dist/legacy/chatbot/ui/ModelSelector.js +122 -0
- package/dist/legacy/chatbot/ui/NotificationModal.d.ts +15 -0
- package/dist/legacy/chatbot/ui/NotificationModal.d.ts.map +1 -0
- package/dist/legacy/chatbot/ui/NotificationModal.js +53 -0
- package/dist/legacy/chatbot/ui/PermissionForm.d.ts +8 -0
- package/dist/legacy/chatbot/ui/PermissionForm.d.ts.map +1 -0
- package/dist/legacy/chatbot/ui/PermissionForm.js +465 -0
- package/dist/legacy/chatbot/ui/PresetMessages.d.ts +4 -0
- package/dist/legacy/chatbot/ui/PresetMessages.d.ts.map +1 -0
- package/dist/legacy/chatbot/ui/PresetMessages.js +33 -0
- package/dist/legacy/chatbot/ui/VoiceModePanel.d.ts +3 -0
- package/dist/legacy/chatbot/ui/VoiceModePanel.d.ts.map +1 -0
- package/dist/legacy/chatbot/ui/VoiceModePanel.js +95 -0
- package/dist/legacy/chatbot/ui/styles/isolatedStyles.d.ts +73 -0
- package/dist/legacy/chatbot/ui/styles/isolatedStyles.d.ts.map +1 -0
- package/dist/legacy/chatbot/ui/styles/isolatedStyles.js +985 -0
- package/dist/legacy/index.d.ts +14 -0
- package/dist/legacy/index.d.ts.map +1 -0
- package/dist/legacy/index.js +12 -0
- package/dist/theme/defaultTheme.d.ts +3 -0
- package/dist/theme/defaultTheme.d.ts.map +1 -0
- package/dist/theme/defaultTheme.js +20 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/voice/useVoiceConnectOrchestration.d.ts +21 -0
- package/dist/voice/useVoiceConnectOrchestration.d.ts.map +1 -0
- package/dist/voice/useVoiceConnectOrchestration.js +86 -0
- package/dist/voice/useVoiceMicState.d.ts +15 -0
- package/dist/voice/useVoiceMicState.d.ts.map +1 -0
- package/dist/voice/useVoiceMicState.js +94 -0
- package/dist/voice/useVoiceSilenceCommit.d.ts +10 -0
- package/dist/voice/useVoiceSilenceCommit.d.ts.map +1 -0
- package/dist/voice/useVoiceSilenceCommit.js +67 -0
- package/dist/voice/useVoiceTranscriptMessages.d.ts +16 -0
- package/dist/voice/useVoiceTranscriptMessages.d.ts.map +1 -0
- package/dist/voice/useVoiceTranscriptMessages.js +129 -0
- package/dist/voice/useWsRealtimeAudio.d.ts +18 -0
- package/dist/voice/useWsRealtimeAudio.d.ts.map +1 -0
- package/dist/voice/useWsRealtimeAudio.js +102 -0
- package/dist/voice/voiceMicConstants.d.ts +4 -0
- package/dist/voice/voiceMicConstants.d.ts.map +1 -0
- package/dist/voice/voiceMicConstants.js +10 -0
- package/dist/voice/ws/BrowserWsPcmPlayer.d.ts +23 -0
- package/dist/voice/ws/BrowserWsPcmPlayer.d.ts.map +1 -0
- package/dist/voice/ws/BrowserWsPcmPlayer.js +137 -0
- package/dist/voice/ws/BrowserWsPcmRecorder.d.ts +17 -0
- package/dist/voice/ws/BrowserWsPcmRecorder.d.ts.map +1 -0
- package/dist/voice/ws/BrowserWsPcmRecorder.js +71 -0
- package/dist/voice/ws/float32ToPcm16.d.ts +2 -0
- package/dist/voice/ws/float32ToPcm16.d.ts.map +1 -0
- package/dist/voice/ws/float32ToPcm16.js +8 -0
- package/dist/voice/ws/voiceSilenceConstants.d.ts +5 -0
- package/dist/voice/ws/voiceSilenceConstants.d.ts.map +1 -0
- package/dist/voice/ws/voiceSilenceConstants.js +4 -0
- package/dist/voice/ws/wsRealtimeConstants.d.ts +2 -0
- package/dist/voice/ws/wsRealtimeConstants.d.ts.map +1 -0
- package/dist/voice/ws/wsRealtimeConstants.js +1 -0
- package/package.json +60 -0
- package/src/NxtlinqAgentChat.tsx +79 -0
- package/src/components/AgentAssistantShell.tsx +104 -0
- package/src/components/AgentComposer.tsx +134 -0
- package/src/components/AgentMessageList.tsx +78 -0
- package/src/components/AgentRemoteAudio.tsx +34 -0
- package/src/components/AgentVoiceBar.tsx +173 -0
- package/src/components/PresetMessageChips.tsx +41 -0
- package/src/context/AgentAssistantContext.tsx +276 -0
- package/src/index.ts +78 -0
- package/src/legacy/assets/images/adiSideItalicDataUri.ts +1 -0
- package/src/legacy/chatbot/ChatBot.tsx +61 -0
- package/src/legacy/chatbot/context/ChatBotContext.tsx +3227 -0
- package/src/legacy/chatbot/types/ChatBotTypes.ts +195 -0
- package/src/legacy/chatbot/ui/BerifyMeModal.tsx +145 -0
- package/src/legacy/chatbot/ui/ChatBotUI.tsx +949 -0
- package/src/legacy/chatbot/ui/MessageInput.tsx +517 -0
- package/src/legacy/chatbot/ui/MessageList.tsx +764 -0
- package/src/legacy/chatbot/ui/ModelSelector.tsx +190 -0
- package/src/legacy/chatbot/ui/NotificationModal.tsx +110 -0
- package/src/legacy/chatbot/ui/PermissionForm.tsx +632 -0
- package/src/legacy/chatbot/ui/PresetMessages.tsx +50 -0
- package/src/legacy/chatbot/ui/VoiceModePanel.tsx +168 -0
- package/src/legacy/chatbot/ui/styles/isolatedStyles.ts +1058 -0
- package/src/legacy/index.ts +26 -0
- package/src/theme/defaultTheme.ts +22 -0
- package/src/types.ts +65 -0
- package/src/voice/useVoiceConnectOrchestration.ts +117 -0
- package/src/voice/useVoiceMicState.ts +117 -0
- package/src/voice/useVoiceTranscriptMessages.ts +173 -0
- package/src/voice/voiceMicConstants.ts +13 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { BrowserWsPcmPlayer } from './ws/BrowserWsPcmPlayer';
|
|
3
|
+
import { BrowserWsPcmRecorder } from './ws/BrowserWsPcmRecorder';
|
|
4
|
+
import { useVoiceSilenceCommit } from './useVoiceSilenceCommit';
|
|
5
|
+
export function useWsRealtimeAudio(isCaptureActive, isVoiceActive, options) {
|
|
6
|
+
const playerRef = useRef(null);
|
|
7
|
+
const recorderRef = useRef(null);
|
|
8
|
+
const sessionRef = useRef(null);
|
|
9
|
+
const isCaptureActiveRef = useRef(isCaptureActive);
|
|
10
|
+
isCaptureActiveRef.current = isCaptureActive;
|
|
11
|
+
const prevCaptureActiveRef = useRef(isCaptureActive);
|
|
12
|
+
const getSession = useCallback(() => sessionRef.current, []);
|
|
13
|
+
const muteAfterSilenceCommitRef = useRef(options.muteAfterSilenceCommit);
|
|
14
|
+
muteAfterSilenceCommitRef.current = options.muteAfterSilenceCommit;
|
|
15
|
+
const silence = useVoiceSilenceCommit(getSession, () => muteAfterSilenceCommitRef.current(), options.voiceStatus);
|
|
16
|
+
const silenceRef = useRef(silence);
|
|
17
|
+
silenceRef.current = silence;
|
|
18
|
+
const ensurePlayer = useCallback(async () => {
|
|
19
|
+
if (!playerRef.current) {
|
|
20
|
+
playerRef.current = new BrowserWsPcmPlayer();
|
|
21
|
+
playerRef.current.prewarm();
|
|
22
|
+
}
|
|
23
|
+
await playerRef.current.ensureRunning();
|
|
24
|
+
}, []);
|
|
25
|
+
const stopCapture = useCallback((commit) => {
|
|
26
|
+
const s = silenceRef.current;
|
|
27
|
+
s.clearPoll();
|
|
28
|
+
recorderRef.current?.stop();
|
|
29
|
+
if (!commit)
|
|
30
|
+
return;
|
|
31
|
+
if (s.consumeSkipCommitOnMute())
|
|
32
|
+
return;
|
|
33
|
+
s.tryCommit('manual');
|
|
34
|
+
}, []);
|
|
35
|
+
const startCapture = useCallback(async () => {
|
|
36
|
+
const session = sessionRef.current;
|
|
37
|
+
if (!session)
|
|
38
|
+
return;
|
|
39
|
+
const s = silenceRef.current;
|
|
40
|
+
if (!recorderRef.current)
|
|
41
|
+
recorderRef.current = new BrowserWsPcmRecorder();
|
|
42
|
+
playerRef.current?.clearQueue();
|
|
43
|
+
recorderRef.current.bindSession(session);
|
|
44
|
+
recorderRef.current.setOnRms(s.onSpeechRms);
|
|
45
|
+
s.resetTurn();
|
|
46
|
+
try {
|
|
47
|
+
await recorderRef.current.start();
|
|
48
|
+
s.startPoll();
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
s.clearPoll();
|
|
52
|
+
console.error('[nxtlinq] mic capture start failed', err);
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
55
|
+
const cleanup = useCallback(() => {
|
|
56
|
+
silenceRef.current.clearPoll();
|
|
57
|
+
recorderRef.current?.cleanup();
|
|
58
|
+
recorderRef.current = null;
|
|
59
|
+
playerRef.current?.cleanup();
|
|
60
|
+
playerRef.current = null;
|
|
61
|
+
sessionRef.current = null;
|
|
62
|
+
}, []);
|
|
63
|
+
const bindSession = useCallback((session, captureWhenUnmuted = false) => {
|
|
64
|
+
sessionRef.current = session;
|
|
65
|
+
recorderRef.current?.bindSession(session);
|
|
66
|
+
if (session && captureWhenUnmuted && isCaptureActiveRef.current) {
|
|
67
|
+
void startCapture();
|
|
68
|
+
}
|
|
69
|
+
}, [startCapture]);
|
|
70
|
+
const buildCallbacks = useCallback((overrides) => ({
|
|
71
|
+
onOpen: () => {
|
|
72
|
+
void ensurePlayer();
|
|
73
|
+
overrides?.onOpen?.();
|
|
74
|
+
},
|
|
75
|
+
onAudioDelta: (pcm16) => {
|
|
76
|
+
void ensurePlayer().then(() => playerRef.current?.addAudio(pcm16));
|
|
77
|
+
overrides?.onAudioDelta?.(pcm16);
|
|
78
|
+
},
|
|
79
|
+
onClose: (reason) => {
|
|
80
|
+
cleanup();
|
|
81
|
+
overrides?.onClose?.(reason);
|
|
82
|
+
},
|
|
83
|
+
onError: (err) => {
|
|
84
|
+
cleanup();
|
|
85
|
+
overrides?.onError?.(err);
|
|
86
|
+
},
|
|
87
|
+
}), [cleanup, ensurePlayer]);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!isVoiceActive) {
|
|
90
|
+
prevCaptureActiveRef.current = false;
|
|
91
|
+
cleanup();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const prev = prevCaptureActiveRef.current;
|
|
95
|
+
prevCaptureActiveRef.current = isCaptureActive;
|
|
96
|
+
if (isCaptureActive && !prev)
|
|
97
|
+
void startCapture();
|
|
98
|
+
else if (!isCaptureActive && prev)
|
|
99
|
+
stopCapture(true);
|
|
100
|
+
}, [isCaptureActive, isVoiceActive, startCapture, stopCapture, cleanup]);
|
|
101
|
+
return { bindSession, buildCallbacks, getOutputAudioLevel: () => playerRef.current?.getAudioLevel() ?? 0 };
|
|
102
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { VoiceStatus } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
export declare const ASSISTANT_MIC_HOLD_STATUSES: ReadonlySet<VoiceStatus>;
|
|
3
|
+
export declare const SPEAKER_ACTIVE_STATUSES: ReadonlySet<VoiceStatus>;
|
|
4
|
+
//# sourceMappingURL=voiceMicConstants.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voiceMicConstants.d.ts","sourceRoot":"","sources":["../../src/voice/voiceMicConstants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8CAA8C,CAAC;AAEhF,eAAO,MAAM,2BAA2B,EAAE,WAAW,CAAC,WAAW,CAK/D,CAAC;AAEH,eAAO,MAAM,uBAAuB,EAAE,WAAW,CAAC,WAAW,CAG3D,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare class BrowserWsPcmPlayer {
|
|
2
|
+
private readonly sampleRate;
|
|
3
|
+
private readonly fadeSamples;
|
|
4
|
+
private audioContext;
|
|
5
|
+
private gainNode;
|
|
6
|
+
private analyserNode;
|
|
7
|
+
private analyserBuffer;
|
|
8
|
+
private readonly activeSources;
|
|
9
|
+
private queue;
|
|
10
|
+
private isPlaying;
|
|
11
|
+
private playHead;
|
|
12
|
+
private lastChunkRms;
|
|
13
|
+
private lastChunkAt;
|
|
14
|
+
ensureRunning(): Promise<void>;
|
|
15
|
+
prewarm(): void;
|
|
16
|
+
addAudio(pcm16: Int16Array | ArrayBuffer): void;
|
|
17
|
+
getAudioLevel(): number;
|
|
18
|
+
clearQueue(): void;
|
|
19
|
+
cleanup(): void;
|
|
20
|
+
private ensureContext;
|
|
21
|
+
private pipelineQueue;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=BrowserWsPcmPlayer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BrowserWsPcmPlayer.d.ts","sourceRoot":"","sources":["../../../src/voice/ws/BrowserWsPcmPlayer.ts"],"names":[],"mappings":"AAeA,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA2B;IACtD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAmD;IAC/E,OAAO,CAAC,YAAY,CAA6B;IACjD,OAAO,CAAC,QAAQ,CAAyB;IACzC,OAAO,CAAC,YAAY,CAA6B;IACjD,OAAO,CAAC,cAAc,CAA6B;IACnD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAoC;IAClE,OAAO,CAAC,KAAK,CAAqB;IAClC,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,WAAW,CAAK;IAElB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC;IAMpC,OAAO,IAAI,IAAI;IAIf,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,WAAW,GAAG,IAAI;IAqB/C,aAAa,IAAI,MAAM;IAevB,UAAU,IAAI,IAAI;IAWlB,OAAO,IAAI,IAAI;IAUf,OAAO,CAAC,aAAa;IAcrB,OAAO,CAAC,aAAa;CA2BtB"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { WS_REALTIME_SAMPLE_RATE } from './wsRealtimeConstants';
|
|
2
|
+
const LEVEL_SCALE = 3.2;
|
|
3
|
+
const LOOKAHEAD_SECONDS = 0.02;
|
|
4
|
+
const CROSSFADE_SECONDS = 0.04;
|
|
5
|
+
function applyFade(buffer, fadeSamples) {
|
|
6
|
+
const fade = Math.min(fadeSamples, Math.floor(buffer.length / 2));
|
|
7
|
+
if (fade <= 0)
|
|
8
|
+
return;
|
|
9
|
+
for (let i = 0; i < fade; i += 1) {
|
|
10
|
+
buffer[i] *= i / fade;
|
|
11
|
+
buffer[buffer.length - 1 - i] *= (fade - i) / fade;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class BrowserWsPcmPlayer {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.sampleRate = WS_REALTIME_SAMPLE_RATE;
|
|
17
|
+
this.fadeSamples = Math.max(1, Math.floor(this.sampleRate * 0.01));
|
|
18
|
+
this.audioContext = null;
|
|
19
|
+
this.gainNode = null;
|
|
20
|
+
this.analyserNode = null;
|
|
21
|
+
this.analyserBuffer = null;
|
|
22
|
+
this.activeSources = new Set();
|
|
23
|
+
this.queue = [];
|
|
24
|
+
this.isPlaying = true;
|
|
25
|
+
this.playHead = 0;
|
|
26
|
+
this.lastChunkRms = 0;
|
|
27
|
+
this.lastChunkAt = 0;
|
|
28
|
+
}
|
|
29
|
+
async ensureRunning() {
|
|
30
|
+
const ctx = this.ensureContext();
|
|
31
|
+
if (ctx.state === 'suspended')
|
|
32
|
+
await ctx.resume();
|
|
33
|
+
this.isPlaying = true;
|
|
34
|
+
}
|
|
35
|
+
prewarm() {
|
|
36
|
+
this.ensureContext();
|
|
37
|
+
}
|
|
38
|
+
addAudio(pcm16) {
|
|
39
|
+
const ctx = this.ensureContext();
|
|
40
|
+
const int16 = pcm16 instanceof ArrayBuffer ? new Int16Array(pcm16) : pcm16;
|
|
41
|
+
if (int16.length === 0)
|
|
42
|
+
return;
|
|
43
|
+
const floats = new Float32Array(int16.length);
|
|
44
|
+
let sumSq = 0;
|
|
45
|
+
for (let i = 0; i < int16.length; i += 1) {
|
|
46
|
+
const sample = int16[i] / 32768;
|
|
47
|
+
floats[i] = Math.max(-1, Math.min(1, sample));
|
|
48
|
+
sumSq += sample * sample;
|
|
49
|
+
}
|
|
50
|
+
applyFade(floats, this.fadeSamples);
|
|
51
|
+
this.lastChunkRms = Math.min(1, Math.sqrt(sumSq / int16.length) * LEVEL_SCALE);
|
|
52
|
+
this.lastChunkAt = Date.now();
|
|
53
|
+
const buffer = ctx.createBuffer(1, floats.length, this.sampleRate);
|
|
54
|
+
buffer.getChannelData(0).set(floats);
|
|
55
|
+
this.queue.push(buffer);
|
|
56
|
+
this.isPlaying = true;
|
|
57
|
+
this.pipelineQueue();
|
|
58
|
+
}
|
|
59
|
+
getAudioLevel() {
|
|
60
|
+
const analyser = this.analyserNode;
|
|
61
|
+
const buf = this.analyserBuffer;
|
|
62
|
+
if (analyser && buf && this.activeSources.size > 0) {
|
|
63
|
+
analyser.getFloatTimeDomainData(buf);
|
|
64
|
+
let sumSq = 0;
|
|
65
|
+
for (let i = 0; i < buf.length; i += 1) {
|
|
66
|
+
const v = buf[i];
|
|
67
|
+
sumSq += v * v;
|
|
68
|
+
}
|
|
69
|
+
return Math.min(1, Math.sqrt(sumSq / buf.length) * LEVEL_SCALE);
|
|
70
|
+
}
|
|
71
|
+
return Date.now() - this.lastChunkAt < 500 ? this.lastChunkRms : 0;
|
|
72
|
+
}
|
|
73
|
+
clearQueue() {
|
|
74
|
+
for (const source of this.activeSources) {
|
|
75
|
+
try {
|
|
76
|
+
source.stop();
|
|
77
|
+
}
|
|
78
|
+
catch { /* noop */ }
|
|
79
|
+
}
|
|
80
|
+
this.activeSources.clear();
|
|
81
|
+
this.queue = [];
|
|
82
|
+
this.playHead = this.audioContext?.currentTime ?? 0;
|
|
83
|
+
this.lastChunkRms = 0;
|
|
84
|
+
this.lastChunkAt = 0;
|
|
85
|
+
}
|
|
86
|
+
cleanup() {
|
|
87
|
+
this.clearQueue();
|
|
88
|
+
this.isPlaying = false;
|
|
89
|
+
void this.audioContext?.close();
|
|
90
|
+
this.analyserNode = null;
|
|
91
|
+
this.analyserBuffer = null;
|
|
92
|
+
this.gainNode = null;
|
|
93
|
+
this.audioContext = null;
|
|
94
|
+
}
|
|
95
|
+
ensureContext() {
|
|
96
|
+
if (!this.audioContext) {
|
|
97
|
+
this.audioContext = new AudioContext({ sampleRate: this.sampleRate });
|
|
98
|
+
this.gainNode = this.audioContext.createGain();
|
|
99
|
+
this.analyserNode = this.audioContext.createAnalyser();
|
|
100
|
+
this.analyserNode.fftSize = 512;
|
|
101
|
+
this.analyserBuffer = new Float32Array(this.analyserNode.fftSize);
|
|
102
|
+
this.gainNode.connect(this.analyserNode);
|
|
103
|
+
this.analyserNode.connect(this.audioContext.destination);
|
|
104
|
+
this.playHead = this.audioContext.currentTime;
|
|
105
|
+
}
|
|
106
|
+
return this.audioContext;
|
|
107
|
+
}
|
|
108
|
+
pipelineQueue() {
|
|
109
|
+
const ctx = this.audioContext;
|
|
110
|
+
const gain = this.gainNode;
|
|
111
|
+
if (!ctx || !gain || !this.isPlaying)
|
|
112
|
+
return;
|
|
113
|
+
while (this.queue.length > 0) {
|
|
114
|
+
const audioBuffer = this.queue.shift();
|
|
115
|
+
if (!audioBuffer)
|
|
116
|
+
return;
|
|
117
|
+
const source = ctx.createBufferSource();
|
|
118
|
+
source.buffer = audioBuffer;
|
|
119
|
+
const perSourceGain = ctx.createGain();
|
|
120
|
+
source.connect(perSourceGain);
|
|
121
|
+
perSourceGain.connect(gain);
|
|
122
|
+
const now = ctx.currentTime;
|
|
123
|
+
const startAt = Math.max((this.playHead || now) - CROSSFADE_SECONDS * 0.75, now + LOOKAHEAD_SECONDS);
|
|
124
|
+
const duration = audioBuffer.duration;
|
|
125
|
+
const endAt = startAt + duration;
|
|
126
|
+
const fade = Math.min(CROSSFADE_SECONDS, duration / 2);
|
|
127
|
+
perSourceGain.gain.setValueAtTime(0, startAt);
|
|
128
|
+
perSourceGain.gain.linearRampToValueAtTime(1, startAt + fade);
|
|
129
|
+
perSourceGain.gain.setValueAtTime(1, endAt - fade);
|
|
130
|
+
perSourceGain.gain.linearRampToValueAtTime(0, endAt);
|
|
131
|
+
this.playHead = endAt;
|
|
132
|
+
this.activeSources.add(source);
|
|
133
|
+
source.start(startAt);
|
|
134
|
+
source.onended = () => this.activeSources.delete(source);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { VoiceSession } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
/** getUserMedia only on start() — Berify hold-to-talk aligned. */
|
|
3
|
+
export declare class BrowserWsPcmRecorder {
|
|
4
|
+
private session;
|
|
5
|
+
private onRms?;
|
|
6
|
+
private stream;
|
|
7
|
+
private audioContext;
|
|
8
|
+
private processor;
|
|
9
|
+
private source;
|
|
10
|
+
private silentGain;
|
|
11
|
+
bindSession(session: VoiceSession | null): void;
|
|
12
|
+
setOnRms(handler: (rms: number) => void): void;
|
|
13
|
+
start(): Promise<void>;
|
|
14
|
+
stop(): void;
|
|
15
|
+
cleanup(): void;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=BrowserWsPcmRecorder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BrowserWsPcmRecorder.d.ts","sourceRoot":"","sources":["../../../src/voice/ws/BrowserWsPcmRecorder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8CAA8C,CAAC;AAajF,kEAAkE;AAClE,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,OAAO,CAA6B;IAC5C,OAAO,CAAC,KAAK,CAAC,CAAwB;IACtC,OAAO,CAAC,MAAM,CAA4B;IAC1C,OAAO,CAAC,YAAY,CAA6B;IACjD,OAAO,CAAC,SAAS,CAAoC;IACrD,OAAO,CAAC,MAAM,CAA2C;IACzD,OAAO,CAAC,UAAU,CAAyB;IAE3C,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,GAAG,IAAI;IAI/C,QAAQ,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI;IAIxC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAwB5B,IAAI,IAAI,IAAI;IAgBZ,OAAO,IAAI,IAAI;CAIhB"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { float32ToPcm16 } from './float32ToPcm16';
|
|
2
|
+
import { WS_REALTIME_SAMPLE_RATE } from './wsRealtimeConstants';
|
|
3
|
+
function computeRms(channel) {
|
|
4
|
+
let sum = 0;
|
|
5
|
+
for (let i = 0; i < channel.length; i += 1) {
|
|
6
|
+
const v = channel[i];
|
|
7
|
+
sum += v * v;
|
|
8
|
+
}
|
|
9
|
+
return channel.length > 0 ? Math.sqrt(sum / channel.length) : 0;
|
|
10
|
+
}
|
|
11
|
+
/** getUserMedia only on start() — Berify hold-to-talk aligned. */
|
|
12
|
+
export class BrowserWsPcmRecorder {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.session = null;
|
|
15
|
+
this.stream = null;
|
|
16
|
+
this.audioContext = null;
|
|
17
|
+
this.processor = null;
|
|
18
|
+
this.source = null;
|
|
19
|
+
this.silentGain = null;
|
|
20
|
+
}
|
|
21
|
+
bindSession(session) {
|
|
22
|
+
this.session = session;
|
|
23
|
+
}
|
|
24
|
+
setOnRms(handler) {
|
|
25
|
+
this.onRms = handler;
|
|
26
|
+
}
|
|
27
|
+
async start() {
|
|
28
|
+
if (this.processor)
|
|
29
|
+
return;
|
|
30
|
+
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
31
|
+
const ctx = new AudioContext({ sampleRate: WS_REALTIME_SAMPLE_RATE });
|
|
32
|
+
this.audioContext = ctx;
|
|
33
|
+
this.source = ctx.createMediaStreamSource(this.stream);
|
|
34
|
+
this.processor = ctx.createScriptProcessor(4096, 1, 1);
|
|
35
|
+
this.silentGain = ctx.createGain();
|
|
36
|
+
this.silentGain.gain.value = 0;
|
|
37
|
+
this.source.connect(this.processor);
|
|
38
|
+
this.processor.connect(this.silentGain);
|
|
39
|
+
this.silentGain.connect(ctx.destination);
|
|
40
|
+
this.processor.onaudioprocess = (event) => {
|
|
41
|
+
if (!this.session?.appendInputAudio)
|
|
42
|
+
return;
|
|
43
|
+
const channel = event.inputBuffer.getChannelData(0);
|
|
44
|
+
const rms = computeRms(channel);
|
|
45
|
+
this.onRms?.(rms);
|
|
46
|
+
this.session.appendInputAudio(float32ToPcm16(channel));
|
|
47
|
+
};
|
|
48
|
+
if (ctx.state === 'suspended') {
|
|
49
|
+
await ctx.resume();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
stop() {
|
|
53
|
+
this.processor?.disconnect();
|
|
54
|
+
this.source?.disconnect();
|
|
55
|
+
this.silentGain?.disconnect();
|
|
56
|
+
this.processor = null;
|
|
57
|
+
this.source = null;
|
|
58
|
+
this.silentGain = null;
|
|
59
|
+
for (const track of this.stream?.getTracks() ?? []) {
|
|
60
|
+
track.stop();
|
|
61
|
+
}
|
|
62
|
+
this.stream = null;
|
|
63
|
+
void this.audioContext?.close();
|
|
64
|
+
this.audioContext = null;
|
|
65
|
+
this.session?.clearInputAudio?.();
|
|
66
|
+
}
|
|
67
|
+
cleanup() {
|
|
68
|
+
this.stop();
|
|
69
|
+
this.session = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"float32ToPcm16.d.ts","sourceRoot":"","sources":["../../../src/voice/ws/float32ToPcm16.ts"],"names":[],"mappings":"AAAA,wBAAgB,cAAc,CAAC,KAAK,EAAE,YAAY,GAAG,UAAU,CAO9D"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voiceSilenceConstants.d.ts","sourceRoot":"","sources":["../../../src/voice/ws/voiceSilenceConstants.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,wBAAwB,QAAQ,CAAC;AAC9C,eAAO,MAAM,0BAA0B,QAAQ,CAAC;AAChD,eAAO,MAAM,qBAAqB,MAAM,CAAC;AACzC,eAAO,MAAM,mBAAmB,MAAM,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wsRealtimeConstants.d.ts","sourceRoot":"","sources":["../../../src/voice/ws/wsRealtimeConstants.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,uBAAuB,QAAQ,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const WS_REALTIME_SAMPLE_RATE = 24000;
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bytexbyte/nxtlinq-ai-agent-ui-react-development",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Official React Web UI for nxtlinq AI Agent — drop-in chat widget",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"clean": "rm -rf dist",
|
|
14
|
+
"prepublishOnly": "yarn build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"nxtlinq",
|
|
18
|
+
"ai-agent",
|
|
19
|
+
"react",
|
|
20
|
+
"web",
|
|
21
|
+
"ui",
|
|
22
|
+
"chatbot"
|
|
23
|
+
],
|
|
24
|
+
"author": "ByteXByte",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://dev.azure.com/nxtlinqLLC/nxtlinq/_git/nxtlinq-AI-Agent-SDK",
|
|
29
|
+
"directory": "packages/ai-agent-ui-react"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"registry": "https://registry.npmjs.org/"
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": ">=18.0.0",
|
|
38
|
+
"react-dom": ">=18.0.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@bytexbyte/nxtlinq-ai-agent-core-development": "0.3.8",
|
|
42
|
+
"@bytexbyte/nxtlinq-ai-agent-web-development": "0.1.1",
|
|
43
|
+
"@emotion/react": "^11.14.0",
|
|
44
|
+
"@emotion/styled": "^11.14.1",
|
|
45
|
+
"@mui/icons-material": "^7.2.0",
|
|
46
|
+
"@mui/material": "^7.2.0",
|
|
47
|
+
"ethers": "^6.16.0",
|
|
48
|
+
"fast-json-stable-stringify": "^2.1.0",
|
|
49
|
+
"uuid": "^11.1.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@bytexbyte/nxtlinq-ai-agent-core-development": "workspace:^",
|
|
53
|
+
"@bytexbyte/nxtlinq-ai-agent-web-development": "workspace:^",
|
|
54
|
+
"@types/react": "^18.2.64",
|
|
55
|
+
"@types/react-dom": "^18.2.25",
|
|
56
|
+
"react": "^18.2.0",
|
|
57
|
+
"react-dom": "^18.2.0",
|
|
58
|
+
"typescript": "^5.4.2"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { NxtlinqAgentProvider } from '@bytexbyte/nxtlinq-ai-agent-web-development';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { AgentAssistantShell } from './components/AgentAssistantShell';
|
|
4
|
+
import type { NxtlinqAgentChatProps } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drop-in React Web assistant UI wired to `@bytexbyte/nxtlinq-ai-agent-web-development`.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* import { NxtlinqAgentChat } from '@bytexbyte/nxtlinq-ai-agent-ui-react-development';
|
|
12
|
+
*
|
|
13
|
+
* export default function Page() {
|
|
14
|
+
* return (
|
|
15
|
+
* <NxtlinqAgentChat
|
|
16
|
+
* style={{ height: '100vh' }}
|
|
17
|
+
* serviceId="..."
|
|
18
|
+
* apiKey="..."
|
|
19
|
+
* apiSecret="..."
|
|
20
|
+
* environment="staging"
|
|
21
|
+
* pseudoId={userId}
|
|
22
|
+
* loadHistoryOnMount
|
|
23
|
+
* />
|
|
24
|
+
* );
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function NxtlinqAgentChat({
|
|
29
|
+
title,
|
|
30
|
+
placeholder,
|
|
31
|
+
presetMessages,
|
|
32
|
+
loadHistoryOnMount = false,
|
|
33
|
+
historyLast,
|
|
34
|
+
enableVoice = true,
|
|
35
|
+
enableFileUpload = true,
|
|
36
|
+
startInVoiceMode = false,
|
|
37
|
+
startWithMicMuted = true,
|
|
38
|
+
holdMicDuringAssistant = true,
|
|
39
|
+
theme,
|
|
40
|
+
style,
|
|
41
|
+
headerStyle,
|
|
42
|
+
onMessage,
|
|
43
|
+
onError,
|
|
44
|
+
onToolUse,
|
|
45
|
+
children,
|
|
46
|
+
fetchImpl,
|
|
47
|
+
getTimezone,
|
|
48
|
+
resetOnIdentityChange,
|
|
49
|
+
...agentConfig
|
|
50
|
+
}: NxtlinqAgentChatProps): React.ReactElement {
|
|
51
|
+
return (
|
|
52
|
+
<NxtlinqAgentProvider
|
|
53
|
+
fetchImpl={fetchImpl}
|
|
54
|
+
getTimezone={getTimezone}
|
|
55
|
+
resetOnIdentityChange={resetOnIdentityChange}
|
|
56
|
+
onMessage={onMessage}
|
|
57
|
+
onError={onError}
|
|
58
|
+
onToolUse={onToolUse}
|
|
59
|
+
{...agentConfig}
|
|
60
|
+
>
|
|
61
|
+
<AgentAssistantShell
|
|
62
|
+
title={title}
|
|
63
|
+
placeholder={placeholder}
|
|
64
|
+
presetMessages={presetMessages}
|
|
65
|
+
loadHistoryOnMount={loadHistoryOnMount}
|
|
66
|
+
historyLast={historyLast}
|
|
67
|
+
enableVoice={enableVoice}
|
|
68
|
+
enableFileUpload={enableFileUpload}
|
|
69
|
+
startInVoiceMode={startInVoiceMode}
|
|
70
|
+
startWithMicMuted={startWithMicMuted}
|
|
71
|
+
holdMicDuringAssistant={holdMicDuringAssistant}
|
|
72
|
+
theme={theme}
|
|
73
|
+
style={style}
|
|
74
|
+
headerStyle={headerStyle}
|
|
75
|
+
/>
|
|
76
|
+
{children}
|
|
77
|
+
</NxtlinqAgentProvider>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
AgentAssistantProvider,
|
|
4
|
+
useAgentAssistant,
|
|
5
|
+
} from '../context/AgentAssistantContext';
|
|
6
|
+
import type { NxtlinqAgentChatProps } from '../types';
|
|
7
|
+
import { AgentComposer } from './AgentComposer';
|
|
8
|
+
import { AgentMessageList } from './AgentMessageList';
|
|
9
|
+
import { AgentRemoteAudio } from './AgentRemoteAudio';
|
|
10
|
+
import { AgentVoiceBar } from './AgentVoiceBar';
|
|
11
|
+
import { PresetMessageChips } from './PresetMessageChips';
|
|
12
|
+
|
|
13
|
+
export type AgentAssistantShellProps = Pick<
|
|
14
|
+
NxtlinqAgentChatProps,
|
|
15
|
+
| 'title'
|
|
16
|
+
| 'placeholder'
|
|
17
|
+
| 'presetMessages'
|
|
18
|
+
| 'enableVoice'
|
|
19
|
+
| 'enableFileUpload'
|
|
20
|
+
| 'theme'
|
|
21
|
+
| 'style'
|
|
22
|
+
| 'headerStyle'
|
|
23
|
+
| 'loadHistoryOnMount'
|
|
24
|
+
| 'historyLast'
|
|
25
|
+
| 'startInVoiceMode'
|
|
26
|
+
| 'startWithMicMuted'
|
|
27
|
+
| 'holdMicDuringAssistant'
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
function AgentAssistantInner({
|
|
31
|
+
title,
|
|
32
|
+
headerStyle,
|
|
33
|
+
style,
|
|
34
|
+
loadHistoryOnMount,
|
|
35
|
+
historyLast,
|
|
36
|
+
startInVoiceMode,
|
|
37
|
+
}: AgentAssistantShellProps): React.ReactElement {
|
|
38
|
+
const { theme, loadHistory, startVoice, isVoiceAvailable } = useAgentAssistant();
|
|
39
|
+
const [historyReady, setHistoryReady] = useState(!loadHistoryOnMount);
|
|
40
|
+
const voiceAutoStartRef = useRef(false);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (loadHistoryOnMount) {
|
|
44
|
+
void loadHistory({ last: historyLast ?? 50 }).finally(() => setHistoryReady(true));
|
|
45
|
+
}
|
|
46
|
+
}, [loadHistory, loadHistoryOnMount, historyLast]);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!startInVoiceMode || !isVoiceAvailable || voiceAutoStartRef.current) return;
|
|
50
|
+
voiceAutoStartRef.current = true;
|
|
51
|
+
void startVoice();
|
|
52
|
+
}, [startInVoiceMode, isVoiceAvailable, startVoice]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
style={{
|
|
57
|
+
display: 'flex',
|
|
58
|
+
flexDirection: 'column',
|
|
59
|
+
flex: 1,
|
|
60
|
+
minHeight: 0,
|
|
61
|
+
backgroundColor: theme.colors.background,
|
|
62
|
+
...style,
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<header
|
|
66
|
+
style={{
|
|
67
|
+
padding: theme.spacing.md,
|
|
68
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
69
|
+
backgroundColor: theme.colors.surface,
|
|
70
|
+
fontSize: theme.typography.titleSize,
|
|
71
|
+
fontWeight: 600,
|
|
72
|
+
color: theme.colors.assistantText,
|
|
73
|
+
...headerStyle,
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{title ?? 'AI Assistant'}
|
|
77
|
+
</header>
|
|
78
|
+
<PresetMessageChips />
|
|
79
|
+
{historyReady ? <AgentMessageList /> : <div style={{ flex: 1 }} />}
|
|
80
|
+
<AgentVoiceBar />
|
|
81
|
+
<AgentComposer />
|
|
82
|
+
<AgentRemoteAudio />
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function AgentAssistantShell(props: AgentAssistantShellProps): React.ReactElement {
|
|
88
|
+
return (
|
|
89
|
+
<AgentAssistantProvider
|
|
90
|
+
ui={{
|
|
91
|
+
title: props.title,
|
|
92
|
+
placeholder: props.placeholder,
|
|
93
|
+
presetMessages: props.presetMessages,
|
|
94
|
+
enableVoice: props.enableVoice,
|
|
95
|
+
enableFileUpload: props.enableFileUpload,
|
|
96
|
+
theme: props.theme,
|
|
97
|
+
startWithMicMuted: props.startWithMicMuted,
|
|
98
|
+
holdMicDuringAssistant: props.holdMicDuringAssistant,
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<AgentAssistantInner {...props} />
|
|
102
|
+
</AgentAssistantProvider>
|
|
103
|
+
);
|
|
104
|
+
}
|