@vanira/sdk-react-native 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +239 -0
- package/package.json +53 -0
- package/src/__tests__/WebRTCClient.integration.test.ts +396 -0
- package/src/__tests__/adapters.test.ts +475 -0
- package/src/__tests__/httpResponse.test.ts +25 -0
- package/src/__tests__/mocks/react-native-incall-manager.ts +8 -0
- package/src/__tests__/mocks/react-native-permissions.ts +15 -0
- package/src/__tests__/mocks/react-native-webrtc.ts +6 -0
- package/src/__tests__/mocks/react-native.ts +28 -0
- package/src/__tests__/preset.test.ts +239 -0
- package/src/__tests__/resolveRuntimeConfig.test.ts +90 -0
- package/src/__tests__/storage.test.ts +211 -0
- package/src/__tests__/webrtcSignaling.test.ts +42 -0
- package/src/adapters/PeerConnectionAdapter.ts +101 -0
- package/src/adapters/browser/BrowserAudioAdapter.ts +43 -0
- package/src/adapters/browser/BrowserDataChannelAdapter.ts +69 -0
- package/src/adapters/browser/BrowserMediaAdapter.ts +15 -0
- package/src/adapters/browser/BrowserPeerAdapter.ts +14 -0
- package/src/adapters/browser/index.ts +4 -0
- package/src/adapters/interfaces.ts +84 -0
- package/src/adapters/react-native/RNAudioAdapter.ts +42 -0
- package/src/adapters/react-native/RNDataChannelAdapter.ts +79 -0
- package/src/adapters/react-native/RNMediaAdapter.ts +46 -0
- package/src/adapters/react-native/RNPeerAdapter.ts +28 -0
- package/src/adapters/react-native/callAudioRouting.ts +115 -0
- package/src/adapters/react-native/decodeUtf8.ts +72 -0
- package/src/adapters/react-native/index.ts +4 -0
- package/src/adapters/react-native/rnUploadFile.ts +76 -0
- package/src/adapters/storage/BrowserDualStorageAdapter.ts +71 -0
- package/src/adapters/storage/MemoryStorageAdapter.ts +50 -0
- package/src/adapters/storage/StorageAdapter.ts +21 -0
- package/src/adapters/storage/createSyncStorageAdapter.ts +40 -0
- package/src/adapters/storage/index.ts +7 -0
- package/src/api/services/ChatService.ts +304 -0
- package/src/api/services/ConfigService.ts +33 -0
- package/src/assets/icons.js +35 -0
- package/src/cdn.ts +68 -0
- package/src/core/CallSessionStore.ts +137 -0
- package/src/core/DraggableController.ts +83 -0
- package/src/core/SessionManager.ts +322 -0
- package/src/core/VaniraAI.ts +464 -0
- package/src/core/WebRTCClient.ts +1012 -0
- package/src/core/httpResponse.ts +22 -0
- package/src/core/iceServers.ts +18 -0
- package/src/core/toolCallNormalize.ts +80 -0
- package/src/core/voice-client.js +236 -0
- package/src/core/webrtcSignaling.ts +72 -0
- package/src/index.js +34 -0
- package/src/index.ts +6 -0
- package/src/platforms/browser.ts +67 -0
- package/src/platforms/react-native.ts +105 -0
- package/src/presets/BookingCalendarModal.tsx +457 -0
- package/src/presets/CameraModal.tsx +576 -0
- package/src/presets/DynamicFormModal.tsx +378 -0
- package/src/presets/NativePresetRenderer.tsx +350 -0
- package/src/presets/NavigateHandler.tsx +75 -0
- package/src/presets/PresetHost.tsx +155 -0
- package/src/presets/PresetShellModal.tsx +97 -0
- package/src/presets/UploadModal.tsx +321 -0
- package/src/presets/calendar/calendarUtils.ts +386 -0
- package/src/presets/call/CallSpeakerToggle.tsx +59 -0
- package/src/presets/call/callAudioRouting.ts +2 -0
- package/src/presets/call/useCallSpeaker.ts +31 -0
- package/src/presets/camera/cameraPermissions.ts +18 -0
- package/src/presets/camera/cameraStream.ts +19 -0
- package/src/presets/camera/cameraUtils.ts +21 -0
- package/src/presets/camera/useLivenessFlow.ts +95 -0
- package/src/presets/chalkboard/ChalkboardOverlay.tsx +156 -0
- package/src/presets/chalkboard/EraseTextHandler.tsx +95 -0
- package/src/presets/chalkboard/TypeTextHandler.tsx +107 -0
- package/src/presets/chalkboard/boardAbort.ts +36 -0
- package/src/presets/chalkboard/boardQueue.ts +620 -0
- package/src/presets/chalkboard/chalkboardSession.ts +75 -0
- package/src/presets/chalkboard/drawUtils.ts +123 -0
- package/src/presets/chalkboard/textUtils.ts +109 -0
- package/src/presets/clipRegion/ClipRegionModal.tsx +261 -0
- package/src/presets/clipRegion/clipRegionBridge.ts +19 -0
- package/src/presets/form/formValidation.ts +104 -0
- package/src/presets/form/parseFormFields.ts +171 -0
- package/src/presets/host/HostElementPresetHandler.tsx +155 -0
- package/src/presets/host/hostPresetBridge.ts +71 -0
- package/src/presets/index.ts +63 -0
- package/src/presets/liveScreen/CloseLiveScreenHandler.tsx +36 -0
- package/src/presets/liveScreen/LiveScreenCaptureHost.tsx +312 -0
- package/src/presets/liveScreen/LiveScreenHandler.tsx +25 -0
- package/src/presets/liveScreen/LiveScreenPipOverlay.tsx +6 -0
- package/src/presets/liveScreen/liveScreenSession.ts +73 -0
- package/src/presets/liveVision/CloseLiveVisionHandler.tsx +29 -0
- package/src/presets/liveVision/LiveVisionCameraHost.tsx +317 -0
- package/src/presets/liveVision/LiveVisionHandler.tsx +26 -0
- package/src/presets/liveVision/LiveVisionPipOverlay.tsx +7 -0
- package/src/presets/liveVision/liveVisionFrameLoop.ts +38 -0
- package/src/presets/liveVision/liveVisionSession.ts +75 -0
- package/src/presets/liveVision/liveVisionUpload.ts +62 -0
- package/src/presets/navigation/internalRouteRegistry.ts +25 -0
- package/src/presets/navigation/navigationBridge.ts +76 -0
- package/src/presets/navigation/navigationTypes.ts +12 -0
- package/src/presets/parseToolCall.ts +60 -0
- package/src/presets/presetClientAdapter.ts +29 -0
- package/src/presets/presetCompletion.ts +91 -0
- package/src/presets/presetEventHelpers.ts +45 -0
- package/src/presets/registry.ts +128 -0
- package/src/presets/streaming/mediaFrameUpload.ts +93 -0
- package/src/presets/types.ts +74 -0
- package/src/presets/upload/pickUploadFile.ts +256 -0
- package/src/presets/upload/uploadFormats.ts +163 -0
- package/src/presets/upload/uploadUtils.ts +68 -0
- package/src/react/PresetRenderer.tsx +144 -0
- package/src/react/index.ts +1 -0
- package/src/runtime/browserRuntime.ts +54 -0
- package/src/runtime/platform.ts +17 -0
- package/src/runtime/reactNativeRuntime.ts +68 -0
- package/src/runtime/resolveRuntimeConfig.ts +75 -0
- package/src/runtime/runtimeBundles.ts +74 -0
- package/src/runtime/types.ts +135 -0
- package/src/types/react-native-incall-manager.d.ts +17 -0
- package/src/types/react-native-webrtc.d.ts +47 -0
- package/src/types.ts +133 -0
- package/src/ui/VaniraWidget.ts +87 -0
- package/src/ui/abstraction/AbstractWidgetProvider.ts +18 -0
- package/src/ui/abstraction/interfaces.ts +12 -0
- package/src/ui/adapters/VaniraChatAdapter.ts +42 -0
- package/src/ui/components/AvatarView.ts +81 -0
- package/src/ui/components/ChatWindow.ts +263 -0
- package/src/ui/components/FloatingButton.ts +163 -0
- package/src/ui/components/FloatingWelcomeChips.ts +137 -0
- package/src/ui/components/Panel.ts +120 -0
- package/src/ui/components/VoiceOrb.ts +79 -0
- package/src/ui/components/VoiceOverlay.ts +497 -0
- package/src/ui/components/index.ts +7 -0
- package/src/ui/factory/WidgetFactory.ts +16 -0
- package/src/ui/icons_data.ts +2 -0
- package/src/ui/presets/WidgetPresetRenderer.ts +1802 -0
- package/src/ui/presets/types.ts +16 -0
- package/src/ui/providers/VaniraInternalProvider.ts +1066 -0
- package/src/ui/styles/index.ts +323 -0
- package/src/ui/styles/keyframes.ts +76 -0
- package/src/ui/styles/theme.ts +57 -0
- package/src/ui/styles/widget.css.ts +838 -0
- package/src/ui/utils.ts +37 -0
- package/src/ui/views/AbstractChatView.ts +93 -0
- package/src/ui/views/AbstractVoiceView.ts +57 -0
- package/src/ui/views/AvatarOnlyView.ts +78 -0
- package/src/ui/views/ChatAvatarView.ts +66 -0
- package/src/ui/views/ChatOnlyView.ts +28 -0
- package/src/ui/views/ChatVoiceView.ts +15 -0
- package/src/ui/views/VoiceOnlyView.ts +25 -0
- package/src/ui/views/index.ts +5 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface IChatAdapter {
|
|
2
|
+
sendMessage(text: string, agentId: string, prospectId: string, chatId: string | null, onResponse: (response: any) => void, onStream?: (text: string) => void, onChatIdUpdate?: (newId: string) => void, widgetId?: string, pkKey?: string): Promise<void>;
|
|
3
|
+
sendFile?(file: File): Promise<void>;
|
|
4
|
+
likeDislike?(messageId: string, action: 'like' | 'dislike'): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface IWidgetProvider {
|
|
8
|
+
create_call(): Promise<void>;
|
|
9
|
+
end_call(): void;
|
|
10
|
+
ui_renderer(root: ShadowRoot): void;
|
|
11
|
+
initialize(config: any): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { IChatAdapter } from '../abstraction/interfaces';
|
|
2
|
+
import { ChatService } from '../../api/services/ChatService';
|
|
3
|
+
|
|
4
|
+
export class VaniraChatAdapter implements IChatAdapter {
|
|
5
|
+
async sendMessage(
|
|
6
|
+
text: string,
|
|
7
|
+
agentId: string,
|
|
8
|
+
prospectId: string,
|
|
9
|
+
chatId: string | null,
|
|
10
|
+
onResponse: (response: any) => void,
|
|
11
|
+
onStream?: (text: string) => void,
|
|
12
|
+
onChatIdUpdate?: (newId: string, newConversationId?: string) => void,
|
|
13
|
+
widgetId?: string,
|
|
14
|
+
pkKey?: string
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
return ChatService.sendChatMessage(
|
|
17
|
+
agentId,
|
|
18
|
+
prospectId,
|
|
19
|
+
text,
|
|
20
|
+
chatId,
|
|
21
|
+
(stream) => {
|
|
22
|
+
if (onStream) onStream(stream);
|
|
23
|
+
},
|
|
24
|
+
onResponse,
|
|
25
|
+
(newId, newConversationId) => {
|
|
26
|
+
if (onChatIdUpdate && newId) onChatIdUpdate(newId, newConversationId || undefined);
|
|
27
|
+
},
|
|
28
|
+
widgetId,
|
|
29
|
+
pkKey
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async sendFile(file: File): Promise<void> {
|
|
34
|
+
console.warn("sendFile not implemented for VaniraChatAdapter yet - Incompatible function adapted", file);
|
|
35
|
+
throw new Error("Method not implemented.");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async likeDislike(messageId: string, action: 'like' | 'dislike'): Promise<void> {
|
|
39
|
+
console.warn("likeDislike not implemented for VaniraChatAdapter yet - Incompatible function adapted", messageId, action);
|
|
40
|
+
// Implement API call here when available
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
|
|
2
|
+
export class AvatarView {
|
|
3
|
+
private element: HTMLDivElement;
|
|
4
|
+
private videoElement: HTMLVideoElement;
|
|
5
|
+
private placeholderContainer: HTMLDivElement;
|
|
6
|
+
|
|
7
|
+
constructor(avatarUrl?: string | null) {
|
|
8
|
+
this.element = document.createElement('div');
|
|
9
|
+
this.element.className = 'avatar-container';
|
|
10
|
+
this.element.style.position = 'relative';
|
|
11
|
+
this.element.style.width = '100%';
|
|
12
|
+
this.element.style.height = '100%';
|
|
13
|
+
this.element.style.background = '#ffffff'; // White card background
|
|
14
|
+
this.element.style.borderRadius = '16px';
|
|
15
|
+
this.element.style.overflow = 'hidden';
|
|
16
|
+
|
|
17
|
+
// 1. Placeholder Container (Centered Circle)
|
|
18
|
+
this.placeholderContainer = document.createElement('div');
|
|
19
|
+
this.placeholderContainer.style.width = '100%';
|
|
20
|
+
this.placeholderContainer.style.height = '100%';
|
|
21
|
+
this.placeholderContainer.style.display = 'flex';
|
|
22
|
+
this.placeholderContainer.style.alignItems = 'center';
|
|
23
|
+
this.placeholderContainer.style.justifyContent = 'center';
|
|
24
|
+
|
|
25
|
+
// Determine Avatar Source
|
|
26
|
+
// Use provided URL or fallback to default video
|
|
27
|
+
const sourceUrl = avatarUrl || "https://www.simli.com/jenna.mp4";
|
|
28
|
+
const isVideo = sourceUrl.endsWith('.mp4') || sourceUrl.endsWith('.webm');
|
|
29
|
+
|
|
30
|
+
let mediaElement: HTMLElement;
|
|
31
|
+
|
|
32
|
+
if (isVideo) {
|
|
33
|
+
const vid = document.createElement('video');
|
|
34
|
+
vid.src = sourceUrl;
|
|
35
|
+
vid.muted = true;
|
|
36
|
+
vid.loop = true;
|
|
37
|
+
vid.autoplay = true;
|
|
38
|
+
vid.playsInline = true;
|
|
39
|
+
mediaElement = vid;
|
|
40
|
+
} else {
|
|
41
|
+
const img = document.createElement('img');
|
|
42
|
+
img.src = sourceUrl;
|
|
43
|
+
mediaElement = img;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Apply Circle Styles
|
|
47
|
+
mediaElement.style.width = '140px';
|
|
48
|
+
mediaElement.style.height = '140px';
|
|
49
|
+
mediaElement.style.borderRadius = '50%';
|
|
50
|
+
mediaElement.style.objectFit = 'cover';
|
|
51
|
+
mediaElement.style.backgroundColor = '#f3f4f6';
|
|
52
|
+
|
|
53
|
+
this.placeholderContainer.appendChild(mediaElement);
|
|
54
|
+
this.element.appendChild(this.placeholderContainer);
|
|
55
|
+
|
|
56
|
+
// 2. Video Element
|
|
57
|
+
this.videoElement = document.createElement('video');
|
|
58
|
+
this.videoElement.className = 'avatar-video';
|
|
59
|
+
this.videoElement.autoplay = true;
|
|
60
|
+
this.videoElement.playsInline = true;
|
|
61
|
+
this.videoElement.muted = true; // Often required for autoplay
|
|
62
|
+
this.videoElement.style.display = 'none';
|
|
63
|
+
|
|
64
|
+
this.element.appendChild(this.videoElement);
|
|
65
|
+
|
|
66
|
+
// Initial State - NO DEFAULT ICON per user request
|
|
67
|
+
// this.setPlaceholder(this.currentGender as any);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public getElement(): HTMLDivElement {
|
|
71
|
+
return this.element;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public setStream(stream: MediaStream) {
|
|
75
|
+
this.videoElement.srcObject = stream;
|
|
76
|
+
this.videoElement.style.display = 'block';
|
|
77
|
+
this.placeholderContainer.style.display = 'none';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
|
|
2
|
+
import { icons } from '../styles';
|
|
3
|
+
import { cleanLatexText } from '../utils';
|
|
4
|
+
|
|
5
|
+
export interface ChatMessage {
|
|
6
|
+
role: 'user' | 'assistant';
|
|
7
|
+
content: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ChatWindow {
|
|
11
|
+
private element: HTMLDivElement;
|
|
12
|
+
private messageContainer: HTMLDivElement;
|
|
13
|
+
private inputArea: HTMLDivElement;
|
|
14
|
+
private input: HTMLInputElement;
|
|
15
|
+
private sendBtn: HTMLButtonElement;
|
|
16
|
+
private typingIndicator: HTMLDivElement | null = null;
|
|
17
|
+
private resolveBar: HTMLDivElement | null = null;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private onSend: (message: string) => void
|
|
21
|
+
) {
|
|
22
|
+
this.element = document.createElement('div');
|
|
23
|
+
this.element.style.display = 'flex';
|
|
24
|
+
this.element.style.flexDirection = 'column';
|
|
25
|
+
this.element.style.flex = '1';
|
|
26
|
+
this.element.style.overflow = 'hidden';
|
|
27
|
+
this.element.style.minHeight = '0'; // Crucial for nested flex scrolling
|
|
28
|
+
|
|
29
|
+
// Messages Area
|
|
30
|
+
this.messageContainer = document.createElement('div');
|
|
31
|
+
this.messageContainer.className = 'chat-messages';
|
|
32
|
+
|
|
33
|
+
// Input Area
|
|
34
|
+
this.inputArea = document.createElement('div');
|
|
35
|
+
this.inputArea.className = 'chat-input-area';
|
|
36
|
+
|
|
37
|
+
this.input = document.createElement('input');
|
|
38
|
+
this.input.className = 'chat-input';
|
|
39
|
+
this.input.placeholder = 'Type a message...';
|
|
40
|
+
this.input.addEventListener('keypress', (e) => {
|
|
41
|
+
if (e.key === 'Enter') this.handleSend();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this.sendBtn = document.createElement('button');
|
|
45
|
+
this.sendBtn.className = 'chat-send-btn';
|
|
46
|
+
this.sendBtn.innerHTML = icons.send;
|
|
47
|
+
this.sendBtn.onclick = () => this.handleSend();
|
|
48
|
+
|
|
49
|
+
this.inputArea.appendChild(this.input);
|
|
50
|
+
this.inputArea.appendChild(this.sendBtn);
|
|
51
|
+
|
|
52
|
+
// Branding Area
|
|
53
|
+
const brandingArea = document.createElement('div');
|
|
54
|
+
brandingArea.className = 'branding-footer';
|
|
55
|
+
brandingArea.innerHTML = `Technology Powered by <b>Vanira AI</b>`;
|
|
56
|
+
|
|
57
|
+
this.element.appendChild(this.messageContainer);
|
|
58
|
+
this.element.appendChild(this.inputArea);
|
|
59
|
+
this.element.appendChild(brandingArea);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public getElement(): HTMLDivElement {
|
|
63
|
+
return this.element;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private formatTime(date: Date = new Date()): string {
|
|
67
|
+
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public addMessage(role: 'user' | 'assistant', content: string, timestamp?: number) {
|
|
71
|
+
const wrapper = document.createElement('div');
|
|
72
|
+
wrapper.style.cssText = `
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
align-items: ${role === 'user' ? 'flex-end' : 'flex-start'};
|
|
76
|
+
margin-bottom: 2px;
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
const msgDiv = document.createElement('div');
|
|
80
|
+
msgDiv.className = `chat-message ${role}`;
|
|
81
|
+
msgDiv.textContent = cleanLatexText(content);
|
|
82
|
+
wrapper.appendChild(msgDiv);
|
|
83
|
+
|
|
84
|
+
const ts = document.createElement('span');
|
|
85
|
+
ts.style.cssText = `
|
|
86
|
+
font-size: 10px;
|
|
87
|
+
color: #9ca3af;
|
|
88
|
+
margin: 2px 4px 6px;
|
|
89
|
+
display: block;
|
|
90
|
+
`;
|
|
91
|
+
ts.textContent = this.formatTime(timestamp ? new Date(timestamp) : undefined);
|
|
92
|
+
wrapper.appendChild(ts);
|
|
93
|
+
|
|
94
|
+
this.messageContainer.appendChild(wrapper);
|
|
95
|
+
this.scrollToBottom();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public updateLastAssistantMessage(content: string) {
|
|
99
|
+
// The last child is the wrapper div; the message is its first child
|
|
100
|
+
const lastWrapper = this.messageContainer.lastElementChild as HTMLElement;
|
|
101
|
+
if (lastWrapper) {
|
|
102
|
+
const lastMsg = lastWrapper.querySelector('.chat-message.assistant') as HTMLElement;
|
|
103
|
+
if (lastMsg) {
|
|
104
|
+
lastMsg.textContent = cleanLatexText(content);
|
|
105
|
+
this.scrollToBottom();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
this.addMessage('assistant', content);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public addButtons(buttons: Array<{ title: string; payload: string }>) {
|
|
113
|
+
const buttonsContainer = document.createElement('div');
|
|
114
|
+
buttonsContainer.className = 'chat-buttons-container';
|
|
115
|
+
buttonsContainer.style.display = 'flex';
|
|
116
|
+
buttonsContainer.style.flexWrap = 'wrap';
|
|
117
|
+
buttonsContainer.style.gap = '8px';
|
|
118
|
+
buttonsContainer.style.marginTop = '8px';
|
|
119
|
+
buttonsContainer.style.marginBottom = '12px';
|
|
120
|
+
buttonsContainer.style.justifyContent = 'flex-start';
|
|
121
|
+
|
|
122
|
+
buttons.forEach(btn => {
|
|
123
|
+
const buttonEl = document.createElement('button');
|
|
124
|
+
buttonEl.className = 'chat-suggestion-chip';
|
|
125
|
+
buttonEl.textContent = btn.title;
|
|
126
|
+
// Basic styles if CSS class not defined
|
|
127
|
+
buttonEl.style.padding = '6px 12px';
|
|
128
|
+
buttonEl.style.borderRadius = '16px';
|
|
129
|
+
buttonEl.style.border = '1px solid #e5e7eb';
|
|
130
|
+
buttonEl.style.backgroundColor = 'white';
|
|
131
|
+
buttonEl.style.fontSize = '12px';
|
|
132
|
+
buttonEl.style.color = '#4b5563';
|
|
133
|
+
buttonEl.style.cursor = 'pointer';
|
|
134
|
+
buttonEl.style.transition = 'all 0.2s';
|
|
135
|
+
|
|
136
|
+
buttonEl.onmouseenter = () => {
|
|
137
|
+
buttonEl.style.backgroundColor = '#f3f4f6';
|
|
138
|
+
buttonEl.style.borderColor = '#d1d5db';
|
|
139
|
+
};
|
|
140
|
+
buttonEl.onmouseleave = () => {
|
|
141
|
+
buttonEl.style.backgroundColor = 'white';
|
|
142
|
+
buttonEl.style.borderColor = '#e5e7eb';
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
buttonEl.onclick = () => {
|
|
146
|
+
this.handleSend(btn.payload);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
buttonsContainer.appendChild(buttonEl);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Append to the last message if probable? Or just append.
|
|
153
|
+
// Usually buttons belong to the last assistant message.
|
|
154
|
+
// For simplicity, append to container.
|
|
155
|
+
this.messageContainer.appendChild(buttonsContainer);
|
|
156
|
+
this.scrollToBottom();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private handleSend(overrideText?: string) {
|
|
160
|
+
const text = overrideText || this.input.value.trim();
|
|
161
|
+
if (!text) return;
|
|
162
|
+
|
|
163
|
+
this.onSend(text);
|
|
164
|
+
if (!overrideText) {
|
|
165
|
+
this.input.value = '';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
public setTyping(isTyping: boolean) {
|
|
170
|
+
if (this.typingIndicator) {
|
|
171
|
+
this.typingIndicator.remove();
|
|
172
|
+
this.typingIndicator = null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (isTyping) {
|
|
176
|
+
this.typingIndicator = document.createElement('div');
|
|
177
|
+
this.typingIndicator.className = 'typing-indicator';
|
|
178
|
+
|
|
179
|
+
// 3 dots
|
|
180
|
+
for (let i = 0; i < 3; i++) {
|
|
181
|
+
const dot = document.createElement('div');
|
|
182
|
+
dot.className = 'typing-dot';
|
|
183
|
+
this.typingIndicator.appendChild(dot);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.messageContainer.appendChild(this.typingIndicator);
|
|
187
|
+
this.scrollToBottom();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Inject a "Voice call ended" card into the message list.
|
|
193
|
+
* @param durationMs call duration in milliseconds
|
|
194
|
+
* @param timestamp unix ms when the call started (for ordering across restore)
|
|
195
|
+
*/
|
|
196
|
+
public addCallRecord(durationMs: number, timestamp: number = Date.now()) {
|
|
197
|
+
const mins = Math.floor(durationMs / 60000);
|
|
198
|
+
const secs = Math.floor((durationMs % 60000) / 1000);
|
|
199
|
+
const label = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
|
200
|
+
|
|
201
|
+
const card = document.createElement('div');
|
|
202
|
+
card.dataset.callTimestamp = String(timestamp);
|
|
203
|
+
card.style.cssText = `
|
|
204
|
+
display: flex; justify-content: center;
|
|
205
|
+
margin: 10px 16px;
|
|
206
|
+
`;
|
|
207
|
+
|
|
208
|
+
card.innerHTML = `
|
|
209
|
+
<div style="
|
|
210
|
+
display: flex; align-items: center; gap: 8px;
|
|
211
|
+
background: #f3f4f6; border: 1px solid #e5e7eb;
|
|
212
|
+
border-radius: 12px; padding: 8px 14px;
|
|
213
|
+
font-size: 12px; color: #6b7280;
|
|
214
|
+
max-width: 210px; width: fit-content;
|
|
215
|
+
">
|
|
216
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
217
|
+
<rect x="2" y="9" width="4" height="12" rx="2"/>
|
|
218
|
+
<rect x="9" y="5" width="4" height="16" rx="2"/>
|
|
219
|
+
<rect x="16" y="2" width="4" height="20" rx="2"/>
|
|
220
|
+
</svg>
|
|
221
|
+
<span>
|
|
222
|
+
<span style="font-weight:500; color:#374151;">Voice call ended</span>
|
|
223
|
+
<span style="margin-left:6px; color:#9ca3af;">·</span>
|
|
224
|
+
<span style="margin-left:6px;">${label}</span>
|
|
225
|
+
</span>
|
|
226
|
+
</div>
|
|
227
|
+
`;
|
|
228
|
+
|
|
229
|
+
this.messageContainer.appendChild(card);
|
|
230
|
+
this.scrollToBottom();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
public clearMessages() {
|
|
234
|
+
this.messageContainer.innerHTML = '';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
public setResolveCallback(onResolve: () => void) {
|
|
238
|
+
if (this.resolveBar) {
|
|
239
|
+
this.resolveBar.remove();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.resolveBar = document.createElement('div');
|
|
243
|
+
this.resolveBar.className = 'chat-resolve-bar';
|
|
244
|
+
|
|
245
|
+
const link = document.createElement('button');
|
|
246
|
+
link.className = 'chat-resolve-link';
|
|
247
|
+
link.textContent = 'Mark as resolved, start new';
|
|
248
|
+
link.onclick = () => {
|
|
249
|
+
if (confirm('Are you sure you want to mark this conversation as resolved and start fresh?')) {
|
|
250
|
+
onResolve();
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
this.resolveBar.appendChild(link);
|
|
255
|
+
|
|
256
|
+
// Insert resolveBar right above inputArea
|
|
257
|
+
this.element.insertBefore(this.resolveBar, this.inputArea);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private scrollToBottom() {
|
|
261
|
+
this.messageContainer.scrollTop = this.messageContainer.scrollHeight;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
|
|
2
|
+
import { icons } from '../styles';
|
|
3
|
+
|
|
4
|
+
export class FloatingButton {
|
|
5
|
+
private element: HTMLButtonElement;
|
|
6
|
+
private iconContainer: HTMLElement;
|
|
7
|
+
|
|
8
|
+
private posX = 0;
|
|
9
|
+
private posY = 0;
|
|
10
|
+
private isDragging = false;
|
|
11
|
+
private startX = 0;
|
|
12
|
+
private startY = 0;
|
|
13
|
+
private currentX = 0;
|
|
14
|
+
private currentY = 0;
|
|
15
|
+
private hasMoved = false;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private onClick: () => void,
|
|
19
|
+
private onMove?: (x: number, y: number) => void,
|
|
20
|
+
private onMoveEnd?: (x: number, y: number) => void,
|
|
21
|
+
initialIcon: string = icons.chat
|
|
22
|
+
) {
|
|
23
|
+
this.element = document.createElement('button');
|
|
24
|
+
this.element.className = 'widget-fab';
|
|
25
|
+
|
|
26
|
+
this.element.onpointerdown = this.dragStart.bind(this);
|
|
27
|
+
|
|
28
|
+
this.iconContainer = document.createElement('div');
|
|
29
|
+
this.iconContainer.style.cssText = 'display:flex; align-items:center; justify-content:center; width:100%; height:100%;';
|
|
30
|
+
this.iconContainer.innerHTML = initialIcon;
|
|
31
|
+
|
|
32
|
+
this.element.appendChild(this.iconContainer);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private dragStart(e: PointerEvent) {
|
|
36
|
+
if (e.button !== 0) return; // Only left click
|
|
37
|
+
|
|
38
|
+
const rect = this.element.getBoundingClientRect();
|
|
39
|
+
this.isDragging = true;
|
|
40
|
+
this.hasMoved = false;
|
|
41
|
+
this.startX = e.clientX;
|
|
42
|
+
this.startY = e.clientY;
|
|
43
|
+
this.currentX = rect.left;
|
|
44
|
+
this.currentY = rect.top;
|
|
45
|
+
|
|
46
|
+
this.element.setPointerCapture(e.pointerId);
|
|
47
|
+
this.element.style.transition = 'none';
|
|
48
|
+
this.element.style.cursor = 'grabbing';
|
|
49
|
+
|
|
50
|
+
const onPointerMove = (moveEvent: PointerEvent) => {
|
|
51
|
+
if (!this.isDragging) return;
|
|
52
|
+
|
|
53
|
+
const dx = moveEvent.clientX - this.startX;
|
|
54
|
+
const dy = moveEvent.clientY - this.startY;
|
|
55
|
+
|
|
56
|
+
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
|
57
|
+
this.hasMoved = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (this.hasMoved) {
|
|
61
|
+
this.posX = this.currentX + dx;
|
|
62
|
+
this.posY = this.currentY + dy;
|
|
63
|
+
|
|
64
|
+
// Clamp to viewport
|
|
65
|
+
this.posX = Math.max(0, Math.min(this.posX, window.innerWidth - this.element.offsetWidth));
|
|
66
|
+
this.posY = Math.max(0, Math.min(this.posY, window.innerHeight - this.element.offsetHeight));
|
|
67
|
+
|
|
68
|
+
this.element.style.left = `${this.posX}px`;
|
|
69
|
+
this.element.style.top = `${this.posY}px`;
|
|
70
|
+
this.element.style.right = 'auto';
|
|
71
|
+
this.element.style.bottom = 'auto';
|
|
72
|
+
|
|
73
|
+
this.onMove?.(this.posX, this.posY);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const onPointerUp = (upEvent: PointerEvent) => {
|
|
78
|
+
this.isDragging = false;
|
|
79
|
+
if (this.element.hasPointerCapture(upEvent.pointerId)) {
|
|
80
|
+
this.element.releasePointerCapture(upEvent.pointerId);
|
|
81
|
+
}
|
|
82
|
+
this.element.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
|
83
|
+
this.element.style.cursor = 'pointer';
|
|
84
|
+
|
|
85
|
+
window.removeEventListener('pointermove', onPointerMove);
|
|
86
|
+
window.removeEventListener('pointerup', onPointerUp);
|
|
87
|
+
|
|
88
|
+
if (!this.hasMoved) {
|
|
89
|
+
this.onClick();
|
|
90
|
+
} else {
|
|
91
|
+
this.onMoveEnd?.(this.posX, this.posY);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
96
|
+
window.addEventListener('pointerup', onPointerUp);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public getElement(): HTMLButtonElement {
|
|
100
|
+
return this.element;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public setIcon(iconHtml: string) {
|
|
104
|
+
this.iconContainer.innerHTML = iconHtml;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public setPosition(positionStyle: string) {
|
|
108
|
+
this.element.style.cssText = ''; // Clear previous
|
|
109
|
+
const styles = positionStyle.split(';').filter(s => s.trim());
|
|
110
|
+
styles.forEach(s => {
|
|
111
|
+
const [prop, val] = s.split(':').map(str => str.trim());
|
|
112
|
+
if (prop && val) (this.element.style as any)[prop] = val;
|
|
113
|
+
});
|
|
114
|
+
this.element.style.position = 'fixed';
|
|
115
|
+
this.element.style.zIndex = '2147483647';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public updateCoordinates(x: number, y: number) {
|
|
119
|
+
this.element.style.left = `${x}px`;
|
|
120
|
+
this.element.style.top = `${y}px`;
|
|
121
|
+
this.element.style.right = 'auto';
|
|
122
|
+
this.element.style.bottom = 'auto';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public static get styles() {
|
|
126
|
+
return `
|
|
127
|
+
.widget-fab {
|
|
128
|
+
position: fixed;
|
|
129
|
+
z-index: 9999;
|
|
130
|
+
width: 60px;
|
|
131
|
+
height: 60px;
|
|
132
|
+
border-radius: 50%;
|
|
133
|
+
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
|
|
134
|
+
border: none;
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
justify-content: center;
|
|
139
|
+
box-shadow: var(--shadow);
|
|
140
|
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
141
|
+
animation: fadeInScale 0.4s ease-out;
|
|
142
|
+
touch-action: none;
|
|
143
|
+
padding: 0;
|
|
144
|
+
margin: 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.widget-fab:hover {
|
|
148
|
+
transform: scale(1.05);
|
|
149
|
+
box-shadow: var(--shadow-lg);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.widget-fab:active {
|
|
153
|
+
transform: scale(0.95);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.widget-fab svg {
|
|
157
|
+
width: 26px;
|
|
158
|
+
height: 26px;
|
|
159
|
+
color: white;
|
|
160
|
+
}
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
}
|