Add Aria chatbot
- New POST /api/chat endpoint for AI-powered chat - Calls Anthropic Claude Haiku 3.5 with embedded Dealspace knowledge - Rate limiting: 20 requests/IP/hour - Lead capture: emails detected and saved to /opt/dealspace/data/leads.jsonl - Frontend chat widget (chat.js, chat.css) added to all HTML pages - Navy/gold theme matching site design - Mobile responsive - CORS configured for muskepo.com
This commit is contained in:
parent
a8379a2a0c
commit
4e89f79a67
|
|
@ -0,0 +1,344 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ChatRequest is the incoming chat message from the client.
|
||||
type ChatRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Message string `json:"message"`
|
||||
History []Message `json:"history"`
|
||||
}
|
||||
|
||||
// Message represents a chat message.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ChatResponse is returned to the client.
|
||||
type ChatResponse struct {
|
||||
Reply string `json:"reply"`
|
||||
SessionID string `json:"session_id"`
|
||||
LeadCaptured bool `json:"lead_captured,omitempty"`
|
||||
}
|
||||
|
||||
// Lead represents a captured lead.
|
||||
type Lead struct {
|
||||
Email string `json:"email"`
|
||||
SessionID string `json:"session_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Context string `json:"context"`
|
||||
}
|
||||
|
||||
// Rate limiter for chat endpoint (20 requests/IP/hour)
|
||||
var (
|
||||
chatRateMu sync.Mutex
|
||||
chatRateMap = make(map[string]*chatRateEntry)
|
||||
emailRegex = regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`)
|
||||
leadsMu sync.Mutex
|
||||
)
|
||||
|
||||
type chatRateEntry struct {
|
||||
windowStart time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
// Aria's system prompt with embedded knowledge
|
||||
const ariaSystemPrompt = `You are Aria, the Dealspace product assistant. Dealspace is an M&A deal workflow platform for investment banks and advisors.
|
||||
|
||||
Answer ONLY questions about Dealspace — its features, pricing, security, onboarding, use cases, and how it compares to alternatives like email-based data rooms or SharePoint.
|
||||
|
||||
If asked anything outside Dealspace (personal advice, coding help, current events, competitor products, etc.), respond: "That's outside my expertise, but I'd love to connect you with our team. What's your email address?"
|
||||
|
||||
If a user provides an email, respond: "Thanks! Someone from the Dealspace team will reach out to you shortly." Then stop.
|
||||
|
||||
Be concise. 2-3 sentences max unless a detailed answer is genuinely needed. You are on a marketing website — your goal is to inform and convert, not to write essays.
|
||||
|
||||
--- DEALSPACE KNOWLEDGE ---
|
||||
|
||||
OVERVIEW:
|
||||
Dealspace is an M&A workflow platform trusted by investment banks. It's request-centric, secure, and intelligently simple. Unlike traditional VDRs that are document-centric (500-folder hierarchies nobody can navigate), Dealspace makes the Request the unit of work.
|
||||
|
||||
CORE FEATURES:
|
||||
- Request-Centric Workflow: Every question, every answer, every status update is tracked, routed, and resolved. Structured request lists, status at a glance (open, assigned, answered, vetted, published), threaded communication.
|
||||
- Role-Based Simplicity: Your accountant sees their 3 tasks. Your CFO sees the big picture. Workstream-based access (Finance sees Finance, Legal sees Legal). Task inbox for contributors.
|
||||
- AI Matching: When a buyer submits a question, AI searches for existing answers. Human confirms, answer broadcasts to everyone who asked. Semantic search understands "revenue breakdown" and "sales by segment" are the same. Human in the loop - AI suggests, human confirms. Zero retention - deal data never trains AI models.
|
||||
- Work Where You Are: Email, Slack, Teams integration. No login required for basic responses.
|
||||
- Complete Audit Trail: Every access, every download, every routing hop logged. Access logs, download tracking, workflow history.
|
||||
|
||||
HOW IT WORKS:
|
||||
1. IB Creates Request List - Configure workstreams, invite participants, issue structured requests
|
||||
2. Seller Responds - Internal routing, upload documents, mark complete
|
||||
3. IB Vets & Approves - Quality control, approve to publish or reject with feedback
|
||||
4. Buyers Access Data Room - Submit questions, AI matches to existing answers
|
||||
|
||||
SECURITY:
|
||||
- SOC 2 Type II certified
|
||||
- FIPS 140-3 validated encryption (AES-256-GCM)
|
||||
- Per-deal encryption keys - one deal's compromise doesn't affect others
|
||||
- Encryption at rest and in transit (TLS 1.3)
|
||||
- ISO 27001 certified ISMS
|
||||
- GDPR compliant
|
||||
- Dynamic watermarking - every document watermarked with viewer identity, timestamp, and deal ID at serve time
|
||||
- SSO/SAML support, MFA required, IP allowlisting, session management
|
||||
- 99.99% uptime SLA, 24/7 security monitoring, <15min incident response
|
||||
|
||||
PRICING:
|
||||
- Starter: $2,500/month - 1 concurrent deal, up to 10 participants, 10GB storage, request workflow, dynamic watermarking, audit trail, email support. No AI matching or SSO.
|
||||
- Professional (Most Popular): $7,500/month - 5 concurrent deals, unlimited participants, 100GB storage, everything in Starter plus AI matching and priority support.
|
||||
- Enterprise: Custom pricing - Unlimited deals, unlimited participants, unlimited storage, everything in Professional plus SSO/SAML, custom watermarks, dedicated support, 99.99% SLA, on-premise option.
|
||||
- Additional storage: $0.10/GB/month (no markups, actual cost)
|
||||
- 14-day free trial with full Professional features, no credit card required
|
||||
- Annual billing: 15% discount
|
||||
|
||||
COMPARISON (50GB deal, 100 participants):
|
||||
- Dealspace Professional: $7,500/month total
|
||||
- Competitor A: $25,500/month ($5k base + $15k storage + $2.5k participants + $3k AI)
|
||||
- Competitor B: $17,500/month ($8k base + $8k storage + $1.5k participants)
|
||||
|
||||
FAQ:
|
||||
- "Concurrent deal" = active deal not archived. Archived deals don't count toward limit.
|
||||
- Free trial: 14 days, full Professional features, no credit card
|
||||
- Storage overage: $0.10/GB, notified before billing
|
||||
- Upgrades prorated immediately, downgrades at next billing cycle
|
||||
|
||||
COMPANY:
|
||||
- Operated by Muskepo B.V., Amsterdam
|
||||
- Offices: Amsterdam, New York, London
|
||||
- Contact: sales@dealspace.io, security@dealspace.io`
|
||||
|
||||
// ChatHandler handles POST /api/chat requests
|
||||
func (h *Handlers) ChatHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// CORS for muskepo.com
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "https://muskepo.com" || origin == "http://localhost:8080" || strings.HasPrefix(origin, "http://82.24.174.112") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
}
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limiting: 20 requests/IP/hour
|
||||
ip := realIP(r)
|
||||
if !checkChatRateLimit(ip) {
|
||||
ErrorResponse(w, http.StatusTooManyRequests, "rate_limited", "Too many requests. Please try again later.")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request
|
||||
var req ChatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Message == "" {
|
||||
ErrorResponse(w, http.StatusBadRequest, "missing_message", "Message is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Limit history to last 6 messages
|
||||
if len(req.History) > 6 {
|
||||
req.History = req.History[len(req.History)-6:]
|
||||
}
|
||||
|
||||
// Check for email in user message
|
||||
leadCaptured := false
|
||||
if emailRegex.MatchString(req.Message) {
|
||||
email := emailRegex.FindString(req.Message)
|
||||
if err := saveLead(email, req.SessionID, req.Message); err != nil {
|
||||
log.Printf("Failed to save lead: %v", err)
|
||||
} else {
|
||||
leadCaptured = true
|
||||
}
|
||||
}
|
||||
|
||||
// Call Anthropic API
|
||||
reply, err := callAnthropicAPI(req.Message, req.History)
|
||||
if err != nil {
|
||||
log.Printf("Anthropic API error: %v", err)
|
||||
ErrorResponse(w, http.StatusInternalServerError, "api_error", "Sorry, I'm having trouble responding right now. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if assistant reply asks for email and user already provided one
|
||||
if leadCaptured && strings.Contains(strings.ToLower(reply), "email") {
|
||||
reply = "Thanks! Someone from the Dealspace team will reach out to you shortly."
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, ChatResponse{
|
||||
Reply: reply,
|
||||
SessionID: req.SessionID,
|
||||
LeadCaptured: leadCaptured,
|
||||
})
|
||||
}
|
||||
|
||||
func checkChatRateLimit(ip string) bool {
|
||||
chatRateMu.Lock()
|
||||
defer chatRateMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
entry, exists := chatRateMap[ip]
|
||||
|
||||
// Clean up old entries
|
||||
for k, v := range chatRateMap {
|
||||
if now.Sub(v.windowStart) > time.Hour {
|
||||
delete(chatRateMap, k)
|
||||
}
|
||||
}
|
||||
|
||||
if !exists || now.Sub(entry.windowStart) > time.Hour {
|
||||
chatRateMap[ip] = &chatRateEntry{windowStart: now, count: 1}
|
||||
return true
|
||||
}
|
||||
|
||||
entry.count++
|
||||
return entry.count <= 20
|
||||
}
|
||||
|
||||
func saveLead(email, sessionID, context string) error {
|
||||
leadsMu.Lock()
|
||||
defer leadsMu.Unlock()
|
||||
|
||||
lead := Lead{
|
||||
Email: email,
|
||||
SessionID: sessionID,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Context: context,
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll("/opt/dealspace/data", 0755); err != nil {
|
||||
return fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile("/opt/dealspace/data/leads.jsonl", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open leads file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := json.Marshal(lead)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal lead: %w", err)
|
||||
}
|
||||
|
||||
if _, err := f.Write(append(data, '\n')); err != nil {
|
||||
return fmt.Errorf("write lead: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Lead captured: %s (session: %s)", email, sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AnthropicRequest is the request body for Anthropic API
|
||||
type AnthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System string `json:"system"`
|
||||
Messages []AnthropicMessage `json:"messages"`
|
||||
}
|
||||
|
||||
// AnthropicMessage represents a message in Anthropic format
|
||||
type AnthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// AnthropicResponse is the response from Anthropic API
|
||||
type AnthropicResponse struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func callAnthropicAPI(userMessage string, history []Message) (string, error) {
|
||||
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("ANTHROPIC_API_KEY not set")
|
||||
}
|
||||
|
||||
// Build messages array
|
||||
messages := make([]AnthropicMessage, 0, len(history)+1)
|
||||
for _, m := range history {
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
})
|
||||
}
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "user",
|
||||
Content: userMessage,
|
||||
})
|
||||
|
||||
reqBody := AnthropicRequest{
|
||||
Model: "claude-3-5-haiku-latest",
|
||||
MaxTokens: 300,
|
||||
System: ariaSystemPrompt,
|
||||
Messages: messages,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("API request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var apiResp AnthropicResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return "", fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error != nil {
|
||||
return "", fmt.Errorf("API error: %s", apiResp.Error.Message)
|
||||
}
|
||||
|
||||
if len(apiResp.Content) == 0 {
|
||||
return "", fmt.Errorf("empty response from API")
|
||||
}
|
||||
|
||||
return apiResp.Content[0].Text, nil
|
||||
}
|
||||
|
|
@ -21,6 +21,10 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
|
|||
// Health check (unauthenticated)
|
||||
r.Get("/health", h.Health)
|
||||
|
||||
// Chat endpoint (unauthenticated, for Aria chatbot)
|
||||
r.Post("/api/chat", h.ChatHandler)
|
||||
r.Options("/api/chat", h.ChatHandler)
|
||||
|
||||
// API routes (authenticated)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(db, cfg.JWTSecret))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,286 @@
|
|||
/* Aria Chat Widget Styles */
|
||||
#aria-chat-button {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: #0F1B35;
|
||||
border: 2px solid #C9A84C;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
#aria-chat-button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
#aria-chat-button svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
#aria-chat-panel {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
right: 24px;
|
||||
width: 380px;
|
||||
height: 520px;
|
||||
background: #0F1B35;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 9998;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#aria-chat-panel.open {
|
||||
display: flex;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
#aria-chat-header {
|
||||
background: #1a2847;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#aria-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #C9A84C 0%, #d4b85f 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#aria-avatar span {
|
||||
color: #0F1B35;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#aria-header-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#aria-header-text h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#aria-header-text p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
#aria-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #9CA3AF;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
#aria-close-btn:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
#aria-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aria-message {
|
||||
max-width: 85%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.aria-message.user {
|
||||
background: #2B4680;
|
||||
color: white;
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.aria-message.assistant {
|
||||
background: #1a2847;
|
||||
color: #E5E7EB;
|
||||
align-self: flex-start;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.aria-typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
background: #1a2847;
|
||||
border-radius: 16px;
|
||||
border-bottom-left-radius: 4px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.aria-typing span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #C9A84C;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite;
|
||||
}
|
||||
|
||||
.aria-typing span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.aria-typing span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#aria-chat-input {
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
background: #1a2847;
|
||||
}
|
||||
|
||||
#aria-message-input {
|
||||
flex: 1;
|
||||
background: #0F1B35;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
#aria-message-input::placeholder {
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
#aria-message-input:focus {
|
||||
border-color: #C9A84C;
|
||||
}
|
||||
|
||||
#aria-send-btn {
|
||||
background: #C9A84C;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#aria-send-btn:hover {
|
||||
background: #d4b85f;
|
||||
}
|
||||
|
||||
#aria-send-btn:disabled {
|
||||
background: #4B5563;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#aria-send-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: #0F1B35;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 480px) {
|
||||
#aria-chat-panel {
|
||||
width: calc(100% - 32px);
|
||||
right: 16px;
|
||||
bottom: 90px;
|
||||
height: 60vh;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
#aria-chat-button {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
#aria-chat-messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
#aria-chat-messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#aria-chat-messages::-webkit-scrollbar-thumb {
|
||||
background: #2B4680;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#aria-chat-messages::-webkit-scrollbar-thumb:hover {
|
||||
background: #3B5998;
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
// Aria Chat Widget - Dealspace Product Assistant
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Generate or retrieve session ID
|
||||
function getSessionId() {
|
||||
let sessionId = sessionStorage.getItem('aria_session_id');
|
||||
if (!sessionId) {
|
||||
sessionId = 'aria_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
sessionStorage.setItem('aria_session_id', sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
// Chat state
|
||||
const state = {
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
history: [],
|
||||
sessionId: getSessionId()
|
||||
};
|
||||
|
||||
// Create chat widget HTML
|
||||
function createWidget() {
|
||||
// Chat button
|
||||
const button = document.createElement('button');
|
||||
button.id = 'aria-chat-button';
|
||||
button.setAttribute('aria-label', 'Open chat with Aria');
|
||||
button.innerHTML = `
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Chat panel
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'aria-chat-panel';
|
||||
panel.innerHTML = `
|
||||
<div id="aria-chat-header">
|
||||
<div id="aria-avatar"><span>A</span></div>
|
||||
<div id="aria-header-text">
|
||||
<h3>Aria</h3>
|
||||
<p>Dealspace Assistant</p>
|
||||
</div>
|
||||
<button id="aria-close-btn" aria-label="Close chat">×</button>
|
||||
</div>
|
||||
<div id="aria-chat-messages"></div>
|
||||
<div id="aria-chat-input">
|
||||
<input type="text" id="aria-message-input" placeholder="Ask about Dealspace..." autocomplete="off">
|
||||
<button id="aria-send-btn" aria-label="Send message">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(button);
|
||||
document.body.appendChild(panel);
|
||||
|
||||
// Event listeners
|
||||
button.addEventListener('click', toggleChat);
|
||||
document.getElementById('aria-close-btn').addEventListener('click', toggleChat);
|
||||
document.getElementById('aria-send-btn').addEventListener('click', sendMessage);
|
||||
document.getElementById('aria-message-input').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleChat() {
|
||||
const panel = document.getElementById('aria-chat-panel');
|
||||
state.isOpen = !state.isOpen;
|
||||
|
||||
if (state.isOpen) {
|
||||
panel.classList.add('open');
|
||||
// Show welcome message if no history
|
||||
if (state.history.length === 0) {
|
||||
addMessage("Hi, I'm Aria! I can answer questions about Dealspace — features, pricing, security, or how it works. What would you like to know?", 'assistant');
|
||||
}
|
||||
document.getElementById('aria-message-input').focus();
|
||||
} else {
|
||||
panel.classList.remove('open');
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(content, role) {
|
||||
const messagesContainer = document.getElementById('aria-chat-messages');
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = 'aria-message ' + role;
|
||||
messageDiv.textContent = content;
|
||||
messagesContainer.appendChild(messageDiv);
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
|
||||
// Store in history (exclude welcome message)
|
||||
if (role !== 'assistant' || state.history.length > 0 || content !== "Hi, I'm Aria! I can answer questions about Dealspace — features, pricing, security, or how it works. What would you like to know?") {
|
||||
state.history.push({ role: role, content: content });
|
||||
// Keep only last 6 messages
|
||||
if (state.history.length > 6) {
|
||||
state.history = state.history.slice(-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showTyping() {
|
||||
const messagesContainer = document.getElementById('aria-chat-messages');
|
||||
const typingDiv = document.createElement('div');
|
||||
typingDiv.id = 'aria-typing-indicator';
|
||||
typingDiv.className = 'aria-typing';
|
||||
typingDiv.innerHTML = '<span></span><span></span><span></span>';
|
||||
messagesContainer.appendChild(typingDiv);
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
|
||||
function hideTyping() {
|
||||
const typingIndicator = document.getElementById('aria-typing-indicator');
|
||||
if (typingIndicator) {
|
||||
typingIndicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('aria-message-input');
|
||||
const sendBtn = document.getElementById('aria-send-btn');
|
||||
const message = input.value.trim();
|
||||
|
||||
if (!message || state.isLoading) return;
|
||||
|
||||
// Add user message
|
||||
addMessage(message, 'user');
|
||||
input.value = '';
|
||||
|
||||
// Show loading state
|
||||
state.isLoading = true;
|
||||
sendBtn.disabled = true;
|
||||
showTyping();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: state.sessionId,
|
||||
message: message,
|
||||
history: state.history.slice(0, -1) // Exclude the message we just added
|
||||
})
|
||||
});
|
||||
|
||||
hideTyping();
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Something went wrong');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
addMessage(data.reply, 'assistant');
|
||||
|
||||
} catch (error) {
|
||||
hideTyping();
|
||||
console.error('Chat error:', error);
|
||||
addMessage("Sorry, I'm having trouble connecting. Please try again in a moment.", 'assistant');
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
sendBtn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', createWidget);
|
||||
} else {
|
||||
createWidget();
|
||||
}
|
||||
})();
|
||||
|
|
@ -344,5 +344,7 @@
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
<link rel="stylesheet" href="/chat.css">
|
||||
<script src="/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -597,5 +597,7 @@
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
<link rel="stylesheet" href="/chat.css">
|
||||
<script src="/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -563,5 +563,7 @@
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
<link rel="stylesheet" href="/chat.css">
|
||||
<script src="/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -485,5 +485,7 @@
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
<link rel="stylesheet" href="/chat.css">
|
||||
<script src="/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -283,5 +283,7 @@
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
<link rel="stylesheet" href="/chat.css">
|
||||
<script src="/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -580,5 +580,7 @@
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
<link rel="stylesheet" href="/chat.css">
|
||||
<script src="/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -317,5 +317,7 @@
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
<link rel="stylesheet" href="/chat.css">
|
||||
<script src="/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue