modified: bot.db
modified: handlers/dashboard.go modified: main.go modified: services/openrouter.go modified: templates/dashboard.html
This commit is contained in:
@@ -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
|
// Add this to handlers/dashboard.go
|
||||||
func CreateAppointmentHandler(c *gin.Context) {
|
func CreateAppointmentHandler(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
@@ -172,25 +149,38 @@ func PostMessageHandler(c *gin.Context) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Content string `json:"content"`
|
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)
|
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)
|
rows, _ := db.Conn.Query("SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at ASC", chatId)
|
||||||
var history []services.Message
|
var history []services.Message
|
||||||
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m services.Message
|
var m services.Message
|
||||||
rows.Scan(&m.Role, &m.Content)
|
rows.Scan(&m.Role, &m.Content)
|
||||||
history = append(history, m)
|
history = append(history, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Get AI Response
|
// 3. Set Headers for Streaming
|
||||||
aiResp, _ := services.GetAIResponse(history)
|
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
|
// 4. Call the Stream Service
|
||||||
db.Conn.Exec("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", chatId, aiResp)
|
// 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)
|
||||||
}
|
}
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -28,7 +28,7 @@ func main() {
|
|||||||
r.GET("/webhook", handlers.VerifyWebhook)
|
r.GET("/webhook", handlers.VerifyWebhook)
|
||||||
r.POST("/webhook", handlers.HandleMessage)
|
r.POST("/webhook", handlers.HandleMessage)
|
||||||
r.GET("/dashboard", handlers.ShowDashboard)
|
r.GET("/dashboard", handlers.ShowDashboard)
|
||||||
r.POST("/admin/test-ai", handlers.TestAIHandler)
|
|
||||||
r.DELETE("/admin/appointment/:id", handlers.DeleteAppointmentHandler)
|
r.DELETE("/admin/appointment/:id", handlers.DeleteAppointmentHandler)
|
||||||
r.POST("/admin/appointment", handlers.CreateAppointmentHandler)
|
r.POST("/admin/appointment", handlers.CreateAppointmentHandler)
|
||||||
r.PUT("/admin/appointment/:id", handlers.UpdateAppointmentHandler)
|
r.PUT("/admin/appointment/:id", handlers.UpdateAppointmentHandler)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
bytes "bytes"
|
"bufio"
|
||||||
json "encoding/json"
|
"bytes"
|
||||||
io "io"
|
"encoding/json"
|
||||||
http "net/http"
|
"fmt"
|
||||||
os "os"
|
"net/http"
|
||||||
"whatsapp-bot/db" // Ensure this matches your module name
|
"os"
|
||||||
|
"strings"
|
||||||
|
"whatsapp-bot/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
@@ -14,9 +16,10 @@ type Message struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenRouterResponse struct {
|
// Structs to parse the incoming stream from OpenRouter
|
||||||
|
type StreamResponse struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Message struct {
|
Delta struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ToolCalls []struct {
|
ToolCalls []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -26,37 +29,40 @@ type OpenRouterResponse struct {
|
|||||||
Arguments string `json:"arguments"`
|
Arguments string `json:"arguments"`
|
||||||
} `json:"function"`
|
} `json:"function"`
|
||||||
} `json:"tool_calls"`
|
} `json:"tool_calls"`
|
||||||
} `json:"message"`
|
} `json:"delta"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
} `json:"choices"`
|
} `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")
|
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
||||||
url := "https://openrouter.ai/api/v1/chat/completions"
|
url := "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
|
||||||
// 1. Build the full message list with a System Prompt
|
|
||||||
fullMessages := append([]Message{
|
fullMessages := append([]Message{
|
||||||
{
|
{
|
||||||
Role: "system",
|
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...)
|
}, chatHistory...)
|
||||||
|
|
||||||
// 2. Define the tools the AI can use
|
|
||||||
payload := map[string]interface{}{
|
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,
|
"messages": fullMessages,
|
||||||
|
"stream": true, // <--- THIS IS KEY
|
||||||
"tools": []map[string]interface{}{
|
"tools": []map[string]interface{}{
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": map[string]interface{}{
|
"function": map[string]interface{}{
|
||||||
"name": "create_appointment",
|
"name": "create_appointment",
|
||||||
"description": "Schedules a new appointment in the database",
|
"description": "Schedules a new appointment",
|
||||||
"parameters": map[string]interface{}{
|
"parameters": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"customer_phone": map[string]string{"type": "string", "description": "The user's phone number"},
|
"customer_phone": map[string]string{"type": "string"},
|
||||||
"date": map[string]string{"type": "string", "description": "The date and time in YYYY-MM-DD HH:MM format"},
|
"date": map[string]string{"type": "string"},
|
||||||
},
|
},
|
||||||
"required": []string{"customer_phone", "date"},
|
"required": []string{"customer_phone", "date"},
|
||||||
},
|
},
|
||||||
@@ -70,50 +76,82 @@ func GetAIResponse(chatHistory []Message) (string, error) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
// Prepare to read the stream line by line
|
||||||
if resp.StatusCode != http.StatusOK {
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
return "Error from OpenRouter: " + string(bodyBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var result OpenRouterResponse
|
var fullContentBuffer strings.Builder
|
||||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
var toolCallBuffer strings.Builder
|
||||||
return "", err
|
var toolName string
|
||||||
}
|
isToolCall := false
|
||||||
|
|
||||||
if len(result.Choices) > 0 {
|
for scanner.Scan() {
|
||||||
aiMsg := result.Choices[0].Message
|
line := scanner.Text()
|
||||||
|
|
||||||
// 3. Handle Tool Calls (The "Logic" part)
|
// OpenRouter sends "data: {JSON}" lines.
|
||||||
if len(aiMsg.ToolCalls) > 0 {
|
// Use specific string trimming to handle the format.
|
||||||
tc := aiMsg.ToolCalls[0].Function
|
if !strings.HasPrefix(line, "data: ") {
|
||||||
if tc.Name == "create_appointment" {
|
continue
|
||||||
var args struct {
|
}
|
||||||
Phone string `json:"customer_phone"`
|
jsonStr := strings.TrimPrefix(line, "data: ")
|
||||||
Date string `json:"date"`
|
|
||||||
|
// The stream ends with "data: [DONE]"
|
||||||
|
if jsonStr == "[DONE]" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunk StreamResponse
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &chunk); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chunk.Choices) > 0 {
|
||||||
|
delta := chunk.Choices[0].Delta
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
// Unmarshal the AI-generated arguments
|
toolCallBuffer.WriteString(delta.ToolCalls[0].Function.Arguments)
|
||||||
json.Unmarshal([]byte(tc.Arguments), &args)
|
|
||||||
|
|
||||||
// Save to DB using your helper
|
|
||||||
err := db.SaveAppointment(args.Phone, args.Date)
|
|
||||||
if err != nil {
|
|
||||||
return "I tried to book it, but the database hates me: " + err.Error(), nil
|
|
||||||
}
|
|
||||||
return "✅ [SYSTEM] Appointment automatically booked for " + args.Phone + " at " + args.Date, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Return plain text if no tool was called
|
// If it was a tool call, execute it now that the stream is finished
|
||||||
if aiMsg.Content != "" {
|
if isToolCall && toolName == "create_appointment" {
|
||||||
return aiMsg.Content, nil
|
var args struct {
|
||||||
|
Phone string `json:"customer_phone"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
}
|
||||||
|
// 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 {
|
||||||
|
resultMsg = "\n[System: Failed to book appointment.]"
|
||||||
|
} else {
|
||||||
|
resultMsg = fmt.Sprintf("\n✅ Booked for %s at %s", args.Phone, args.Date)
|
||||||
|
}
|
||||||
|
// Send the tool result to the frontend
|
||||||
|
onToken(resultMsg)
|
||||||
|
fullContentBuffer.WriteString(resultMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "The AI is giving me the silent treatment, seki.", nil
|
return fullContentBuffer.String(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,98 +2,243 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>SekiBot | Conversations</title>
|
<title>SekiBot | Dashboard</title>
|
||||||
<style>
|
<style>
|
||||||
/* DISCORD THEME COLORS */
|
/* DISCORD-ISH THEME */
|
||||||
:root { --blurple: #5865F2; --bg-sidebar: #2f3136; --bg-chat: #36393f; --bg-input: #40444b; --text: #dcddde; }
|
: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: sans-serif; background: var(--bg-chat); color: var(--text); }
|
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; }
|
/* SIDEBAR */
|
||||||
.chat-list { flex-grow: 1; overflow-y: auto; }
|
.sidebar { width: 260px; background: var(--bg-sidebar); display: flex; flex-direction: column; padding: 15px; border-right: 1px solid #202225; }
|
||||||
.chat-item { padding: 10px; border-radius: 4px; cursor: pointer; margin-bottom: 5px; background: rgba(255,255,255,0.05); }
|
.chat-list { flex-grow: 1; overflow-y: auto; margin-top: 10px; }
|
||||||
.chat-item:hover, .active { background: var(--bg-input); color: white; }
|
.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; }
|
/* LAYOUT */
|
||||||
.messages { flex-grow: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
|
.main-content { flex-grow: 1; display: none; flex-direction: column; height: 100vh; }
|
||||||
.msg { max-width: 80%; padding: 10px 15px; border-radius: 8px; line-height: 1.4; }
|
.show { display: flex !important; }
|
||||||
.user { align-self: flex-end; background: var(--blurple); color: white; }
|
|
||||||
.assistant { align-self: flex-start; background: var(--bg-input); }
|
|
||||||
|
|
||||||
.input-container { padding: 20px; background: var(--bg-chat); }
|
/* CHAT AREA */
|
||||||
#user-input { width: 100%; background: var(--bg-input); border: none; padding: 15px; border-radius: 8px; color: white; outline: none; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<h2>SekiBot</h2>
|
<h2 style="color: white; margin-bottom: 20px;">🤖 SekiBot</h2>
|
||||||
<nav>
|
|
||||||
<div class="chat-item active" onclick="showPanel('chat-panel')" id="nav-chat"># conversations</div>
|
<div class="nav-item active" onclick="switchPanel('chat')" id="nav-chat">💬 Conversations</div>
|
||||||
<div class="chat-item" onclick="showPanel('appt-panel')" id="nav-appt"># appointments-manager</div>
|
<div class="nav-item" onclick="switchPanel('appt')" id="nav-appt">📅 Appointments</div>
|
||||||
</nav>
|
|
||||||
<hr style="border: 0.5px solid #42454a; margin: 15px 0;">
|
<hr style="border: 0; border-top: 1px solid #42454a; margin: 15px 0;">
|
||||||
<button class="new-chat" onclick="newChat()">+ New Chat</button>
|
|
||||||
<div class="chat-list">
|
<button class="btn-primary" onclick="createNewChat()">+ New Chat</button>
|
||||||
|
|
||||||
|
<div class="chat-list" id="chat-list-container">
|
||||||
{{range .Chats}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MAIN CHAT PANEL -->
|
<div class="main-content show" id="panel-chat">
|
||||||
<div class="main-content" id="chat-panel">
|
<div class="messages" id="message-pane">
|
||||||
<div class="messages" id="message-pane" style="height: calc(100vh - 120px); overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 10px;">
|
<div style="text-align: center; margin-top: 40vh; color: #72767d;">
|
||||||
<p style="text-align: center; color: var(--text-muted);">Select a chat to begin.</p>
|
<p>Select a chat from the sidebar to start.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-container" style="padding: 20px; background: var(--bg-chat);">
|
<div class="input-area">
|
||||||
<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;">
|
<input type="text" id="user-input" placeholder="Message #general..." onkeypress="handleEnter(event)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- APPOINTMENTS CRUD PANEL -->
|
<div class="main-content" id="panel-appt">
|
||||||
<div class="main-content" id="appt-panel" style="display: none; padding: 40px;">
|
<div class="panel-container">
|
||||||
<h1>Appointments Manager</h1>
|
<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="background: var(--bg-sidebar); padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr 100px; gap: 10px;">
|
<h3 style="margin-top: 0;">Add Manual Appointment</h3>
|
||||||
<input type="text" id="m-phone" placeholder="Phone">
|
<div style="display: flex; gap: 10px;">
|
||||||
<input type="datetime-local" id="m-date">
|
<input type="text" id="m-phone" placeholder="+56 9 1234 5678" style="flex: 1;">
|
||||||
<button onclick="createMockAppt()">Add</button>
|
<input type="datetime-local" id="m-date" style="flex: 1;">
|
||||||
|
<button class="btn-success" onclick="createMockAppt()">Add</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Phone</th>
|
||||||
|
<th>Date (YYYY-MM-DD HH:MM)</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Appointments}}
|
||||||
|
<tr id="appt-{{.ID}}">
|
||||||
|
<td>#{{.ID}}</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 class="btn-success" onclick="updateAppt({{.ID}})">💾</button>
|
||||||
|
<button class="btn-danger" onclick="deleteAppt({{.ID}})">🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>ID</th><th>Phone</th><th>Date</th><th>Status</th><th>Actions</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="appt-table-body">
|
|
||||||
{{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><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>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function showPanel(panelId) {
|
let currentChatId = null;
|
||||||
document.getElementById('chat-panel').style.display = panelId === 'chat-panel' ? 'flex' : 'none';
|
|
||||||
document.getElementById('appt-panel').style.display = panelId === 'appt-panel' ? 'block' : 'none';
|
// --- NAVIGATION ---
|
||||||
document.querySelectorAll('nav .chat-item').forEach(el => el.classList.remove('active'));
|
function switchPanel(panel) {
|
||||||
document.getElementById(panelId === 'chat-panel' ? 'nav-chat' : 'nav-appt').classList.add('active');
|
// 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() {
|
async function createMockAppt() {
|
||||||
const phone = document.getElementById('m-phone').value;
|
const phone = document.getElementById('m-phone').value;
|
||||||
const date = document.getElementById('m-date').value;
|
const date = document.getElementById('m-date').value;
|
||||||
|
if(!phone || !date) return alert("Fill in fields, seki.");
|
||||||
|
|
||||||
await fetch('/admin/appointment', {
|
await fetch('/admin/appointment', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
@@ -105,21 +250,25 @@
|
|||||||
async function updateAppt(id) {
|
async function updateAppt(id) {
|
||||||
const phone = document.getElementById(`edit-phone-${id}`).value;
|
const phone = document.getElementById(`edit-phone-${id}`).value;
|
||||||
const date = document.getElementById(`edit-date-${id}`).value;
|
const date = document.getElementById(`edit-date-${id}`).value;
|
||||||
await fetch(`/admin/appointment/${id}`, {
|
|
||||||
|
const res = await fetch(`/admin/appointment/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({phone, date})
|
body: JSON.stringify({phone, date})
|
||||||
});
|
});
|
||||||
alert("Updated, seki.");
|
|
||||||
|
if(res.ok) alert("Updated successfully.");
|
||||||
|
else alert("Update failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAppt(id) {
|
async function deleteAppt(id) {
|
||||||
if(!confirm("Kill it?")) return;
|
if(!confirm("Are you sure you want to delete this appointment?")) return;
|
||||||
await fetch(`/admin/appointment/${id}`, { method: 'DELETE' });
|
|
||||||
document.getElementById(`appt-${id}`).remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... (Keep your existing newChat, loadChat, and sendMessage scripts here) ...
|
const res = await fetch(`/admin/appointment/${id}`, { method: 'DELETE' });
|
||||||
|
if(res.ok) {
|
||||||
|
document.getElementById(`appt-${id}`).remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user