modified: bot.db

modified:   handlers/dashboard.go
	modified:   main.go
	modified:   services/openrouter.go
	modified:   templates/dashboard.html
This commit is contained in:
2026-03-01 07:10:01 -03:00
parent d4d395356b
commit 6f7987c3fe
5 changed files with 328 additions and 151 deletions

BIN
bot.db

Binary file not shown.

View File

@@ -60,29 +60,6 @@ func ShowDashboard(c *gin.Context) {
})
}
// Test OpenRouter via the Dashboard
func TestAIHandler(c *gin.Context) {
var body struct {
Prompt string `json:"prompt"`
}
if err := c.BindJSON(&body); err != nil {
c.JSON(400, gin.H{"response": "Invalid request, dummy."})
return
}
// Calling the service we wrote earlier
response, err := services.GetAIResponse([]services.Message{
{Role: "user", Content: body.Prompt},
})
if err != nil {
c.JSON(500, gin.H{"response": "AI Error: " + err.Error()})
return
}
c.JSON(200, gin.H{"response": response})
}
// Add this to handlers/dashboard.go
func CreateAppointmentHandler(c *gin.Context) {
var body struct {
@@ -172,25 +149,38 @@ func PostMessageHandler(c *gin.Context) {
var body struct {
Content string `json:"content"`
}
c.BindJSON(&body)
if err := c.BindJSON(&body); err != nil {
c.Status(400)
return
}
// 1. Save User Message
// 1. Save User Message to DB first
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", chatId, body.Content)
// 2. Fetch history for AI
// 2. Fetch history
rows, _ := db.Conn.Query("SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at ASC", chatId)
var history []services.Message
defer rows.Close()
for rows.Next() {
var m services.Message
rows.Scan(&m.Role, &m.Content)
history = append(history, m)
}
// 3. Get AI Response
aiResp, _ := services.GetAIResponse(history)
// 3. Set Headers for Streaming
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("Transfer-Encoding", "chunked")
// 4. Save Assistant Message
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", chatId, aiResp)
// 4. Call the Stream Service
// We pass a function that writes chunks directly to the HTTP response
fullResponse, _ := services.StreamAIResponse(history, func(chunk string) {
c.Writer.Write([]byte(chunk))
c.Writer.Flush() // Important: Send it NOW, don't buffer
})
c.JSON(200, gin.H{"response": aiResp})
// 5. Save the FULL Assistant Message to DB for history
// We do this AFTER the stream finishes so the next load has the full text
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", chatId, fullResponse)
}

View File

@@ -28,7 +28,7 @@ func main() {
r.GET("/webhook", handlers.VerifyWebhook)
r.POST("/webhook", handlers.HandleMessage)
r.GET("/dashboard", handlers.ShowDashboard)
r.POST("/admin/test-ai", handlers.TestAIHandler)
r.DELETE("/admin/appointment/:id", handlers.DeleteAppointmentHandler)
r.POST("/admin/appointment", handlers.CreateAppointmentHandler)
r.PUT("/admin/appointment/:id", handlers.UpdateAppointmentHandler)

View File

@@ -1,12 +1,14 @@
package services
import (
bytes "bytes"
json "encoding/json"
io "io"
http "net/http"
os "os"
"whatsapp-bot/db" // Ensure this matches your module name
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"whatsapp-bot/db"
)
type Message struct {
@@ -14,9 +16,10 @@ type Message struct {
Content string `json:"content"`
}
type OpenRouterResponse struct {
// Structs to parse the incoming stream from OpenRouter
type StreamResponse struct {
Choices []struct {
Message struct {
Delta struct {
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
@@ -26,37 +29,40 @@ type OpenRouterResponse struct {
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
func GetAIResponse(chatHistory []Message) (string, error) {
// StreamAIResponse handles the streaming connection
// onToken: a function that gets called every time we get text (we use this to push to the browser)
// Returns: the full final string (so we can save it to the DB)
func StreamAIResponse(chatHistory []Message, onToken func(string)) (string, error) {
apiKey := os.Getenv("OPENROUTER_API_KEY")
url := "https://openrouter.ai/api/v1/chat/completions"
// 1. Build the full message list with a System Prompt
fullMessages := append([]Message{
{
Role: "system",
Content: "You are a helpful Chilean business assistant. You can book appointments. If a user wants to schedule something, use the create_appointment tool. Always be concise and polite.",
Content: "You are a helpful business assistant. You can book appointments. If the user mentions scheduling an appointment always ask for his phone number and the time and date of the appointment, if the user wants to schedule something before doing it, it is requiered to have a phone number and a date, use the create_appointment tool. Be concise and polite.",
},
}, chatHistory...)
// 2. Define the tools the AI can use
payload := map[string]interface{}{
"model": "stepfun/step-3.5-flash:free",
"model": "arcee-ai/trinity-large-preview:free", // arcee-ai/trinity-large-preview:free, stepfun/step-3.5-flash:free
"messages": fullMessages,
"stream": true, // <--- THIS IS KEY
"tools": []map[string]interface{}{
{
"type": "function",
"function": map[string]interface{}{
"name": "create_appointment",
"description": "Schedules a new appointment in the database",
"description": "Schedules a new appointment",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"customer_phone": map[string]string{"type": "string", "description": "The user's phone number"},
"date": map[string]string{"type": "string", "description": "The date and time in YYYY-MM-DD HH:MM format"},
"customer_phone": map[string]string{"type": "string"},
"date": map[string]string{"type": "string"},
},
"required": []string{"customer_phone", "date"},
},
@@ -70,50 +76,82 @@ func GetAIResponse(chatHistory []Message) (string, error) {
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "Error from OpenRouter: " + string(bodyBytes), nil
// Prepare to read the stream line by line
scanner := bufio.NewScanner(resp.Body)
var fullContentBuffer strings.Builder
var toolCallBuffer strings.Builder
var toolName string
isToolCall := false
for scanner.Scan() {
line := scanner.Text()
// OpenRouter sends "data: {JSON}" lines.
// Use specific string trimming to handle the format.
if !strings.HasPrefix(line, "data: ") {
continue
}
jsonStr := strings.TrimPrefix(line, "data: ")
// The stream ends with "data: [DONE]"
if jsonStr == "[DONE]" {
break
}
var result OpenRouterResponse
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return "", err
var chunk StreamResponse
if err := json.Unmarshal([]byte(jsonStr), &chunk); err != nil {
continue
}
if len(result.Choices) > 0 {
aiMsg := result.Choices[0].Message
if len(chunk.Choices) > 0 {
delta := chunk.Choices[0].Delta
// 3. Handle Tool Calls (The "Logic" part)
if len(aiMsg.ToolCalls) > 0 {
tc := aiMsg.ToolCalls[0].Function
if tc.Name == "create_appointment" {
// 1. Handle Text Content
if delta.Content != "" {
fullContentBuffer.WriteString(delta.Content)
// Send this chunk to the frontend immediately
onToken(delta.Content)
}
// 2. Handle Tool Calls (Accumulate them, don't stream execution yet)
if len(delta.ToolCalls) > 0 {
isToolCall = true
if delta.ToolCalls[0].Function.Name != "" {
toolName = delta.ToolCalls[0].Function.Name
}
toolCallBuffer.WriteString(delta.ToolCalls[0].Function.Arguments)
}
}
}
// If it was a tool call, execute it now that the stream is finished
if isToolCall && toolName == "create_appointment" {
var args struct {
Phone string `json:"customer_phone"`
Date string `json:"date"`
}
// Unmarshal the AI-generated arguments
json.Unmarshal([]byte(tc.Arguments), &args)
// Save to DB using your helper
// Try to parse the accumulated JSON arguments
if err := json.Unmarshal([]byte(toolCallBuffer.String()), &args); err == nil {
err := db.SaveAppointment(args.Phone, args.Date)
resultMsg := ""
if err != nil {
return "I tried to book it, but the database hates me: " + err.Error(), nil
resultMsg = "\n[System: Failed to book appointment.]"
} else {
resultMsg = fmt.Sprintf("\n✅ Booked for %s at %s", args.Phone, args.Date)
}
return "✅ [SYSTEM] Appointment automatically booked for " + args.Phone + " at " + args.Date, nil
// Send the tool result to the frontend
onToken(resultMsg)
fullContentBuffer.WriteString(resultMsg)
}
}
// 4. Return plain text if no tool was called
if aiMsg.Content != "" {
return aiMsg.Content, nil
}
}
return "The AI is giving me the silent treatment, seki.", nil
return fullContentBuffer.String(), nil
}

View File

@@ -2,98 +2,243 @@
<!DOCTYPE html>
<html>
<head>
<title>SekiBot | Conversations</title>
<title>SekiBot | Dashboard</title>
<style>
/* DISCORD THEME COLORS */
:root { --blurple: #5865F2; --bg-sidebar: #2f3136; --bg-chat: #36393f; --bg-input: #40444b; --text: #dcddde; }
body { margin: 0; display: flex; height: 100vh; font-family: sans-serif; background: var(--bg-chat); color: var(--text); }
/* DISCORD-ISH THEME */
:root { --blurple: #5865F2; --bg-sidebar: #2f3136; --bg-chat: #36393f; --bg-input: #40444b; --text: #dcddde; --success: #43b581; --danger: #f04747; }
body { margin: 0; display: flex; height: 100vh; font-family: 'Segoe UI', sans-serif; background: var(--bg-chat); color: var(--text); }
.sidebar { width: 240px; background: var(--bg-sidebar); display: flex; flex-direction: column; padding: 15px; }
.chat-list { flex-grow: 1; overflow-y: auto; }
.chat-item { padding: 10px; border-radius: 4px; cursor: pointer; margin-bottom: 5px; background: rgba(255,255,255,0.05); }
.chat-item:hover, .active { background: var(--bg-input); color: white; }
/* SIDEBAR */
.sidebar { width: 260px; background: var(--bg-sidebar); display: flex; flex-direction: column; padding: 15px; border-right: 1px solid #202225; }
.chat-list { flex-grow: 1; overflow-y: auto; margin-top: 10px; }
.nav-item, .chat-item { padding: 10px; border-radius: 4px; cursor: pointer; margin-bottom: 5px; color: #8e9297; transition: 0.2s; }
.nav-item:hover, .chat-item:hover { background: rgba(79,84,92,0.4); color: var(--text); }
.nav-item.active, .chat-item.active { background: rgba(79,84,92,0.6); color: white; font-weight: bold; }
.chat-area { flex-grow: 1; display: flex; flex-direction: column; }
.messages { flex-grow: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
.msg { max-width: 80%; padding: 10px 15px; border-radius: 8px; line-height: 1.4; }
.user { align-self: flex-end; background: var(--blurple); color: white; }
.assistant { align-self: flex-start; background: var(--bg-input); }
/* LAYOUT */
.main-content { flex-grow: 1; display: none; flex-direction: column; height: 100vh; }
.show { display: flex !important; }
.input-container { padding: 20px; background: var(--bg-chat); }
#user-input { width: 100%; background: var(--bg-input); border: none; padding: 15px; border-radius: 8px; color: white; outline: none; }
/* CHAT AREA */
.messages { flex-grow: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; scroll-behavior: smooth; }
.msg { max-width: 80%; padding: 12px 16px; border-radius: 8px; line-height: 1.5; font-size: 0.95rem; white-space: pre-wrap;}
.user { align-self: flex-end; background: var(--blurple); color: white; border-bottom-right-radius: 0; }
.assistant { align-self: flex-start; background: var(--bg-input); border-bottom-left-radius: 0; }
button.new-chat { background: var(--blurple); color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; margin-bottom: 20px; }
.input-area { padding: 20px; background: var(--bg-chat); margin-bottom: 20px;}
#user-input { width: 95%; background: var(--bg-input); border: none; padding: 15px; border-radius: 8px; color: white; outline: none; font-size: 1rem; }
/* APPOINTMENTS AREA */
.panel-container { padding: 40px; overflow-y: auto; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; background: var(--bg-sidebar); border-radius: 8px; overflow: hidden; }
th, td { padding: 15px; text-align: left; border-bottom: 1px solid var(--bg-input); }
th { background: #202225; text-transform: uppercase; font-size: 0.8rem; letter-spacing: 1px; }
input[type="text"], input[type="datetime-local"] { background: var(--bg-input); border: 1px solid #202225; color: white; padding: 8px; border-radius: 4px; }
button { cursor: pointer; border: none; padding: 8px 16px; border-radius: 4px; color: white; font-weight: bold; transition: 0.2s; }
.btn-primary { background: var(--blurple); width: 100%; padding: 12px; margin-bottom: 10px; }
.btn-success { background: var(--success); }
.btn-danger { background: var(--danger); }
.status-pill { background: #faa61a; color: black; padding: 4px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: bold; text-transform: uppercase; }
</style>
</head>
<body>
<div class="sidebar">
<h2>SekiBot</h2>
<nav>
<div class="chat-item active" onclick="showPanel('chat-panel')" id="nav-chat"># conversations</div>
<div class="chat-item" onclick="showPanel('appt-panel')" id="nav-appt"># appointments-manager</div>
</nav>
<hr style="border: 0.5px solid #42454a; margin: 15px 0;">
<button class="new-chat" onclick="newChat()">+ New Chat</button>
<div class="chat-list">
<h2 style="color: white; margin-bottom: 20px;">🤖 SekiBot</h2>
<div class="nav-item active" onclick="switchPanel('chat')" id="nav-chat">💬 Conversations</div>
<div class="nav-item" onclick="switchPanel('appt')" id="nav-appt">📅 Appointments</div>
<hr style="border: 0; border-top: 1px solid #42454a; margin: 15px 0;">
<button class="btn-primary" onclick="createNewChat()">+ New Chat</button>
<div class="chat-list" id="chat-list-container">
{{range .Chats}}
<div class="chat-item" onclick="loadChat({{.ID}})"># chat-{{.ID}}</div>
<div class="chat-item" onclick="loadChat({{.ID}}, this)" id="chat-link-{{.ID}}">
# {{.Title}}
</div>
{{end}}
</div>
</div>
<!-- MAIN CHAT PANEL -->
<div class="main-content" id="chat-panel">
<div class="messages" id="message-pane" style="height: calc(100vh - 120px); overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 10px;">
<p style="text-align: center; color: var(--text-muted);">Select a chat to begin.</p>
<div class="main-content show" id="panel-chat">
<div class="messages" id="message-pane">
<div style="text-align: center; margin-top: 40vh; color: #72767d;">
<p>Select a chat from the sidebar to start.</p>
</div>
<div class="input-container" style="padding: 20px; background: var(--bg-chat);">
<input type="text" id="user-input" placeholder="Type a message..." onkeypress="if(event.key==='Enter') sendMessage()" style="width: 100%; background: var(--bg-input); border: none; padding: 15px; border-radius: 8px; color: white;">
</div>
<div class="input-area">
<input type="text" id="user-input" placeholder="Message #general..." onkeypress="handleEnter(event)">
</div>
</div>
<!-- APPOINTMENTS CRUD PANEL -->
<div class="main-content" id="appt-panel" style="display: none; padding: 40px;">
<div class="main-content" id="panel-appt">
<div class="panel-container">
<h1>Appointments Manager</h1>
<div style="background: var(--bg-sidebar); padding: 20px; border-radius: 8px; margin-bottom: 20px;">
<h3>Create Mock Appointment</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr 100px; gap: 10px;">
<input type="text" id="m-phone" placeholder="Phone">
<input type="datetime-local" id="m-date">
<button onclick="createMockAppt()">Add</button>
<div style="background: var(--bg-sidebar); padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Add Manual Appointment</h3>
<div style="display: flex; gap: 10px;">
<input type="text" id="m-phone" placeholder="+56 9 1234 5678" style="flex: 1;">
<input type="datetime-local" id="m-date" style="flex: 1;">
<button class="btn-success" onclick="createMockAppt()">Add</button>
</div>
</div>
<table>
<thead>
<tr><th>ID</th><th>Phone</th><th>Date</th><th>Status</th><th>Actions</th></tr>
<tr>
<th>ID</th>
<th>Phone</th>
<th>Date (YYYY-MM-DD HH:MM)</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="appt-table-body">
<tbody>
{{range .Appointments}}
<tr id="appt-{{.ID}}">
<td>#{{.ID}}</td>
<td><input type="text" value="{{.CustomerPhone}}" id="edit-phone-{{.ID}}" style="width: 120px; margin:0;"></td>
<td><input type="text" value="{{.Date}}" id="edit-date-{{.ID}}" style="width: 160px; margin:0;"></td>
<td><input type="text" value="{{.CustomerPhone}}" id="edit-phone-{{.ID}}"></td>
<td><input type="text" value="{{.Date}}" id="edit-date-{{.ID}}"></td>
<td><span class="status-pill">{{.Status}}</span></td>
<td>
<button style="background: var(--success);" onclick="updateAppt({{.ID}})">Save</button>
<button class="delete" onclick="deleteAppt({{.ID}})">Del</button>
<button class="btn-success" onclick="updateAppt({{.ID}})">💾</button>
<button class="btn-danger" onclick="deleteAppt({{.ID}})">🗑️</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<script>
function showPanel(panelId) {
document.getElementById('chat-panel').style.display = panelId === 'chat-panel' ? 'flex' : 'none';
document.getElementById('appt-panel').style.display = panelId === 'appt-panel' ? 'block' : 'none';
document.querySelectorAll('nav .chat-item').forEach(el => el.classList.remove('active'));
document.getElementById(panelId === 'chat-panel' ? 'nav-chat' : 'nav-appt').classList.add('active');
let currentChatId = null;
// --- NAVIGATION ---
function switchPanel(panel) {
// Toggle Content
document.getElementById('panel-chat').classList.toggle('show', panel === 'chat');
document.getElementById('panel-appt').classList.toggle('show', panel === 'appt');
// Toggle Sidebar Active State
document.getElementById('nav-chat').classList.toggle('active', panel === 'chat');
document.getElementById('nav-appt').classList.toggle('active', panel === 'appt');
}
// --- CHAT LOGIC (RESTORED) ---
async function createNewChat() {
try {
const res = await fetch('/admin/chat', { method: 'POST' });
const data = await res.json();
if (data.id) {
location.reload(); // Simple reload to show new chat
}
} catch (e) {
alert("Error creating chat: " + e);
}
}
async function loadChat(id, el) {
currentChatId = id;
// UI Cleanup
document.querySelectorAll('.chat-item').forEach(i => i.classList.remove('active'));
if(el) el.classList.add('active');
const pane = document.getElementById('message-pane');
pane.innerHTML = '<p style="text-align:center; color:#72767d;">Loading messages...</p>';
// Fetch Messages
const res = await fetch(`/admin/chat/${id}/messages`);
const messages = await res.json();
pane.innerHTML = ''; // Clear loading
if (!messages || messages.length === 0) {
pane.innerHTML = '<p style="text-align:center; color:#72767d;">No messages yet. Say hi!</p>';
} else {
messages.forEach(msg => appendMessage(msg.role, msg.content));
}
scrollToBottom();
}
async function sendMessage() {
if (!currentChatId) return alert("Select a chat first!");
const input = document.getElementById('user-input');
const content = input.value.trim();
if (!content) return;
// 1. Show User Message
appendMessage('user', content);
input.value = '';
scrollToBottom();
// 2. Create a placeholder for the AI response
// We create the DOM element empty, then fill it as data arrives
const aiMsgDiv = document.createElement('div');
aiMsgDiv.className = 'msg assistant';
document.getElementById('message-pane').appendChild(aiMsgDiv);
scrollToBottom();
try {
const response = await fetch(`/admin/chat/${currentChatId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
// 3. Set up the stream reader
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Decode the chunk (Uint8Array) to string
const textChunk = decoder.decode(value, { stream: true });
// Append to the current message div
aiMsgDiv.innerText += textChunk;
scrollToBottom();
}
} catch (e) {
aiMsgDiv.innerText += "\n[Error: Connection lost]";
}
}
function appendMessage(role, text) {
const pane = document.getElementById('message-pane');
const div = document.createElement('div');
div.className = `msg ${role}`;
div.innerText = text;
pane.appendChild(div);
}
function scrollToBottom() {
const pane = document.getElementById('message-pane');
pane.scrollTop = pane.scrollHeight;
}
function handleEnter(e) {
if (e.key === 'Enter') sendMessage();
}
// --- APPOINTMENT LOGIC (KEPT) ---
async function createMockAppt() {
const phone = document.getElementById('m-phone').value;
const date = document.getElementById('m-date').value;
if(!phone || !date) return alert("Fill in fields, seki.");
await fetch('/admin/appointment', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@@ -105,21 +250,25 @@
async function updateAppt(id) {
const phone = document.getElementById(`edit-phone-${id}`).value;
const date = document.getElementById(`edit-date-${id}`).value;
await fetch(`/admin/appointment/${id}`, {
const res = await fetch(`/admin/appointment/${id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({phone, date})
});
alert("Updated, seki.");
if(res.ok) alert("Updated successfully.");
else alert("Update failed.");
}
async function deleteAppt(id) {
if(!confirm("Kill it?")) return;
await fetch(`/admin/appointment/${id}`, { method: 'DELETE' });
if(!confirm("Are you sure you want to delete this appointment?")) return;
const res = await fetch(`/admin/appointment/${id}`, { method: 'DELETE' });
if(res.ok) {
document.getElementById(`appt-${id}`).remove();
}
// ... (Keep your existing newChat, loadChat, and sendMessage scripts here) ...
}
</script>
</body>
</html>