modified: handlers/dashboard.go modified: main.go modified: services/openrouter.go modified: templates/dashboard.html
181 lines
5.1 KiB
Go
181 lines
5.1 KiB
Go
package services
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
"whatsapp-bot/db"
|
|
)
|
|
|
|
type Message struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// Structs to parse the incoming stream from OpenRouter
|
|
type StreamResponse struct {
|
|
Choices []struct {
|
|
Delta struct {
|
|
Content string `json:"content"`
|
|
ToolCalls []struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
} `json:"function"`
|
|
} `json:"tool_calls"`
|
|
} `json:"delta"`
|
|
FinishReason string `json:"finish_reason"`
|
|
} `json:"choices"`
|
|
}
|
|
|
|
// 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. Get Current Time for the LLM
|
|
currentTime := time.Now().Format("Monday, 2006-01-02 15:04")
|
|
|
|
// 2. Strict System Prompt
|
|
systemPrompt := fmt.Sprintf(`
|
|
You are a helpful scheduler assistant for a business in Chile.
|
|
Current Date/Time: %s
|
|
|
|
RULES FOR BOOKING:
|
|
1. You MUST get three things from the user before booking:
|
|
- The Date (day/month)
|
|
- The Time (hour)
|
|
- The Phone Number
|
|
2. If any of these are missing, ASK for them. Do NOT assume or guess.
|
|
3. If the user says "tomorrow" or "next Friday", calculate the date based on the Current Date/Time above.
|
|
4. Only when you have all details, use the 'create_appointment' tool.
|
|
`, currentTime)
|
|
|
|
// 3. Prepend System Prompt to History
|
|
fullMessages := append([]Message{
|
|
{Role: "system", Content: systemPrompt},
|
|
}, chatHistory...)
|
|
|
|
payload := map[string]interface{}{
|
|
"model": "arcee-ai/trinity-large-preview:free", // stepfun/step-3.5-flash:free, arcee-ai/trinity-large-preview:free
|
|
"messages": fullMessages,
|
|
"stream": true,
|
|
"tools": []map[string]interface{}{
|
|
{
|
|
"type": "function",
|
|
"function": map[string]interface{}{
|
|
"name": "create_appointment",
|
|
"description": "Schedules a new appointment. ONLY use this when you have a confirm date, time, and phone number.",
|
|
"parameters": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"customer_phone": map[string]string{
|
|
"type": "string",
|
|
"description": "The user's phone number (e.g., +569...)",
|
|
},
|
|
"date": map[string]string{
|
|
"type": "string",
|
|
"description": "The full date and time in YYYY-MM-DD HH:MM format",
|
|
},
|
|
},
|
|
"required": []string{"customer_phone", "date"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
jsonData, _ := json.Marshal(payload)
|
|
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// 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 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
|
|
}
|
|
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"`
|
|
}
|
|
// 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 fullContentBuffer.String(), nil
|
|
}
|