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
|
||||
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)
|
||||
}
|
||||
|
||||
2
main.go
2
main.go
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user