Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion src/components/VapiWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
emptyHybridMessage = 'Use voice or text to communicate', // deprecated
// Chat configuration
chatFirstMessage,
chatEndMessage,
firstChatMessage, // deprecated
chatPlaceholder,
// Voice configuration
Expand All @@ -79,6 +80,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
const [isExpanded, setIsExpanded] = useState(false);
const [hasConsent, setHasConsent] = useState(false);
const [chatInput, setChatInput] = useState('');
const [showEndScreen, setShowEndScreen] = useState(false);

const conversationEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -109,6 +111,8 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
const effectiveOnVoiceStart = onVoiceStart ?? onCallStart;
const effectiveOnVoiceEnd = onVoiceEnd ?? onCallEnd;
const effectiveChatPlaceholder = chatPlaceholder ?? 'Type your message...';
const effectiveChatEndMessage =
chatEndMessage ?? 'This chat has ended. Thank you.';

const vapi = useVapiWidget({
mode,
Expand Down Expand Up @@ -253,6 +257,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({

const handleReset = () => {
vapi.clearConversation();
setShowEndScreen(false);

if (vapi.voice.isCallActive) {
vapi.voice.endCall();
Expand All @@ -267,6 +272,34 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
}
};

const handleChatComplete = async () => {
try {
await vapi.chat.sendMessage('Ending chat...', true);
setShowEndScreen(true);
} finally {
setChatInput('');
}
};

const handleStartNewChat = () => {
vapi.clearConversation();
setShowEndScreen(false);
if (mode === 'chat' || mode === 'hybrid') {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
};

const handleCloseWidget = () => {
if (showEndScreen) {
vapi.clearConversation();
setShowEndScreen(false);
setChatInput('');
}
setIsExpanded(false);
};

const handleFloatingButtonClick = () => {
setIsExpanded(true);
};
Expand Down Expand Up @@ -316,6 +349,38 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
};

const renderConversationArea = () => {
if (showEndScreen) {
return (
<div
className="flex flex-col items-center justify-center text-center gap-4"
style={{ width: '100%' }}
>
<div
className={`text-base ${styles.theme === 'dark' ? 'text-gray-200' : 'text-gray-800'}`}
>
{effectiveChatEndMessage}
</div>
<div className="flex items-center gap-3">
<button
onClick={handleStartNewChat}
className="px-3 py-1.5 rounded-md"
style={{
backgroundColor: colors.ctaButtonColor,
color: colors.ctaButtonTextColor,
}}
>
Start new chat
</button>
<button
onClick={handleCloseWidget}
className={`px-3 py-1.5 rounded-md ${styles.theme === 'dark' ? 'bg-gray-800 text-gray-100' : 'bg-gray-100 text-gray-800'}`}
>
Close
</button>
</div>
</div>
);
}
// Chat mode: always show conversation messages
if (mode === 'chat') {
return renderConversationMessages();
Expand Down Expand Up @@ -380,6 +445,9 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
};

const renderControls = () => {
if (showEndScreen) {
return null;
}
if (mode === 'voice') {
return (
<VoiceControls
Expand Down Expand Up @@ -460,8 +528,10 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
isTyping={vapi.chat.isTyping}
hasActiveConversation={vapi.conversation.length > 0}
mainLabel={effectiveTextWidgetTitle}
onClose={() => setIsExpanded(false)}
onClose={handleCloseWidget}
onReset={handleReset}
onChatComplete={handleChatComplete}
showEndChatButton={!showEndScreen}
colors={colors}
styles={styles}
/>
Expand Down
3 changes: 3 additions & 0 deletions src/components/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface VapiWidgetProps {
// Chat Configuration
chatFirstMessage?: string;
chatPlaceholder?: string;
chatEndMessage?: string;

// Voice Configuration
voiceShowTranscript?: boolean;
Expand Down Expand Up @@ -153,6 +154,8 @@ export interface WidgetHeaderProps {
mainLabel: string;
onClose: () => void;
onReset: () => void;
onChatComplete: () => void;
showEndChatButton?: boolean;
colors: ColorScheme;
styles: StyleConfig;
}
Expand Down
11 changes: 11 additions & 0 deletions src/components/widget/WidgetHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const WidgetHeader: React.FC<WidgetHeaderProps> = ({
mainLabel,
onClose,
onReset,
onChatComplete,
showEndChatButton,
colors,
styles,
}) => {
Expand Down Expand Up @@ -68,6 +70,15 @@ const WidgetHeader: React.FC<WidgetHeaderProps> = ({
</div>
</div>
<div className="flex items-center space-x-2">
{showEndChatButton !== false && (
<button
onClick={onChatComplete}
className={`text-red-600 text-sm font-medium px-2 py-1 border border-transparent hover:border-red-600 rounded-md transition-colors`}
title="End Chat"
>
End Chat
</button>
)}
<button
onClick={onReset}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all}`}
Expand Down
104 changes: 70 additions & 34 deletions src/hooks/useVapiChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface VapiChatState {
}

export interface VapiChatHandlers {
sendMessage: (text: string) => Promise<void>;
sendMessage: (text: string, sessionEnd?: boolean) => Promise<void>;
clearMessages: () => void;
}

Expand Down Expand Up @@ -195,6 +195,7 @@ export const useVapiChat = ({
const abortFnRef = useRef<(() => void) | null>(null);
const currentAssistantMessageRef = useRef<string>(''); // Accumulates assistant message content
const assistantMessageIndexRef = useRef<number | null>(null); // Tracks array position
const isEndingSessionRef = useRef<boolean>(false);

useEffect(() => {
if (publicKey && enabled) {
Expand Down Expand Up @@ -223,8 +224,15 @@ export const useVapiChat = ({
);

const sendMessage = useCallback(
async (text: string) => {
async (text: string, sessionEnd: boolean = false) => {
try {
if (sessionEnd) {
if (isEndingSessionRef.current) {
return; // IMP: Prevent duplicate end-session sends
}
isEndingSessionRef.current = true;
}

validateChatInput(
text,
enabled,
Expand All @@ -235,15 +243,30 @@ export const useVapiChat = ({

setIsLoading(true);

const userMessage = createUserMessage(text);
addMessage(userMessage);
if (!sessionEnd && text.trim()) {
const userMessage = createUserMessage(text);
addMessage(userMessage);
}

resetAssistantMessageTracking(
currentAssistantMessageRef,
assistantMessageIndexRef
);
preallocateAssistantMessage(assistantMessageIndexRef, setMessages);
setIsTyping(true);
if (!sessionEnd) {
resetAssistantMessageTracking(
currentAssistantMessageRef,
assistantMessageIndexRef
);
preallocateAssistantMessage(assistantMessageIndexRef, setMessages);
setIsTyping(true);
} else {
const endingText = text.trim() || 'Ending chat...';
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: endingText,
timestamp: new Date(),
},
]);
setIsTyping(true);
}

const onStreamError = (error: Error) =>
handleStreamError(
Expand All @@ -263,33 +286,42 @@ export const useVapiChat = ({
setMessages
);

const onComplete = () =>
handleStreamComplete(
setIsTyping,
assistantMessageIndexRef,
currentAssistantMessageRef,
onMessage
);
const onComplete = sessionEnd
? () => {
setIsTyping(false);
assistantMessageIndexRef.current = null;
}
: () =>
handleStreamComplete(
setIsTyping,
assistantMessageIndexRef,
currentAssistantMessageRef,
onMessage
);

let input: string | Array<{ role: string; content: string }>;
if (
firstChatMessage &&
firstChatMessage.trim() !== '' &&
messages.length === 1 &&
messages[0].role === 'assistant'
) {
input = [
{
role: 'assistant',
content: firstChatMessage,
},
{
role: 'user',
content: text.trim(),
},
];
} else {
if (sessionEnd) {
input = text.trim();
} else {
if (
firstChatMessage &&
firstChatMessage.trim() !== '' &&
messages.length === 1 &&
messages[0].role === 'assistant'
) {
input = [
{
role: 'assistant',
content: firstChatMessage,
},
{
role: 'user',
content: text.trim(),
},
];
} else {
input = text.trim();
}
}

const abort = await clientRef.current!.streamChat(
Expand All @@ -299,6 +331,7 @@ export const useVapiChat = ({
assistantOverrides,
sessionId,
stream: true,
sessionEnd,
},
onChunk,
onStreamError,
Expand All @@ -314,6 +347,9 @@ export const useVapiChat = ({
throw error;
} finally {
setIsLoading(false);
if (sessionEnd) {
isEndingSessionRef.current = false;
}
}
},
[
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useVapiWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export const useVapiWidget = ({
}, []);

const sendMessage = useCallback(
async (text: string) => {
async (text: string, sessionEnd: boolean = false) => {
// In hybrid mode, switch to chat and clear all conversations only if switching from voice
if (mode === 'hybrid') {
if (voice.isCallActive) {
Expand All @@ -132,7 +132,7 @@ export const useVapiWidget = ({
}
setActiveMode('chat');
}
await chat.sendMessage(text);
await chat.sendMessage(text, sessionEnd);
},
[mode, chat, voice, activeMode]
);
Expand Down
1 change: 1 addition & 0 deletions src/utils/vapiChatClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface VapiChatMessage {
assistantOverrides?: AssistantOverrides;
sessionId?: string;
stream?: boolean;
sessionEnd?: boolean;
}

export interface VapiChatStreamChunk {
Expand Down
Loading