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)
|
// Health check (unauthenticated)
|
||||||
r.Get("/health", h.Health)
|
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)
|
// API routes (authenticated)
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.Use(AuthMiddleware(db, cfg.JWTSecret))
|
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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/chat.css">
|
||||||
|
<script src="/chat.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -597,5 +597,7 @@
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/chat.css">
|
||||||
|
<script src="/chat.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -563,5 +563,7 @@
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/chat.css">
|
||||||
|
<script src="/chat.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -485,5 +485,7 @@
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/chat.css">
|
||||||
|
<script src="/chat.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -283,5 +283,7 @@
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/chat.css">
|
||||||
|
<script src="/chat.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -580,5 +580,7 @@
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/chat.css">
|
||||||
|
<script src="/chat.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -317,5 +317,7 @@
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/chat.css">
|
||||||
|
<script src="/chat.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue