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

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 (91) hide show
  1. package/dist/ChatBot.d.ts +5 -0
  2. package/dist/ChatBot.d.ts.map +1 -0
  3. package/dist/ChatBot.js +35 -0
  4. package/dist/assets/images/adiSideItalicDataUri.d.ts +2 -0
  5. package/dist/assets/images/adiSideItalicDataUri.d.ts.map +1 -0
  6. package/dist/assets/images/adiSideItalicDataUri.js +1 -0
  7. package/dist/context/ChatBotContext.d.ts +5 -0
  8. package/dist/context/ChatBotContext.d.ts.map +1 -0
  9. package/dist/context/ChatBotContext.js +2908 -0
  10. package/dist/index.d.ts +5 -13
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +4 -11
  13. package/dist/legacy/chatbot/context/ChatBotContext.d.ts.map +1 -1
  14. package/dist/legacy/chatbot/context/ChatBotContext.js +14 -0
  15. package/dist/types/ChatBotTypes.d.ts +166 -0
  16. package/dist/types/ChatBotTypes.d.ts.map +1 -0
  17. package/dist/types/ChatBotTypes.js +1 -0
  18. package/dist/ui/BerifyMeModal.d.ts +17 -0
  19. package/dist/ui/BerifyMeModal.d.ts.map +1 -0
  20. package/dist/ui/BerifyMeModal.js +110 -0
  21. package/dist/ui/ChatBotHeader.d.ts +15 -0
  22. package/dist/ui/ChatBotHeader.d.ts.map +1 -0
  23. package/dist/ui/ChatBotHeader.js +62 -0
  24. package/dist/ui/ChatBotUI.d.ts +3 -0
  25. package/dist/ui/ChatBotUI.d.ts.map +1 -0
  26. package/dist/ui/ChatBotUI.js +557 -0
  27. package/dist/ui/MessageInput.d.ts +3 -0
  28. package/dist/ui/MessageInput.d.ts.map +1 -0
  29. package/dist/ui/MessageInput.js +321 -0
  30. package/dist/ui/MessageList.d.ts +4 -0
  31. package/dist/ui/MessageList.d.ts.map +1 -0
  32. package/dist/ui/MessageList.js +455 -0
  33. package/dist/ui/ModelSelector.d.ts +4 -0
  34. package/dist/ui/ModelSelector.d.ts.map +1 -0
  35. package/dist/ui/ModelSelector.js +122 -0
  36. package/dist/ui/NotificationModal.d.ts +15 -0
  37. package/dist/ui/NotificationModal.d.ts.map +1 -0
  38. package/dist/ui/NotificationModal.js +53 -0
  39. package/dist/ui/PermissionForm.d.ts +8 -0
  40. package/dist/ui/PermissionForm.d.ts.map +1 -0
  41. package/dist/ui/PermissionForm.js +465 -0
  42. package/dist/ui/PresetMessages.d.ts +4 -0
  43. package/dist/ui/PresetMessages.d.ts.map +1 -0
  44. package/dist/ui/PresetMessages.js +33 -0
  45. package/dist/ui/VoiceModePanel.d.ts +3 -0
  46. package/dist/ui/VoiceModePanel.d.ts.map +1 -0
  47. package/dist/ui/VoiceModePanel.js +95 -0
  48. package/dist/ui/chatBotHeaderParts.d.ts +15 -0
  49. package/dist/ui/chatBotHeaderParts.d.ts.map +1 -0
  50. package/dist/ui/chatBotHeaderParts.js +50 -0
  51. package/dist/ui/index.d.ts +9 -0
  52. package/dist/ui/index.d.ts.map +1 -0
  53. package/dist/ui/index.js +8 -0
  54. package/dist/ui/styles/isolatedStyles.d.ts +73 -0
  55. package/dist/ui/styles/isolatedStyles.d.ts.map +1 -0
  56. package/dist/ui/styles/isolatedStyles.js +985 -0
  57. package/package.json +2 -2
  58. package/src/{legacy/chatbot/context → context}/ChatBotContext.tsx +0 -1
  59. package/src/index.ts +17 -40
  60. package/src/{legacy/chatbot/ui → ui}/ModelSelector.tsx +1 -1
  61. package/src/{legacy/chatbot/ui → ui}/VoiceModePanel.tsx +1 -1
  62. package/src/ui/index.ts +8 -0
  63. package/src/NxtlinqAgentChat.tsx +0 -79
  64. package/src/components/AgentAssistantShell.tsx +0 -104
  65. package/src/components/AgentComposer.tsx +0 -134
  66. package/src/components/AgentMessageList.tsx +0 -78
  67. package/src/components/AgentRemoteAudio.tsx +0 -34
  68. package/src/components/AgentVoiceBar.tsx +0 -173
  69. package/src/components/PresetMessageChips.tsx +0 -41
  70. package/src/context/AgentAssistantContext.tsx +0 -294
  71. package/src/legacy/index.ts +0 -26
  72. package/src/theme/defaultTheme.ts +0 -22
  73. package/src/types.ts +0 -65
  74. package/src/voice/useVoiceConnectOrchestration.ts +0 -117
  75. package/src/voice/useVoiceMicState.ts +0 -117
  76. package/src/voice/useVoiceTranscriptMessages.ts +0 -188
  77. package/src/voice/voiceMicConstants.ts +0 -13
  78. package/src/voice/voiceUserBubble.ts +0 -71
  79. /package/src/{legacy/chatbot/ChatBot.tsx → ChatBot.tsx} +0 -0
  80. /package/src/{legacy/assets → assets}/images/adiSideItalicDataUri.ts +0 -0
  81. /package/src/{legacy/chatbot/types → types}/ChatBotTypes.ts +0 -0
  82. /package/src/{legacy/chatbot/ui → ui}/BerifyMeModal.tsx +0 -0
  83. /package/src/{legacy/chatbot/ui → ui}/ChatBotHeader.tsx +0 -0
  84. /package/src/{legacy/chatbot/ui → ui}/ChatBotUI.tsx +0 -0
  85. /package/src/{legacy/chatbot/ui → ui}/MessageInput.tsx +0 -0
  86. /package/src/{legacy/chatbot/ui → ui}/MessageList.tsx +0 -0
  87. /package/src/{legacy/chatbot/ui → ui}/NotificationModal.tsx +0 -0
  88. /package/src/{legacy/chatbot/ui → ui}/PermissionForm.tsx +0 -0
  89. /package/src/{legacy/chatbot/ui → ui}/PresetMessages.tsx +0 -0
  90. /package/src/{legacy/chatbot/ui → ui}/chatBotHeaderParts.tsx +0 -0
  91. /package/src/{legacy/chatbot/ui → ui}/styles/isolatedStyles.ts +0 -0
@@ -0,0 +1,2908 @@
1
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ import { ethers } from 'ethers';
3
+ import stringify from 'fast-json-stable-stringify';
4
+ import * as React from 'react';
5
+ import { flushSync } from 'react-dom';
6
+ import { v4 as uuidv4 } from 'uuid';
7
+ import { createNxtlinqApi, setApiHosts, synthesizeSpeechToBuffer, useLocalStorage, useSessionStorage, useSpeechToTextFromMic, useVoiceMode, metakeepClient, getEthers, sleep, walletTextUtils, } from '@bytexbyte/nxtlinq-ai-agent-web-development';
8
+ const MIC_ENABLED_SESSION_KEY = 'chatbot-mic-enabled';
9
+ const ChatBotContext = React.createContext(undefined);
10
+ export const useChatBot = () => {
11
+ const context = React.useContext(ChatBotContext);
12
+ if (!context) {
13
+ throw new Error('useChatBot must be used within a ChatBotProvider');
14
+ }
15
+ return context;
16
+ };
17
+ export const ChatBotProvider = ({ onMessage, onError, onToolUse, presetMessages = [], placeholder = 'Type a message...', className = '', maxRetries = 3, retryDelay = 2000, serviceId, apiKey, apiSecret, environment = 'production', onVerifyWallet, permissionGroup, children,
18
+ // AI Model related attributes
19
+ onModelChange,
20
+ // Storage mode configuration
21
+ storageMode = "local-storage",
22
+ // Wallet verification configuration
23
+ requireWalletIDVVerification = true, isSemiAutomaticMode = false,
24
+ // Custom user identity information
25
+ customUserInfo,
26
+ // Custom username for wallet verification
27
+ customUsername,
28
+ // IDV suggestion banner configuration
29
+ idvBannerDismissSeconds = 86400,
30
+ // 24 hours in seconds
31
+ isStopRecordingOnSend = false,
32
+ // Custom error message to display in chat
33
+ customError,
34
+ // PII display mode
35
+ piiDisplayMode = 'redacted', }) => {
36
+ // Set API hosts immediately based on environment (before any API calls)
37
+ setApiHosts(environment);
38
+ const nxtlinqApi = React.useMemo(() => createNxtlinqApi(apiKey, apiSecret), [apiKey, apiSecret]);
39
+ const apiKeyRef = React.useRef(apiKey);
40
+ const apiSecretRef = React.useRef(apiSecret);
41
+ const clientTtsVoiceRef = React.useRef(undefined);
42
+ React.useEffect(() => {
43
+ apiKeyRef.current = apiKey;
44
+ apiSecretRef.current = apiSecret;
45
+ }, [apiKey, apiSecret]);
46
+ // Custom hook
47
+ const { isMicEnabled, transcript, partialTranscript, start: startRecording, stop: stopRecording, clear: clearRecording } = useSpeechToTextFromMic({
48
+ apiKey,
49
+ apiSecret
50
+ });
51
+ // Messages: Use storage based on storageMode to support cross-tab sync (local-storage) or session isolation (session-storage)
52
+ const [messages, setMessages] = storageMode === "local-storage"
53
+ ? useLocalStorage('chatbot-messages', [])
54
+ : useSessionStorage('chatbot-messages', []);
55
+ const [isOpen, setIsOpen] = storageMode === "session-storage"
56
+ ? useSessionStorage('chatbot-is-open', false)
57
+ : React.useState(false);
58
+ const [inputValue, setInputValue] = React.useState('');
59
+ const [isLoading, setIsLoading] = React.useState(false);
60
+ const [hitAddress, setHitAddress] = React.useState(null);
61
+ const [ait, setAit] = React.useState(null);
62
+ const [permissions, setPermissions] = React.useState([]);
63
+ const [availablePermissions, setAvailablePermissions] = React.useState([]);
64
+ const [showPermissionForm, setShowPermissionForm] = React.useState(false);
65
+ const [isPermissionFormOpen, setIsPermissionFormOpen] = React.useState(false);
66
+ const [isAITLoading, setIsAITLoading] = React.useState(false);
67
+ const [isDisabled, setIsDisabled] = React.useState(true);
68
+ const [signer, setSigner] = React.useState(null);
69
+ const [walletInfo, setWalletInfo] = React.useState(null);
70
+ const [isWalletLoading, setIsWalletLoading] = React.useState(false);
71
+ const [isAutoConnecting, setIsAutoConnecting] = React.useState(false);
72
+ // Persistent data (always use localStorage)
73
+ const [nxtlinqAITServiceAccessToken, setNxtlinqAITServiceAccessToken] = useLocalStorage('nxtlinqAITServiceAccessToken', '');
74
+ const [pseudoId, setPseudoId] = useLocalStorage('pseudoId', uuidv4());
75
+ // Suggestions: Use storage based on storageMode to support cross-tab sync (local-storage) or session isolation (session-storage)
76
+ const [suggestions, setSuggestions] = storageMode === "local-storage"
77
+ ? useLocalStorage('chatbot-suggestions', presetMessages)
78
+ : useSessionStorage('chatbot-suggestions', presetMessages);
79
+ const [isAITEnabling, setIsAITEnabling] = React.useState(false);
80
+ const [isAwaitingMicGesture, setIsAwaitingMicGesture] = React.useState(false);
81
+ // autoSendEnabled: Always use localStorage (do not sync across tabs to avoid conflicts with speech-to-text)
82
+ const [autoSendEnabled, setAutoSendEnabled] = useLocalStorage('chatbot-auto-send-enabled', true);
83
+ // Speech related state
84
+ const [textToSpeechEnabled, setTextToSpeechEnabled] = useLocalStorage('chatbot-text-to-speech-enabled', false);
85
+ const [preferredChatMode, setPreferredChatMode] = useLocalStorage('chatbot-active-mode', 'text');
86
+ const [speechingIndex, setSpeechingIndex] = React.useState(undefined);
87
+ // Speech related refs
88
+ const audioCtxRef = React.useRef(null);
89
+ const audioSourceRef = React.useRef(null);
90
+ const audioElementRef = React.useRef(null);
91
+ const speechingRef = React.useRef(false);
92
+ const [isTtsProcessing, setIsTtsProcessing] = React.useState(false);
93
+ const [requiresGesture, setRequiresGesture] = React.useState(false);
94
+ const pendingTtsRef = React.useRef(null);
95
+ /** Azure Speech STT:每次麥克風成功開啟時記錄,供寫入後端 usage log(開麥→送出) */
96
+ const messagesRef = React.useRef(messages);
97
+ messagesRef.current = messages;
98
+ const micSessionStartForLogRef = React.useRef(null);
99
+ // Clean up stale PII scanning state on mount (e.g. page refresh during active SSE)
100
+ React.useEffect(() => {
101
+ setMessages(prev => {
102
+ const hasStale = prev.some(m => m.piiStatus === 'scanning');
103
+ if (!hasStale)
104
+ return prev;
105
+ return prev.map(m => m.piiStatus === 'scanning'
106
+ ? { ...m, piiStatus: undefined, piiStep: undefined }
107
+ : m);
108
+ });
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ }, []);
111
+ // Use refs to get latest state values in hasPermission function
112
+ const hitAddressRef = React.useRef(hitAddress);
113
+ const aitRef = React.useRef(ait);
114
+ const permissionsRef = React.useRef(permissions);
115
+ const nxtlinqAITServiceAccessTokenRef = React.useRef(nxtlinqAITServiceAccessToken);
116
+ const signerRef = React.useRef(signer);
117
+ // Helper function to combine customUsername with Adilas customUserInfo
118
+ const getFinalCustomUsername = React.useCallback((username) => {
119
+ if (!username)
120
+ return username;
121
+ // For Adilas: combine customUsername, corpId and serverDomain
122
+ if (walletTextUtils.isAdilasService(serviceId) && customUserInfo) {
123
+ const { corpId, serverDomain } = customUserInfo;
124
+ if (corpId && serverDomain) {
125
+ // Format: customUsername|(corpId)|[serverDomain]
126
+ const parts = [username];
127
+ if (corpId)
128
+ parts.push(`(${corpId})`);
129
+ if (serverDomain)
130
+ parts.push(`[${serverDomain}]`);
131
+ return parts.join('|');
132
+ }
133
+ }
134
+ return username;
135
+ }, [serviceId, customUserInfo]);
136
+ // Refs for input value and recording state
137
+ const isMicEnabledRef = React.useRef(false);
138
+ const isRestoringRecordingRef = React.useRef(false);
139
+ const hasSyncedMicStateRef = React.useRef(false);
140
+ const isReacquiringMicRef = React.useRef(false);
141
+ const pendingMicAutoStartRef = React.useRef(false);
142
+ const autoStartGestureCleanupRef = React.useRef(null);
143
+ const textInputRef = React.useRef(null);
144
+ const lastPartialRangeRef = React.useRef(null);
145
+ const lastAutoSentTranscriptRef = React.useRef('');
146
+ const autoSendTimerRef = React.useRef(null);
147
+ const lastCustomErrorRef = React.useRef(undefined);
148
+ function insertPartial(input, partial, caret) {
149
+ let start = caret;
150
+ let end = caret;
151
+ if (lastPartialRangeRef.current) {
152
+ start = lastPartialRangeRef.current.start;
153
+ end = lastPartialRangeRef.current.end;
154
+ }
155
+ const before = input.slice(0, start);
156
+ const after = input.slice(end);
157
+ const next = before + partial + after;
158
+ const newCaret = start + partial.length;
159
+ lastPartialRangeRef.current = { start, end: newCaret };
160
+ return { next, caret: newCaret };
161
+ }
162
+ function finalizePartial(input, finalText) {
163
+ if (!lastPartialRangeRef.current) {
164
+ return { next: input, caret: input.length };
165
+ }
166
+ const { start, end } = lastPartialRangeRef.current;
167
+ const before = input.slice(0, start);
168
+ const after = input.slice(end);
169
+ const next = before + finalText + after;
170
+ const caret = start + finalText.length;
171
+ lastPartialRangeRef.current = null;
172
+ return { next, caret };
173
+ }
174
+ function normalizeTranscript(text) {
175
+ return text
176
+ .toLowerCase()
177
+ // Remove period only when NOT between digits (preserve decimals like 130.00)
178
+ .replace(/(?<!\d)\.(?!\d)/g, '')
179
+ .replace(/[,!?;:]/g, '')
180
+ .replace(/\s+/g, ' ')
181
+ .trim();
182
+ }
183
+ // Simple token cleanup function
184
+ const clearExpiredToken = React.useCallback(() => {
185
+ try {
186
+ localStorage.removeItem('nxtlinqAITServiceAccessToken');
187
+ setNxtlinqAITServiceAccessToken('');
188
+ }
189
+ catch (error) {
190
+ console.error('Error clearing expired token:', error);
191
+ }
192
+ }, []);
193
+ // Internal: Play text-to-speech with retry mechanism
194
+ const playTextToSpeechWithRetry = React.useCallback(async (text, messageIndex, attempt = 0) => {
195
+ if (attempt > 2) {
196
+ console.error('TTS playback failed after retries');
197
+ setIsTtsProcessing(false);
198
+ return;
199
+ }
200
+ const synth = await synthesizeSpeechToBuffer({
201
+ text,
202
+ apiKey: apiKeyRef.current,
203
+ apiSecret: apiSecretRef.current,
204
+ settings: clientTtsVoiceRef.current,
205
+ });
206
+ if (!synth)
207
+ throw new Error('Failed to get audio buffer');
208
+ const bufferData = synth.buffer;
209
+ const audioMimeType = synth.mimeType;
210
+ const isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
211
+ if (isMobileDevice) {
212
+ const blob = new Blob([bufferData], { type: audioMimeType });
213
+ const audioUrl = URL.createObjectURL(blob);
214
+ const audio = new Audio(audioUrl);
215
+ if ('setSinkId' in HTMLMediaElement.prototype) {
216
+ try {
217
+ const devices = await navigator.mediaDevices?.enumerateDevices?.();
218
+ const speakerDevice = devices?.find((d) => d.kind === 'audiooutput' && d.label.toLowerCase().includes('speaker'));
219
+ if (speakerDevice)
220
+ await audio.setSinkId(speakerDevice.deviceId);
221
+ else
222
+ await audio.setSinkId('default');
223
+ }
224
+ catch (err) {
225
+ console.debug('Could not set audio sink:', err);
226
+ }
227
+ }
228
+ audio.volume = 1.0;
229
+ audioElementRef.current = audio;
230
+ speechingRef.current = true;
231
+ if (messageIndex !== undefined)
232
+ setSpeechingIndex(messageIndex);
233
+ // Track if onplay has been called to avoid clearing state multiple times
234
+ let playStarted = false;
235
+ const clearProcessingState = () => {
236
+ if (!playStarted) {
237
+ playStarted = true;
238
+ setIsTtsProcessing(false);
239
+ }
240
+ };
241
+ await new Promise(async (resolve, reject) => {
242
+ audio.onended = () => {
243
+ speechingRef.current = false;
244
+ setSpeechingIndex(undefined);
245
+ URL.revokeObjectURL(audioUrl);
246
+ audioElementRef.current = null;
247
+ resolve();
248
+ };
249
+ audio.onerror = (error) => {
250
+ speechingRef.current = false;
251
+ setSpeechingIndex(undefined);
252
+ URL.revokeObjectURL(audioUrl);
253
+ audioElementRef.current = null;
254
+ clearProcessingState();
255
+ reject(error);
256
+ };
257
+ audio.onplay = () => {
258
+ // Clear processing state when audio actually starts playing
259
+ setRequiresGesture(false);
260
+ clearProcessingState();
261
+ };
262
+ // Handle play() promise - some mobile browsers require user interaction
263
+ audio.play().then(() => {
264
+ // Play promise resolved, but wait for onplay event to confirm actual playback
265
+ // Don't clear requiresGesture here - let onplay handle it
266
+ setTimeout(clearProcessingState, 100);
267
+ }).catch((playError) => {
268
+ // Mark requires gesture and store pending text
269
+ setRequiresGesture(true);
270
+ pendingTtsRef.current = text;
271
+ // Fallback: try WebAudio (AudioContext) playback on mobile
272
+ try {
273
+ if (audioCtxRef.current === null) {
274
+ audioCtxRef.current = new AudioContext();
275
+ }
276
+ audioSourceRef.current = audioCtxRef.current.createBufferSource();
277
+ if (!audioSourceRef.current) {
278
+ throw playError;
279
+ }
280
+ audioCtxRef.current.decodeAudioData(bufferData.slice(0), (decodedBuffer) => {
281
+ if (!audioSourceRef.current) {
282
+ reject(playError);
283
+ return;
284
+ }
285
+ audioSourceRef.current.buffer = decodedBuffer;
286
+ audioSourceRef.current.connect(audioCtxRef.current.destination);
287
+ speechingRef.current = true;
288
+ clearProcessingState();
289
+ audioSourceRef.current.onended = () => {
290
+ speechingRef.current = false;
291
+ setSpeechingIndex(undefined);
292
+ audioSourceRef.current = null;
293
+ // cleanup audio element blob URL
294
+ URL.revokeObjectURL(audioUrl);
295
+ audioElementRef.current = null;
296
+ resolve();
297
+ };
298
+ try {
299
+ // Ensure AudioContext is running before starting
300
+ if (audioCtxRef.current?.state === 'suspended') {
301
+ audioCtxRef.current.resume().then(() => {
302
+ audioSourceRef.current?.start();
303
+ // Only clear requiresGesture after successfully starting
304
+ setRequiresGesture(false);
305
+ }).catch((resumeErr) => {
306
+ // AudioContext resume failed, keep requiresGesture true
307
+ reject(resumeErr);
308
+ });
309
+ }
310
+ else {
311
+ audioSourceRef.current.start();
312
+ // Only clear requiresGesture after successfully starting
313
+ setRequiresGesture(false);
314
+ }
315
+ }
316
+ catch (e) {
317
+ // start() failed, keep requiresGesture true
318
+ reject(e);
319
+ }
320
+ }, (decodeErr) => {
321
+ // If decode also fails, give up
322
+ speechingRef.current = false;
323
+ setSpeechingIndex(undefined);
324
+ URL.revokeObjectURL(audioUrl);
325
+ audioElementRef.current = null;
326
+ clearProcessingState();
327
+ reject(decodeErr);
328
+ });
329
+ }
330
+ catch (fallbackErr) {
331
+ // Play failed (e.g., user interaction required)
332
+ speechingRef.current = false;
333
+ setSpeechingIndex(undefined);
334
+ URL.revokeObjectURL(audioUrl);
335
+ audioElementRef.current = null;
336
+ clearProcessingState();
337
+ reject(fallbackErr);
338
+ }
339
+ });
340
+ });
341
+ return;
342
+ }
343
+ if (audioCtxRef.current === null) {
344
+ audioCtxRef.current = new AudioContext();
345
+ }
346
+ audioSourceRef.current = audioCtxRef.current.createBufferSource();
347
+ if (!audioSourceRef.current)
348
+ return;
349
+ const buffer = await new Promise((resolve, reject) => {
350
+ if (audioCtxRef.current !== null && bufferData) {
351
+ audioCtxRef.current.decodeAudioData(bufferData, (decodedBuffer) => resolve(decodedBuffer), (error) => reject(error));
352
+ }
353
+ else {
354
+ reject(new Error('Audio context or buffer data is missing'));
355
+ }
356
+ });
357
+ if (!audioSourceRef.current.buffer) {
358
+ audioSourceRef.current.buffer = buffer;
359
+ audioSourceRef.current.connect(audioCtxRef.current.destination);
360
+ speechingRef.current = true;
361
+ if (messageIndex !== undefined)
362
+ setSpeechingIndex(messageIndex);
363
+ // Clear processing state when audio starts playing
364
+ setRequiresGesture(false);
365
+ setIsTtsProcessing(false);
366
+ await new Promise((resolve) => {
367
+ audioSourceRef.current.onended = () => {
368
+ speechingRef.current = false;
369
+ setSpeechingIndex(undefined);
370
+ audioSourceRef.current = null;
371
+ resolve();
372
+ };
373
+ audioSourceRef.current.start();
374
+ });
375
+ }
376
+ }, []);
377
+ // Update refs when state changes
378
+ React.useEffect(() => {
379
+ hitAddressRef.current = hitAddress;
380
+ }, [hitAddress]);
381
+ React.useEffect(() => {
382
+ aitRef.current = ait;
383
+ }, [ait]);
384
+ React.useEffect(() => {
385
+ permissionsRef.current = permissions;
386
+ }, [permissions]);
387
+ React.useEffect(() => {
388
+ nxtlinqAITServiceAccessTokenRef.current = nxtlinqAITServiceAccessToken;
389
+ }, [nxtlinqAITServiceAccessToken]);
390
+ React.useEffect(() => {
391
+ signerRef.current = signer;
392
+ }, [signer]);
393
+ React.useEffect(() => {
394
+ if (!textInputRef.current)
395
+ return;
396
+ const el = textInputRef.current;
397
+ const selStart = el.selectionStart ?? inputValue.length;
398
+ if (partialTranscript) {
399
+ const { next, caret } = insertPartial(inputValue, partialTranscript, selStart);
400
+ setInputValue(next);
401
+ setTimeout(() => {
402
+ if (textInputRef.current) {
403
+ textInputRef.current.selectionStart = caret;
404
+ textInputRef.current.selectionEnd = caret;
405
+ }
406
+ }, 0);
407
+ }
408
+ }, [partialTranscript]);
409
+ React.useEffect(() => {
410
+ if (!transcript)
411
+ return;
412
+ if (!textInputRef.current)
413
+ return;
414
+ // 每段新辨識結果重設 STT 計時起點(長期開麥、多輪自動送出時,每則請求各自一段,避免只記到第一則)
415
+ micSessionStartForLogRef.current = performance.now();
416
+ const { next, caret } = finalizePartial(inputValue, transcript);
417
+ const normalizedText = normalizeTranscript(next);
418
+ setInputValue(normalizedText);
419
+ setTimeout(() => {
420
+ if (textInputRef.current) {
421
+ textInputRef.current.selectionStart = caret;
422
+ textInputRef.current.selectionEnd = caret;
423
+ }
424
+ }, 0);
425
+ // Auto send on speech complete (if user enabled the toggle)
426
+ if (autoSendEnabled && isMicEnabled && normalizedText.trim()) {
427
+ // Avoid duplicate sends
428
+ if (lastAutoSentTranscriptRef.current !== normalizedText.trim()) {
429
+ // Clear any existing timer
430
+ if (autoSendTimerRef.current) {
431
+ clearTimeout(autoSendTimerRef.current);
432
+ }
433
+ const currentTranscript = normalizedText.trim();
434
+ // Wait 3 seconds before auto-sending
435
+ // Timer will be cleared if user continues speaking (transcript/partialTranscript changes)
436
+ autoSendTimerRef.current = setTimeout(() => {
437
+ if (lastAutoSentTranscriptRef.current !== currentTranscript && !isLoading) {
438
+ lastAutoSentTranscriptRef.current = currentTranscript;
439
+ sendMessage(currentTranscript).catch(error => {
440
+ console.error('Failed to auto-send message from speech:', error);
441
+ lastAutoSentTranscriptRef.current = '';
442
+ });
443
+ }
444
+ autoSendTimerRef.current = null;
445
+ }, 3000); // 3 seconds delay
446
+ }
447
+ }
448
+ else if (autoSendTimerRef.current) {
449
+ // Clear timer if auto-send disabled or mic disabled
450
+ clearTimeout(autoSendTimerRef.current);
451
+ autoSendTimerRef.current = null;
452
+ }
453
+ // eslint-disable-next-line react-hooks/exhaustive-deps
454
+ }, [transcript, autoSendEnabled, isMicEnabled, isLoading]);
455
+ // Clear timer when user continues speaking (partialTranscript updates)
456
+ React.useEffect(() => {
457
+ if (partialTranscript && partialTranscript.trim() && autoSendEnabled && isMicEnabled && autoSendTimerRef.current) {
458
+ clearTimeout(autoSendTimerRef.current);
459
+ autoSendTimerRef.current = null;
460
+ }
461
+ }, [partialTranscript, autoSendEnabled, isMicEnabled]);
462
+ // Cleanup timer when mic is disabled or component unmounts
463
+ React.useEffect(() => {
464
+ if (!isMicEnabled) {
465
+ if (autoSendTimerRef.current) {
466
+ clearTimeout(autoSendTimerRef.current);
467
+ autoSendTimerRef.current = null;
468
+ }
469
+ }
470
+ return () => {
471
+ if (autoSendTimerRef.current) {
472
+ clearTimeout(autoSendTimerRef.current);
473
+ autoSendTimerRef.current = null;
474
+ }
475
+ };
476
+ }, [isMicEnabled]);
477
+ React.useEffect(() => {
478
+ isMicEnabledRef.current = isMicEnabled;
479
+ // Reset auto-sent transcript ref when starting new recording
480
+ if (isMicEnabled) {
481
+ lastAutoSentTranscriptRef.current = '';
482
+ // Clear any pending auto-send timer
483
+ if (autoSendTimerRef.current) {
484
+ clearTimeout(autoSendTimerRef.current);
485
+ autoSendTimerRef.current = null;
486
+ }
487
+ // Hide gesture prompt once mic is actually enabled
488
+ setIsAwaitingMicGesture(false);
489
+ // Remove any pending gesture listeners
490
+ if (autoStartGestureCleanupRef.current) {
491
+ autoStartGestureCleanupRef.current();
492
+ autoStartGestureCleanupRef.current = null;
493
+ }
494
+ }
495
+ }, [isMicEnabled]);
496
+ React.useEffect(() => {
497
+ if (typeof window === 'undefined') {
498
+ return;
499
+ }
500
+ if (storageMode !== "session-storage") {
501
+ sessionStorage.removeItem(MIC_ENABLED_SESSION_KEY);
502
+ hasSyncedMicStateRef.current = false;
503
+ return;
504
+ }
505
+ if (!hasSyncedMicStateRef.current) {
506
+ return;
507
+ }
508
+ try {
509
+ sessionStorage.setItem(MIC_ENABLED_SESSION_KEY, JSON.stringify(isMicEnabled));
510
+ }
511
+ catch (error) {
512
+ console.warn('Failed to persist recording state to sessionStorage:', error);
513
+ }
514
+ }, [isMicEnabled, storageMode]);
515
+ React.useEffect(() => {
516
+ if (typeof window === 'undefined') {
517
+ return;
518
+ }
519
+ const cleanupAutoStartListeners = () => {
520
+ if (autoStartGestureCleanupRef.current) {
521
+ autoStartGestureCleanupRef.current();
522
+ autoStartGestureCleanupRef.current = null;
523
+ }
524
+ };
525
+ if (storageMode !== "session-storage") {
526
+ cleanupAutoStartListeners();
527
+ pendingMicAutoStartRef.current = false;
528
+ setIsAwaitingMicGesture(false);
529
+ return;
530
+ }
531
+ if (hasSyncedMicStateRef.current || isReacquiringMicRef.current) {
532
+ return;
533
+ }
534
+ const rawStoredValue = sessionStorage.getItem(MIC_ENABLED_SESSION_KEY);
535
+ const shouldRestore = rawStoredValue === 'true';
536
+ if (!shouldRestore || isMicEnabledRef.current || isRestoringRecordingRef.current) {
537
+ hasSyncedMicStateRef.current = true;
538
+ setIsAwaitingMicGesture(false);
539
+ return;
540
+ }
541
+ pendingMicAutoStartRef.current = true;
542
+ function scheduleRetryOnUserGesture() {
543
+ if (autoStartGestureCleanupRef.current)
544
+ return;
545
+ setIsAwaitingMicGesture(true);
546
+ const events = ['pointerdown', 'keydown', 'touchstart'];
547
+ const handler = async () => {
548
+ cleanupAutoStartListeners();
549
+ await attemptAutoStart('user-gesture');
550
+ };
551
+ events.forEach(event => window.addEventListener(event, handler, { once: true }));
552
+ autoStartGestureCleanupRef.current = () => {
553
+ events.forEach(event => window.removeEventListener(event, handler));
554
+ autoStartGestureCleanupRef.current = null;
555
+ };
556
+ }
557
+ async function attemptAutoStart(reason) {
558
+ isReacquiringMicRef.current = true;
559
+ // For initial restore, show gesture prompt immediately while checking
560
+ // This gives user early feedback and allows them to click anytime
561
+ if (reason === 'initial') {
562
+ scheduleRetryOnUserGesture();
563
+ }
564
+ if (typeof navigator !== 'undefined' && navigator.mediaDevices?.getUserMedia) {
565
+ try {
566
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
567
+ stream.getTracks().forEach(track => track.stop());
568
+ }
569
+ catch (error) {
570
+ console.error('Failed to reacquire microphone stream:', error);
571
+ const errorName = error?.name || '';
572
+ const errorMessage = error instanceof Error ? error.message : String(error);
573
+ if (reason === 'initial') {
574
+ const isPermissionPermanentlyDenied = errorName === 'NotAllowedError' &&
575
+ /permission.*denied|blocked|not.*granted/i.test(errorMessage);
576
+ if (isPermissionPermanentlyDenied) {
577
+ // Permission permanently denied, hide the prompt
578
+ pendingMicAutoStartRef.current = false;
579
+ setIsAwaitingMicGesture(false);
580
+ try {
581
+ sessionStorage.setItem(MIC_ENABLED_SESSION_KEY, JSON.stringify(false));
582
+ }
583
+ catch (storageError) {
584
+ console.warn('Failed to reset recording state in sessionStorage:', storageError);
585
+ }
586
+ cleanupAutoStartListeners();
587
+ }
588
+ // Otherwise, keep the prompt showing (already shown above)
589
+ isReacquiringMicRef.current = false;
590
+ return;
591
+ }
592
+ // For user-gesture retry, treat as error
593
+ pendingMicAutoStartRef.current = false;
594
+ setIsAwaitingMicGesture(false);
595
+ try {
596
+ sessionStorage.setItem(MIC_ENABLED_SESSION_KEY, JSON.stringify(false));
597
+ }
598
+ catch (storageError) {
599
+ console.warn('Failed to reset recording state in sessionStorage:', storageError);
600
+ }
601
+ isReacquiringMicRef.current = false;
602
+ hasSyncedMicStateRef.current = true;
603
+ cleanupAutoStartListeners();
604
+ return;
605
+ }
606
+ }
607
+ else if (reason === 'initial') {
608
+ // getUserMedia not available, prompt already shown above
609
+ isReacquiringMicRef.current = false;
610
+ return;
611
+ }
612
+ isRestoringRecordingRef.current = true;
613
+ // Intercept console.warn to detect AudioContext warnings
614
+ let audioContextWarningDetected = false;
615
+ const originalWarn = console.warn;
616
+ console.warn = (...args) => {
617
+ const message = args.join(' ');
618
+ if (message.includes('AudioContext was not allowed to start')) {
619
+ audioContextWarningDetected = true;
620
+ }
621
+ originalWarn.apply(console, args);
622
+ };
623
+ try {
624
+ await startRecording();
625
+ console.warn = originalWarn;
626
+ // Check mic status after a short delay
627
+ await new Promise(resolve => setTimeout(resolve, 300));
628
+ // If mic started successfully, hide the prompt
629
+ if (isMicEnabledRef.current) {
630
+ pendingMicAutoStartRef.current = false;
631
+ setIsAwaitingMicGesture(false);
632
+ cleanupAutoStartListeners();
633
+ }
634
+ else if (reason === 'initial') {
635
+ // Mic didn't start, keep the prompt showing (already shown earlier)
636
+ isRestoringRecordingRef.current = false;
637
+ return;
638
+ }
639
+ }
640
+ catch (error) {
641
+ console.error('startRecording threw an error:', error);
642
+ try {
643
+ sessionStorage.setItem(MIC_ENABLED_SESSION_KEY, JSON.stringify(false));
644
+ }
645
+ catch (storageError) {
646
+ console.warn('Failed to reset recording state in sessionStorage:', storageError);
647
+ }
648
+ }
649
+ finally {
650
+ console.warn = originalWarn;
651
+ isRestoringRecordingRef.current = false;
652
+ isReacquiringMicRef.current = false;
653
+ hasSyncedMicStateRef.current = true;
654
+ }
655
+ }
656
+ attemptAutoStart('initial');
657
+ return () => {
658
+ cleanupAutoStartListeners();
659
+ pendingMicAutoStartRef.current = false;
660
+ setIsAwaitingMicGesture(false);
661
+ };
662
+ }, [storageMode, startRecording]);
663
+ const [notification, setNotification] = React.useState({
664
+ show: false,
665
+ type: 'info',
666
+ message: '',
667
+ autoHide: true,
668
+ duration: 5000
669
+ });
670
+ // AI Model related state
671
+ const [modelsFromApi, setModelsFromApi] = React.useState([]);
672
+ const [selectedModelIndex, setSelectedModelIndex] = useLocalStorage('selectedAIModelIndex', 0);
673
+ // Use models from API only (no default fallback)
674
+ // This ensures SDK always uses Dashboard-configured models
675
+ const effectiveAvailableModels = React.useMemo(() => {
676
+ return modelsFromApi;
677
+ }, [modelsFromApi]);
678
+ // Auto determine if selector should be shown based on number of models
679
+ // Show selector only when there are 2 or more models
680
+ const showModelSelector = React.useMemo(() => {
681
+ return effectiveAvailableModels.length >= 2;
682
+ }, [effectiveAvailableModels]);
683
+ // Fetch available models from API on mount
684
+ React.useEffect(() => {
685
+ const fetchModels = async () => {
686
+ try {
687
+ const result = await nxtlinqApi.agent.getServiceModels({ apiKey, apiSecret });
688
+ if ('availableModels' in result && Array.isArray(result.availableModels)) {
689
+ setModelsFromApi(result.availableModels);
690
+ }
691
+ }
692
+ catch (error) {
693
+ console.error('Failed to fetch available models from API:', error);
694
+ // Silently fail and use default models
695
+ }
696
+ };
697
+ fetchModels();
698
+ }, [apiKey, apiSecret, nxtlinqApi]);
699
+ // Validate selectedModelIndex against effectiveAvailableModels
700
+ React.useEffect(() => {
701
+ // Check if selectedModelIndex is out of bounds
702
+ if (selectedModelIndex >= effectiveAvailableModels.length) {
703
+ setSelectedModelIndex(0);
704
+ return;
705
+ }
706
+ // Check if the selected model still exists in effectiveAvailableModels
707
+ const selectedModel = effectiveAvailableModels[selectedModelIndex];
708
+ if (!selectedModel) {
709
+ setSelectedModelIndex(0);
710
+ return;
711
+ }
712
+ }, [effectiveAvailableModels, selectedModelIndex, setSelectedModelIndex]);
713
+ // Notification functions
714
+ const showNotification = (type, message, duration = 5000) => {
715
+ setNotification({
716
+ show: true,
717
+ type,
718
+ message,
719
+ autoHide: true,
720
+ duration
721
+ });
722
+ if (duration > 0) {
723
+ setTimeout(() => {
724
+ setNotification(prev => ({ ...prev, show: false }));
725
+ }, duration);
726
+ }
727
+ };
728
+ const showSuccess = (message) => showNotification('success', message, 3000);
729
+ const showError = (message) => showNotification('error', message, 5000);
730
+ const showWarning = (message) => showNotification('warning', message, 4000);
731
+ const showInfo = (message) => showNotification('info', message, 3000);
732
+ // Show "Please log in again." instead of raw "Invalid or expired token" to users (English)
733
+ const normalizeErrorForUser = (message) => /invalid\s+or\s+expired\s+token/i.test(message) ? 'Please log in again.' : message;
734
+ // Fetch available permissions. Returns the service's permission list when successful, otherwise undefined.
735
+ const fetchAvailablePermissions = async () => {
736
+ if (!serviceId)
737
+ return undefined;
738
+ try {
739
+ const result = await nxtlinqApi.permissions.getServicePermissions({
740
+ serviceId,
741
+ ...(permissionGroup && { groupName: permissionGroup })
742
+ });
743
+ if ('error' in result) {
744
+ console.error('Failed to fetch permissions:', result.error);
745
+ return undefined;
746
+ }
747
+ setAvailablePermissions(result.permissions);
748
+ return result.permissions;
749
+ }
750
+ catch (error) {
751
+ console.error('Error fetching permissions:', error);
752
+ return undefined;
753
+ }
754
+ };
755
+ // Refresh AIT
756
+ const refreshAIT = async (forceUpdatePermissions = false) => {
757
+ if (!hitAddress) {
758
+ setAit(null);
759
+ setPermissions([]);
760
+ setWalletInfo(null);
761
+ setIsAITLoading(false);
762
+ return;
763
+ }
764
+ setIsAITLoading(true);
765
+ try {
766
+ // Get wallet info first - always try to get wallet info if we have a token
767
+ if (nxtlinqAITServiceAccessToken) {
768
+ try {
769
+ const walletResponse = await nxtlinqApi.wallet.getWallet({ address: hitAddress }, nxtlinqAITServiceAccessToken);
770
+ if (!('error' in walletResponse)) {
771
+ setWalletInfo(walletResponse);
772
+ }
773
+ else {
774
+ // Check if the error is due to invalid/expired token
775
+ if (walletResponse.error.includes('Invalid or expired token')) {
776
+ console.log('Token appears to be invalid during wallet info fetch, clearing it');
777
+ clearExpiredToken();
778
+ }
779
+ }
780
+ }
781
+ catch (error) {
782
+ console.error('Failed to fetch wallet info:', error);
783
+ }
784
+ }
785
+ // Only try to fetch AIT if we have a token
786
+ if (nxtlinqAITServiceAccessToken) {
787
+ const response = await nxtlinqApi.ait.getAITByServiceIdAndController({
788
+ serviceId,
789
+ controller: hitAddress,
790
+ customUsername: (!requireWalletIDVVerification && customUsername) ? getFinalCustomUsername(customUsername) : undefined
791
+ }, nxtlinqAITServiceAccessToken);
792
+ if ('error' in response) {
793
+ console.error('Failed to fetch AIT:', response.error);
794
+ setAit(null);
795
+ setPermissions([]);
796
+ return;
797
+ }
798
+ setAit(response);
799
+ if (!isPermissionFormOpen || forceUpdatePermissions) {
800
+ const newPermissions = response.metadata?.permissions || [];
801
+ setPermissions(newPermissions);
802
+ }
803
+ }
804
+ else {
805
+ // No token available, clear AIT data
806
+ setAit(null);
807
+ setPermissions([]);
808
+ }
809
+ }
810
+ catch (error) {
811
+ console.error('Failed to fetch AIT:', error);
812
+ setAit(null);
813
+ setPermissions([]);
814
+ }
815
+ finally {
816
+ setIsAITLoading(false);
817
+ }
818
+ };
819
+ // Check if user needs to sign in
820
+ const isNeedSignInWithWallet = React.useMemo(() => {
821
+ if (!hitAddress)
822
+ return false;
823
+ if (!nxtlinqAITServiceAccessToken)
824
+ return true;
825
+ try {
826
+ const payload = JSON.parse(atob(nxtlinqAITServiceAccessToken.split('.')[1]));
827
+ const address = payload.address;
828
+ if (address !== hitAddress)
829
+ return true;
830
+ return false;
831
+ }
832
+ catch (error) {
833
+ console.error('Error parsing token:', error);
834
+ return true;
835
+ }
836
+ }, [hitAddress, nxtlinqAITServiceAccessToken]);
837
+ // Connect wallet
838
+ const connectWallet = React.useCallback(async (autoShowSignInMessage = true) => {
839
+ if (typeof window === 'undefined') {
840
+ console.error('Web3 is not available in server-side rendering');
841
+ return;
842
+ }
843
+ try {
844
+ getEthers();
845
+ const web3Provider = await metakeepClient.ethereum;
846
+ if (!web3Provider) {
847
+ throw new Error('Web3 provider not available');
848
+ }
849
+ await web3Provider.enable();
850
+ // ethers v6: Use BrowserProvider instead of Web3Provider
851
+ const ethersProvider = new ethers.BrowserProvider(web3Provider);
852
+ // ethers v6: getSigner() and getAddress() are now async
853
+ const userSigner = await ethersProvider.getSigner();
854
+ const userAddress = await userSigner.getAddress();
855
+ localStorage.setItem('walletAddress', userAddress);
856
+ setHitAddress(userAddress);
857
+ setSigner(userSigner);
858
+ // Immediately update refs to ensure they're available for subsequent operations
859
+ hitAddressRef.current = userAddress;
860
+ signerRef.current = userSigner;
861
+ // Check if we have a valid token before trying to load AIT
862
+ if (nxtlinqAITServiceAccessToken) {
863
+ try {
864
+ const payload = JSON.parse(atob(nxtlinqAITServiceAccessToken.split('.')[1]));
865
+ const address = payload.address;
866
+ // Check if address matches
867
+ if (address !== userAddress) {
868
+ console.log('Token address mismatch, clearing token');
869
+ clearExpiredToken();
870
+ // Don't call refreshAIT with invalid token
871
+ }
872
+ else {
873
+ // Token address matches, try to load AIT
874
+ await refreshAIT();
875
+ await sleep(1000);
876
+ }
877
+ }
878
+ catch (error) {
879
+ console.error('Error parsing token during wallet connection:', error);
880
+ clearExpiredToken();
881
+ }
882
+ }
883
+ // If requireIDV is false and we have customUsername, check if wallet exists in AIT Service
884
+ if (!requireWalletIDVVerification && customUsername) {
885
+ try {
886
+ // Check if wallet exists in AIT Service
887
+ const walletResponse = await nxtlinqApi.wallet.getWallet({ address: userAddress }, '');
888
+ if ('error' in walletResponse) {
889
+ // Wallet doesn't exist, create it with custom method
890
+ console.log('Wallet not found in AIT Service, creating with custom method...');
891
+ const verifyPayload = {
892
+ address: userAddress,
893
+ method: 'custom',
894
+ timestamp: Date.now(),
895
+ customUsername: getFinalCustomUsername(customUsername)
896
+ };
897
+ const verifyResponse = await nxtlinqApi.wallet.verifyWallet(verifyPayload, '');
898
+ if ('error' in verifyResponse) {
899
+ console.error('Failed to create wallet with custom method:', verifyResponse.error);
900
+ }
901
+ }
902
+ }
903
+ catch (error) {
904
+ console.error('Error checking/creating wallet in AIT Service:', error);
905
+ }
906
+ }
907
+ if (autoShowSignInMessage) {
908
+ if (isNeedSignInWithWallet) {
909
+ setMessages(prev => [...prev, {
910
+ id: Date.now().toString(),
911
+ content: walletTextUtils.getWalletText('Please sign in with your wallet to continue.', serviceId),
912
+ role: 'assistant',
913
+ timestamp: new Date().toISOString(),
914
+ button: 'signIn'
915
+ }]);
916
+ }
917
+ else {
918
+ showSuccess(walletTextUtils.getWalletText('Successfully connected your HIT wallet. You are already signed in and can use the AI agent.', serviceId));
919
+ }
920
+ }
921
+ return userAddress;
922
+ }
923
+ catch (error) {
924
+ console.error('Failed to connect wallet:', error);
925
+ localStorage.removeItem('walletAddress');
926
+ setHitAddress(null);
927
+ setSigner(null);
928
+ if (autoShowSignInMessage) {
929
+ throw error;
930
+ }
931
+ return false;
932
+ }
933
+ }, [nxtlinqAITServiceAccessToken, refreshAIT, isNeedSignInWithWallet, requireWalletIDVVerification, customUsername, nxtlinqApi]);
934
+ const signInWallet = async (autoShowSuccessMessage = true) => {
935
+ // Use refs to get latest state values for consistency with hasPermission
936
+ const currentHitAddress = hitAddressRef.current;
937
+ const currentSigner = signerRef.current;
938
+ // If refs don't have the values, try using current state values
939
+ let addressToUse = currentHitAddress || hitAddress;
940
+ let signerToUse = currentSigner || signer;
941
+ // If we have an address but no signer, try to auto-connect wallet
942
+ if (addressToUse && !signerToUse) {
943
+ const storedWalletAddress = localStorage.getItem('walletAddress');
944
+ if (storedWalletAddress === addressToUse) {
945
+ try {
946
+ const autoConnectResult = await connectWallet(false); // Don't show sign-in message yet
947
+ if (autoConnectResult) {
948
+ // Wait for state to update
949
+ await new Promise(resolve => setTimeout(resolve, 1000));
950
+ // Get the updated signer
951
+ signerToUse = signerRef.current || signer;
952
+ addressToUse = hitAddressRef.current || hitAddress;
953
+ }
954
+ }
955
+ catch (error) {
956
+ console.error('Auto-connect failed during sign in:', error);
957
+ }
958
+ }
959
+ }
960
+ if (!addressToUse) {
961
+ console.log('No address available, returning early');
962
+ if (autoShowSuccessMessage) {
963
+ showError(walletTextUtils.getWalletText('Please connect your wallet first.', serviceId));
964
+ }
965
+ return;
966
+ }
967
+ if (!signerToUse) {
968
+ console.log('No signer available, returning early');
969
+ if (autoShowSuccessMessage) {
970
+ showError(walletTextUtils.getWalletText('Please connect your wallet first.', serviceId));
971
+ }
972
+ return;
973
+ }
974
+ try {
975
+ const nonceResponse = await nxtlinqApi.auth.getNonce({ address: addressToUse });
976
+ if ('error' in nonceResponse) {
977
+ if (autoShowSuccessMessage) {
978
+ showError(normalizeErrorForUser(nonceResponse.error));
979
+ }
980
+ return;
981
+ }
982
+ const payload = {
983
+ address: addressToUse,
984
+ code: nonceResponse.code,
985
+ timestamp: nonceResponse.timestamp
986
+ };
987
+ const stringToSign = stringify(payload);
988
+ const signature = await signerToUse.signMessage(stringToSign || '');
989
+ const response = await nxtlinqApi.auth.signIn({
990
+ ...payload,
991
+ signature
992
+ });
993
+ if ('error' in response) {
994
+ if (autoShowSuccessMessage) {
995
+ showError(normalizeErrorForUser(response.error));
996
+ }
997
+ return;
998
+ }
999
+ const { accessToken } = response;
1000
+ setNxtlinqAITServiceAccessToken(accessToken);
1001
+ // Wait for state to update before refreshing AIT
1002
+ await new Promise(resolve => setTimeout(resolve, 100));
1003
+ await refreshAIT();
1004
+ if (autoShowSuccessMessage) {
1005
+ showSuccess(walletTextUtils.getWalletText('Successfully signed in with your HIT wallet. You can now use the AI agent.', serviceId));
1006
+ }
1007
+ }
1008
+ catch (error) {
1009
+ console.error('Failed to sign in:', error);
1010
+ if (autoShowSuccessMessage) {
1011
+ showError('Failed to sign in. Please try again.');
1012
+ }
1013
+ }
1014
+ };
1015
+ // Check permissions
1016
+ const hasPermission = async (requiredPermission, autoRetry = true, onAutoConnect, onAutoSignIn) => {
1017
+ // Use refs to get latest state values
1018
+ const currentHitAddress = hitAddressRef.current;
1019
+ const currentAit = aitRef.current;
1020
+ const currentPermissions = permissionsRef.current;
1021
+ const currentToken = nxtlinqAITServiceAccessTokenRef.current;
1022
+ if (!currentHitAddress) {
1023
+ if (autoRetry) {
1024
+ setIsLoading(false); // Stop thinking before showing message
1025
+ setMessages(prev => [...prev, {
1026
+ id: Date.now().toString(),
1027
+ content: walletTextUtils.getWalletText('Please connect your HIT wallet to continue.', serviceId),
1028
+ role: 'assistant',
1029
+ timestamp: new Date().toISOString(),
1030
+ button: 'connectWallet'
1031
+ }]);
1032
+ try {
1033
+ setIsAutoConnecting(true); // Mark as auto-connecting
1034
+ await connectWallet(false); // Don't show sign-in message yet
1035
+ setIsAutoConnecting(false); // Clear auto-connecting state
1036
+ // Show brief success message for auto-connect
1037
+ showSuccess(walletTextUtils.getWalletText('Auto wallet connection successful', serviceId));
1038
+ onAutoConnect?.(); // Call callback if provided
1039
+ await new Promise(resolve => setTimeout(resolve, 2000));
1040
+ // After auto connect, if not signed in, then auto sign-in
1041
+ const tokenAfterConnect = nxtlinqAITServiceAccessTokenRef.current;
1042
+ if (!tokenAfterConnect) {
1043
+ setIsAutoConnecting(true);
1044
+ await signInWallet(false);
1045
+ onAutoSignIn?.(); // Call callback if provided
1046
+ setIsAutoConnecting(false);
1047
+ showSuccess(walletTextUtils.getWalletText('Auto sign-in successful after wallet connect', serviceId));
1048
+ await refreshAIT();
1049
+ // Wait for AIT to be fully loaded with polling
1050
+ let attempts = 0;
1051
+ const maxAttempts = 5;
1052
+ while (!aitRef.current && attempts < maxAttempts) {
1053
+ await new Promise(resolve => setTimeout(resolve, 2000));
1054
+ attempts++;
1055
+ }
1056
+ }
1057
+ // If connection (and sign-in if needed) successful, continue with permission check
1058
+ const result = await hasPermission(requiredPermission, false);
1059
+ return result;
1060
+ }
1061
+ catch (error) {
1062
+ console.error('Failed to auto-connect wallet:', error);
1063
+ setIsAutoConnecting(false); // Clear auto-connecting state on error
1064
+ return false;
1065
+ }
1066
+ }
1067
+ // If autoRetry is false, don't show message again, just return false
1068
+ return false;
1069
+ }
1070
+ if (!currentToken) {
1071
+ if (autoRetry) {
1072
+ setIsLoading(false); // Stop thinking before showing message
1073
+ setMessages(prev => [...prev, {
1074
+ id: Date.now().toString(),
1075
+ content: walletTextUtils.getWalletText('Please sign in with your HIT wallet to continue.', serviceId),
1076
+ role: 'assistant',
1077
+ timestamp: new Date().toISOString(),
1078
+ button: 'signIn'
1079
+ }]);
1080
+ try {
1081
+ setIsAutoConnecting(true); // Mark as auto-signing
1082
+ await signInWallet(false); // Don't show success message yet
1083
+ onAutoSignIn?.(); // Call callback if provided
1084
+ setIsAutoConnecting(false); // Clear auto-signing state
1085
+ // Show brief success message for auto-sign-in
1086
+ showSuccess('Auto sign-in successful');
1087
+ // Ensure AIT is refreshed after sign-in
1088
+ await refreshAIT();
1089
+ // Wait for AIT to be fully loaded with polling
1090
+ let attempts = 0;
1091
+ const maxAttempts = 5; // Wait up to 10 seconds (5 * 2000ms)
1092
+ while (!aitRef.current && attempts < maxAttempts) {
1093
+ await new Promise(resolve => setTimeout(resolve, 2000));
1094
+ attempts++;
1095
+ }
1096
+ // Only continue if AIT is actually loaded
1097
+ if (aitRef.current) {
1098
+ // Wait a bit more to ensure permissions are also loaded
1099
+ await new Promise(resolve => setTimeout(resolve, 2000));
1100
+ // If sign-in successful, continue with permission check
1101
+ const result = await hasPermission(requiredPermission, false);
1102
+ return result;
1103
+ }
1104
+ else {
1105
+ return false;
1106
+ }
1107
+ }
1108
+ catch (error) {
1109
+ console.error('Failed to auto-sign-in wallet:', error);
1110
+ setIsAutoConnecting(false); // Clear auto-signing state on error
1111
+ return false;
1112
+ }
1113
+ }
1114
+ // If autoRetry is false, don't show message again, just return false
1115
+ return false;
1116
+ }
1117
+ try {
1118
+ const payload = JSON.parse(atob(currentToken.split('.')[1]));
1119
+ const address = payload.address;
1120
+ if (address !== currentHitAddress) {
1121
+ setNxtlinqAITServiceAccessToken('');
1122
+ if (autoRetry) {
1123
+ setIsLoading(false); // Stop thinking before showing message
1124
+ setMessages(prev => [...prev, {
1125
+ id: Date.now().toString(),
1126
+ content: walletTextUtils.getWalletText('Wallet address mismatch. Please sign in with the correct wallet.', serviceId),
1127
+ role: 'assistant',
1128
+ timestamp: new Date().toISOString(),
1129
+ button: 'signIn'
1130
+ }]);
1131
+ try {
1132
+ setIsAutoConnecting(true); // Mark as auto-signing
1133
+ await signInWallet(false); // Don't show success message yet
1134
+ onAutoSignIn?.(); // Call callback if provided
1135
+ setIsAutoConnecting(false); // Clear auto-signing state
1136
+ // Show brief success message for auto-sign-in after address mismatch
1137
+ showSuccess('Auto sign-in successful after address mismatch');
1138
+ // Ensure AIT is refreshed after sign-in
1139
+ await refreshAIT();
1140
+ // Wait for AIT to be fully loaded with polling
1141
+ let attempts = 0;
1142
+ const maxAttempts = 5; // Wait up to 10 seconds (5 * 2000ms)
1143
+ while (!aitRef.current && attempts < maxAttempts) {
1144
+ await new Promise(resolve => setTimeout(resolve, 2000));
1145
+ attempts++;
1146
+ }
1147
+ // Only continue if AIT is actually loaded
1148
+ if (aitRef.current) {
1149
+ // Wait a bit more to ensure permissions are also loaded
1150
+ await new Promise(resolve => setTimeout(resolve, 2000));
1151
+ // If sign-in successful, continue with permission check
1152
+ const result = await hasPermission(requiredPermission, false);
1153
+ return result;
1154
+ }
1155
+ else {
1156
+ return false;
1157
+ }
1158
+ }
1159
+ catch (error) {
1160
+ console.error('Failed to auto-sign-in after address mismatch:', error);
1161
+ setIsAutoConnecting(false); // Clear auto-signing state on error
1162
+ return false;
1163
+ }
1164
+ }
1165
+ // If autoRetry is false, don't show message again, just return false
1166
+ return false;
1167
+ }
1168
+ }
1169
+ catch (error) {
1170
+ console.error('Error parsing token:', error);
1171
+ setNxtlinqAITServiceAccessToken('');
1172
+ if (autoRetry) {
1173
+ setIsLoading(false); // Stop thinking before showing message
1174
+ setMessages(prev => [...prev, {
1175
+ id: Date.now().toString(),
1176
+ content: walletTextUtils.getWalletText('Invalid wallet session. Please sign in again.', serviceId),
1177
+ role: 'assistant',
1178
+ timestamp: new Date().toISOString(),
1179
+ button: 'signIn'
1180
+ }]);
1181
+ try {
1182
+ setIsAutoConnecting(true); // Mark as auto-signing
1183
+ await signInWallet(false); // Don't show success message yet
1184
+ onAutoSignIn?.(); // Call callback if provided
1185
+ setIsAutoConnecting(false); // Clear auto-signing state
1186
+ // Show brief success message for auto-sign-in after token parse error
1187
+ showSuccess('Auto sign-in successful after token error');
1188
+ // Ensure AIT is refreshed after sign-in
1189
+ await refreshAIT();
1190
+ // Wait for AIT to be fully loaded with polling
1191
+ let attempts = 0;
1192
+ const maxAttempts = 5; // Wait up to 10 seconds (5 * 2000ms)
1193
+ while (!aitRef.current && attempts < maxAttempts) {
1194
+ await new Promise(resolve => setTimeout(resolve, 2000));
1195
+ attempts++;
1196
+ }
1197
+ // Only continue if AIT is actually loaded
1198
+ if (aitRef.current) {
1199
+ // Wait a bit more to ensure permissions are also loaded
1200
+ await new Promise(resolve => setTimeout(resolve, 2000));
1201
+ // If sign-in successful, continue with permission check
1202
+ const result = await hasPermission(requiredPermission, false);
1203
+ return result;
1204
+ }
1205
+ else {
1206
+ return false;
1207
+ }
1208
+ }
1209
+ catch (signInError) {
1210
+ console.error('Failed to auto-sign-in after token parse error:', signInError);
1211
+ setIsAutoConnecting(false); // Clear auto-signing state on error
1212
+ return false;
1213
+ }
1214
+ }
1215
+ // If autoRetry is false, don't show message again, just return false
1216
+ return false;
1217
+ }
1218
+ if (!currentAit) {
1219
+ // Show loading message if AIT is still loading
1220
+ if (isAITLoading) {
1221
+ setIsLoading(false); // Stop thinking before showing message
1222
+ setMessages(prev => [...prev, {
1223
+ id: Date.now().toString(),
1224
+ content: walletTextUtils.getWalletText('Loading your wallet configuration... Please wait a moment.', serviceId),
1225
+ role: 'assistant',
1226
+ timestamp: new Date().toISOString()
1227
+ }]);
1228
+ return false;
1229
+ }
1230
+ // If AIT is not loaded but we have a token, try to refresh it once
1231
+ if (currentToken && !isAITLoading) {
1232
+ try {
1233
+ await refreshAIT();
1234
+ // Wait for AIT to be loaded with polling
1235
+ let attempts = 0;
1236
+ const maxAttempts = 5; // Wait up to 10 seconds (5 * 2000ms)
1237
+ while (!aitRef.current && attempts < maxAttempts) {
1238
+ await new Promise(resolve => setTimeout(resolve, 2000));
1239
+ attempts++;
1240
+ }
1241
+ // Check again after refresh
1242
+ if (!aitRef.current) {
1243
+ setIsLoading(false); // Stop thinking before showing message
1244
+ setMessages(prev => [...prev, {
1245
+ id: Date.now().toString(),
1246
+ content: walletTextUtils.getWalletText('No AIT found for your wallet. Please click the settings button (⚙️) to configure your AIT permissions.', serviceId),
1247
+ role: 'assistant',
1248
+ timestamp: new Date().toISOString()
1249
+ }]);
1250
+ return false;
1251
+ }
1252
+ }
1253
+ catch (error) {
1254
+ console.error('Failed to refresh AIT during permission check:', error);
1255
+ setIsLoading(false); // Stop thinking before showing message
1256
+ setMessages(prev => [...prev, {
1257
+ id: Date.now().toString(),
1258
+ content: 'No AIT found for your wallet. Please click the settings button (⚙️) to configure your AIT permissions.',
1259
+ role: 'assistant',
1260
+ timestamp: new Date().toISOString()
1261
+ }]);
1262
+ return false;
1263
+ }
1264
+ }
1265
+ else {
1266
+ setIsLoading(false); // Stop thinking before showing message
1267
+ setMessages(prev => [...prev, {
1268
+ id: Date.now().toString(),
1269
+ content: 'No AIT found for your wallet. Please click the settings button (⚙️) to configure your AIT permissions.',
1270
+ role: 'assistant',
1271
+ timestamp: new Date().toISOString()
1272
+ }]);
1273
+ return false;
1274
+ }
1275
+ }
1276
+ if (availablePermissions.length === 0) {
1277
+ setIsLoading(false); // Stop thinking before showing message
1278
+ setMessages(prev => [...prev, {
1279
+ id: Date.now().toString(),
1280
+ content: `No permissions available for your current identity provider. Please check your service configuration or contact support. Service ID: ${serviceId}, Permission Group: ${permissionGroup || 'None'}`,
1281
+ role: 'assistant',
1282
+ timestamp: new Date().toISOString()
1283
+ }]);
1284
+ return false;
1285
+ }
1286
+ const checkToolPermissionResult = await nxtlinqApi.agent.checkToolPermission({
1287
+ apiKey,
1288
+ apiSecret,
1289
+ aitToken: currentToken,
1290
+ controller: currentHitAddress,
1291
+ toolName: requiredPermission
1292
+ });
1293
+ if ('error' in checkToolPermissionResult) {
1294
+ // Check if AIT is still loading or if we just auto-connected
1295
+ if (isAITLoading || isAutoConnecting) {
1296
+ setIsLoading(false); // Stop thinking before showing message
1297
+ setMessages(prev => [...prev, {
1298
+ id: Date.now().toString(),
1299
+ content: walletTextUtils.getWalletText('Loading your wallet configuration... Please wait a moment.', serviceId),
1300
+ role: 'assistant',
1301
+ timestamp: new Date().toISOString()
1302
+ }]);
1303
+ return false;
1304
+ }
1305
+ // Refresh service permissions before judging; use latest result to decide message
1306
+ const freshAvailablePermissions = await fetchAvailablePermissions();
1307
+ const latestAvailablePermissions = freshAvailablePermissions ?? availablePermissions;
1308
+ const requiredPermission = checkToolPermissionResult.requiredPermission;
1309
+ const isInServiceList = latestAvailablePermissions.some((p) => p.label === requiredPermission);
1310
+ // Permission not in service's available list → "not available for your current identity provider"
1311
+ if (!isInServiceList) {
1312
+ setIsLoading(false); // Stop thinking before showing message
1313
+ setMessages(prev => [...prev, {
1314
+ id: Date.now().toString(),
1315
+ content: `This permission (${requiredPermission}) is not available for your current identity provider.`,
1316
+ role: 'assistant',
1317
+ timestamp: new Date().toISOString()
1318
+ }]);
1319
+ return false;
1320
+ }
1321
+ // AIT loaded but permissions empty (no AIT found)
1322
+ if (currentAit && currentPermissions.length === 0) {
1323
+ setIsLoading(false); // Stop thinking before showing message
1324
+ setMessages(prev => [...prev, {
1325
+ id: Date.now().toString(),
1326
+ content: 'No AIT found for your wallet. Please click the settings button (⚙️) to configure your AIT permissions.',
1327
+ role: 'assistant',
1328
+ timestamp: new Date().toISOString()
1329
+ }]);
1330
+ return false;
1331
+ }
1332
+ // User has AIT but hasn't enabled this permission → prompt to enable
1333
+ setIsLoading(false); // Stop thinking before showing message
1334
+ const permissionMsg = `You don't have the required AIT permission: ${requiredPermission}. Would you like to enable AIT permission?`;
1335
+ setMessages(prev => [
1336
+ ...prev,
1337
+ {
1338
+ id: Date.now().toString(),
1339
+ content: permissionMsg,
1340
+ role: 'assistant',
1341
+ timestamp: new Date().toISOString(),
1342
+ button: 'enableAIT',
1343
+ metadata: { requiredPermission }
1344
+ }
1345
+ ]);
1346
+ return false;
1347
+ }
1348
+ return true;
1349
+ };
1350
+ // AI Model related functions
1351
+ const handleModelChange = React.useCallback((modelIndex) => {
1352
+ setSelectedModelIndex(modelIndex);
1353
+ const selectedModel = effectiveAvailableModels[modelIndex];
1354
+ onModelChange?.(selectedModel);
1355
+ }, [effectiveAvailableModels, onModelChange, setSelectedModelIndex]);
1356
+ const getCurrentModel = React.useCallback(() => {
1357
+ // Safety check: ensure selectedModelIndex is within bounds
1358
+ let safeIndex = selectedModelIndex;
1359
+ if (selectedModelIndex >= effectiveAvailableModels.length) {
1360
+ safeIndex = 0;
1361
+ }
1362
+ // If no models available, return a default model
1363
+ if (effectiveAvailableModels.length === 0) {
1364
+ return { label: 'Unknown', value: 'unknown' };
1365
+ }
1366
+ return effectiveAvailableModels[safeIndex];
1367
+ }, [effectiveAvailableModels, selectedModelIndex]);
1368
+ const updateSuggestions = React.useCallback(async (pseudoId, externalId) => {
1369
+ const result = await nxtlinqApi.agent.generateSuggestions({
1370
+ apiKey,
1371
+ apiSecret,
1372
+ pseudoId,
1373
+ externalId
1374
+ });
1375
+ if ('error' in result) {
1376
+ console.error('Failed to fetch suggestions:', result.error);
1377
+ setSuggestions([]);
1378
+ return;
1379
+ }
1380
+ setSuggestions(result.suggestions.map((sug) => ({
1381
+ text: sug,
1382
+ autoSend: true
1383
+ })));
1384
+ }, []);
1385
+ // Updated sendMessage function to support different AI models and attachments
1386
+ const sendMessage = async (content, retryCount = 0, isPresetMessage = false, attachments, clientPipelineOverride) => {
1387
+ const hasContent = content.trim() || (attachments && attachments.length > 0);
1388
+ if (!hasContent || isLoading)
1389
+ return;
1390
+ const currentModel = getCurrentModel();
1391
+ // Initialize with current model, will be updated with actual model from backend response
1392
+ let actualModelUsed = currentModel.value;
1393
+ // Only add user message on first attempt, not on retries
1394
+ // Also check if this is not a preset message (preset messages are added separately)
1395
+ if (retryCount === 0 && !isPresetMessage) {
1396
+ const userMessage = {
1397
+ id: Date.now().toString(),
1398
+ content: content || (attachments && attachments.length > 0 ? `Uploaded ${attachments.length} file(s)` : ''),
1399
+ role: 'user',
1400
+ timestamp: new Date().toISOString(),
1401
+ attachments: attachments,
1402
+ metadata: {
1403
+ model: currentModel.value,
1404
+ permissions: permissions,
1405
+ issuedBy: hitAddress || ''
1406
+ },
1407
+ piiStatus: piiDisplayMode === 'redacted' ? 'scanning' : undefined,
1408
+ };
1409
+ setMessages(prev => [...prev, userMessage]);
1410
+ setInputValue('');
1411
+ }
1412
+ setIsLoading(true);
1413
+ let streamAssistantId = null;
1414
+ try {
1415
+ // Prepare attachments for API
1416
+ const apiAttachments = attachments?.map(att => ({
1417
+ type: att.type,
1418
+ url: att.url,
1419
+ name: att.name,
1420
+ mimeType: att.mimeType
1421
+ }));
1422
+ let clientPipelineTimings;
1423
+ if (retryCount === 0) {
1424
+ if (clientPipelineOverride?.length) {
1425
+ clientPipelineTimings = clientPipelineOverride;
1426
+ }
1427
+ else if (micSessionStartForLogRef.current != null) {
1428
+ clientPipelineTimings = [
1429
+ {
1430
+ name: 'speech_to_text',
1431
+ durationMs: Math.round(performance.now() - micSessionStartForLogRef.current),
1432
+ },
1433
+ ];
1434
+ micSessionStartForLogRef.current = null;
1435
+ }
1436
+ }
1437
+ const isAgentStreamModel = currentModel.value.endsWith('-stream');
1438
+ let streamedReplyBuffer = '';
1439
+ if (isAgentStreamModel && retryCount === 0) {
1440
+ const sid = `stream-assistant-${Date.now()}`;
1441
+ streamAssistantId = sid;
1442
+ setMessages(prev => [...prev, {
1443
+ id: sid,
1444
+ role: 'assistant',
1445
+ content: '',
1446
+ partialContent: '',
1447
+ isStreaming: true,
1448
+ timestamp: new Date().toISOString(),
1449
+ metadata: {
1450
+ model: currentModel.value,
1451
+ agentLlmStream: true,
1452
+ permissions: permissions,
1453
+ issuedBy: hitAddress || ''
1454
+ }
1455
+ }]);
1456
+ }
1457
+ const response = await nxtlinqApi.agent.sendMessage({
1458
+ model: currentModel.value,
1459
+ apiKey,
1460
+ apiSecret,
1461
+ pseudoId: pseudoId,
1462
+ externalId: localStorage.getItem('walletAddress') || undefined,
1463
+ customUserInfo,
1464
+ customUsername,
1465
+ message: content || (attachments && attachments.length > 0 ? `Uploaded ${attachments.length} file(s)` : ''),
1466
+ attachments: apiAttachments,
1467
+ isRetry: retryCount > 0,
1468
+ ...(clientPipelineTimings?.length ? { clientPipelineTimings } : {}),
1469
+ onStreamDelta: streamAssistantId
1470
+ ? (text) => {
1471
+ streamedReplyBuffer += text;
1472
+ flushSync(() => {
1473
+ setMessages(prev => prev.map(m => m.id === streamAssistantId
1474
+ ? { ...m, partialContent: (m.partialContent || '') + text }
1475
+ : m));
1476
+ });
1477
+ if (text) {
1478
+ setIsLoading(false);
1479
+ }
1480
+ }
1481
+ : undefined,
1482
+ onPiiProgress: piiDisplayMode === 'redacted' ? (step, data) => {
1483
+ const stepMap = {
1484
+ scan_start: 0, // Scan active
1485
+ scan_complete: 1, // {N} protected — data available, show redacted
1486
+ send_start: 2, // Sending to AI
1487
+ };
1488
+ const piiStep = stepMap[step];
1489
+ if (piiStep === undefined)
1490
+ return;
1491
+ setMessages(prev => {
1492
+ const updated = [...prev];
1493
+ for (let i = updated.length - 1; i >= 0; i--) {
1494
+ if (updated[i].role === 'user' && updated[i].piiStatus === 'scanning') {
1495
+ const patch = { piiStep };
1496
+ // scan_complete carries anonymized data — set piiProtection early for immediate redact
1497
+ if (step === 'scan_complete' && data?.anonymizedUserMessage) {
1498
+ patch.piiProtection = {
1499
+ anonymizedContent: data.anonymizedUserMessage,
1500
+ mapping: data.mapping || {},
1501
+ };
1502
+ }
1503
+ updated[i] = { ...updated[i], ...patch };
1504
+ break;
1505
+ }
1506
+ }
1507
+ return updated;
1508
+ });
1509
+ } : undefined,
1510
+ });
1511
+ if (!('error' in response && response.error)) {
1512
+ const tr = response;
1513
+ if (tr.ttsVoice &&
1514
+ typeof tr.ttsVoice === 'object' &&
1515
+ (tr.ttsVoice.provider === 'azure' || tr.ttsVoice.provider === 'openai')) {
1516
+ clientTtsVoiceRef.current = tr.ttsVoice;
1517
+ }
1518
+ else {
1519
+ clientTtsVoiceRef.current = undefined;
1520
+ }
1521
+ }
1522
+ // Get the actual model used from response (may differ due to fallback)
1523
+ actualModelUsed = response.model || currentModel.value;
1524
+ if ('error' in response || response.result === 'error') {
1525
+ // Check if it's an AIT-related error
1526
+ const errorResponse = response;
1527
+ if (errorResponse.requiresWalletConnection) {
1528
+ // Case where wallet connection is required - trigger auto-connect logic
1529
+ // Try to auto-connect wallet first
1530
+ const autoConnectResult = await connectWallet(false); // Don't show sign-in message yet
1531
+ if (autoConnectResult) {
1532
+ // If auto-connect successful, try to sign in and then retry
1533
+ try {
1534
+ // Try to sign in wallet
1535
+ await signInWallet(false); // Don't show success message yet
1536
+ if (isSemiAutomaticMode) {
1537
+ setIsLoading(false);
1538
+ setMessages(prev => [...prev, {
1539
+ id: Date.now().toString(),
1540
+ content: 'Click button to continue using tool',
1541
+ role: 'assistant',
1542
+ timestamp: new Date().toISOString(),
1543
+ button: 'continue'
1544
+ }]);
1545
+ return;
1546
+ }
1547
+ else {
1548
+ // Wait a bit for everything to settle
1549
+ await new Promise(resolve => setTimeout(resolve, 2000));
1550
+ // Retry the message
1551
+ sendMessage(content, retryCount + 1, isPresetMessage);
1552
+ }
1553
+ }
1554
+ catch (signInError) {
1555
+ setIsLoading(false);
1556
+ setMessages(prev => [...prev, {
1557
+ id: Date.now().toString(),
1558
+ content: 'Wallet connected but sign-in failed. Please try signing in manually.',
1559
+ role: 'assistant',
1560
+ timestamp: new Date().toISOString(),
1561
+ button: 'signIn',
1562
+ metadata: {
1563
+ requiresWalletConnection: true,
1564
+ toolName: errorResponse.toolName
1565
+ }
1566
+ }]);
1567
+ }
1568
+ return;
1569
+ }
1570
+ else {
1571
+ // If auto-connect failed, show the error message with connect button
1572
+ setIsLoading(false);
1573
+ setMessages(prev => [...prev, {
1574
+ id: Date.now().toString(),
1575
+ content: normalizeErrorForUser(errorResponse.message),
1576
+ role: 'assistant',
1577
+ timestamp: new Date().toISOString(),
1578
+ button: 'connectWallet',
1579
+ metadata: {
1580
+ requiresWalletConnection: true,
1581
+ toolName: errorResponse.toolName
1582
+ }
1583
+ }]);
1584
+ return;
1585
+ }
1586
+ }
1587
+ else if (errorResponse.requiresPermissionUpdate) {
1588
+ // Case where permission update is required - show manual enable option
1589
+ setIsLoading(false);
1590
+ setMessages(prev => [...prev, {
1591
+ id: Date.now().toString(),
1592
+ content: normalizeErrorForUser(errorResponse.message),
1593
+ role: 'assistant',
1594
+ timestamp: new Date().toISOString(),
1595
+ button: 'enableAIT',
1596
+ metadata: {
1597
+ requiresPermissionUpdate: true,
1598
+ toolName: errorResponse.toolName,
1599
+ requiredPermission: errorResponse.requiredPermission
1600
+ }
1601
+ }]);
1602
+ return;
1603
+ }
1604
+ else {
1605
+ // Other errors (show user-friendly message for invalid/expired token)
1606
+ throw new Error(normalizeErrorForUser(errorResponse.error || errorResponse.message));
1607
+ }
1608
+ }
1609
+ let botResponse;
1610
+ let handledOpenAiStreamAssistant = false;
1611
+ if (streamAssistantId && response.toolCall?.toolUse) {
1612
+ setMessages(prev => prev.filter(m => m.id !== streamAssistantId));
1613
+ streamAssistantId = null;
1614
+ }
1615
+ if (streamAssistantId && !response.toolCall?.toolUse) {
1616
+ handledOpenAiStreamAssistant = true;
1617
+ let replyText = '';
1618
+ if (response.reply) {
1619
+ replyText = Array.isArray(response.reply)
1620
+ ? response.reply
1621
+ .map((item) => item.text)
1622
+ .join(' ') || ''
1623
+ : response.reply || '';
1624
+ }
1625
+ else {
1626
+ replyText = response.fullResponse?.output?.message?.content
1627
+ ?.find((item) => item.text)?.text || '';
1628
+ }
1629
+ if (!replyText.trim() && streamedReplyBuffer.trim()) {
1630
+ replyText = streamedReplyBuffer.trim();
1631
+ }
1632
+ if (!replyText.trim()) {
1633
+ replyText = 'Sorry, I cannot understand your question';
1634
+ }
1635
+ updateSuggestions(pseudoId, localStorage.getItem('walletAddress') || undefined);
1636
+ setMessages(prev => prev.map(m => m.id === streamAssistantId
1637
+ ? {
1638
+ ...m,
1639
+ content: replyText,
1640
+ isStreaming: false,
1641
+ partialContent: undefined,
1642
+ metadata: {
1643
+ model: actualModelUsed,
1644
+ agentLlmStream: true,
1645
+ permissions: permissions,
1646
+ issuedBy: hitAddress || ''
1647
+ },
1648
+ piiProtection: response.piiProtection?.anonymizedReply
1649
+ ? { anonymizedContent: response.piiProtection.anonymizedReply ?? undefined, mapping: response.piiProtection.mapping ?? undefined }
1650
+ : undefined,
1651
+ }
1652
+ : m));
1653
+ botResponse = {
1654
+ id: streamAssistantId,
1655
+ content: replyText,
1656
+ role: 'assistant',
1657
+ timestamp: new Date().toISOString(),
1658
+ metadata: {
1659
+ model: actualModelUsed,
1660
+ agentLlmStream: true,
1661
+ permissions: permissions,
1662
+ issuedBy: hitAddress || ''
1663
+ },
1664
+ piiProtection: response.piiProtection?.anonymizedReply
1665
+ ? { anonymizedContent: response.piiProtection.anonymizedReply ?? undefined, mapping: response.piiProtection.mapping ?? undefined }
1666
+ : undefined,
1667
+ };
1668
+ }
1669
+ // Store redirectUrl for later use (don't redirect yet)
1670
+ const redirectUrl = response?.redirectUrl;
1671
+ if (!handledOpenAiStreamAssistant && response.toolCall?.toolUse) {
1672
+ // Handle tool calls
1673
+ const toolUse = response.toolCall.toolUse;
1674
+ let toolMsg = '';
1675
+ if (onToolUse) {
1676
+ let wasAutoConnected = false;
1677
+ let wasAutoSignedIn = false;
1678
+ // Added: Mark if permission denied due to missing AIT permission
1679
+ let permissionDenied = false;
1680
+ // Use requiredPermission from response if available, otherwise fall back to toolUse.name
1681
+ const permissionToCheck = response?.requiredPermission || toolUse.name;
1682
+ const isToolAllowed = await hasPermission(permissionToCheck, true, () => { wasAutoConnected = true; }, () => { wasAutoSignedIn = true; });
1683
+ // If currentPermissions does not include permissionToCheck and availablePermissionLabels includes permissionToCheck, it means AIT permission is missing
1684
+ if (!isToolAllowed && !permissions.includes(permissionToCheck) && availablePermissions.map(p => p.label).includes(permissionToCheck)) {
1685
+ permissionDenied = true;
1686
+ }
1687
+ if (!isToolAllowed) {
1688
+ // If permission denied due to missing AIT permission return
1689
+ if (permissionDenied) {
1690
+ setIsLoading(false);
1691
+ return;
1692
+ }
1693
+ if (isSemiAutomaticMode) {
1694
+ setIsLoading(false);
1695
+ setMessages(prev => [...prev, {
1696
+ id: Date.now().toString(),
1697
+ content: 'Click button to continue using tool',
1698
+ role: 'assistant',
1699
+ timestamp: new Date().toISOString(),
1700
+ button: 'continue'
1701
+ }]);
1702
+ return;
1703
+ }
1704
+ else {
1705
+ // Only retry for auto-connect/auto-sign-in scenarios
1706
+ if (wasAutoConnected && retryCount < 1) {
1707
+ // Clear loading state and retry immediately
1708
+ setIsLoading(false);
1709
+ // Check if wallet is already signed in
1710
+ const currentToken = nxtlinqAITServiceAccessTokenRef.current;
1711
+ if (!currentToken) {
1712
+ // If not signed in, directly retry the message without waiting for AIT
1713
+ setTimeout(() => {
1714
+ sendMessage(content, retryCount + 1, isPresetMessage);
1715
+ }, 2000);
1716
+ }
1717
+ else {
1718
+ // If already signed in, wait for AIT to be fully loaded before retrying
1719
+ setTimeout(async () => {
1720
+ // Wait for AIT to be loaded if needed
1721
+ if (!aitRef.current && nxtlinqAITServiceAccessTokenRef.current) {
1722
+ await refreshAIT();
1723
+ }
1724
+ // Wait for AIT to be fully loaded with polling
1725
+ let attempts = 0;
1726
+ const maxAttempts = 5; // Wait up to 10 seconds (5 * 2000ms)
1727
+ while (!aitRef.current && attempts < maxAttempts) {
1728
+ await new Promise(resolve => setTimeout(resolve, 2000));
1729
+ attempts++;
1730
+ }
1731
+ // Only retry if AIT is actually loaded
1732
+ if (aitRef.current) {
1733
+ // Wait a bit more to ensure permissions are also loaded
1734
+ await new Promise(resolve => setTimeout(resolve, 3000));
1735
+ sendMessage(content, retryCount + 1, isPresetMessage);
1736
+ }
1737
+ }, 2000);
1738
+ }
1739
+ }
1740
+ return;
1741
+ }
1742
+ }
1743
+ if (isSemiAutomaticMode && wasAutoSignedIn) {
1744
+ setIsLoading(false);
1745
+ setMessages(prev => [...prev, {
1746
+ id: Date.now().toString(),
1747
+ content: 'Click button to continue using tool',
1748
+ role: 'assistant',
1749
+ timestamp: new Date().toISOString(),
1750
+ button: 'continue'
1751
+ }]);
1752
+ return;
1753
+ }
1754
+ // Create streaming message for tool execution
1755
+ const streamingMessageId = `streaming-${Date.now()}`;
1756
+ const streamingMessage = {
1757
+ id: streamingMessageId,
1758
+ content: '',
1759
+ role: 'assistant',
1760
+ timestamp: new Date().toISOString(),
1761
+ isStreaming: true,
1762
+ streamingToolName: toolUse.name,
1763
+ streamingStatus: `Starting ${toolUse.name}...`,
1764
+ streamingProgress: 0,
1765
+ streamingSteps: [],
1766
+ metadata: {
1767
+ model: actualModelUsed,
1768
+ permissions: permissions,
1769
+ issuedBy: hitAddress || '',
1770
+ toolUse: toolUse
1771
+ }
1772
+ };
1773
+ // Add streaming message to chat
1774
+ setMessages(prev => [...prev, streamingMessage]);
1775
+ // Execute tool with streaming updates
1776
+ const toolResult = await onToolUse(toolUse, (update) => {
1777
+ // Update streaming message with progress
1778
+ setMessages(prev => prev.map(msg => msg.id === streamingMessageId
1779
+ ? {
1780
+ ...msg,
1781
+ content: update.partialResult || msg.content,
1782
+ partialContent: update.partialContent, // Token-level streaming
1783
+ streamingProgress: update.progress,
1784
+ streamingStatus: update.status,
1785
+ streamingSteps: update.steps || msg.streamingSteps
1786
+ }
1787
+ : msg));
1788
+ });
1789
+ toolMsg = toolResult?.content || `Tool ${toolUse.name} executed successfully`;
1790
+ // Merge AI original reply
1791
+ let replyText = '';
1792
+ if (response.reply) {
1793
+ replyText = Array.isArray(response.reply)
1794
+ ? response.reply
1795
+ .map((item) => item.text)
1796
+ .join(' ') || ''
1797
+ : response.reply || '';
1798
+ }
1799
+ else if (response.fullResponse?.output?.message?.content) {
1800
+ replyText = response.fullResponse.output.message.content
1801
+ ?.find((item) => item.text)?.text || '';
1802
+ }
1803
+ let finalContent = toolMsg;
1804
+ if (replyText && replyText.trim()) {
1805
+ finalContent += `\n\n${replyText}`;
1806
+ }
1807
+ // Mark streaming as complete with final content
1808
+ setMessages(prev => prev.map(msg => msg.id === streamingMessageId
1809
+ ? {
1810
+ ...msg,
1811
+ isStreaming: false,
1812
+ streamingProgress: 100,
1813
+ content: finalContent,
1814
+ metadata: {
1815
+ ...msg.metadata,
1816
+ model: actualModelUsed,
1817
+ toolUse: toolUse
1818
+ },
1819
+ piiProtection: response.piiProtection?.anonymizedReply
1820
+ ? { anonymizedContent: response.piiProtection.anonymizedReply ?? undefined, mapping: response.piiProtection.mapping ?? undefined }
1821
+ : undefined,
1822
+ }
1823
+ : msg));
1824
+ // Update message content in database if assistantMessageId is available
1825
+ if (response.assistantMessageId && apiKey && apiSecret) {
1826
+ try {
1827
+ await nxtlinqApi.agent.updateMessageContent({
1828
+ apiKey,
1829
+ apiSecret,
1830
+ messageId: response.assistantMessageId,
1831
+ content: finalContent,
1832
+ });
1833
+ }
1834
+ catch (error) {
1835
+ console.error('Failed to update message content in database:', error);
1836
+ // Don't block the UI if update fails
1837
+ }
1838
+ }
1839
+ updateSuggestions(pseudoId, localStorage.getItem('walletAddress') || undefined);
1840
+ // Skip creating a new botResponse since we already updated the streaming message
1841
+ }
1842
+ else {
1843
+ toolMsg = `Tool ${toolUse.name} executed successfully`;
1844
+ // No onToolUse callback - create message without streaming
1845
+ let replyText = '';
1846
+ if (response.reply) {
1847
+ replyText = Array.isArray(response.reply)
1848
+ ? response.reply
1849
+ .map((item) => item.text)
1850
+ .join(' ') || ''
1851
+ : response.reply || '';
1852
+ }
1853
+ else if (response.fullResponse?.output?.message?.content) {
1854
+ replyText = response.fullResponse.output.message.content
1855
+ ?.find((item) => item.text)?.text || '';
1856
+ }
1857
+ let mergedContent = '';
1858
+ if (toolMsg) {
1859
+ mergedContent += toolMsg;
1860
+ }
1861
+ if (replyText && replyText.trim()) {
1862
+ mergedContent += `\n\n${replyText}`;
1863
+ }
1864
+ // Update message content in database if assistantMessageId is available
1865
+ if (response.assistantMessageId && apiKey && apiSecret) {
1866
+ try {
1867
+ await nxtlinqApi.agent.updateMessageContent({
1868
+ apiKey,
1869
+ apiSecret,
1870
+ messageId: response.assistantMessageId,
1871
+ content: mergedContent,
1872
+ });
1873
+ }
1874
+ catch (error) {
1875
+ console.error('Failed to update message content in database:', error);
1876
+ // Don't block the UI if update fails
1877
+ }
1878
+ }
1879
+ updateSuggestions(pseudoId, localStorage.getItem('walletAddress') || undefined);
1880
+ const newBotResponse = {
1881
+ id: (Date.now() + 1).toString(),
1882
+ content: mergedContent,
1883
+ role: 'assistant',
1884
+ timestamp: new Date().toISOString(),
1885
+ metadata: {
1886
+ model: actualModelUsed,
1887
+ permissions: permissions,
1888
+ issuedBy: hitAddress || '',
1889
+ toolUse: toolUse
1890
+ },
1891
+ piiProtection: response.piiProtection?.anonymizedReply
1892
+ ? { anonymizedContent: response.piiProtection.anonymizedReply ?? undefined, mapping: response.piiProtection.mapping ?? undefined }
1893
+ : undefined,
1894
+ };
1895
+ setMessages(prev => [...prev, newBotResponse]);
1896
+ botResponse = newBotResponse;
1897
+ }
1898
+ }
1899
+ else if (!handledOpenAiStreamAssistant && response.reply) {
1900
+ const replyText = Array.isArray(response.reply)
1901
+ ? response.reply
1902
+ .map((item) => item.text)
1903
+ .join(' ') || 'Sorry, I cannot understand your question'
1904
+ : response.reply || 'Sorry, I cannot understand your question';
1905
+ updateSuggestions(pseudoId, localStorage.getItem('walletAddress') || undefined);
1906
+ const newBotResponse = {
1907
+ id: (Date.now() + 1).toString(),
1908
+ content: replyText,
1909
+ role: 'assistant',
1910
+ timestamp: new Date().toISOString(),
1911
+ metadata: {
1912
+ model: actualModelUsed,
1913
+ permissions: permissions,
1914
+ issuedBy: hitAddress || ''
1915
+ },
1916
+ piiProtection: response.piiProtection?.anonymizedReply
1917
+ ? { anonymizedContent: response.piiProtection.anonymizedReply ?? undefined, mapping: response.piiProtection.mapping ?? undefined }
1918
+ : undefined,
1919
+ };
1920
+ setMessages(prev => [...prev, newBotResponse]);
1921
+ botResponse = newBotResponse;
1922
+ }
1923
+ else if (!handledOpenAiStreamAssistant) {
1924
+ const replyText = response.fullResponse?.output?.message?.content
1925
+ ?.find((item) => item.text)?.text || 'Sorry, I cannot understand your question';
1926
+ const newBotResponse = {
1927
+ id: (Date.now() + 1).toString(),
1928
+ content: replyText,
1929
+ role: 'assistant',
1930
+ timestamp: new Date().toISOString(),
1931
+ metadata: {
1932
+ model: actualModelUsed,
1933
+ permissions: permissions,
1934
+ issuedBy: hitAddress || ''
1935
+ },
1936
+ piiProtection: response.piiProtection?.anonymizedReply
1937
+ ? { anonymizedContent: response.piiProtection.anonymizedReply ?? undefined, mapping: response.piiProtection.mapping ?? undefined }
1938
+ : undefined,
1939
+ };
1940
+ setMessages(prev => [...prev, newBotResponse]);
1941
+ botResponse = newBotResponse;
1942
+ }
1943
+ // ===== PII Protection: Update user message with anonymized version =====
1944
+ const anonymizedUserMsg = response.piiProtection?.anonymizedUserMessage;
1945
+ const piiMappingData = response.piiProtection?.mapping ?? undefined;
1946
+ if (anonymizedUserMsg) {
1947
+ setMessages(prev => {
1948
+ const updated = [...prev];
1949
+ for (let i = updated.length - 1; i >= 0; i--) {
1950
+ if (updated[i].role === 'user') {
1951
+ updated[i] = {
1952
+ ...updated[i],
1953
+ piiProtection: { anonymizedContent: anonymizedUserMsg, mapping: piiMappingData },
1954
+ piiStatus: 'complete',
1955
+ };
1956
+ break;
1957
+ }
1958
+ }
1959
+ return updated;
1960
+ });
1961
+ }
1962
+ else if (piiDisplayMode === 'redacted') {
1963
+ // No PII detected — set piiStatus to 'none' to trigger "No sensitive data" indicator
1964
+ setMessages(prev => {
1965
+ const updated = [...prev];
1966
+ for (let i = updated.length - 1; i >= 0; i--) {
1967
+ if (updated[i].role === 'user' && updated[i].piiStatus === 'scanning') {
1968
+ updated[i] = { ...updated[i], piiStatus: 'none' };
1969
+ break;
1970
+ }
1971
+ }
1972
+ return updated;
1973
+ });
1974
+ }
1975
+ // Execute redirect after all message processing is complete
1976
+ if (redirectUrl) {
1977
+ // Use setTimeout to ensure the message is displayed before redirect
1978
+ setTimeout(() => {
1979
+ window.location.href = redirectUrl;
1980
+ }, 100);
1981
+ }
1982
+ // Play text-to-speech for AI response if enabled
1983
+ if (botResponse && botResponse.role === 'assistant' && textToSpeechEnabled) {
1984
+ const ttsContent = botResponse.content;
1985
+ setTimeout(() => {
1986
+ playTextToSpeech(ttsContent).catch(error => {
1987
+ console.error('Failed to play text-to-speech:', error);
1988
+ });
1989
+ }, 100);
1990
+ }
1991
+ }
1992
+ catch (error) {
1993
+ console.error('Failed to send message:', error);
1994
+ if (streamAssistantId) {
1995
+ setMessages(prev => prev.filter(m => m.id !== streamAssistantId));
1996
+ streamAssistantId = null;
1997
+ }
1998
+ if (retryCount < maxRetries) {
1999
+ // Show retry indicator to user
2000
+ const retryMessage = {
2001
+ id: `retry-${Date.now()}-${retryCount}`,
2002
+ content: `Retrying... (Attempt ${retryCount + 1}/${maxRetries})`,
2003
+ role: 'assistant',
2004
+ timestamp: new Date().toISOString(),
2005
+ metadata: {
2006
+ model: actualModelUsed,
2007
+ permissions: permissions,
2008
+ issuedBy: hitAddress || '',
2009
+ isRetry: true
2010
+ }
2011
+ };
2012
+ setMessages(prev => [...prev, retryMessage]);
2013
+ setTimeout(() => {
2014
+ // Remove retry message before next attempt
2015
+ setMessages(prev => prev.filter(msg => msg.id !== retryMessage.id));
2016
+ sendMessage(content, retryCount + 1, isPresetMessage);
2017
+ }, retryDelay);
2018
+ return;
2019
+ }
2020
+ // Clear piiStatus on error so stepper doesn't get stuck
2021
+ if (piiDisplayMode === 'redacted') {
2022
+ setMessages(prev => {
2023
+ const updated = [...prev];
2024
+ for (let i = updated.length - 1; i >= 0; i--) {
2025
+ if (updated[i].role === 'user' && updated[i].piiStatus === 'scanning') {
2026
+ updated[i] = { ...updated[i], piiStatus: undefined };
2027
+ break;
2028
+ }
2029
+ }
2030
+ return updated;
2031
+ });
2032
+ }
2033
+ const rawMessage = error instanceof Error ? error.message : 'An error occurred while sending the message';
2034
+ const errorMessage = {
2035
+ id: (Date.now() + 1).toString(),
2036
+ content: normalizeErrorForUser(rawMessage),
2037
+ role: 'assistant',
2038
+ timestamp: new Date().toISOString(),
2039
+ metadata: {
2040
+ model: currentModel.value,
2041
+ permissions: permissions,
2042
+ issuedBy: hitAddress || ''
2043
+ }
2044
+ };
2045
+ setMessages(prev => [...prev, errorMessage]);
2046
+ onError?.(error instanceof Error ? error : new Error('Unknown error'));
2047
+ }
2048
+ finally {
2049
+ setIsLoading(false);
2050
+ }
2051
+ };
2052
+ // Handle submit
2053
+ const handleSubmit = async (e, attachments) => {
2054
+ e.preventDefault();
2055
+ if (isStopRecordingOnSend) {
2056
+ while (isMicEnabledRef.current) {
2057
+ stopRecording();
2058
+ await sleep(1000);
2059
+ }
2060
+ }
2061
+ else {
2062
+ if (isMicEnabledRef.current) {
2063
+ await sleep(1000);
2064
+ clearRecording();
2065
+ }
2066
+ }
2067
+ if (!textInputRef.current)
2068
+ return;
2069
+ const hasContent = textInputRef.current.value.trim() || (attachments && attachments.length > 0);
2070
+ if (!hasContent || isLoading)
2071
+ return;
2072
+ // 如果此時有語音自動發送的計時器,優先取消,避免與手動發送同內容時重複送出
2073
+ if (autoSendTimerRef.current) {
2074
+ clearTimeout(autoSendTimerRef.current);
2075
+ autoSendTimerRef.current = null;
2076
+ }
2077
+ // 標記這次手動送出的內容,讓語音自動發送邏輯視為已送出,避免同一段文字再自動發送一次
2078
+ const manualContent = textInputRef.current.value.trim();
2079
+ if (manualContent) {
2080
+ lastAutoSentTranscriptRef.current = manualContent;
2081
+ }
2082
+ // Stop current speech playback when user sends new message
2083
+ stopTextToSpeechAndReset();
2084
+ // Show loading message if AIT is still loading
2085
+ if (isAITLoading) {
2086
+ const loadingMessage = {
2087
+ id: Date.now().toString(),
2088
+ content: 'Loading your wallet configuration... Please wait a moment.',
2089
+ role: 'assistant',
2090
+ timestamp: new Date().toISOString(),
2091
+ metadata: {
2092
+ model: getCurrentModel().value,
2093
+ permissions: permissions,
2094
+ issuedBy: hitAddress || ''
2095
+ }
2096
+ };
2097
+ setMessages(prev => [...prev, loadingMessage]);
2098
+ return;
2099
+ }
2100
+ try {
2101
+ const clientPipelineOverride = micSessionStartForLogRef.current != null
2102
+ ? [
2103
+ {
2104
+ name: 'speech_to_text',
2105
+ durationMs: Math.round(performance.now() - micSessionStartForLogRef.current),
2106
+ },
2107
+ ]
2108
+ : undefined;
2109
+ if (micSessionStartForLogRef.current != null) {
2110
+ micSessionStartForLogRef.current = null;
2111
+ }
2112
+ await sendMessage(textInputRef.current.value, 0, false, attachments, clientPipelineOverride);
2113
+ }
2114
+ catch (error) {
2115
+ console.error('Failed to send message:', error);
2116
+ onError?.(error instanceof Error ? error : new Error('Failed to send message'));
2117
+ const errorMessage = {
2118
+ id: Date.now().toString(),
2119
+ content: 'Sorry, there was an error processing your message. Please try again.',
2120
+ role: 'assistant',
2121
+ timestamp: new Date().toISOString(),
2122
+ metadata: {
2123
+ model: getCurrentModel().value,
2124
+ permissions: permissions,
2125
+ issuedBy: hitAddress || ''
2126
+ }
2127
+ };
2128
+ setMessages(prev => [...prev, errorMessage]);
2129
+ }
2130
+ };
2131
+ // Stop text-to-speech (does not clear queue)
2132
+ const stopTextToSpeech = React.useCallback(() => {
2133
+ if (speechingRef.current) {
2134
+ // Stop AudioContext source if exists
2135
+ if (audioSourceRef.current) {
2136
+ audioSourceRef.current.stop();
2137
+ audioSourceRef.current.disconnect();
2138
+ audioSourceRef.current = null;
2139
+ }
2140
+ // Stop HTML Audio element if exists
2141
+ if (audioElementRef.current) {
2142
+ audioElementRef.current.pause();
2143
+ audioElementRef.current.currentTime = 0;
2144
+ if (audioElementRef.current.src.startsWith('blob:')) {
2145
+ URL.revokeObjectURL(audioElementRef.current.src);
2146
+ }
2147
+ audioElementRef.current = null;
2148
+ }
2149
+ speechingRef.current = false;
2150
+ }
2151
+ setSpeechingIndex(undefined);
2152
+ }, []);
2153
+ // Stop text-to-speech (used when user sends new message)
2154
+ const stopTextToSpeechAndReset = React.useCallback(() => {
2155
+ stopTextToSpeech();
2156
+ setRequiresGesture(false);
2157
+ setIsTtsProcessing(false);
2158
+ pendingTtsRef.current = null;
2159
+ }, [stopTextToSpeech]);
2160
+ // When TTS is disabled during playback, stop immediately
2161
+ React.useEffect(() => {
2162
+ if (!textToSpeechEnabled) {
2163
+ stopTextToSpeechAndReset();
2164
+ }
2165
+ }, [textToSpeechEnabled, stopTextToSpeechAndReset]);
2166
+ const { isVoiceMode, voiceStatus, isVoiceConnecting, isMicMuted, remoteAudioRef, enterVoiceMode, exitVoiceMode, toggleMicMute: toggleVoiceMicMute, interruptVoice, } = useVoiceMode({
2167
+ apiKey,
2168
+ apiSecret,
2169
+ pseudoId,
2170
+ externalId: hitAddress || undefined,
2171
+ aitId: ait?.aitId,
2172
+ walletAddress: hitAddress || undefined,
2173
+ aitToken: nxtlinqAITServiceAccessToken || undefined,
2174
+ metadata: customUserInfo,
2175
+ nxtlinqApi,
2176
+ getMessages: () => messagesRef.current,
2177
+ setMessages,
2178
+ onError,
2179
+ stopRecording,
2180
+ stopTextToSpeech: stopTextToSpeechAndReset,
2181
+ voiceTransport: 'ws-realtime',
2182
+ });
2183
+ React.useEffect(() => {
2184
+ if (!isOpen && isVoiceMode) {
2185
+ void exitVoiceMode();
2186
+ }
2187
+ }, [isOpen, isVoiceMode, exitVoiceMode]);
2188
+ React.useEffect(() => {
2189
+ if (isOpen && preferredChatMode === 'voice' && !isVoiceMode && !isVoiceConnecting) {
2190
+ void enterVoiceMode();
2191
+ }
2192
+ }, [isOpen, preferredChatMode, isVoiceMode, isVoiceConnecting, enterVoiceMode]);
2193
+ React.useEffect(() => {
2194
+ if (isVoiceMode || isVoiceConnecting) {
2195
+ stopRecording();
2196
+ }
2197
+ }, [isVoiceMode, isVoiceConnecting, stopRecording]);
2198
+ const handleEnterVoiceMode = React.useCallback(async () => {
2199
+ stopRecording();
2200
+ setPreferredChatMode('voice');
2201
+ await enterVoiceMode();
2202
+ }, [enterVoiceMode, setPreferredChatMode, stopRecording]);
2203
+ const handleExitVoiceMode = React.useCallback(async () => {
2204
+ setPreferredChatMode('text');
2205
+ await exitVoiceMode();
2206
+ }, [exitVoiceMode, setPreferredChatMode]);
2207
+ // Play text-to-speech (simplified, no queue)
2208
+ const playTextToSpeech = React.useCallback(async (text, messageIndex) => {
2209
+ if (!textToSpeechEnabled || !text.trim())
2210
+ return;
2211
+ // Stop any ongoing speech
2212
+ stopTextToSpeech();
2213
+ // Show processing indicator
2214
+ setIsTtsProcessing(true);
2215
+ // Retry up to 2 times (3 attempts total)
2216
+ let lastError;
2217
+ for (let attempt = 0; attempt < 3; attempt++) {
2218
+ try {
2219
+ await playTextToSpeechWithRetry(text, messageIndex, attempt);
2220
+ return; // Success, exit
2221
+ }
2222
+ catch (err) {
2223
+ lastError = err;
2224
+ if (attempt < 2) {
2225
+ // Continue to next attempt
2226
+ continue;
2227
+ }
2228
+ }
2229
+ }
2230
+ // All attempts failed
2231
+ console.error('TTS playback failed after retries:', lastError);
2232
+ setIsTtsProcessing(false);
2233
+ }, [textToSpeechEnabled, stopTextToSpeech, playTextToSpeechWithRetry]);
2234
+ const retryTtsWithGesture = React.useCallback(async () => {
2235
+ try {
2236
+ setRequiresGesture(false);
2237
+ // Try to resume audio context
2238
+ if (audioCtxRef.current) {
2239
+ try {
2240
+ await audioCtxRef.current.resume();
2241
+ }
2242
+ catch { }
2243
+ }
2244
+ const pending = pendingTtsRef.current;
2245
+ if (pending && textToSpeechEnabled) {
2246
+ setIsTtsProcessing(true);
2247
+ await playTextToSpeech(pending);
2248
+ pendingTtsRef.current = null;
2249
+ }
2250
+ }
2251
+ catch (err) {
2252
+ console.error('Retry TTS with gesture failed:', err);
2253
+ setIsTtsProcessing(false);
2254
+ }
2255
+ }, [textToSpeechEnabled, playTextToSpeech]);
2256
+ // When browser blocks autoplay (requires gesture), retry on next user interaction
2257
+ React.useEffect(() => {
2258
+ if (!requiresGesture || !textToSpeechEnabled || !pendingTtsRef.current)
2259
+ return;
2260
+ const handleUserGesture = () => {
2261
+ retryTtsWithGesture();
2262
+ };
2263
+ window.addEventListener('pointerdown', handleUserGesture, { once: true });
2264
+ return () => window.removeEventListener('pointerdown', handleUserGesture);
2265
+ }, [requiresGesture, textToSpeechEnabled, retryTtsWithGesture]);
2266
+ const uploadAttachment = React.useCallback(async (file) => {
2267
+ const result = await nxtlinqApi.agent.uploadAttachment({
2268
+ apiKey,
2269
+ apiSecret,
2270
+ pseudoId,
2271
+ file,
2272
+ });
2273
+ if ('error' in result)
2274
+ return result;
2275
+ return { url: result.url };
2276
+ }, [nxtlinqApi, apiKey, apiSecret, pseudoId]);
2277
+ // Handle preset message
2278
+ const handlePresetMessage = (message) => {
2279
+ // If preset is configured as auto-send, avoid duplicate sends when user clicks repeatedly
2280
+ if (message.autoSend) {
2281
+ const trimmedText = (message.text || '').trim();
2282
+ if (!trimmedText)
2283
+ return;
2284
+ // Prevent sending messages while AI Agent is processing
2285
+ if (isLoading) {
2286
+ return;
2287
+ }
2288
+ // If this exact preset text was just sent (by auto-send / manual / preset), skip to prevent duplicates
2289
+ if (lastAutoSentTranscriptRef.current === trimmedText) {
2290
+ return;
2291
+ }
2292
+ // For preset messages, we need to add the user message first since sendMessage won't add it on retries
2293
+ const userMessage = {
2294
+ id: Date.now().toString(),
2295
+ content: trimmedText,
2296
+ role: 'user',
2297
+ timestamp: new Date().toISOString(),
2298
+ metadata: {
2299
+ model: getCurrentModel().value,
2300
+ permissions: permissions,
2301
+ issuedBy: hitAddress || ''
2302
+ }
2303
+ };
2304
+ setMessages(prev => [...prev, userMessage]);
2305
+ // Mark as last sent to guard against rapid re-clicks and other duplicate flows
2306
+ lastAutoSentTranscriptRef.current = trimmedText;
2307
+ // Pass a flag to indicate this is a preset message so sendMessage won't add user message again
2308
+ sendMessage(trimmedText, 0, true);
2309
+ }
2310
+ else {
2311
+ setInputValue(message.text);
2312
+ }
2313
+ };
2314
+ // Generate and register AIT
2315
+ const generateAndRegisterAIT = async (newPermissions, isFromAIAgent = false) => {
2316
+ let currentSigner = signer;
2317
+ let currentAddress = hitAddress;
2318
+ // If we have an address but no signer, try to auto-connect wallet
2319
+ if (currentAddress && !currentSigner) {
2320
+ const storedWalletAddress = localStorage.getItem('walletAddress');
2321
+ if (storedWalletAddress === currentAddress) {
2322
+ try {
2323
+ const autoConnectResult = await connectWallet(false); // Don't show sign-in message yet
2324
+ if (autoConnectResult) {
2325
+ // Wait for state to update
2326
+ await new Promise(resolve => setTimeout(resolve, 1000));
2327
+ // Get the updated signer and address
2328
+ currentSigner = signerRef.current || signer;
2329
+ currentAddress = hitAddressRef.current || hitAddress;
2330
+ }
2331
+ }
2332
+ catch (error) {
2333
+ console.error('Auto-connect failed during AIT generation:', error);
2334
+ }
2335
+ }
2336
+ }
2337
+ if (!currentSigner || !currentAddress) {
2338
+ throw new Error('Missing signer or wallet address');
2339
+ }
2340
+ return generateAndRegisterAITWithSigner(newPermissions, isFromAIAgent, currentSigner, currentAddress);
2341
+ };
2342
+ // Generate and register AIT with explicit signer and address
2343
+ const generateAndRegisterAITWithSigner = async (newPermissions, isFromAIAgent = false, explicitSigner, explicitAddress) => {
2344
+ const currentSigner = explicitSigner || signer;
2345
+ const currentAddress = explicitAddress || hitAddress;
2346
+ if (!currentSigner || !currentAddress) {
2347
+ throw new Error('Missing signer or wallet address');
2348
+ }
2349
+ // If AI Agent is creating, must check if user has existing AIT
2350
+ if (isFromAIAgent && !aitRef.current) {
2351
+ throw new Error('You must have an existing AIT before AI Agent can create new AITs');
2352
+ }
2353
+ const timestamp = Math.floor(Date.now() / 1000);
2354
+ const aitId = `did:polygon:nxtlinq:${currentAddress}:${timestamp}`;
2355
+ const metadata = {
2356
+ permissions: newPermissions || permissions,
2357
+ issuedBy: currentAddress,
2358
+ };
2359
+ const metadataStr = stringify(metadata);
2360
+ // ethers v6: utils.keccak256 -> keccak256, utils.toUtf8Bytes -> toUtf8Bytes
2361
+ const metadataHash = ethers.keccak256(ethers.toUtf8Bytes(metadataStr));
2362
+ const uploadResponse = await nxtlinqApi.metadata.createMetadata(metadata, nxtlinqAITServiceAccessToken || '');
2363
+ if ('error' in uploadResponse) {
2364
+ throw new Error(`Failed to upload metadata: ${uploadResponse.error}`);
2365
+ }
2366
+ const { metadataCid } = uploadResponse;
2367
+ const createAITParams = {
2368
+ aitId,
2369
+ controller: currentAddress,
2370
+ serviceId,
2371
+ metadataHash,
2372
+ metadataCid,
2373
+ isFromAIAgent: isFromAIAgent,
2374
+ parentAITId: isFromAIAgent ? aitRef.current?.aitId : undefined,
2375
+ };
2376
+ const createAITResponse = await nxtlinqApi.ait.createAIT(createAITParams, nxtlinqAITServiceAccessToken || '');
2377
+ if ('error' in createAITResponse) {
2378
+ throw new Error(`Failed to create AIT: ${createAITResponse.error}`);
2379
+ }
2380
+ const aitInfo = {
2381
+ aitId,
2382
+ controller: currentAddress,
2383
+ metadata,
2384
+ metadataHash,
2385
+ metadataCid,
2386
+ };
2387
+ setAit(aitInfo);
2388
+ setPermissions(newPermissions || permissions);
2389
+ };
2390
+ // Auto enable AIT permission
2391
+ const enableAIT = async (toolName) => {
2392
+ if (isAITEnabling)
2393
+ return false; // Prevent duplicate
2394
+ setIsAITEnabling(true);
2395
+ let currentSigner = signer;
2396
+ let currentHitAddress = hitAddress;
2397
+ // If no wallet connected, try to auto-connect first
2398
+ if (!currentSigner || !currentHitAddress) {
2399
+ const autoConnectResult = await connectWallet(false); // Don't show sign-in message yet
2400
+ if (autoConnectResult) {
2401
+ // Wait for state to update - increase wait time
2402
+ await new Promise(resolve => setTimeout(resolve, 1500));
2403
+ // Get the latest state values after connection
2404
+ currentSigner = signerRef.current || signer;
2405
+ currentHitAddress = hitAddressRef.current || hitAddress;
2406
+ // Double check that we have the required state
2407
+ if (!currentSigner || !currentHitAddress) {
2408
+ await new Promise(resolve => setTimeout(resolve, 1500));
2409
+ currentSigner = signerRef.current || signer;
2410
+ currentHitAddress = hitAddressRef.current || hitAddress;
2411
+ }
2412
+ try {
2413
+ await signInWallet(false); // Don't show success message yet
2414
+ }
2415
+ catch (signInError) {
2416
+ showError(walletTextUtils.getWalletText('Wallet connected but sign-in failed. Please try signing in manually.', serviceId));
2417
+ setIsAITEnabling(false);
2418
+ return false;
2419
+ }
2420
+ }
2421
+ else {
2422
+ showError(walletTextUtils.getWalletText('Please connect your wallet first', serviceId));
2423
+ setIsAITEnabling(false);
2424
+ return false;
2425
+ }
2426
+ }
2427
+ // Final check before proceeding
2428
+ if (!currentSigner || !currentHitAddress) {
2429
+ console.error('Missing signer or wallet address after connection process');
2430
+ console.error('Current signer:', !!currentSigner);
2431
+ console.error('Current hitAddress:', currentHitAddress);
2432
+ console.error('Signer ref:', !!signerRef.current);
2433
+ console.error('HitAddress ref:', hitAddressRef.current);
2434
+ showError(walletTextUtils.getWalletText('Wallet connection failed. Please try connecting your wallet manually.', serviceId));
2435
+ setIsAITEnabling(false);
2436
+ return false;
2437
+ }
2438
+ // If we don't have AIT loaded but we have a token, try to refresh it first
2439
+ if (!aitRef.current && nxtlinqAITServiceAccessToken) {
2440
+ try {
2441
+ await refreshAIT();
2442
+ // Wait a bit for AIT to be loaded
2443
+ await new Promise(resolve => setTimeout(resolve, 1000));
2444
+ }
2445
+ catch (error) {
2446
+ // Continue anyway, we'll create a new AIT if needed
2447
+ console.error('Failed to refresh AIT:', error);
2448
+ }
2449
+ }
2450
+ try {
2451
+ // Get available permissions to find the tool
2452
+ const availablePermissionLabels = availablePermissions.map(p => p.label);
2453
+ if (!availablePermissionLabels.includes(toolName)) {
2454
+ showError(`Tool ${toolName} is not available for your current identity provider`);
2455
+ setIsAITEnabling(false);
2456
+ return false;
2457
+ }
2458
+ // Get current permissions from AIT metadata instead of React state
2459
+ // This ensures we don't lose existing permissions when enabling new ones
2460
+ const currentAITPermissions = aitRef.current?.metadata?.permissions || permissions;
2461
+ const newPermissions = [...currentAITPermissions];
2462
+ if (!newPermissions.includes(toolName)) {
2463
+ newPermissions.push(toolName);
2464
+ }
2465
+ // Filter out deleted permissions before saving
2466
+ const validPermissions = newPermissions.filter(permission => availablePermissionLabels.includes(permission));
2467
+ // Generate and register AIT with new permissions
2468
+ // For auto-enable, we should create a regular AIT (not AI Agent AIT) if user doesn't have existing AIT
2469
+ const shouldCreateAsAIAgent = !!aitRef.current; // Only create as AI Agent if user already has an AIT
2470
+ await generateAndRegisterAITWithSigner(validPermissions, shouldCreateAsAIAgent, currentSigner, currentHitAddress);
2471
+ showSuccess('AIT permission enabled successfully! You can now use the AI agent.');
2472
+ await refreshAIT(true);
2473
+ setIsAITEnabling(false);
2474
+ return true;
2475
+ }
2476
+ catch (error) {
2477
+ console.error('Failed to auto-enable AIT:', error);
2478
+ setIsAITEnabling(false);
2479
+ if (error instanceof Error) {
2480
+ showError(error.message);
2481
+ }
2482
+ else {
2483
+ showError('Failed to enable AIT permission. Please try again.');
2484
+ }
2485
+ return false;
2486
+ }
2487
+ };
2488
+ // Save permissions
2489
+ const savePermissions = async (newPermissions) => {
2490
+ setIsDisabled(true);
2491
+ try {
2492
+ // Filter out deleted permissions before saving
2493
+ const permissionsToSave = newPermissions || permissions;
2494
+ const availablePermissionLabels = availablePermissions.map(p => p.label);
2495
+ const validPermissions = permissionsToSave.filter(permission => availablePermissionLabels.includes(permission));
2496
+ await generateAndRegisterAIT(validPermissions, false);
2497
+ showSuccess('AIT permissions saved successfully! You can now use the AI agent with your configured permissions.');
2498
+ setShowPermissionForm(false);
2499
+ setIsPermissionFormOpen(false);
2500
+ await refreshAIT(true);
2501
+ }
2502
+ catch (error) {
2503
+ console.error('Failed to generate AIT:', error);
2504
+ setIsDisabled(false);
2505
+ if (error instanceof Error) {
2506
+ showError(error.message);
2507
+ }
2508
+ else {
2509
+ showError('Failed to save permissions. Please try again.');
2510
+ }
2511
+ }
2512
+ };
2513
+ // Handle verify wallet click
2514
+ const handleVerifyWalletClick = async (method) => {
2515
+ if (!hitAddress) {
2516
+ showError('Please connect your wallet first.');
2517
+ return;
2518
+ }
2519
+ try {
2520
+ setIsLoading(true);
2521
+ if (method === 'berifyme') {
2522
+ if (!onVerifyWallet) {
2523
+ showError('Berify.me verification is not available. Please provide onVerifyWallet callback.');
2524
+ setIsLoading(false);
2525
+ return;
2526
+ }
2527
+ const result = await onVerifyWallet();
2528
+ if (!result) {
2529
+ setIsLoading(false);
2530
+ return;
2531
+ }
2532
+ const { token } = result;
2533
+ const address = hitAddress;
2534
+ if (token && address) {
2535
+ const payload = {
2536
+ address: hitAddress,
2537
+ token,
2538
+ timestamp: Date.now(),
2539
+ method: 'berifyme'
2540
+ };
2541
+ try {
2542
+ const verifyWalletResponse = await nxtlinqApi.wallet.verifyWallet({ ...payload }, token);
2543
+ if ('error' in verifyWalletResponse) {
2544
+ if (verifyWalletResponse.error === 'Wallet already exists') {
2545
+ // Wallet already exists with the same method, just refresh AIT
2546
+ setIsWalletLoading(true);
2547
+ try {
2548
+ const walletResponse = await nxtlinqApi.wallet.getWallet({ address }, token);
2549
+ if (!('error' in walletResponse)) {
2550
+ setWalletInfo(walletResponse);
2551
+ const aitResponse = await nxtlinqApi.ait.getAITByServiceIdAndController({
2552
+ serviceId,
2553
+ controller: address,
2554
+ customUsername: (!requireWalletIDVVerification && customUsername) ? getFinalCustomUsername(customUsername) : undefined
2555
+ }, token);
2556
+ if (!('error' in aitResponse)) {
2557
+ setAit(aitResponse);
2558
+ }
2559
+ }
2560
+ }
2561
+ finally {
2562
+ setIsWalletLoading(false);
2563
+ }
2564
+ if (typeof window !== 'undefined') {
2565
+ try {
2566
+ const url = new URL(window.location.href);
2567
+ url.searchParams.delete('token');
2568
+ url.searchParams.delete('isAutoConnect');
2569
+ url.searchParams.delete('method');
2570
+ url.searchParams.delete('returnUrl');
2571
+ window.history.replaceState({}, '', url.toString());
2572
+ }
2573
+ catch (e) {
2574
+ console.error('Failed to clean URL:', e);
2575
+ }
2576
+ }
2577
+ setIsLoading(false);
2578
+ showSuccess(walletTextUtils.getWalletText('Wallet verification completed successfully! Your wallet is now verified and ready to use.', serviceId));
2579
+ refreshAIT();
2580
+ return;
2581
+ }
2582
+ showError(verifyWalletResponse.error);
2583
+ setIsLoading(false);
2584
+ return;
2585
+ }
2586
+ setIsWalletLoading(true);
2587
+ try {
2588
+ const walletResponse = await nxtlinqApi.wallet.getWallet({ address }, token);
2589
+ if (!('error' in walletResponse)) {
2590
+ setWalletInfo(walletResponse);
2591
+ const aitResponse = await nxtlinqApi.ait.getAITByServiceIdAndController({
2592
+ serviceId,
2593
+ controller: address,
2594
+ customUsername: (!requireWalletIDVVerification && customUsername) ? getFinalCustomUsername(customUsername) : undefined
2595
+ }, token);
2596
+ if (!('error' in aitResponse)) {
2597
+ setAit(aitResponse);
2598
+ }
2599
+ }
2600
+ }
2601
+ finally {
2602
+ setIsWalletLoading(false);
2603
+ }
2604
+ if (typeof window !== 'undefined') {
2605
+ try {
2606
+ const url = new URL(window.location.href);
2607
+ url.searchParams.delete('token');
2608
+ url.searchParams.delete('isAutoConnect');
2609
+ url.searchParams.delete('method');
2610
+ url.searchParams.delete('returnUrl');
2611
+ window.history.replaceState({}, '', url.toString());
2612
+ }
2613
+ catch (e) {
2614
+ console.error('Failed to clean URL:', e);
2615
+ }
2616
+ }
2617
+ setIsLoading(false);
2618
+ showSuccess('Wallet verification completed successfully! Your wallet is now verified and ready to use.');
2619
+ refreshAIT();
2620
+ return;
2621
+ }
2622
+ catch (error) {
2623
+ let msg = 'Verification failed';
2624
+ if (typeof error === 'object' && error !== null && 'response' in error) {
2625
+ msg = error.response?.data?.error || error.message || msg;
2626
+ }
2627
+ else if (error instanceof Error) {
2628
+ msg = error.message;
2629
+ }
2630
+ console.error('Wallet verification failed:', error);
2631
+ showError(msg);
2632
+ setIsLoading(false);
2633
+ throw error;
2634
+ }
2635
+ }
2636
+ setIsLoading(false);
2637
+ return;
2638
+ }
2639
+ if (method === 'custom') {
2640
+ if (!customUsername) {
2641
+ showError('Custom username is required for custom verification method.');
2642
+ setIsLoading(false);
2643
+ return;
2644
+ }
2645
+ const payload = {
2646
+ address: hitAddress,
2647
+ method: 'custom',
2648
+ timestamp: Date.now(),
2649
+ customUsername: getFinalCustomUsername(customUsername)
2650
+ };
2651
+ try {
2652
+ const verifyWalletResponse = await nxtlinqApi.wallet.verifyWallet(payload, '');
2653
+ if ('error' in verifyWalletResponse) {
2654
+ if (verifyWalletResponse.error === 'Wallet already exists') {
2655
+ // Wallet already exists with the same method, just refresh AIT
2656
+ await refreshAIT();
2657
+ setIsLoading(false);
2658
+ showSuccess(walletTextUtils.getWalletText('Wallet verification completed successfully! Your wallet is now verified and ready to use.', serviceId));
2659
+ return;
2660
+ }
2661
+ // Handle specific error messages
2662
+ if (verifyWalletResponse.error === 'Cannot downgrade from Berify.me verification to custom verification') {
2663
+ showError(walletTextUtils.getWalletText('This wallet is already verified with Berify.me. Custom verification cannot override Berify.me verification.', serviceId));
2664
+ }
2665
+ else {
2666
+ showError(verifyWalletResponse.error);
2667
+ }
2668
+ setIsLoading(false);
2669
+ return;
2670
+ }
2671
+ // Successfully created wallet with custom method
2672
+ await refreshAIT();
2673
+ setIsLoading(false);
2674
+ showSuccess(walletTextUtils.getWalletText('Wallet verification completed successfully! Your wallet is now verified and ready to use.', serviceId));
2675
+ return;
2676
+ }
2677
+ catch (error) {
2678
+ console.error('Custom wallet verification failed:', error);
2679
+ showError(walletTextUtils.getWalletText('Failed to verify wallet with custom method. Please try again.', serviceId));
2680
+ setIsLoading(false);
2681
+ return;
2682
+ }
2683
+ }
2684
+ // If we reach here, the method is not supported
2685
+ showError(`Unsupported verification method: ${method}`);
2686
+ setIsLoading(false);
2687
+ }
2688
+ catch (error) {
2689
+ console.error('Failed to verify wallet:', error);
2690
+ setIsLoading(false);
2691
+ showError(walletTextUtils.getWalletText('Failed to verify wallet. Please try again.', serviceId));
2692
+ throw error;
2693
+ }
2694
+ };
2695
+ // Initialize wallet address from localStorage on component mount
2696
+ React.useEffect(() => {
2697
+ const storedWalletAddress = localStorage.getItem('walletAddress');
2698
+ if (storedWalletAddress && !hitAddress) {
2699
+ setHitAddress(storedWalletAddress);
2700
+ hitAddressRef.current = storedWalletAddress;
2701
+ }
2702
+ }, []);
2703
+ React.useEffect(() => {
2704
+ if (hitAddress && nxtlinqAITServiceAccessToken) {
2705
+ refreshAIT();
2706
+ }
2707
+ }, [hitAddress, nxtlinqAITServiceAccessToken]);
2708
+ // Set loading state when permission form opens
2709
+ React.useEffect(() => {
2710
+ if (isPermissionFormOpen && hitAddress) {
2711
+ setIsAITLoading(true);
2712
+ }
2713
+ }, [isPermissionFormOpen, hitAddress]);
2714
+ React.useEffect(() => {
2715
+ fetchAvailablePermissions();
2716
+ }, [serviceId, permissionGroup]);
2717
+ React.useEffect(() => {
2718
+ if (typeof window !== 'undefined') {
2719
+ try {
2720
+ const urlParams = new URLSearchParams(window.location.search);
2721
+ const token = urlParams.get('token');
2722
+ if (token && hitAddress) {
2723
+ handleVerifyWalletClick('berifyme');
2724
+ }
2725
+ }
2726
+ catch (e) {
2727
+ console.error('Failed to get URL params:', e);
2728
+ }
2729
+ }
2730
+ }, [hitAddress]);
2731
+ // Handle custom error message display
2732
+ React.useEffect(() => {
2733
+ const trimmedError = customError?.trim();
2734
+ // Only display error if it's a new value (different from last displayed error)
2735
+ if (trimmedError && trimmedError !== lastCustomErrorRef.current) {
2736
+ lastCustomErrorRef.current = trimmedError;
2737
+ const errorMessage = {
2738
+ id: `custom-error-${Date.now()}`,
2739
+ content: trimmedError,
2740
+ role: 'assistant',
2741
+ timestamp: new Date().toISOString(),
2742
+ metadata: {
2743
+ model: getCurrentModel().value,
2744
+ permissions: permissions,
2745
+ issuedBy: hitAddress || ''
2746
+ }
2747
+ };
2748
+ setMessages(prev => [...prev, errorMessage]);
2749
+ onMessage?.(errorMessage);
2750
+ }
2751
+ else if (!trimmedError) {
2752
+ // Reset ref when customError is cleared
2753
+ lastCustomErrorRef.current = undefined;
2754
+ }
2755
+ }, [customError, getCurrentModel, permissions, hitAddress, onMessage]);
2756
+ React.useEffect(() => {
2757
+ if (messages.length > 0) {
2758
+ const lastMessage = messages[messages.length - 1];
2759
+ onMessage?.(lastMessage);
2760
+ }
2761
+ }, [messages, onMessage]);
2762
+ React.useEffect(() => {
2763
+ if (!hitAddress) {
2764
+ return;
2765
+ }
2766
+ const updateExternalId = async () => {
2767
+ const externalId = hitAddress;
2768
+ await nxtlinqApi.agent.updateMessagesExternalIdByPseudoId({
2769
+ apiKey,
2770
+ apiSecret,
2771
+ pseudoId,
2772
+ externalId: externalId,
2773
+ });
2774
+ await nxtlinqApi.agent.cloneUserProfileByPseudoId({
2775
+ apiKey,
2776
+ apiSecret,
2777
+ pseudoId,
2778
+ externalId: externalId,
2779
+ });
2780
+ await nxtlinqApi.agent.cloneUserTopicByPseudoId({
2781
+ apiKey,
2782
+ apiSecret,
2783
+ pseudoId,
2784
+ externalId: externalId,
2785
+ });
2786
+ };
2787
+ updateExternalId();
2788
+ }, [hitAddress, nxtlinqApi, apiKey, apiSecret, pseudoId]);
2789
+ const contextValue = {
2790
+ // State
2791
+ messages,
2792
+ inputValue,
2793
+ isLoading,
2794
+ isOpen,
2795
+ hitAddress,
2796
+ ait,
2797
+ permissions,
2798
+ availablePermissions,
2799
+ showPermissionForm,
2800
+ isPermissionFormOpen,
2801
+ isAITLoading,
2802
+ isDisabled,
2803
+ walletInfo,
2804
+ isWalletLoading,
2805
+ isAutoConnecting,
2806
+ notification,
2807
+ isMicEnabled,
2808
+ isAwaitingMicGesture,
2809
+ transcript,
2810
+ textInputRef,
2811
+ autoSendEnabled,
2812
+ // Speech related state
2813
+ textToSpeechEnabled,
2814
+ speechingIndex,
2815
+ isTtsProcessing,
2816
+ requiresGesture,
2817
+ // AI Model related state
2818
+ availableModels: effectiveAvailableModels,
2819
+ selectedModelIndex,
2820
+ showModelSelector,
2821
+ suggestions,
2822
+ isAITEnabling,
2823
+ piiDisplayMode,
2824
+ isVoiceMode,
2825
+ voiceStatus,
2826
+ isVoiceConnecting,
2827
+ isMicMuted,
2828
+ remoteAudioRef,
2829
+ // Actions
2830
+ setInputValue,
2831
+ setIsOpen,
2832
+ setShowPermissionForm,
2833
+ setIsPermissionFormOpen,
2834
+ setPermissions,
2835
+ setIsDisabled,
2836
+ setIsWalletLoading,
2837
+ setNotification,
2838
+ // AI Model related actions
2839
+ setSelectedModelIndex,
2840
+ setSuggestions,
2841
+ setAutoSendEnabled,
2842
+ setTextToSpeechEnabled,
2843
+ // Functions
2844
+ connectWallet,
2845
+ signInWallet,
2846
+ sendMessage,
2847
+ handleSubmit,
2848
+ handlePresetMessage,
2849
+ savePermissions,
2850
+ enableAIT,
2851
+ handleVerifyWalletClick,
2852
+ showSuccess,
2853
+ showError,
2854
+ showWarning,
2855
+ showInfo,
2856
+ refreshAIT,
2857
+ startRecording,
2858
+ stopRecording,
2859
+ clearRecording,
2860
+ // AI Model related functions
2861
+ handleModelChange,
2862
+ getCurrentModel,
2863
+ // Speech related functions
2864
+ playTextToSpeech,
2865
+ stopTextToSpeech,
2866
+ retryTtsWithGesture,
2867
+ uploadAttachment,
2868
+ enterVoiceMode: handleEnterVoiceMode,
2869
+ exitVoiceMode: handleExitVoiceMode,
2870
+ toggleVoiceMicMute,
2871
+ interruptVoice,
2872
+ // Additional properties for PermissionForm
2873
+ onSave: savePermissions,
2874
+ onConnectWallet: () => connectWallet(false),
2875
+ onSignIn: () => signInWallet(false),
2876
+ isNeedSignInWithWallet,
2877
+ onVerifyWallet: (method) => handleVerifyWalletClick(method),
2878
+ serviceId,
2879
+ permissionGroup,
2880
+ // Props
2881
+ props: {
2882
+ onMessage,
2883
+ onError,
2884
+ onToolUse,
2885
+ presetMessages,
2886
+ placeholder,
2887
+ className,
2888
+ maxRetries,
2889
+ retryDelay,
2890
+ serviceId,
2891
+ apiKey,
2892
+ apiSecret,
2893
+ onVerifyWallet,
2894
+ permissionGroup,
2895
+ onModelChange,
2896
+ storageMode,
2897
+ requireWalletIDVVerification,
2898
+ isSemiAutomaticMode,
2899
+ customUserInfo,
2900
+ customUsername,
2901
+ idvBannerDismissSeconds,
2902
+ isStopRecordingOnSend,
2903
+ customError,
2904
+ },
2905
+ nxtlinqApi
2906
+ };
2907
+ return (_jsx(ChatBotContext.Provider, { value: contextValue, children: children }));
2908
+ };