Files
Trackeep/backend/handlers/chat.go
T
Tomas Dvorak d27cf14110 first test
2026-02-08 14:14:55 +01:00

361 lines
10 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/trackeep/backend/models"
"github.com/trackeep/backend/services"
)
// MistralConfig holds configuration for Mistral AI
type MistralConfig struct {
APIKey string
BaseURL string
Model string
MaxTokens int
Temperature float64
}
// ChatRequest represents a chat message request
type ChatRequest struct {
Message string `json:"message" binding:"required"`
SessionID *string `json:"session_id,omitempty"`
Context map[string]bool `json:"context,omitempty"` // what data to include
Provider string `json:"provider,omitempty"` // "mistral", "longcat", "grok", "deepseek", "ollama", "openrouter"
ModelType string `json:"model_type,omitempty"` // "standard", "thinking", "upgraded_thinking"
}
// ChatResponse represents a chat response
type ChatResponse struct {
ID string `json:"id"`
Message string `json:"message"`
Role string `json:"role"`
SessionID string `json:"session_id"`
Timestamp time.Time `json:"timestamp"`
Model string `json:"model"`
TokenUsage TokenUsage `json:"token_usage"`
ContextUsed []string `json:"context_used"`
}
// TokenUsage represents token usage information
type TokenUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// MistralMessage represents a message for Mistral API
type MistralMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// MistralRequest represents a request to Mistral API
type MistralRequest struct {
Model string `json:"model"`
Messages []MistralMessage `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
}
// MistralResponse represents a response from Mistral API
type MistralResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []struct {
Index int `json:"index"`
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
var mistralConfig = MistralConfig{
APIKey: os.Getenv("MISTRAL_API_KEY"),
BaseURL: "https://api.mistral.ai/v1",
Model: "mistral-small-latest", // Cheap and capable model
MaxTokens: 4000,
Temperature: 0.7,
}
// GetMistralConfig returns current Mistral configuration
func GetMistralConfig() MistralConfig {
return mistralConfig
}
// SendMessage handles chat message requests
func SendMessage(c *gin.Context) {
userID := c.GetUint("user_id")
var req ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get or create session
var session models.ChatSession
if req.SessionID != nil {
if err := models.DB.Where("id = ? AND user_id = ?", *req.SessionID, userID).First(&session).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
return
}
} else {
// Create new session
session = models.ChatSession{
UserID: userID,
Title: fmt.Sprintf("Chat %s", time.Now().Format("Jan 2, 3:04 PM")),
IncludeBookmarks: true,
IncludeTasks: true,
IncludeFiles: true,
IncludeNotes: true,
}
if req.Context != nil {
session.IncludeBookmarks = req.Context["bookmarks"]
session.IncludeTasks = req.Context["tasks"]
session.IncludeFiles = req.Context["files"]
session.IncludeNotes = req.Context["notes"]
}
if err := models.DB.Create(&session).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
return
}
}
// Save user message
userMessage := models.ChatMessage{
UserID: userID,
SessionID: strconv.Itoa(int(session.ID)),
Content: req.Message,
Role: "user",
}
if err := models.DB.Create(&userMessage).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save message"})
return
}
// Get conversation history
var messages []models.ChatMessage
models.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&messages)
// Build context from user data
contextData, err := buildUserContext(userID, session)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build context"})
return
}
// Build messages for AI provider (system + history)
aiMessages := []services.Message{
{
Role: "system",
Content: buildSystemPrompt(contextData),
},
}
// Add conversation history (limit to last 10 messages to manage token count)
startIdx := 0
if len(messages) > 11 { // system + 10 messages
startIdx = len(messages) - 10
}
for i := startIdx; i < len(messages); i++ {
aiMessages = append(aiMessages, services.Message{
Role: messages[i].Role,
Content: messages[i].Content,
})
}
// Determine AI provider
aiProvider := services.ProviderMistral
switch req.Provider {
case "longcat":
aiProvider = services.ProviderLongCat
case "grok":
aiProvider = services.ProviderGrok
case "deepseek":
aiProvider = services.ProviderDeepSeek
case "ollama":
aiProvider = services.ProviderOllama
case "openrouter":
aiProvider = services.ProviderOpenRouter
}
aiService := services.NewAIService(aiProvider)
aiReq := services.AIRequest{
Messages: aiMessages,
MaxTokens: 2000,
Temperature: 0.7,
ModelType: req.ModelType,
}
// Call AI provider
startTime := time.Now()
var aiResp *services.AIResponse
switch req.ModelType {
case "thinking":
aiResp, err = aiService.ChatCompletionWithThinking(aiReq)
case "upgraded_thinking":
aiResp, err = aiService.ChatCompletionWithUpgradedThinking(aiReq)
default:
aiResp, err = aiService.ChatCompletion(aiReq)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to call AI: " + err.Error()})
return
}
processingMs := time.Since(startTime).Milliseconds()
if len(aiResp.Choices) == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "No response from AI"})
return
}
// Extract assistant response, handling thinking models where needed
assistantContent := services.ParseThinkingResponse(aiResp, aiProvider, req.ModelType)
// Save assistant message
assistantMessage := models.ChatMessage{
UserID: userID,
SessionID: strconv.Itoa(int(session.ID)),
Content: assistantContent,
Role: "assistant",
ModelUsed: aiResp.Model,
TokenCount: aiResp.Usage.TotalTokens,
ProcessingMs: processingMs,
ContextItems: getContextItemIDs(contextData),
}
if err := models.DB.Create(&assistantMessage).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save response"})
return
}
// Update session
session.MessageCount = len(messages) + 1
now := time.Now()
session.LastMessageAt = &now
models.DB.Save(&session)
// Return response
response := ChatResponse{
ID: aiResp.ID,
Message: assistantContent,
Role: "assistant",
SessionID: strconv.Itoa(int(session.ID)),
Timestamp: time.Now(),
Model: aiResp.Model,
TokenUsage: TokenUsage{
PromptTokens: aiResp.Usage.PromptTokens,
CompletionTokens: aiResp.Usage.CompletionTokens,
TotalTokens: aiResp.Usage.TotalTokens,
},
ContextUsed: getContextItemIDs(contextData),
}
c.JSON(http.StatusOK, response)
}
// GetSessions retrieves user's chat sessions
func GetSessions(c *gin.Context) {
userID := c.GetUint("user_id")
var sessions []models.ChatSession
models.DB.Where("user_id = ?", userID).Order("updated_at desc").Find(&sessions)
c.JSON(http.StatusOK, sessions)
}
// GetSessionMessages retrieves messages for a specific session
func GetSessionMessages(c *gin.Context) {
userID := c.GetUint("user_id")
sessionID := c.Param("id")
// Verify session belongs to user
var session models.ChatSession
if err := models.DB.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
return
}
var messages []models.ChatMessage
models.DB.Where("session_id = ?", sessionID).Order("created_at asc").Find(&messages)
c.JSON(http.StatusOK, messages)
}
// DeleteSession deletes a chat session and its messages
func DeleteSession(c *gin.Context) {
userID := c.GetUint("user_id")
sessionID := c.Param("id")
// Verify session belongs to user
var session models.ChatSession
if err := models.DB.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
return
}
// Delete messages first
models.DB.Where("session_id = ?", sessionID).Delete(&models.ChatMessage{})
// Delete session
models.DB.Delete(&session)
c.JSON(http.StatusOK, gin.H{"message": "Session deleted"})
}
func callMistral(messages []MistralMessage) (*MistralResponse, error) {
reqBody := MistralRequest{
Model: mistralConfig.Model,
Messages: messages,
MaxTokens: mistralConfig.MaxTokens,
Temperature: mistralConfig.Temperature,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", mistralConfig.BaseURL+"/chat/completions", strings.NewReader(string(jsonData)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+mistralConfig.APIKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Mistral API returned status %d", resp.StatusCode)
}
var mistralResp MistralResponse
if err := json.NewDecoder(resp.Body).Decode(&mistralResp); err != nil {
return nil, err
}
return &mistralResp, nil
}