Clean up: Remove all old widgets, keep only V2 (disabled by default)
This commit is contained in:
@@ -1,98 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// MiniMax API configuration
|
||||
const MINIMAX_API_KEY = process.env.MINIMAX_API_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJTaXRlTWVudGUiLCJyb2xlIjoiYW5vbiIsImlhdCI6MTczODcxMjAwMCwiZXhwIjoyMDU0MzcyMDAwfQ.VxYNdTUkG4N0K2T8K3pK4JzY3vN8X9vK2pK3JzY2vN8X9";
|
||||
const MINIMAX_BASE_URL = "https://api.minimax.chat/v1";
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface RequestBody {
|
||||
message: string;
|
||||
language: "es" | "en";
|
||||
businessType: string;
|
||||
businessName: string;
|
||||
history?: Message[];
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: RequestBody = await request.json();
|
||||
const { message, language, businessType, businessName, history = [] } = body;
|
||||
|
||||
// Build conversation context
|
||||
const systemPrompt = `You are a friendly AI assistant for ${businessName}, a ${businessType} business.
|
||||
|
||||
IMPORTANT RULES:
|
||||
- Always respond in the SAME language the user uses
|
||||
- Default to Spanish (respond in Spanish unless user clearly speaks English)
|
||||
- Keep responses SHORT: 1-3 sentences max
|
||||
- Be helpful, friendly, and professional
|
||||
- If asked about pricing, mention: €299-€1,950/month depending on plan
|
||||
- If asked about booking, say you'll help them book
|
||||
- Never make up specific prices or details you don't know
|
||||
|
||||
Current language mode: ${language}`;
|
||||
|
||||
// Build messages array
|
||||
const messages: Message[] = [
|
||||
{ role: "assistant", content: systemPrompt }
|
||||
];
|
||||
|
||||
// Add history (last 5 messages for context)
|
||||
if (history.length > 0) {
|
||||
messages.push(...history.slice(-5));
|
||||
}
|
||||
|
||||
// Add current message
|
||||
messages.push({ role: "user", content: message });
|
||||
|
||||
// Call MiniMax API
|
||||
const response = await fetch(`${MINIMAX_BASE_URL}/text/chatcompletion_v2`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${MINIMAX_API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "MiniMax-M2.5",
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 500
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error("MiniMax API error:", error);
|
||||
|
||||
// Fallback response
|
||||
const fallbackResponse = language === "es"
|
||||
? "Lo siento, tuve un problema técnico. ¿Puedes repetir tu pregunta?"
|
||||
: "Sorry, I had a technical issue. Can you repeat your question?";
|
||||
|
||||
return NextResponse.json({ response: fallbackResponse });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const aiResponse = data.choices?.[0]?.message?.content ||
|
||||
data.reply ||
|
||||
(language === "es"
|
||||
? "Gracias por tu mensaje. ¿En qué más puedo ayudarte?"
|
||||
: "Thanks for your message. How else can I help you?");
|
||||
|
||||
return NextResponse.json({ response: aiResponse });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Voice chat API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Disable body parsing for streaming if needed
|
||||
export const runtime = "nodejs";
|
||||
+4
-2
@@ -8,7 +8,8 @@ import HeroSlider, {
|
||||
import JsonLdSchemas from "../components/site-mente/JsonLdSchemas";
|
||||
import ServicesAndPricing from "../components/site-mente/ServicesAndPricing";
|
||||
import SeoMeta from "../components/site-mente/SeoMeta";
|
||||
import SiteMenteVoiceWidget from "../components/SiteMenteVoiceWidget";
|
||||
// Old widget disabled - using V2 only
|
||||
// import SiteMenteVoiceWidget from "../components/SiteMenteVoiceWidget";
|
||||
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
@@ -781,7 +782,8 @@ export default function HomePage() {
|
||||
<div className="font-sans text-white bg-[#5e4a8a]">
|
||||
<SeoMeta lang={lang} />
|
||||
<JsonLdSchemas lang={lang} />
|
||||
<SiteMenteVoiceWidget initialLang={lang} />
|
||||
{/* Old widget disabled - V2 coming soon */}
|
||||
{/* <SiteMenteVoiceWidget initialLang={lang} /> */}
|
||||
<header className="relative overflow-hidden">
|
||||
<nav className="relative z-10 border-b border-white/10 bg-[#5e4a8a]/90 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4">
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
|
||||
interface MiniMaxVoiceWidgetProps {
|
||||
businessName?: string;
|
||||
businessType?: "restaurant" | "real-estate" | "clinic" | "car-rental" | "default";
|
||||
theme?: "dark" | "light";
|
||||
apiUrl?: string; // Your VPS API endpoint for MiniMax
|
||||
}
|
||||
|
||||
// Language state
|
||||
type Lang = "es" | "en";
|
||||
|
||||
const SPANISH_GREETING = "¡Hola! Soy el asistente de inteligencia artificial. ¿En qué puedo ayudarte hoy?";
|
||||
const ENGLISH_GREETING = "Hello! I'm the AI assistant. How can I help you today?";
|
||||
|
||||
// MiniMax system prompt
|
||||
const SYSTEM_PROMPT = `You are a friendly AI assistant for a business website.
|
||||
You help customers with questions about services, hours, bookings, and general inquiries.
|
||||
Keep responses brief and helpful (2-3 sentences max).
|
||||
Default language is Spanish. After the first greeting, respond in the language the user uses.
|
||||
Business type: {businessType}. Business name: {businessName}.`;
|
||||
|
||||
export default function MiniMaxVoiceWidget({
|
||||
businessName = "SiteMente",
|
||||
businessType = "restaurant",
|
||||
theme = "dark",
|
||||
apiUrl = "/api/ai/voice-chat"
|
||||
}: MiniMaxVoiceWidgetProps) {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [messages, setMessages] = useState<{role: "user" | "assistant", content: string}[]>([]);
|
||||
const [language, setLanguage] = useState<Lang>("es");
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||||
const synthRef = useRef<SpeechSynthesis | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Initialize speech recognition and synthesis
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Speech Recognition (Web Speech API)
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (SpeechRecognition) {
|
||||
recognitionRef.current = new SpeechRecognition();
|
||||
recognitionRef.current.continuous = false;
|
||||
recognitionRef.current.interimResults = true;
|
||||
recognitionRef.current.lang = language === "es" ? "es-ES" : "en-US";
|
||||
|
||||
recognitionRef.current.onresult = (event) => {
|
||||
const transcript = Array.from(event.results)
|
||||
.map(result => result[0].transcript)
|
||||
.join("");
|
||||
|
||||
if (event.results[0].isFinal) {
|
||||
handleUserInput(transcript);
|
||||
}
|
||||
};
|
||||
|
||||
recognitionRef.current.onerror = (event) => {
|
||||
console.error("Speech recognition error:", event.error);
|
||||
setIsListening(false);
|
||||
if (event.error !== "no-speech") {
|
||||
setError(`Speech error: ${event.error}`);
|
||||
}
|
||||
};
|
||||
|
||||
recognitionRef.current.onend = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
}
|
||||
|
||||
// Speech Synthesis
|
||||
synthRef.current = window.speechSynthesis;
|
||||
|
||||
return () => {
|
||||
recognitionRef.current?.stop();
|
||||
synthRef.current?.cancel();
|
||||
};
|
||||
}, [language]);
|
||||
|
||||
// Scroll to bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Initialize with greeting
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
setIsInitialized(true);
|
||||
const greeting = language === "es" ? SPANISH_GREETING : ENGLISH_GREETING;
|
||||
setMessages([{ role: "assistant", content: greeting }]);
|
||||
speak(greeting);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Speak text using browser TTS
|
||||
const speak = useCallback((text: string) => {
|
||||
if (!synthRef.current) return;
|
||||
|
||||
synthRef.current.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = language === "es" ? "es-ES" : "en-US";
|
||||
utterance.rate = 0.9;
|
||||
utterance.pitch = 1;
|
||||
|
||||
utterance.onstart = () => setIsSpeaking(true);
|
||||
utterance.onend = () => setIsSpeaking(false);
|
||||
utterance.onerror = () => setIsSpeaking(false);
|
||||
|
||||
synthRef.current.speak(utterance);
|
||||
}, [language]);
|
||||
|
||||
// Handle user input
|
||||
const handleUserInput = async (text: string) => {
|
||||
if (!text.trim()) return;
|
||||
|
||||
// Add user message
|
||||
setMessages(prev => [...prev, { role: "user", content: text }]);
|
||||
|
||||
// Detect language from first message
|
||||
const spanishWords = ["hola", "gracias", "por favor", "quiero", "necesito", "reserva", "precio", "dónde", "cuándo"];
|
||||
const isSpanish = spanishWords.some(word => text.toLowerCase().includes(word));
|
||||
if (isSpanish && language === "en") {
|
||||
setLanguage("es");
|
||||
} else if (!isSpanish && language === "es" && text.length > 5) {
|
||||
setLanguage("en");
|
||||
}
|
||||
|
||||
setIsSpeaking(true);
|
||||
|
||||
try {
|
||||
// Call MiniMax API
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
message: text,
|
||||
language,
|
||||
businessType,
|
||||
businessName,
|
||||
history: messages.slice(-5) // Last 5 messages for context
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("API request failed");
|
||||
|
||||
const data = await response.json();
|
||||
const aiResponse = data.response || data.message || "Lo siento, no entendí. ¿Puedes repetir?";
|
||||
|
||||
setMessages(prev => [...prev, { role: "assistant", content: aiResponse }]);
|
||||
speak(aiResponse);
|
||||
|
||||
} catch (err) {
|
||||
console.error("API error:", err);
|
||||
// Fallback response
|
||||
const fallbackResponse = language === "es"
|
||||
? "Lo siento, tuve un problema. ¿Puedes repetir?"
|
||||
: "Sorry, I had an issue. Can you repeat that?";
|
||||
setMessages(prev => [...prev, { role: "assistant", content: fallbackResponse }]);
|
||||
speak(fallbackResponse);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle listening
|
||||
const toggleListening = () => {
|
||||
if (!recognitionRef.current) {
|
||||
setError("Speech recognition not supported in this browser");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isListening) {
|
||||
recognitionRef.current.stop();
|
||||
} else {
|
||||
setError(null);
|
||||
recognitionRef.current.lang = language === "es" ? "es-ES" : "en-US";
|
||||
recognitionRef.current.start();
|
||||
setIsListening(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Theme colors
|
||||
const buttonColor = theme === "dark" ? "bg-brand-pink" : "bg-blue-600";
|
||||
const bgColor = theme === "dark" ? "bg-[#1a1625]" : "bg-white";
|
||||
const textColor = theme === "dark" ? "text-white" : "text-gray-900";
|
||||
const inputBg = theme === "dark" ? "bg-white/10" : "bg-gray-100";
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
{/* Chat Toggle */}
|
||||
<button
|
||||
onClick={() => setShowChat(!showChat)}
|
||||
className={`${buttonColor} w-14 h-14 rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110 ${isListening ? "animate-pulse" : ""}`}
|
||||
title={isListening ? "Listening..." : "AI Assistant"}
|
||||
>
|
||||
{isSpeaking ? "🔊" : isListening ? "👂" : "🎙️"}
|
||||
</button>
|
||||
|
||||
{/* Chat Panel */}
|
||||
{showChat && (
|
||||
<div className={`absolute bottom-20 right-0 w-80 ${bgColor} rounded-2xl shadow-2xl border border-white/10 overflow-hidden`}>
|
||||
{/* Header */}
|
||||
<div className={`${theme === "dark" ? "bg-brand-purple" : "bg-blue-600"} p-3 flex items-center justify-between`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🤖</span>
|
||||
<span className={`font-semibold ${theme === "dark" ? "text-white" : "text-white"}`}>
|
||||
{businessName} AI
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setLanguage(lang => lang === "es" ? "en" : "es")}
|
||||
className="text-xs bg-white/20 px-2 py-1 rounded text-white"
|
||||
>
|
||||
{language === "es" ? "🇪🇸 ES" : "🇬🇧 EN"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className={`h-64 overflow-y-auto p-3 space-y-2 ${textColor}`}>
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div className={`max-w-[80%] px-3 py-2 rounded-lg text-sm ${
|
||||
msg.role === "user"
|
||||
? buttonColor + " text-white"
|
||||
: theme === "dark" ? "bg-white/10 text-white" : "bg-gray-100 text-gray-900"
|
||||
}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isListening && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white/10 px-3 py-2 rounded-lg text-sm text-white animate-pulse">
|
||||
🎤 Listening...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className={`p-3 border-t ${theme === "dark" ? "border-white/10" : "border-gray-200"} flex gap-2`}>
|
||||
<button
|
||||
onClick={toggleListening}
|
||||
disabled={isSpeaking}
|
||||
className={`flex-1 py-2 rounded-lg font-semibold text-white transition ${
|
||||
isListening ? "bg-red-500 animate-pulse" : buttonColor + " hover:opacity-90"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{isListening ? "🛑 Stop" : "🎤 Hablar"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="px-3 pb-2 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Type declarations for Web Speech API
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition: typeof SpeechRecognition;
|
||||
webkitSpeechRecognition: typeof SpeechRecognition;
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import SynthflowWidget from "./SynthflowWidget";
|
||||
|
||||
interface SiteMenteVoiceWidgetProps {
|
||||
businessName?: string;
|
||||
businessType?: "restaurant" | "real-estate" | "clinic" | "car-rental" | "default";
|
||||
theme?: "dark" | "light";
|
||||
initialLang?: string;
|
||||
}
|
||||
|
||||
type Mode = "synthflow" | "text" | "off";
|
||||
|
||||
const SYNTHFLOW_WIDGET_ID = "0ee1b79c-43c2-41e0-aa6a-d2a560e0ca6a";
|
||||
|
||||
export default function SiteMenteVoiceWidget({
|
||||
businessName = "SiteMente",
|
||||
businessType = "default",
|
||||
theme = "dark",
|
||||
initialLang = "en"
|
||||
}: SiteMenteVoiceWidgetProps) {
|
||||
const [mode, setMode] = useState<Mode>("off");
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
const [language, setLanguage] = useState<'en' | 'es'>(initialLang as 'en' | 'es');
|
||||
|
||||
// Text chat state
|
||||
const [messages, setMessages] = useState<{role: "user" | "assistant", content: string}[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll to bottom of messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Toggle modes
|
||||
const cycleMode = () => {
|
||||
const modes: Mode[] = ["off", "text", "synthflow"];
|
||||
const currentIndex = modes.indexOf(mode);
|
||||
const nextIndex = (currentIndex + 1) % modes.length;
|
||||
setModeInternal(modes[nextIndex]);
|
||||
};
|
||||
|
||||
const setModeInternal = (newMode: Mode) => {
|
||||
setMode(newMode);
|
||||
};
|
||||
|
||||
// Text mode functions
|
||||
const sendMessage = async () => {
|
||||
if (!input.trim() || isSending) return;
|
||||
|
||||
const userMessage = input.trim();
|
||||
setInput("");
|
||||
setIsSending(true);
|
||||
|
||||
setMessages(prev => [...prev, { role: "user", content: userMessage }]);
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const englishResponses: Record<string, string> = {
|
||||
"hello": `Hello! 👋 I'm the AI assistant for ${businessName}. How can I help you today?`,
|
||||
"hours": `We're open Monday to Sunday. Do you have a specific question?`,
|
||||
"book": "I can help you book right now. What service are you interested in?",
|
||||
"contact": `You can call us at +34 XXX XXX XXX or message us here.`,
|
||||
"price": "We offer packages starting from €299/month. Would you like me to tell you more?",
|
||||
"menu": "We serve delicious food daily. Would you like to see our menu or make a reservation?",
|
||||
"reservation": "I can help you make a reservation! What date and time works for you?",
|
||||
"thanks": "You're welcome! Is there anything else I can help you with?",
|
||||
"thank you": "You're welcome! Is there anything else I can help you with?",
|
||||
};
|
||||
|
||||
const spanishResponses: Record<string, string> = {
|
||||
"hola": `¡Hola! 👋 Soy el asistente de ${businessName}. ¿En qué puedo ayudarte hoy?`,
|
||||
"horario": `Nuestros horarios de atención son de lunes a domingo. ¿Tienes alguna pregunta específica?`,
|
||||
"reservar": "Para hacer una reserva, puedo ayudarte ahora mismo. ¿Qué servicio te interesa?",
|
||||
"contacto": `Puedes llamarnos al +34 XXX XXX XXX o escribirnos aquí.`,
|
||||
};
|
||||
|
||||
const lowerInput = userMessage.toLowerCase();
|
||||
const spanishKeywords = ['hola', 'gracias', 'si', 'no', 'por favor', 'quiero', 'necesito', 'reserva', 'horario', 'precio', 'contacto'];
|
||||
const isSpanish = spanishKeywords.some(keyword => lowerInput.includes(keyword));
|
||||
|
||||
const responses = (language === 'es' || isSpanish) ? spanishResponses : englishResponses;
|
||||
const defaultResponse = language === 'es'
|
||||
? "Gracias por tu mensaje. Un miembro de nuestro equipo te responderá pronto."
|
||||
: "Thanks for your message! A team member will get back to you shortly.";
|
||||
|
||||
let response = defaultResponse;
|
||||
for (const [key, value] of Object.entries(responses)) {
|
||||
if (lowerInput.includes(key)) {
|
||||
response = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, { role: "assistant", content: response }]);
|
||||
} catch (error) {
|
||||
console.error("Send error:", error);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const buttonColor = theme === "dark" ? "bg-brand-pink" : "bg-blue-600";
|
||||
const bgColor = theme === "dark" ? "bg-[#1a1625]" : "bg-white";
|
||||
const textColor = theme === "dark" ? "text-white" : "text-gray-900";
|
||||
const inputBg = theme === "dark" ? "bg-white/10" : "bg-gray-100";
|
||||
|
||||
// Get mode icon
|
||||
const getModeIcon = () => {
|
||||
if (mode === "synthflow") return "🎙️";
|
||||
if (mode === "text") return "💬";
|
||||
return "⚪";
|
||||
};
|
||||
|
||||
const getModeLabel = () => {
|
||||
if (mode === "synthflow") return "AI Voice";
|
||||
if (mode === "text") return "Chat";
|
||||
return "Off";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
{/* Language Toggle */}
|
||||
<button
|
||||
onClick={() => setLanguage(lang => lang === 'en' ? 'es' : 'en')}
|
||||
className={`absolute bottom-28 right-0 ${buttonColor} px-3 py-1.5 rounded-full text-xs text-white shadow-lg mb-2 flex items-center gap-1.5 hover:scale-105 transition-transform`}
|
||||
title="Toggle Language"
|
||||
>
|
||||
{language === 'en' ? '🇪🇸 ES' : '🇬🇧 EN'}
|
||||
</button>
|
||||
|
||||
{/* Mode Toggle Buttons - Direct Selection */}
|
||||
<div className="absolute bottom-16 right-0 flex flex-col gap-1 mb-2">
|
||||
<button
|
||||
onClick={() => setModeInternal("text")}
|
||||
className={`${mode === "text" ? buttonColor : "bg-white/20"} px-3 py-1.5 rounded-full text-xs text-white shadow-lg flex items-center gap-1.5 hover:scale-105 transition-transform`}
|
||||
title="Text Chat"
|
||||
>
|
||||
💬 Chat
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModeInternal("synthflow")}
|
||||
className={`${mode === "synthflow" ? "bg-green-500" : "bg-white/20"} px-3 py-1.5 rounded-full text-xs text-white shadow-lg flex items-center gap-1.5 hover:scale-105 transition-transform`}
|
||||
title="AI Voice"
|
||||
>
|
||||
🎙️ Voice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Synthflow Widget - Custom WebSocket */}
|
||||
{mode === "synthflow" && (
|
||||
<div className="absolute bottom-16 right-0 mb-2">
|
||||
<SynthflowWidget
|
||||
apiKey="yCNoizRk4kRcLrR4V27iem3XkFKZizWrjSXvkao-MZI"
|
||||
assistantId="0ee1b79c-43c2-41e0-aa6a-d2a560e0ca6a"
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Chat Panel */}
|
||||
{mode === "text" && (
|
||||
<div className={`absolute bottom-16 right-0 w-80 ${bgColor} border border-white/20 rounded-xl shadow-2xl mb-2 overflow-hidden`}>
|
||||
{/* Header */}
|
||||
<div className={`flex items-center justify-between p-3 border-b ${theme === "dark" ? "border-white/10" : "border-gray-200"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium ${textColor}">💬 {businessName}</span>
|
||||
</div>
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="h-64 overflow-y-auto p-3 space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-sm text-gray-500 py-4">
|
||||
👋 ¡Hola! Escríbeme para ayudarte
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-brand-pink text-white"
|
||||
: theme === "dark" ? "bg-white/10 text-white" : "bg-gray-100 text-gray-900"
|
||||
}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isSending && (
|
||||
<div className="flex justify-start">
|
||||
<div className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${theme === "dark" ? "bg-white/10 text-white/70" : "bg-gray-100 text-gray-500"}`}>
|
||||
Escribiendo...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className={`p-3 border-t ${theme === "dark" ? "border-white/10" : "border-gray-200"}`}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={language === 'es' ? "Escribe tu mensaje..." : "Type your message..."}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-sm ${inputBg} ${textColor} placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-pink`}
|
||||
disabled={isSending}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || isSending}
|
||||
className={`${buttonColor} p-2 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Button - Toggle chat visibility when in text mode */}
|
||||
<button
|
||||
onClick={() => setShowChat(!showChat)}
|
||||
className={`${buttonColor} w-14 h-14 rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110 ${
|
||||
mode === "synthflow" ? "animate-pulse ring-4 ring-green-500/50" : ""
|
||||
}`}
|
||||
title={mode === "text" ? (showChat ? "Close" : "Open Chat") : "Click to enable"}
|
||||
>
|
||||
{mode === "synthflow" ? (
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
) : showChat ? (
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
|
||||
interface SynthflowWidgetProps {
|
||||
apiKey: string;
|
||||
assistantId: string;
|
||||
theme?: "dark" | "light";
|
||||
}
|
||||
|
||||
export default function SynthflowWidget({
|
||||
apiKey,
|
||||
assistantId,
|
||||
theme = "dark"
|
||||
}: SynthflowWidgetProps) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isTalking, setIsTalking] = useState(false);
|
||||
const [status, setStatus] = useState<"idle" | "connecting" | "ready" | "talking" | "error">("idle");
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const audioChunksRef = useRef<Int16Array[]>([]);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
try {
|
||||
setStatus("connecting");
|
||||
setError("");
|
||||
|
||||
// Get WebSocket token
|
||||
const tokenRes = await fetch(`https://widget.synthflow.ai/websocket/token/${assistantId}`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
throw new Error("Failed to get token");
|
||||
}
|
||||
|
||||
const { sessionURL } = await tokenRes.json();
|
||||
|
||||
// Connect to WebSocket
|
||||
const ws = new WebSocket(sessionURL);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("WebSocket connected");
|
||||
setIsConnected(true);
|
||||
setStatus("ready");
|
||||
|
||||
// Send ready signal
|
||||
ws.send(JSON.stringify({ type: "status_client_ready" }));
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
if (typeof event.data === "string") {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log("WS message:", data);
|
||||
|
||||
if (data.type === "transcript") {
|
||||
setTranscript(data.text || "");
|
||||
} else if (data.type === "status_agent_ready") {
|
||||
setStatus("ready");
|
||||
}
|
||||
} else if (event.data instanceof Blob) {
|
||||
// Audio from agent - play it
|
||||
const arrayBuffer = await event.data.arrayBuffer();
|
||||
await playAudio(new Int16Array(arrayBuffer));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error("WS error:", err);
|
||||
setError("Connection error");
|
||||
setStatus("error");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false);
|
||||
setStatus("idle");
|
||||
};
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Connection failed:", err);
|
||||
setError(err.message);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [apiKey, assistantId]);
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaStreamRef.current = stream;
|
||||
|
||||
// Create audio context
|
||||
const audioContext = new AudioContext({ sampleRate: 48000 });
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
processor.onaudioprocess = (e) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const inputData = e.inputBuffer.getChannelData(0);
|
||||
const int16Data = float32ToInt16(inputData);
|
||||
wsRef.current.send(int16Data);
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
|
||||
setIsTalking(true);
|
||||
setStatus("talking");
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Recording error:", err);
|
||||
setError("Microphone access denied");
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
}
|
||||
setIsTalking(false);
|
||||
setStatus("ready");
|
||||
};
|
||||
|
||||
const playAudio = async (int16Data: Int16Array) => {
|
||||
try {
|
||||
const ctx = new AudioContext({ sampleRate: 16000 });
|
||||
const buffer = ctx.createBuffer(1, int16Data.length, 16000);
|
||||
buffer.getChannelData(0).set(int16ToFloat32(int16Data));
|
||||
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
source.start();
|
||||
} catch (err) {
|
||||
console.error("Play audio error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const float32ToInt16 = (float32: Float32Array): Int16Array => {
|
||||
const int16 = new Int16Array(float32.length);
|
||||
for (let i = 0; i < float32.length; i++) {
|
||||
int16[i] = Math.max(-1, Math.min(1, float32[i])) * 0x7FFF;
|
||||
}
|
||||
return int16;
|
||||
};
|
||||
|
||||
const int16ToFloat32 = (int16: Int16Array): Float32Array => {
|
||||
const float32 = new Float32Array(int16.length);
|
||||
for (let i = 0; i < int16.length; i++) {
|
||||
float32[i] = int16[i] / 0x7FFF;
|
||||
}
|
||||
return float32;
|
||||
};
|
||||
|
||||
const toggleCall = () => {
|
||||
if (!isConnected) {
|
||||
connect();
|
||||
} else if (isTalking) {
|
||||
stopRecording();
|
||||
} else {
|
||||
startRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const colors = theme === "dark"
|
||||
? { bg: "#1a1625", text: "#fff", accent: "#ff69b4" }
|
||||
: { bg: "#fff", text: "#000", accent: "#0066ff" };
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-4 shadow-lg"
|
||||
style={{ backgroundColor: colors.bg, color: colors.text }}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
status === "ready" ? "bg-green-500" :
|
||||
status === "talking" ? "bg-green-500 animate-pulse" :
|
||||
status === "connecting" ? "bg-yellow-500" :
|
||||
status === "error" ? "bg-red-500" : "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{status === "idle" && "Click to start"}
|
||||
{status === "connecting" && "Connecting..."}
|
||||
{status === "ready" && "Ready"}
|
||||
{status === "talking" && "Listening..."}
|
||||
{status === "error" && error || "Error"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Call button */}
|
||||
<button
|
||||
onClick={toggleCall}
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center transition-all ${
|
||||
isTalking
|
||||
? "bg-red-500 animate-pulse"
|
||||
: "bg-green-500 hover:scale-110"
|
||||
}`}
|
||||
>
|
||||
{isTalking ? (
|
||||
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 8l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M5 3a2 2 0 00-2 2v1c0 8.284 6.716 15 15 15h1a2 2 0 002-2v-3.28a1 1 0 00-.684-.948l-4.493-1.498a1 1 0 00-1.21.502l-1.13 2.257a11.042 11.042 0 01-5.516-5.517l2.257-1.128a1 1 0 00.502-1.21L9.228 3.683A1 1 0 008.279 3H5z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Transcript */}
|
||||
{transcript && (
|
||||
<div className="w-full p-3 rounded-lg bg-white/10 text-sm">
|
||||
{transcript}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
# 🎙️ SiteMente Custom Voice Widget
|
||||
|
||||
A custom voice AI chat widget using **MiniMax** as the brain, **Web Speech API** for input, and **Browser TTS** for output.
|
||||
|
||||
---
|
||||
|
||||
## ✅ What's Included
|
||||
|
||||
1. **MiniMaxVoiceWidget.tsx** - React component
|
||||
2. **API endpoint** - `/api/ai/voice-chat`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Deploy
|
||||
|
||||
### 1. Copy Files to Production
|
||||
|
||||
Copy these files to your SiteMente repo:
|
||||
- `components/MiniMaxVoiceWidget.tsx` → `SiteMente/components/MiniMaxVoiceWidget.tsx`
|
||||
- `app/api/ai/voice-chat/route.ts` → `SiteMente/app/api/ai/voice-chat/route.ts`
|
||||
|
||||
### 2. Update .env.local (if needed)
|
||||
|
||||
```env
|
||||
MINIMAX_API_KEY=your_minimax_key_here
|
||||
```
|
||||
|
||||
### 3. Add to Page
|
||||
|
||||
In any page (e.g., `app/page.tsx` or `app/demos/[vertical]/page.tsx`):
|
||||
|
||||
```tsx
|
||||
import MiniMaxVoiceWidget from "@/components/MiniMaxVoiceWidget";
|
||||
|
||||
export default function DemoPage() {
|
||||
return (
|
||||
<>
|
||||
{/* Your page content */}
|
||||
|
||||
<MiniMaxVoiceWidget
|
||||
businessName="Restaurante Ejemplo"
|
||||
businessType="restaurant"
|
||||
theme="dark"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Deploy
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add custom voice widget"
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration Options
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `businessName` | string | "SiteMente" | Name shown in chat |
|
||||
| `businessType` | string | "restaurant" | restaurant, real-estate, clinic, car-rental |
|
||||
| `theme` | string | "dark" | "dark" or "light" |
|
||||
| `apiUrl` | string | "/api/ai/voice-chat" | Custom API endpoint |
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Language Behavior
|
||||
|
||||
- **Default:** Spanish
|
||||
- **Switch:** After first user message, detects language and switches automatically
|
||||
- **Manual toggle:** Button in chat header
|
||||
|
||||
---
|
||||
|
||||
## 💰 Cost
|
||||
|
||||
- **Input (Speech → Text):** FREE (Web Speech API)
|
||||
- **Output (Text → Speech):** FREE (Browser TTS)
|
||||
- **Brain (MiniMax):** Your existing API key (~€0.001/msg)
|
||||
|
||||
**Total cost: Nearly zero for POCs!**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Customization
|
||||
|
||||
### Change Voice
|
||||
|
||||
In `MiniMaxVoiceWidget.tsx`, find `speak()` function:
|
||||
|
||||
```tsx
|
||||
// Different voices available
|
||||
const voices = synthRef.current?.getVoices();
|
||||
// Spanish voices
|
||||
const spanishVoice = voices?.find(v => v.lang.includes("es"));
|
||||
utterance.voice = spanishVoice;
|
||||
```
|
||||
|
||||
### Add More Business Types
|
||||
|
||||
Edit `SYSTEM_PROMPT` in `route.ts` to customize responses per business type.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### "Speech recognition not supported"
|
||||
- Use Chrome, Edge, or Safari (not Firefox)
|
||||
- HTTPS required (or localhost)
|
||||
|
||||
### "API request failed"
|
||||
- Check MiniMax API key is valid
|
||||
- Check API endpoint is accessible
|
||||
|
||||
### Widget not showing
|
||||
- Ensure client-side import: `"use client"`
|
||||
- Check for CSS conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📦 Next Steps (Optional)
|
||||
|
||||
1. **Voice cloning** - ElevenLabs ($5-15/month)
|
||||
2. **Emotions** - Custom prompts for personality
|
||||
3. **Multi-turn** - Longer conversation history
|
||||
4. **Vapi integration** - For phone calls later
|
||||
|
||||
---
|
||||
|
||||
**Status: Ready to test! 🚀**
|
||||
Reference in New Issue
Block a user