diff --git a/api/chat.go b/api/chat.go new file mode 100644 index 0000000..d5b4e46 --- /dev/null +++ b/api/chat.go @@ -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 +} diff --git a/api/routes.go b/api/routes.go index 75edab3..9aa1bb8 100644 --- a/api/routes.go +++ b/api/routes.go @@ -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)) diff --git a/website/chat.css b/website/chat.css new file mode 100644 index 0000000..ced8dc0 --- /dev/null +++ b/website/chat.css @@ -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; +} diff --git a/website/chat.js b/website/chat.js new file mode 100644 index 0000000..f781d49 --- /dev/null +++ b/website/chat.js @@ -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 = ` + + `; + + // Chat panel + const panel = document.createElement('div'); + panel.id = 'aria-chat-panel'; + panel.innerHTML = ` +
Dealspace Assistant
+