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:
James 2026-02-28 04:52:19 -05:00
parent a8379a2a0c
commit 4e89f79a67
11 changed files with 828 additions and 0 deletions

344
api/chat.go Normal file
View File

@ -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
}

View File

@ -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))

286
website/chat.css Normal file
View File

@ -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;
}

180
website/chat.js Normal file
View File

@ -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">&times;</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();
}
})();

View File

@ -344,5 +344,7 @@
</div> </div>
</footer> </footer>
<link rel="stylesheet" href="/chat.css">
<script src="/chat.js"></script>
</body> </body>
</html> </html>

View File

@ -597,5 +597,7 @@
</div> </div>
</footer> </footer>
<link rel="stylesheet" href="/chat.css">
<script src="/chat.js"></script>
</body> </body>
</html> </html>

View File

@ -563,5 +563,7 @@
</div> </div>
</footer> </footer>
<link rel="stylesheet" href="/chat.css">
<script src="/chat.js"></script>
</body> </body>
</html> </html>

View File

@ -485,5 +485,7 @@
</div> </div>
</footer> </footer>
<link rel="stylesheet" href="/chat.css">
<script src="/chat.js"></script>
</body> </body>
</html> </html>

View File

@ -283,5 +283,7 @@
</div> </div>
</footer> </footer>
<link rel="stylesheet" href="/chat.css">
<script src="/chat.js"></script>
</body> </body>
</html> </html>

View File

@ -580,5 +580,7 @@
</div> </div>
</footer> </footer>
<link rel="stylesheet" href="/chat.css">
<script src="/chat.js"></script>
</body> </body>
</html> </html>

View File

@ -317,5 +317,7 @@
</div> </div>
</footer> </footer>
<link rel="stylesheet" href="/chat.css">
<script src="/chat.js"></script>
</body> </body>
</html> </html>